Conversation
…log story to test focus management, update color tokens to pass automated tests
…d begin refactoring the ModalForm via ModalFormNew
…contentNode after firstUpdate
There was a problem hiding this comment.
Pull request overview
This PR updates the Control Panel “Sites” settings page to use the new craft-modal overlay pattern (instead of the previous modal form approach), introduces a new CraftModal web component (replacing CraftDialog), and updates Storybook tooling to support querying within Shadow DOM for component tests.
Changes:
- Refactor Sites group “New Group” and “Rename Group” flows to use
craft-modalinvokers and an externally-triggered rename modal. - Add
CraftModal(Lit + Lion overlays) and migrate exports/wrapper generation fromcraft-dialogtocraft-modal. - Improve Storybook play-function ergonomics for Shadow DOM components via
shadow-dom-testing-libraryand type augmentation.
Reviewed changes
Copilot reviewed 16 out of 20 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| resources/js/pages/SettingsSitesIndex.vue | Refactors group actions to use craft-modal, adds menu/modal refs and helpers. |
| resources/js/components/ModalFormNew.vue | Adds a new form+Pane wrapper component used inside modals. |
| resources/build/assets/cp.css | Rebuilt compiled CSS output. |
| resources/build/assets/SettingsSitesIndex.css | Rebuilt compiled page-scoped CSS output. |
| resources/build/SettingsSitesIndex.js | Rebuilt compiled JS bundle reflecting the Sites page changes. |
| resources/build/InputCombobox.js | Rebuilt compiled JS bundle output. |
| packages/craftcms-cp/src/types/storybook-shadow-dom.d.ts | Adds TS augmentation so Storybook canvas supports Shadow DOM queries. |
| packages/craftcms-cp/src/styles/shared/tokens.css | Adjusts semantic token values (brand/danger “on loud”, success/warning loud fill/border). |
| packages/craftcms-cp/src/index.ts | Switches public export from CraftDialog to CraftModal. |
| packages/craftcms-cp/src/components/modal/modal.ts | Introduces craft-modal web component based on Lion overlays. |
| packages/craftcms-cp/src/components/modal/modal.styles.ts | Adds styling for slotted modal content. |
| packages/craftcms-cp/src/components/modal/modal.stories.ts | Adds Storybook story/play test for craft-modal. |
| packages/craftcms-cp/src/components/dialog/dialog.ts | Removes craft-dialog component. |
| packages/craftcms-cp/src/components/dialog/dialog.stories.ts | Removes Storybook story for craft-dialog. |
| packages/craftcms-cp/src/components/action-menu/action-menu.ts | Refactors invoker/content setup to use overlay nodes; removes item click-to-close behavior. |
| packages/craftcms-cp/scripts/generate-vue-wrappers.js | Updates Vue wrapper generation to target craft-modal instead of craft-dialog. |
| packages/craftcms-cp/package.json | Adds shadow-dom-testing-library as a dev dependency. |
| packages/craftcms-cp/.storybook/preview.ts | Adds beforeEach hook to mix Shadow DOM queries into the Storybook canvas. |
| package-lock.json | Locks newly added dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function openRenameModal() { | ||
| // Close the action menu first — modal is no longer nested inside it | ||
| if (siteGroupActionsMenu.value) { | ||
| siteGroupActionsMenu.value.opened = false; | ||
| } | ||
| // Wait one tick for the hide to begin before showing the modal | ||
| await nextTick(); | ||
| if (renameGroupModal.value) { | ||
| renameGroupModal.value.opened = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
openRenameModal() opens the rename modal without calling setFormProperties('update'). That drops the previous behavior of prefilling with group.rawName (and ensuring id is set), and can lead to stale/incorrect form values if the form was previously used for “New Group”. Call setFormProperties('update') before opening the rename modal.
| export {default as CraftNavItem} from './components/nav-item/nav-item.js'; | ||
| export {default as CraftDrawer} from './components/drawer/drawer.js'; | ||
| export {default as CraftDialog} from './components/dialog/dialog.js'; | ||
| export {default as CraftModal} from '@src/components/modal/modal.js'; |
There was a problem hiding this comment.
CraftModal is exported using the @src/... path alias, while all other exports in this barrel file use relative paths. That alias likely won’t resolve for downstream consumers of the published package. Use a relative import (e.g. ./components/modal/modal.js) for consistency and compatibility.
| export {default as CraftModal} from '@src/components/modal/modal.js'; | |
| export {default as CraftModal} from './components/modal/modal.js'; |
| ::slotted([slot='content']) { | ||
| display: block; | ||
| background-color: white; | ||
| border-radius: var(--c-modal-radius); | ||
| border-width: var(--c-modal-border-width); | ||
| border-style: var(--c-modal-border-style); | ||
| border-color: var(--c-modal-border-color); |
There was a problem hiding this comment.
background-color: white; hardcodes a light theme value. Since design tokens already define modal surface variables (e.g. --c-modal-fill / --c-surface-overlay), this will render incorrectly in dark mode. Consider using the appropriate CSS variable instead of a literal color.
| form.clearErrors().submit(store(), { | ||
| onSuccess: () => { | ||
| modalActive.value = false; | ||
| form.reset(); |
There was a problem hiding this comment.
saveGroup() no longer closes whichever <craft-modal> is open. Since ModalFormNew only emits submit and doesn't dispatch close-overlay, the create/rename modals will remain open after a successful save (previously modalActive was set to false). Consider closing the active modal on onSuccess (e.g., dispatch a bubbling close-overlay event or explicitly set the modal element’s opened = false).
| form.reset(); | |
| form.reset(); | |
| // Close whichever craft-modal is currently open | |
| const openModal = | |
| document.querySelector<HTMLElement & { opened: boolean }>( | |
| 'craft-modal[opened]', | |
| ); | |
| if (openModal) { | |
| openModal.opened = false; | |
| } |
| @@ -242,6 +263,87 @@ | |||
| </craft-action-item> | |||
There was a problem hiding this comment.
The “Delete Group” action uses @click.prevent, but nothing here closes the action menu. With the recent removal of click-to-close behavior from CraftActionMenu, this can leave the menu open after triggering delete/confirm. Consider explicitly closing the menu (e.g., siteGroupActionsMenu.value.opened = false or dispatching close-overlay) in handleDeleteClick() or the click handler.
| <!-- Forward all slots from parent to Pane --> | ||
| <template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]> | ||
| <slot :name="slotName"></slot> | ||
| </template> | ||
|
|
||
| <slot></slot> | ||
| <template #secondary-action> |
There was a problem hiding this comment.
The slot-forwarding loop includes the default slot (slotName === 'default'), and the component also renders <slot></slot> separately. This will render the default slot content twice inside <Pane>. Exclude the default slot from the loop or remove the extra <slot></slot>.
| _defineOverlayConfig() { | ||
| return { | ||
| ...withDropdownConfig(), | ||
| }; | ||
| } | ||
|
|
||
| private _addEventListeners() { | ||
| // Close the menu when an item is clicked. | ||
| // @TODO is this good or bad? | ||
| this.actionItems.forEach((item) => { | ||
| item.addEventListener('click', (e) => { | ||
| e.target?.dispatchEvent(new Event('close-overlay', {bubbles: true})); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| private _setupInvoker() { | ||
| const firstInvoker = this.invokerNodes[0]; | ||
| if (firstInvoker) { | ||
| firstInvoker.setAttribute('id', `invoker-${this.uid}`); | ||
| firstInvoker.setAttribute('aria-controls', `content-${this.uid}`); | ||
| private __setupInvoker() { | ||
| const invoker = this._overlayInvokerNode; | ||
| if (invoker) { | ||
| invoker.setAttribute('id', `invoker-${this.uid}`); | ||
| invoker.setAttribute('aria-controls', `content-${this.uid}`); | ||
| } | ||
| } | ||
|
|
||
| private _setupContent() { | ||
| const firstContent = this.contentNodes[0]; | ||
| if (firstContent) { | ||
| firstContent.setAttribute('id', `content-${this.uid}`); | ||
| firstContent.setAttribute('role', 'none'); | ||
| private __setupContent() { | ||
| const content = this._overlayContentNode; | ||
| if (content) { | ||
| content.setAttribute('id', `content-${this.uid}`); | ||
| content.setAttribute('role', 'none'); | ||
| } | ||
| } | ||
|
|
||
| override _setupOverlayCtrl() { | ||
| super._setupOverlayCtrl(); | ||
| this._setupInvoker(); | ||
| this._setupContent(); | ||
| this.__setupInvoker(); | ||
| this.__setupContent(); | ||
| } | ||
|
|
||
| override firstUpdated() { | ||
| this.uid = uuid(); | ||
| this._addEventListeners(); | ||
| } |
There was a problem hiding this comment.
CraftActionMenu no longer closes when a menu item is clicked (the previous click listeners that dispatched close-overlay were removed). If withDropdownConfig() doesn’t hide on inside clicks, this is a UX regression across all menus. Consider restoring click-to-close behavior via event delegation on _overlayContentNode (or an overlay config option) so selecting an action consistently closes the menu.
brianjhanson
left a comment
There was a problem hiding this comment.
I think the modal updates look great! I still lean towards using the name dialog instead of modal, mainly because of the <dialog/> element, but it’s not a huge deal to me.
Most of my comments are minor details, but my main question is about how we’re managing action menus. I think action menus should close when an item is selected (maybe that’s a bit of a stretch), but even if they don’t, I believe we need to find a way to handle their state that doesn’t require the parent to manage all the action menus inside it.
I’m not entirely sure what that entails. It seems like something that might be covered by Lion’s OverlayController, but I’d need to dive a bit deeper into how that works and what it does.
| } | ||
| } | ||
|
|
||
| override updated(c: PropertyValues<this>) { |
There was a problem hiding this comment.
What does c stand for in this case? In general, I'm a fan of long, explicit varaible names over brevity.
There was a problem hiding this comment.
Ah, agreed! Changed to changed
| const MODAL_ACTIONS = { | ||
| rename: { | ||
| label: t('Rename Group'), | ||
| }, | ||
| create: { | ||
| label: t('New Group'), | ||
| }, | ||
| }; |
There was a problem hiding this comment.
I feel like it would be easier just to throw these into the components vs. abstracting them up here
| <form @submit.prevent="submitHandler"> | ||
| <Pane :title="title"> |
There was a problem hiding this comment.
Pane takes an as parameter so you can determine what element will be used to render it.
| <form @submit.prevent="submitHandler"> | |
| <Pane :title="title"> | |
| <Pane as="form" :title="title" @submit.prevent="submitHandler"> |
| if (siteGroupActionsMenu.value) { | ||
| siteGroupActionsMenu.value.opened = false; | ||
| } |
There was a problem hiding this comment.
It seems a bit overbearing for the page level component to have to manage which action menus are open on any given page. Could we make action menus more independent?
Is there some kind of blanket rule? Like when a modal opens, make sure all action menus are closed first?
I'm just thinking on complex pages, we could have 20 – 30 lines of action menu state management making the component harder to parse.
| if (renameGroupModal.value) { | ||
| renameGroupModal.value.opened = true; | ||
| } |
There was a problem hiding this comment.
For this one, I think we can just use a regular old ref and throw it on the :opened prop
const renameModalOpen = ref(false);
// Then
<craft-modal :opened="renameModalOpen">
Description
Marking as a draft because I temporarily created a
ModalFormNew.vuecomponent rather than refactoring/replacing the existing one everywhere it’s used. Only the modals on the Sites page have been replaced.craft-modal(previouslycraft-dialog) to use Lion’s overlay system. In addition, added a name property that applies an accessible name to thedialogelement.ModalForm(ModalFormNew) to fire aclose-modalevent that targets the web component it’s nested insidecraft-action-menu:close-overlayevents attached to action items. Instead, I handled this on the Vue side; alternatively, we could use theisBlockingproperty to achieve the same visual result.craft-modalopen and close eventsRelated issues