diff --git a/cms-assets/resources/icons/custom-icons/c-debug.svg b/cms-assets/resources/icons/custom-icons/c-debug.svg index be94520453c..13602693df0 100644 --- a/cms-assets/resources/icons/custom-icons/c-debug.svg +++ b/cms-assets/resources/icons/custom-icons/c-debug.svg @@ -1,4 +1,3 @@ - Craft icon diff --git a/cms-assets/resources/icons/custom-icons/craft-cms.svg b/cms-assets/resources/icons/custom-icons/craft-cms.svg index e0f6ff99300..6790bec3d6e 100644 --- a/cms-assets/resources/icons/custom-icons/craft-cms.svg +++ b/cms-assets/resources/icons/custom-icons/craft-cms.svg @@ -1,4 +1,3 @@ - Craft CMS diff --git a/cms-assets/resources/icons/custom-icons/craft-partners.svg b/cms-assets/resources/icons/custom-icons/craft-partners.svg index 6f20ab1e4de..9b575dd9430 100644 --- a/cms-assets/resources/icons/custom-icons/craft-partners.svg +++ b/cms-assets/resources/icons/custom-icons/craft-partners.svg @@ -1,4 +1,3 @@ - diff --git a/cms-assets/resources/icons/custom-icons/default-plugin.svg b/cms-assets/resources/icons/custom-icons/default-plugin.svg index 0dca7fba4d4..7bd87024686 100644 --- a/cms-assets/resources/icons/custom-icons/default-plugin.svg +++ b/cms-assets/resources/icons/custom-icons/default-plugin.svg @@ -1,4 +1,3 @@ - default_plugin @@ -6,4 +5,4 @@ - \ No newline at end of file + diff --git a/packages/craftcms-legacy/cp/src/css/_main.scss b/packages/craftcms-legacy/cp/src/css/_main.scss index c318ee3830a..9c4d25d659d 100644 --- a/packages/craftcms-legacy/cp/src/css/_main.scss +++ b/packages/craftcms-legacy/cp/src/css/_main.scss @@ -2974,12 +2974,6 @@ table:not(.cp-table) { .chips { align-items: flex-start; gap: var(--xs); - - &.chips-small { - li { - height: calc(34rem / 16); - } - } } .inline-chips { diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index c1bfc1daeaf..5989126a745 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -1990,7 +1990,6 @@ $.extend(Craft, { }, _elementIndexClasses: {}, - _elementSelectorModalClasses: {}, _elementEditorClasses: {}, _uploaderClasses: {}, _authFormHandlers: {}, @@ -2031,24 +2030,6 @@ $.extend(Craft, { this._uploaderClasses[fsType] = func; }, - /** - * Registers an element selector modal class for a given element type. - * - * @param {string} elementType - * @param {function} func - */ - registerElementSelectorModalClass: function (elementType, func) { - if (typeof this._elementSelectorModalClasses[elementType] !== 'undefined') { - throw ( - 'An element selector modal class has already been registered for the element type “' + - elementType + - '”.' - ); - } - - this._elementSelectorModalClasses[elementType] = func; - }, - registerAuthFormHandler(method, func) { if (typeof this._authFormHandlers[method] !== 'undefined') { throw `An authentication form handler has already been registered for the method “${method}”.`; @@ -2097,24 +2078,6 @@ $.extend(Craft, { return uploader; }, - /** - * Creates a new element selector modal for a given element type. - * - * @param {string} elementType - * @param {Object} settings - */ - createElementSelectorModal: function (elementType, settings) { - var func; - - if (typeof this._elementSelectorModalClasses[elementType] !== 'undefined') { - func = this._elementSelectorModalClasses[elementType]; - } else { - func = Craft.BaseElementSelectorModal; - } - - return new func(elementType, settings); - }, - /** * @deprecated in 6.0. Use event listeners directly. */ diff --git a/packages/craftcms-ui/package.json b/packages/craftcms-ui/package.json index e20b513aa60..37d1a68a3e1 100644 --- a/packages/craftcms-ui/package.json +++ b/packages/craftcms-ui/package.json @@ -44,6 +44,15 @@ "development": "./src/components/*.ts", "default": "./dist/components/*.ts.mjs" }, + "./core": { + "development": "./src/core/element-selector/index.ts", + "types": "./dist/core/element-selector/index.ts.d.mts", + "import": "./dist/core/element-selector/index.ts.mjs" + }, + "./core/*": { + "development": "./src/core/*.ts", + "default": "./dist/core/*.ts.mjs" + }, "./services/*": { "development": "./src/services/*.ts", "default": "./dist/services/*.ts.mjs" diff --git a/packages/craftcms-ui/scripts/build.js b/packages/craftcms-ui/scripts/build.js index d61b6315868..99fa7ac3a81 100755 --- a/packages/craftcms-ui/scripts/build.js +++ b/packages/craftcms-ui/scripts/build.js @@ -45,8 +45,8 @@ async function generateBundle(config = {}) { )), ...(await resolveFrom('./src/services/**/!(*.(styles|test)).ts')), ...(await resolveFrom('./src/utilities/**/!(*.(styles|test)).ts')), - ...(await resolveFrom('./src/services/**/!(*.(styles|test)).ts')), ...(await resolveFrom('./src/factory/**/!(*.(styles|test)).ts')), + ...(await resolveFrom('./src/core/**/!(*.(styles|test)).ts')), }, minify: !isDeveloping, external: ['lit', '@lion/ui'], diff --git a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts index 7815dce34e6..77becc39a47 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.stories.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.stories.ts @@ -5,36 +5,99 @@ import {html} from 'lit'; import './dialog.js'; import type CraftDialog from './dialog.js'; -// More on how to set up stories at: https://storybook.js.org/docs/writing-stories +interface DialogArgs { + label: string; + nonModal: boolean; + fullscreen: boolean; + closeOnOutsideClick: boolean; + body: string; +} + const meta = { title: 'Components/Dialog', component: 'craft-dialog', - args: {}, + args: { + label: 'Dialog', + nonModal: false, + fullscreen: false, + closeOnOutsideClick: false, + body: 'This is some text within a dialog.', + }, parameters: { layout: 'centered', }, - render: function (args) { - function openDialog() { - const dialog = document.getElementById('storybook-dialog') as CraftDialog; - dialog.opened = true; + render(args: DialogArgs) { + const id = 'storybook-dialog'; + + function open() { + (document.getElementById(id) as CraftDialog).opened = true; } return html` - - This is some text within a dialog. + + ${args.body} Close - Open Dialog + Open Dialog `; }, -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; + +export const Default: Story = {}; + +/** No footer slotted — the footer row collapses rather than leaving a gap. */ +export const NoFooter: Story = { + render(args: DialogArgs) { + function open() { + (document.getElementById('storybook-dialog-bare') as CraftDialog).opened = + true; + } + + return html` + + ${args.body} + + + Open Dialog + `; + }, +}; + +/** + * Opened with `show()` rather than `showModal()`, so the dialog stays out of the + * top layer and content that appends itself to `` — most legacy CP menus — + * still paints above it. The backdrop, Escape and focus containment are the + * component's own here. + */ +export const NonModal: Story = { + args: {nonModal: true}, +}; + +export const Fullscreen: Story = { + args: {fullscreen: true}, +}; + +/** Long content scrolls inside the body rather than growing the surface. */ +export const Scrolling: Story = { + args: { + body: Array.from( + {length: 40}, + (_, i) => `Line ${i + 1} of a dialog body that has to scroll. ` + ).join(''), + }, +}; -// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -export const Default: Story = { - args: {}, +export const ClosesOnOutsideClick: Story = { + args: {closeOnOutsideClick: true}, }; diff --git a/packages/craftcms-ui/src/components/dialog/dialog.styles.ts b/packages/craftcms-ui/src/components/dialog/dialog.styles.ts new file mode 100644 index 00000000000..fe11ad80d57 --- /dev/null +++ b/packages/craftcms-ui/src/components/dialog/dialog.styles.ts @@ -0,0 +1,125 @@ +import {css} from 'lit'; + +export default css` + :host { + display: contents; + + /* + Sizing hooks. Declared on \`:host\` so an outer-tree rule or an inline + style on the host still wins — shadow-tree \`:host\` rules always lose to + the outer tree — and so a subclass can go near-fullscreen without + reaching into this component's internals. + */ + --_dialog-inline-size: var(--c-dialog-inline-size, auto); + --_dialog-min-inline-size: var( + --c-dialog-min-inline-size, + min(90vw, 24rem) + ); + --_dialog-max-inline-size: var( + --c-dialog-max-inline-size, + min(90vw, 40rem) + ); + --_dialog-max-block-size: var(--c-dialog-max-block-size, 85dvh); + } + + :host([fullscreen]) { + --_dialog-inline-size: var(--c-dialog-inline-size, 100vw); + --_dialog-min-inline-size: var(--c-dialog-min-inline-size, 100vw); + --_dialog-max-inline-size: var(--c-dialog-max-inline-size, 100vw); + --_dialog-max-block-size: var(--c-dialog-max-block-size, 100dvh); + } + + dialog { + padding: 0; + border: 0; + background: none; + color: inherit; + max-width: none; + max-height: none; + overflow: visible; + } + + dialog::backdrop { + background-color: rgb(0 0 0 / 0.25); + } + + /* + Non-modal dialogs get no \`::backdrop\` — that pseudo only paints for the + top layer — so one is rendered instead. \`show()\` also leaves the dialog in + normal flow, hence the fixed positioning to centre it. + */ + .backdrop { + position: fixed; + inset: 0; + background-color: rgb(0 0 0 / 0.25); + } + + :host([non-modal]) dialog { + position: fixed; + inset: 0; + margin: auto; + z-index: 100; + } + + .surface { + display: grid; + /* header / body / footer — the body is the only row that flexes. */ + grid-template-rows: auto 1fr auto; + inline-size: var(--_dialog-inline-size); + min-inline-size: var(--_dialog-min-inline-size); + max-inline-size: var(--_dialog-max-inline-size); + max-block-size: var(--_dialog-max-block-size); + background-color: var(--c-surface-raised); + border-radius: var(--c-radius-md); + box-shadow: var(--c-shadow-lg); + overflow: hidden; + } + + :host([fullscreen]) .surface { + border-radius: 0; + } + + .header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--c-spacing-md); + padding-inline: var(--c-spacing-lg); + padding-block-start: var(--c-spacing-lg); + padding-block-end: var(--c-spacing-md); + } + + .title { + font-size: 1.25em; + margin: 0; + } + + .close { + background: none; + border: none; + cursor: pointer; + color: inherit; + padding: var(--c-spacing-xs); + line-height: 1; + } + + .body { + /* + Pairs with \`1fr\` above: without an explicit \`min-block-size: 0\` a grid + item floors at its content height, so a long slotted index would grow the + surface past \`--_dialog-max-block-size\` instead of scrolling inside it. + */ + min-block-size: 0; + overflow: auto; + padding-inline: var(--c-spacing-lg); + padding-block-end: var(--c-spacing-lg); + } + + .footer { + display: flex; + justify-content: flex-end; + gap: var(--c-spacing-sm); + padding-inline: var(--c-spacing-lg); + padding-block-end: var(--c-spacing-lg); + } +`; diff --git a/packages/craftcms-ui/src/components/dialog/dialog.test.ts b/packages/craftcms-ui/src/components/dialog/dialog.test.ts index 2ca194dd65a..bf463ba2464 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.test.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.test.ts @@ -2,12 +2,15 @@ import {beforeEach, describe, expect, it} from 'vite-plus/test'; import type CraftDialog from './dialog.js'; import './dialog.js'; -// happy-dom may not implement the full native API Lion's overlay -// controller uses; polyfill the minimum. +// happy-dom does not implement the full native API; polyfill the +// minimum the component drives. if (typeof HTMLDialogElement !== 'undefined') { HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { this.setAttribute('open', ''); }; + HTMLDialogElement.prototype.show ??= function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { this.removeAttribute('open'); }; @@ -25,80 +28,101 @@ async function createDialog( document.body.append(dialog); await dialog.updateComplete; - await new Promise((resolve) => setTimeout(resolve)); return dialog; } +function shadow( + dialog: CraftDialog, + selector: string +): E | null { + return dialog.shadowRoot!.querySelector(selector); +} + beforeEach(() => { document.body.innerHTML = ''; }); -/** - * Waits out Lion's async OverlayController show/hide cycle so its 'show'/ - * 'hide' events have synced back before the next state change. - */ -function settle(dialog: CraftDialog): Promise { - return dialog.updateComplete.then( - () => new Promise((resolve) => setTimeout(resolve)) - ); -} - describe('craft-dialog', () => { - it('wraps default-slot content into a slot="content" child', async () => { + it('projects content without relocating it', async () => { const dialog = await createDialog(); - const content = dialog.querySelector('[slot="content"]'); - expect(content).not.toBeNull(); - expect(content!.textContent).toContain('Dialog body text'); + + // The whole point of the shadow-slot rewrite: a framework that rendered + // these nodes still owns them, in the position it put them. + const paragraph = dialog.querySelector('p'); + expect(paragraph).not.toBeNull(); + expect(paragraph!.parentElement).toBe(dialog); + expect(dialog.querySelector('[slot="content"]')).toBeNull(); + + const slot = shadow(dialog, '.body slot'); + expect(slot).not.toBeNull(); + expect(slot!.assignedNodes({flatten: true})).toContain(paragraph); }); it('renders the label as a header title', async () => { const dialog = await createDialog(); - const title = dialog.querySelector('.craft-dialog__title'); + const title = shadow(dialog, '.title'); + expect(title).not.toBeNull(); expect(title!.textContent).toBe('Test Dialog'); }); + it('labels the dialog with the title', async () => { + const dialog = await createDialog(); + + expect(shadow(dialog, 'dialog')!.getAttribute('aria-labelledby')).toBe( + shadow(dialog, '.title')!.id + ); + }); + it('renders a close button that closes the dialog', async () => { const dialog = await createDialog(); dialog.opened = true; - await settle(dialog); + await dialog.updateComplete; expect(dialog.opened).toBe(true); - const close = dialog.querySelector( - '.craft-dialog__close' - ); - expect(close).not.toBeNull(); - close!.click(); - await settle(dialog); + shadow(dialog, '.close')!.click(); + await dialog.updateComplete; + expect(dialog.opened).toBe(false); }); it('opens when the open attribute is set before connecting', async () => { const dialog = await createDialog((d) => d.setAttribute('open', '')); + expect(dialog.opened).toBe(true); + expect(shadow(dialog, 'dialog')!.open).toBe(true); }); it('reflects the opened state back to the open attribute', async () => { const dialog = await createDialog(); + dialog.opened = true; - await settle(dialog); + await dialog.updateComplete; expect(dialog.hasAttribute('open')).toBe(true); dialog.opened = false; - await settle(dialog); + await dialog.updateComplete; expect(dialog.hasAttribute('open')).toBe(false); }); - it('moves footer-slotted children into the footer', async () => { + it('shows the footer only when footer content is slotted', async () => { + const bare = await createDialog(); + expect(shadow(bare, '.footer')!.hasAttribute('hidden')).toBe(true); + const dialog = await createDialog((d) => { const button = document.createElement('button'); button.slot = 'footer'; button.textContent = 'Done'; d.append(button); }); - const footer = dialog.querySelector('.craft-dialog__footer'); - expect(footer).not.toBeNull(); - expect(footer!.querySelector('button')?.textContent).toBe('Done'); + + const footer = shadow(dialog, '.footer')!; + expect(footer.hasAttribute('hidden')).toBe(false); + expect( + footer + .querySelector('slot[name="footer"]')! + .assignedNodes({flatten: true}) + ).toContain(dialog.querySelector('button')); }); it('closes when a data-dialog="close" descendant is clicked', async () => { @@ -110,10 +134,11 @@ describe('craft-dialog', () => { d.append(button); }); dialog.opened = true; - await settle(dialog); + await dialog.updateComplete; dialog.querySelector('[data-dialog="close"]')!.click(); - await settle(dialog); + await dialog.updateComplete; + expect(dialog.opened).toBe(false); }); @@ -124,10 +149,109 @@ describe('craft-dialog', () => { dialog.addEventListener('craft-hide', () => events.push('craft-hide')); dialog.opened = true; - await settle(dialog); + await dialog.updateComplete; dialog.opened = false; - await settle(dialog); + await dialog.updateComplete; expect(events).toEqual(['craft-show', 'craft-hide']); }); + + it('does not emit lifecycle events on first render', async () => { + const events: string[] = []; + const dialog = document.createElement('craft-dialog') as CraftDialog; + dialog.addEventListener('craft-hide', () => events.push('craft-hide')); + document.body.append(dialog); + await dialog.updateComplete; + + expect(events).toEqual([]); + }); + + it('emits the after-* pair once the update settles', async () => { + const dialog = await createDialog(); + const events: string[] = []; + dialog.addEventListener('craft-after-show', () => + events.push('craft-after-show') + ); + dialog.addEventListener('craft-after-hide', () => + events.push('craft-after-hide') + ); + + dialog.opened = true; + await dialog.updateComplete; + await Promise.resolve(); + expect(events).toEqual(['craft-after-show']); + + dialog.opened = false; + await dialog.updateComplete; + await Promise.resolve(); + expect(events).toEqual(['craft-after-show', 'craft-after-hide']); + }); + + describe('non-modal', () => { + it('opens without entering the top layer', async () => { + const calls: string[] = []; + const dialog = await createDialog((d) => d.setAttribute('non-modal', '')); + const native = shadow(dialog, 'dialog')!; + + native.show = () => { + calls.push('show'); + native.setAttribute('open', ''); + }; + native.showModal = () => calls.push('showModal'); + + dialog.opened = true; + await dialog.updateComplete; + + expect(calls).toEqual(['show']); + }); + + it('renders its own backdrop, since ::backdrop only paints for the top layer', async () => { + const dialog = await createDialog((d) => d.setAttribute('non-modal', '')); + expect(shadow(dialog, '.backdrop')).toBeNull(); + + dialog.opened = true; + await dialog.updateComplete; + + expect(shadow(dialog, '.backdrop')).not.toBeNull(); + }); + + it('closes on Escape, which the platform no longer handles', async () => { + const dialog = await createDialog((d) => d.setAttribute('non-modal', '')); + dialog.opened = true; + await dialog.updateComplete; + + dialog.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}) + ); + await dialog.updateComplete; + + expect(dialog.opened).toBe(false); + }); + }); + + describe('closeOnOutsideClick', () => { + it('ignores backdrop clicks by default', async () => { + const dialog = await createDialog(); + dialog.opened = true; + await dialog.updateComplete; + + shadow(dialog, 'dialog')!.click(); + await dialog.updateComplete; + + expect(dialog.opened).toBe(true); + }); + + it('closes on a backdrop click when enabled', async () => { + const dialog = await createDialog((d) => + d.setAttribute('close-on-outside-click', '') + ); + dialog.opened = true; + await dialog.updateComplete; + + shadow(dialog, 'dialog')!.click(); + await dialog.updateComplete; + + expect(dialog.opened).toBe(false); + }); + }); }); diff --git a/packages/craftcms-ui/src/components/dialog/dialog.ts b/packages/craftcms-ui/src/components/dialog/dialog.ts index 5e0f1fd6ef7..85d56dd6bfb 100644 --- a/packages/craftcms-ui/src/components/dialog/dialog.ts +++ b/packages/craftcms-ui/src/components/dialog/dialog.ts @@ -1,74 +1,45 @@ -import {css, type PropertyValues} from 'lit'; +import type {PropertyValues, TemplateResult} from 'lit'; +import {html, LitElement, nothing} from 'lit'; import {property} from 'lit/decorators.js'; -import {LionDialog} from '@lion/ui/dialog.js'; -import {t} from '../../utilities/translate'; -import {wireOverlayLifecycleEvents} from '../../utilities/overlay-events.js'; +import hostStyles from '@src/styles/host.styles.js'; +import {t} from '@src/utilities/translate.js'; +import {trapFocus} from '@src/utilities/focus-trap.js'; +import styles from './dialog.styles.js'; import '../icon/icon.js'; -/** - * Styles for the dialog's generated light-DOM content. The content lives in - * the component's light DOM (Lion slots it into a native wrapper), - * so shadow styles can't reach its descendants; adopt a sheet into the root - * node instead. - */ -const contentStyles = - typeof CSSStyleSheet !== 'undefined' ? new CSSStyleSheet() : null; -contentStyles?.replaceSync(` - .craft-dialog { - background-color: var(--c-surface-raised); - border-radius: var(--c-radius-md); - box-shadow: var(--c-shadow-lg); - min-width: min(90vw, 24rem); - max-width: min(90vw, 40rem); - } - - .craft-dialog__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--c-spacing-md); - padding-inline: var(--c-spacing-lg); - padding-block-start: var(--c-spacing-lg); - padding-block-end: var(--c-spacing-md); - } - - .craft-dialog__title { - font-size: 1.25em; - margin: 0; - } - - .craft-dialog__close { - background: none; - border: none; - cursor: pointer; - color: inherit; - padding: var(--c-spacing-xs); - line-height: 1; - } - - .craft-dialog__body { - padding-inline: var(--c-spacing-lg); - padding-block-end: var(--c-spacing-lg); - } - - .craft-dialog__footer { - display: flex; - justify-content: flex-end; - gap: var(--c-spacing-sm); - padding-inline: var(--c-spacing-lg); - padding-block-end: var(--c-spacing-lg); - } -`); +let nextId = 0; /** - * craft-dialog is a modal dialog. Set the `open` attribute to show it; - * default-slot children render as the body, `slot="footer"` children render - * in a footer, and any descendant with `data-dialog="close"` closes the - * dialog when clicked. + * craft-dialog is a modal dialog over a native ``. + * + * Set the `open` attribute (or the `opened` property) to show it; default-slot + * children render as the body, `slot="footer"` children render in a footer, and + * any descendant with `data-dialog="close"` closes the dialog when clicked. + * + * The chrome lives in the shadow root and content is projected through real + * `` elements, so slotted nodes are never moved. That matters for + * framework-rendered content: Vue crashes if it re-patches a subtree a web + * component has relocated, and any child arriving after `connectedCallback` + * would miss a one-shot relocation pass anyway. + * + * Subclasses override {@link renderHeader}, {@link renderBody} and + * {@link renderFooter} rather than `render()`, and resize themselves through the + * `--c-dialog-*` custom properties. + * + * @slot - The dialog body. + * @slot footer - Footer content, typically buttons. + * @csspart dialog - The native `` element. + * @csspart surface - The visible panel inside it. + * @csspart header - The header row. + * @csspart title - The heading. + * @csspart close - The header close button. + * @csspart body - The scrolling body region. + * @csspart footer - The footer row. * - * Note: Lion's overlay system already defines an `open()` *method*, so there - * is no `open` boolean property — use the `open` attribute or the `opened` - * property. + * @fires craft-show - The dialog has opened. + * @fires craft-hide - The dialog has closed. + * @fires craft-after-show - The dialog has opened and finished updating. + * @fires craft-after-hide - The dialog has closed and finished updating. * * @example * const dialog = document.createElement('craft-dialog'); @@ -76,152 +47,267 @@ contentStyles?.replaceSync(` * dialog.append(message); * document.body.appendChild(dialog); */ -export default class CraftDialog extends LionDialog { +export default class CraftDialog extends LitElement { + static override styles = [hostStyles, styles]; + /** - * Backs the `open` attribute (Web Awesome-era API). Named `openAttribute` - * because Lion already uses `open` as a method; consumers should use the - * attribute form or `opened`. + * Whether the dialog is showing. + * + * Backed by the `open` attribute. Both spellings are public — `opened` is the + * property, `open` the attribute — and they stay in sync in both directions. */ - @property({type: Boolean, attribute: 'open', reflect: true}) - openAttribute = false; + @property({type: Boolean, attribute: 'open', reflect: true}) opened = false; /** Title shown in the dialog header. */ @property() label = ''; - #contentWrapper: HTMLElement | null = null; + /** + * Open with `show()` instead of `showModal()`, keeping the dialog out of the + * top layer. + * + * The top layer paints above everything, including menus that append + * themselves to `` — which is most of the legacy jQuery CP. A dialog + * hosting that kind of content has to stay in the normal stacking context. + * The platform stops managing the backdrop, Escape and focus containment in + * this mode, so the component supplies all three. + */ + @property({type: Boolean, attribute: 'non-modal', reflect: true}) + nonModal = false; - #titleElement: HTMLElement | null = null; + /** Fill the viewport. */ + @property({type: Boolean, reflect: true}) fullscreen = false; - constructor() { - super(); - wireOverlayLifecycleEvents(this); - this.addEventListener('opened-changed', () => { - // Lion's JSDoc types `opened` as boxed Boolean; coerce to primitive. - const opened = Boolean(this.opened); - if (this.openAttribute !== opened) { - this.openAttribute = opened; - } - }); + /** Close when the backdrop is clicked. Off by default. */ + @property({type: Boolean, attribute: 'close-on-outside-click'}) + closeOnOutsideClick = false; + + protected readonly titleId = `craft-dialog-title-${++nextId}`; + + /** + * Mirrors `opened` so lifecycle events fire on a real transition rather than + * on the first render, where Lit reports every property as changed. + */ + #lastOpened = false; + + #releaseFocusTrap: (() => void) | null = null; + + /** + * Watches light-DOM children so an empty footer collapses. + * + * Deliberately not `slotchange`: happy-dom assigns slotted nodes but never + * dispatches that event, so anything keyed off it is dead under the unit + * tests. A childList observer behaves the same in both. + */ + #childObserver: MutationObserver | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + + this.addEventListener('click', this.#onHostClick); + this.addEventListener('keydown', this.#onHostKeydown); + + this.#childObserver = new MutationObserver(() => this.requestUpdate()); + this.#childObserver.observe(this, {childList: true}); } - static override get styles() { - return [ - css` - :host { - display: contents; - } - - dialog::backdrop { - background-color: rgb(0 0 0 / 0.25); - } - `, - ]; + override disconnectedCallback(): void { + super.disconnectedCallback(); + + this.removeEventListener('click', this.#onHostClick); + this.removeEventListener('keydown', this.#onHostKeydown); + + this.#childObserver?.disconnect(); + this.#childObserver = null; + + this.#releaseFocusTrap?.(); + this.#releaseFocusTrap = null; } - override connectedCallback() { - this.#adoptContentStyles(); - this.#ensureContentWrapper(); - super.connectedCallback(); + /** The native element, once rendered. */ + protected get dialogElement(): HTMLDialogElement | null { + return this.shadowRoot?.querySelector('dialog') ?? null; } - #adoptContentStyles() { - const root = this.getRootNode(); - if ( - contentStyles && - (root instanceof Document || root instanceof ShadowRoot) && - !root.adoptedStyleSheets.includes(contentStyles) - ) { - root.adoptedStyleSheets = [...root.adoptedStyleSheets, contentStyles]; - } + protected get hasFooter(): boolean { + return this.querySelector(':scope > [slot="footer"]') !== null; } - /** - * Lion expects dialog content as a light-DOM child with `slot="content"`. - * Build it from the consumer's default-slot and footer-slot children. - */ - #ensureContentWrapper() { - if (this.#contentWrapper?.isConnected) { + override render(): TemplateResult { + return html` + ${this.nonModal && this.opened + ? html`
` + : nothing} + +
+ ${this.renderHeader()} ${this.renderBody()} ${this.renderFooter()} +
+
+ `; + } + + protected renderHeader(): TemplateResult { + return html` +
+

${this.label}

+ +
+ `; + } + + protected renderBody(): TemplateResult { + return html`
`; + } + + protected renderFooter(): TemplateResult { + return html` +
+ +
+ `; + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + + if (this.opened === this.#lastOpened) { return; } - const wrapper = document.createElement('div'); - wrapper.slot = 'content'; - wrapper.classList.add('craft-dialog'); - - const body = document.createElement('div'); - body.classList.add('craft-dialog__body'); - body.append( - ...Array.from(this.childNodes).filter( - (node) => !(node instanceof Element) || node.slot === '' - ) - ); - - const footerNodes = Array.from(this.children).filter( - (child) => child.slot === 'footer' - ); - - wrapper.append(this.#buildHeader(), body); - - if (footerNodes.length > 0) { - const footer = document.createElement('footer'); - footer.classList.add('craft-dialog__footer'); - footer.append(...footerNodes); - wrapper.append(footer); + this.#lastOpened = this.opened; + this.opened ? this.#showDialog() : this.#hideDialog(); + this.#emitLifecycle(this.opened); + } + + #showDialog(): void { + const dialog = this.dialogElement; + + if (dialog && !dialog.open) { + // `showModal()` throws if the dialog is already open, hence the guard. + this.nonModal ? dialog.show() : dialog.showModal(); } - wrapper.addEventListener('click', (event) => { - const target = event.target as HTMLElement; - if (target.closest?.('[data-dialog="close"]')) { - this.opened = false; - } - }); + // `showModal()` contains focus for us; `show()` does not. + if (this.nonModal) { + this.#releaseFocusTrap?.(); + this.#releaseFocusTrap = trapFocus(this); + } + } + + #hideDialog(): void { + const dialog = this.dialogElement; - this.append(wrapper); - this.#contentWrapper = wrapper; + if (dialog?.open) { + dialog.close(); + } + + this.#releaseFocusTrap?.(); + this.#releaseFocusTrap = null; } - #buildHeader(): HTMLElement { - const header = document.createElement('header'); - header.classList.add('craft-dialog__header'); + /** + * `craft-show`/`craft-hide` fire on the transition; the `after` pair fires + * once this update has settled. All four bubble and are composed, so + * listeners outside the host's shadow root receive them. + */ + #emitLifecycle(opened: boolean): void { + this.#dispatch(opened ? 'craft-show' : 'craft-hide'); - const title = document.createElement('h2'); - title.classList.add('craft-dialog__title'); - title.textContent = this.label; - this.#titleElement = title; + void this.updateComplete.then(() => { + if (this.opened !== opened) { + return; + } - const close = document.createElement('button'); - close.type = 'button'; - close.classList.add('craft-dialog__close'); - close.setAttribute('aria-label', t('Close')); - close.setAttribute('data-dialog', 'close'); + this.#dispatch(opened ? 'craft-after-show' : 'craft-after-hide'); + }); + } - const icon = document.createElement('craft-icon'); - icon.setAttribute('name', 'xmark'); - close.append(icon); + #dispatch(name: string): void { + this.dispatchEvent(new CustomEvent(name, {bubbles: true, composed: true})); + } - header.append(title, close); - return header; + /** + * A request to dismiss — Escape, the close button, a `data-dialog="close"` + * click, or the backdrop. + * + * Every dismissal path funnels through here so a subclass can route the + * intent somewhere else (a controller that may refuse it, say) instead of + * intercepting four listeners and the platform's own Escape handling. + */ + protected requestClose(): void { + this.opened = false; } - protected override updated(changed: PropertyValues) { - super.updated(changed); + /** + * The platform's Escape handling for a modal dialog. Prevented so the close + * goes through {@link requestClose} like every other dismissal; the base + * implementation then closes it anyway. + */ + #onNativeCancel = (event: Event): void => { + event.preventDefault(); + this.requestClose(); + }; - if (changed.has('openAttribute') && this.openAttribute !== this.opened) { - this.opened = this.openAttribute; + /** + * The platform closes a modal dialog on Escape by itself and fires `close`; + * syncing here covers that as well as any direct `dialogElement.close()`. + */ + #onNativeClose = (): void => { + this.opened = false; + }; + + /** Escape is ours to handle only when the platform isn't managing the dialog. */ + #onHostKeydown = (event: KeyboardEvent): void => { + if (this.nonModal && this.opened && event.key === 'Escape') { + event.preventDefault(); + this.requestClose(); } + }; - if (changed.has('label') && this.#titleElement) { - this.#titleElement.textContent = this.label; + /** + * Honors the `data-dialog="close"` convention on slotted content. Shadow-tree + * clicks retarget to the host and can't be matched with `closest()`, so the + * header's own close button is wired directly instead. + */ + #onHostClick = (event: MouseEvent): void => { + const target = event.target as HTMLElement | null; + + if (target?.closest?.('[data-dialog="close"]')) { + this.requestClose(); } - } + }; + + /** A click on the backdrop of a modal dialog targets the dialog itself. */ + #onDialogClick = (event: MouseEvent): void => { + if (this.closeOnOutsideClick && event.target === this.dialogElement) { + this.requestClose(); + } + }; + + #onBackdropClick = (): void => { + if (this.closeOnOutsideClick) { + this.requestClose(); + } + }; } if (!customElements.get('craft-dialog')) { customElements.define('craft-dialog', CraftDialog); } - -declare global { - interface HTMLElementTagNameMap { - 'craft-dialog': CraftDialog; - } -} diff --git a/packages/craftcms-ui/src/components/element-selector-modal/controller-host.ts b/packages/craftcms-ui/src/components/element-selector-modal/controller-host.ts new file mode 100644 index 00000000000..a343addc874 --- /dev/null +++ b/packages/craftcms-ui/src/components/element-selector-modal/controller-host.ts @@ -0,0 +1,54 @@ +import type {ReactiveController, ReactiveControllerHost} from 'lit'; +import type { + ElementSelectorController, + ElementSelectorState, +} from '@src/core/element-selector/index.js'; + +/** + * Bridges an {@link ElementSelectorController}'s `change` event to a Lit host's + * update cycle. + * + * The core is a plain emitter with no knowledge of Lit — that is what lets a Vue + * component bind to the same instance — so something has to translate "state + * changed" into `requestUpdate()`. A `ReactiveController` is the idiomatic place + * for it, and keeping it in the component folder rather than in `src/core/` + * is what keeps the core free of a Lit import. + */ +export class ElementSelectorHostController implements ReactiveController { + #off: (() => void) | null = null; + + constructor( + private readonly host: ReactiveControllerHost, + private readonly source: () => ElementSelectorController | null + ) { + host.addController(this); + } + + hostConnected(): void { + this.#subscribe(); + } + + hostDisconnected(): void { + this.#unsubscribe(); + } + + /** Call when the host's controller reference changes. */ + resubscribe(): void { + this.#unsubscribe(); + this.#subscribe(); + } + + get state(): ElementSelectorState | null { + return this.source()?.state ?? null; + } + + #subscribe(): void { + this.#off = + this.source()?.on('change', () => this.host.requestUpdate()) ?? null; + } + + #unsubscribe(): void { + this.#off?.(); + this.#off = null; + } +} diff --git a/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.stories.ts b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.stories.ts new file mode 100644 index 00000000000..76c04043108 --- /dev/null +++ b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.stories.ts @@ -0,0 +1,227 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {expect, userEvent, waitFor} from 'storybook/test'; +import {html} from 'lit'; + +import './element-selector-modal.js'; +import type CraftElementSelectorModal from './element-selector-modal.js'; +import {ElementSelectorController} from '@src/core/element-selector/index.js'; + +const MODAL_ID = 'storybook-element-selector-modal'; + +interface ModalArgs { + label: string; + showTitle: boolean; + canSubmit: boolean; + busy: boolean; + loading: boolean; + nonModal: boolean; + fullscreen: boolean; + rows: number; +} + +const modal = () => + document.getElementById(MODAL_ID) as CraftElementSelectorModal; + +/** Stands in for the real element index, which is Vue and lives in the app. */ +function stubIndex(rows: number) { + return html` +
+ +
+
+ Toolbar +
+
+ ${Array.from( + {length: rows}, + (_, i) => html`
Element ${i + 1}
` + )} +
+
+
+ `; +} + +const meta = { + title: 'Components/ElementSelectorModal', + component: 'craft-element-selector-modal', + args: { + label: 'Choose an entry', + showTitle: true, + canSubmit: false, + busy: false, + loading: false, + nonModal: false, + fullscreen: false, + rows: 40, + }, + parameters: {layout: 'centered'}, + render(args: ModalArgs) { + return html` + + ${stubIndex(args.rows)} + + + (modal().opened = true)}> + Open selector + + `; + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithSelection: Story = { + args: {canSubmit: true}, +}; + +/** Mid-save: both buttons are out, and the index is inert rather than merely dim. */ +export const Busy: Story = { + args: {canSubmit: true, busy: true}, +}; + +export const Loading: Story = { + args: {loading: true, rows: 0}, +}; + +export const HiddenTitle: Story = { + args: {showTitle: false}, +}; + +export const Fullscreen: Story = { + args: {fullscreen: true}, +}; + +/** + * Out of the top layer, so ``-appended menus — most of the legacy jQuery + * CP — still paint above it. Used by the asset-move folder picker. + */ +export const NonModal: Story = { + args: {nonModal: true}, +}; + +/** + * The real thing: bound to a controller, which owns open state, the labels and + * whether Select is available. + */ +export const BoundToAController: Story = { + render(args: ModalArgs) { + const controller = new ElementSelectorController({ + elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', + modalTitle: args.label, + showTitle: true, + hideOnSelect: false, + loadIndexBody: async () => ({html: '', props: {}}), + // Stands in for the opener's async work, so `busy` is observable. + onSelect: () => new Promise((resolve) => setTimeout(resolve, 1200)), + }); + + const select = () => + controller.setSelection([ + { + id: 1, + siteId: 1, + label: 'Element 1', + status: 'live', + url: null, + hasThumb: false, + }, + ]); + + return html` + + ${stubIndex(args.rows)} + + Simulate a selection + + + + controller.open()}> + Open selector + + `; + }, + async play({canvasElement}) { + // Query by test id, not by tag: the first craft-button in the DOM is the + // one slotted inside the modal, not the opener. + await userEvent.click( + canvasElement.querySelector('[data-testid="open"]') as HTMLElement + ); + + const element = canvasElement.querySelector( + 'craft-element-selector-modal' + ) as CraftElementSelectorModal; + + await waitFor(() => expect(element.opened).toBe(true)); + + // `showModal()` puts it in the top layer; `:modal` is the observable proof, + // and it is the reason the non-modal escape hatch exists for legacy content. + const dialog = element.shadowRoot!.querySelector('dialog')!; + await expect(dialog.matches(':modal')).toBe(true); + + // Select is unavailable until the controller reports a selection. + const selectButton = element.shadowRoot!.querySelector( + '[part="select"]' + ) as HTMLElement; + await expect(selectButton.hasAttribute('disabled')).toBe(true); + + await userEvent.click( + element.querySelector('[data-testid="simulate-selection"]') as HTMLElement + ); + await waitFor(() => + expect(selectButton.hasAttribute('disabled')).toBe(false) + ); + + // Real hit-testing, not just a dispatched click: Lion's LionButton base + // paints an absolutely-positioned :host::before 44px click target, which has + // previously swallowed pointer events aimed at content inside a shadow root. + // elementFromPoint is what catches that; element.click() would not. + const box = selectButton.getBoundingClientRect(); + const hit = element.shadowRoot!.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2 + ); + await expect(selectButton.contains(hit) || selectButton === hit).toBe(true); + + // Submitting holds `busy`, which makes the index inert. + await userEvent.click(selectButton); + await waitFor(() => expect(element.busy).toBe(true)); + await expect( + element.shadowRoot!.querySelector('.body')!.hasAttribute('inert') + ).toBe(true); + + await waitFor(() => expect(element.busy).toBe(false), {timeout: 3000}); + }, +}; diff --git a/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.styles.ts b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.styles.ts new file mode 100644 index 00000000000..d1b5f0287d1 --- /dev/null +++ b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.styles.ts @@ -0,0 +1,98 @@ +import {css} from 'lit'; + +export default css` + :host { + /* + An element index needs room: a near-fullscreen surface rather than the + confirmation-sized default craft-dialog ships with. + */ + --_dialog-inline-size: var(--c-dialog-inline-size, min(96vw, 80rem)); + --_dialog-min-inline-size: var( + --c-dialog-min-inline-size, + min(96vw, 20rem) + ); + --_dialog-max-inline-size: var(--c-dialog-max-inline-size, 96vw); + --_dialog-max-block-size: var(--c-dialog-max-block-size, 90dvh); + + /* + The index has its own internal scrolling regions (a sticky toolbar over a + scrolling result list), so it needs a resolved height to fill rather than + a body that grows to fit it. + */ + --_dialog-block-size: var(--c-dialog-block-size, 90dvh); + } + + .surface { + block-size: var(--_dialog-block-size); + } + + :host([fullscreen]) { + --_dialog-block-size: var(--c-dialog-block-size, 100dvh); + } + + /* The index supplies its own padding, and its sidebar runs edge to edge. */ + .body { + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .loading { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-block-size: 0; + } + + .header { + padding-block-end: 0; + } + + /* + Kept in the accessibility tree when the title is hidden: the is + labelled by it, so removing it would leave the dialog unnamed. + */ + .title--hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; + } + + :host([show-title]) .header { + padding-block-end: var(--c-spacing-md); + } + + .footer { + /* Secondary actions left, primary right — as the legacy footer did. */ + justify-content: space-between; + align-items: center; + gap: var(--c-spacing-md); + padding-block-start: var(--c-spacing-md); + border-block-start: 1px solid var(--c-border-subtle, transparent); + } + + .footer__group { + display: flex; + align-items: center; + gap: var(--c-spacing-sm); + } + + /* + While a selection is being saved, the index and the secondary actions stop + responding. The interaction block itself is the inert attribute set in + renderBody/renderFooter, which reaches slotted content this component does + not own; these rules only supply the matching visual state. + */ + :host([busy]) .body, + :host([busy]) .footer__group--secondary { + opacity: 0.6; + } +`; diff --git a/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.test.ts b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.test.ts new file mode 100644 index 00000000000..9fb0e951be9 --- /dev/null +++ b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.test.ts @@ -0,0 +1,348 @@ +import {beforeEach, describe, expect, it, vi} from 'vite-plus/test'; +import type CraftElementSelectorModal from './element-selector-modal.js'; +import './element-selector-modal.js'; +import type { + ElementSelectorController, + ElementSelectorState, +} from '@src/core/element-selector/index.js'; + +if (typeof HTMLDialogElement !== 'undefined') { + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.show ??= function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }; +} + +async function createModal( + configure: (modal: CraftElementSelectorModal) => void = () => {} +): Promise { + const modal = document.createElement( + 'craft-element-selector-modal' + ) as CraftElementSelectorModal; + const index = document.createElement('div'); + index.className = 'stub-index'; + index.textContent = 'Index'; + modal.append(index); + configure(modal); + document.body.append(modal); + await modal.updateComplete; + return modal; +} + +function shadow( + modal: CraftElementSelectorModal, + selector: string +): E | null { + return modal.shadowRoot!.querySelector(selector); +} + +const button = (modal: CraftElementSelectorModal, part: 'cancel' | 'select') => + shadow(modal, `[part="${part}"]`)!; + +function state( + overrides: Partial = {} +): ElementSelectorState { + return { + open: true, + loading: false, + busy: false, + selection: [], + disabledElementIds: [], + indexBody: {html: '', props: {}}, + error: null, + title: 'Choose an entry', + showTitle: true, + selectLabel: 'Select', + canSubmit: false, + canCancel: true, + ...overrides, + }; +} + +/** + * A hand-rolled stand-in rather than a real controller: this file is about the + * chrome, and the controller has its own suite on the node environment. + */ +function fakeController(initial = state()) { + const listeners = new Set<(s: ElementSelectorState) => void>(); + let current = initial; + + return { + submit: vi.fn(), + cancel: vi.fn(), + get state() { + return current; + }, + on(_event: string, listener: (s: ElementSelectorState) => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + push(next: Partial) { + current = {...current, ...next}; + listeners.forEach((listener) => listener(current)); + }, + }; +} + +const bind = ( + modal: CraftElementSelectorModal, + controller: ReturnType +) => { + modal.controller = controller as unknown as ElementSelectorController; +}; + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('chrome', () => { + it('renders a heading, a body slot and a footer', async () => { + const modal = await createModal((m) => { + m.label = 'Choose'; + m.showTitle = true; + }); + + expect(shadow(modal, '[part="title"]')!.textContent!.trim()).toBe('Choose'); + expect(shadow(modal, '.body slot')).not.toBeNull(); + expect(shadow(modal, '[part="footer"]')).not.toBeNull(); + }); + + it('keeps the heading in the a11y tree when the title is hidden', async () => { + // The dialog is labelled by it, so it must not be removed — only hidden. + const modal = await createModal((m) => { + m.label = 'Choose'; + }); + const title = shadow(modal, '[part="title"]')!; + + expect(modal.showTitle).toBe(false); + expect(title.classList.contains('title--hidden')).toBe(true); + expect(shadow(modal, 'dialog')!.getAttribute('aria-labelledby')).toBe( + title.id + ); + }); + + it('projects the index without relocating it', async () => { + const modal = await createModal(); + const index = modal.querySelector('.stub-index')!; + const slot = shadow(modal, '.body slot')!; + + expect(index.parentElement).toBe(modal); + expect(slot.assignedNodes({flatten: true})).toContain(index); + }); + + it('exposes the footer slots', async () => { + const modal = await createModal(); + + expect(shadow(modal, 'slot[name="secondary-actions"]')).not.toBeNull(); + expect(shadow(modal, 'slot[name="primary-actions"]')).not.toBeNull(); + }); + + it('shows a spinner while loading with no index yet', async () => { + const modal = await createModal((m) => { + m.loading = true; + }); + + expect(shadow(modal, '.loading craft-spinner')).not.toBeNull(); + }); +}); + +describe('unbound (attribute-driven)', () => { + it('disables Select until can-submit is set', async () => { + const modal = await createModal(); + expect(button(modal, 'select').hasAttribute('disabled')).toBe(true); + + modal.canSubmit = true; + await modal.updateComplete; + + expect(button(modal, 'select').hasAttribute('disabled')).toBe(false); + }); + + it('uses the given labels, falling back to defaults', async () => { + const modal = await createModal(); + expect(button(modal, 'select').textContent!.trim()).toBeTruthy(); + + modal.selectLabel = 'Choose it'; + modal.cancelLabel = 'Never mind'; + await modal.updateComplete; + + expect(button(modal, 'select').textContent!.trim()).toBe('Choose it'); + expect(button(modal, 'cancel').textContent!.trim()).toBe('Never mind'); + }); + + it('emits craft-select when Select is clicked', async () => { + const modal = await createModal((m) => { + m.canSubmit = true; + }); + const seen = vi.fn(); + modal.addEventListener('craft-select', seen); + + button(modal, 'select').click(); + + expect(seen).toHaveBeenCalledTimes(1); + }); + + it('ignores a Select click while it cannot submit', async () => { + const modal = await createModal(); + const seen = vi.fn(); + modal.addEventListener('craft-select', seen); + + button(modal, 'select').click(); + + expect(seen).not.toHaveBeenCalled(); + }); + + it('emits craft-cancel and closes when Cancel is clicked', async () => { + const modal = await createModal((m) => m.setAttribute('open', '')); + const seen = vi.fn(); + modal.addEventListener('craft-cancel', seen); + + button(modal, 'cancel').click(); + await modal.updateComplete; + + expect(seen).toHaveBeenCalledTimes(1); + expect(modal.opened).toBe(false); + }); +}); + +describe('busy', () => { + it('disables both buttons and marks the index inert', async () => { + const modal = await createModal((m) => { + m.canSubmit = true; + m.busy = true; + }); + + expect(button(modal, 'cancel').hasAttribute('disabled')).toBe(true); + expect(button(modal, 'select').hasAttribute('loading')).toBe(true); + expect(shadow(modal, '.body')!.hasAttribute('inert')).toBe(true); + expect( + shadow(modal, '.footer__group--secondary')!.hasAttribute('inert') + ).toBe(true); + }); + + it('reflects busy to an attribute for styling', async () => { + const modal = await createModal((m) => { + m.busy = true; + }); + + expect(modal.hasAttribute('busy')).toBe(true); + }); + + it('refuses a Cancel click', async () => { + const modal = await createModal((m) => { + m.setAttribute('open', ''); + m.busy = true; + }); + const seen = vi.fn(); + modal.addEventListener('craft-cancel', seen); + + button(modal, 'cancel').click(); + await modal.updateComplete; + + expect(seen).not.toHaveBeenCalled(); + expect(modal.opened).toBe(true); + }); +}); + +describe('bound to a controller', () => { + it('takes its state from the controller', async () => { + const controller = fakeController(); + const modal = await createModal((m) => bind(m, controller)); + await modal.updateComplete; + + expect(modal.opened).toBe(true); + expect(modal.label).toBe('Choose an entry'); + expect(modal.showTitle).toBe(true); + expect(button(modal, 'select').hasAttribute('disabled')).toBe(true); + }); + + it('re-renders when the controller emits change', async () => { + const controller = fakeController(); + const modal = await createModal((m) => bind(m, controller)); + await modal.updateComplete; + + controller.push({canSubmit: true, selectLabel: 'Add'}); + await modal.updateComplete; + + expect(modal.canSubmit).toBe(true); + expect(button(modal, 'select').textContent!.trim()).toBe('Add'); + }); + + it('lets the controller win over locally-set properties', async () => { + // One direction only: state flows out of the controller, never into it. + const controller = fakeController(); + const modal = await createModal((m) => bind(m, controller)); + + modal.canSubmit = true; + await modal.updateComplete; + + expect(modal.canSubmit).toBe(false); + }); + + it('routes Select to the controller', async () => { + const controller = fakeController(state({canSubmit: true})); + const modal = await createModal((m) => bind(m, controller)); + await modal.updateComplete; + + button(modal, 'select').click(); + + expect(controller.submit).toHaveBeenCalledTimes(1); + }); + + it('routes Cancel to the controller rather than closing itself', async () => { + const controller = fakeController(); + const modal = await createModal((m) => bind(m, controller)); + await modal.updateComplete; + + button(modal, 'cancel').click(); + await modal.updateComplete; + + expect(controller.cancel).toHaveBeenCalledTimes(1); + // The controller still reports open, so the modal stays open — that is what + // lets it refuse a dismissal mid-save. + expect(modal.opened).toBe(true); + }); + + it('closes when the controller says it has closed', async () => { + const controller = fakeController(); + const modal = await createModal((m) => bind(m, controller)); + await modal.updateComplete; + + controller.push({open: false}); + await modal.updateComplete; + + expect(modal.opened).toBe(false); + }); + + it('unsubscribes from the previous controller when rebound', async () => { + const first = fakeController(); + const second = fakeController(state({title: 'Second'})); + const modal = await createModal((m) => bind(m, first)); + await modal.updateComplete; + + bind(modal, second); + await modal.updateComplete; + expect(modal.label).toBe('Second'); + + first.push({title: 'Stale'}); + await modal.updateComplete; + + expect(modal.label).toBe('Second'); + }); + + it('stops listening once disconnected', async () => { + const controller = fakeController(); + const modal = await createModal((m) => bind(m, controller)); + await modal.updateComplete; + + modal.remove(); + controller.push({title: 'After removal'}); + await modal.updateComplete; + + expect(modal.label).toBe('Choose an entry'); + }); +}); diff --git a/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.ts b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.ts new file mode 100644 index 00000000000..a23f7b92b80 --- /dev/null +++ b/packages/craftcms-ui/src/components/element-selector-modal/element-selector-modal.ts @@ -0,0 +1,258 @@ +import type {PropertyValues, TemplateResult} from 'lit'; +import {html, nothing} from 'lit'; +import {property, state} from 'lit/decorators.js'; +import {t} from '@src/utilities/translate.js'; +import type {ElementSelectorController} from '@src/core/element-selector/index.js'; +import CraftDialog from '../dialog/dialog.js'; +import {ElementSelectorHostController} from './controller-host.js'; +import styles from './element-selector-modal.styles.js'; +import '../button/button.js'; +import '../spinner/spinner.js'; + +/** Below this the index hides its sidebar behind a toggle. */ +const NARROW_THRESHOLD = 550; + +/** + * craft-element-selector-modal is the chrome around an element index. + * + * It renders the dialog, the heading and the footer, and nothing else — the + * index itself is slotted in, because the index is Vue in this app and a legacy + * jQuery widget in the asset-move flow, and neither belongs in a Lit component. + * + * Bind it to an {@link ElementSelectorController} through the `controller` + * property and it becomes a view of that controller: state flows out of the + * controller into this component's reflected properties, and user intent flows + * back in through `submit()` and `cancel()`. Nothing passes between this + * component and the slotted index — they only ever talk to the controller, which + * is what stops a web component and a Vue component from drifting apart. + * + * Without a controller it falls back to its own attributes, so it can be driven + * declaratively in Storybook or by a consumer that only wants the chrome. + * + * @slot - The element index. + * @slot secondary-actions - Footer content pinned left. + * @slot primary-actions - Footer buttons placed before Cancel and Select. + * @csspart footer - The footer row. + * @csspart secondary-actions - The left-hand footer group. + * @csspart primary-actions - The right-hand footer group. + * @csspart cancel - The Cancel button. + * @csspart select - The Select button. + * + * @fires craft-select - The user asked to accept the selection. + * @fires craft-cancel - The user asked to dismiss the modal. + * + * @example + * const modal = document.createElement('craft-element-selector-modal'); + * modal.controller = new ElementSelectorController({elementType, onSelect}); + * modal.append(index); + * document.body.append(modal); + * await modal.controller.open(); + */ +export default class CraftElementSelectorModal extends CraftDialog { + static override styles = [...(CraftDialog.styles as []), styles]; + + /** + * The business layer. Set as a property, never constructed here — one + * controller may be shared with a Vue view of the same modal. + * + * The adapter type is deliberately loose: the chrome never touches the index, + * so a subclass that narrows it (the folder picker, which requires a + * `sourcePath`) has to remain assignable here. + */ + @property({attribute: false}) + controller: ElementSelectorController | null = null; + + /** Show the heading. When false it stays in the a11y tree but is hidden. */ + @property({type: Boolean, attribute: 'show-title', reflect: true}) + showTitle = false; + + @property({attribute: 'select-label'}) selectLabel = ''; + + @property({attribute: 'cancel-label'}) cancelLabel = ''; + + /** A submit is in flight. */ + @property({type: Boolean, reflect: true}) busy = false; + + /** The index body is being fetched. */ + @property({type: Boolean, reflect: true}) loading = false; + + @property({type: Boolean, attribute: 'can-submit', reflect: true}) + canSubmit = false; + + /** + * Reflected from the surface's width. The slotted index keys its sidebar + * toggle off it — the component itself does not act on it. + */ + @property({type: Boolean, reflect: true}) narrow = false; + + @state() private hasIndex = false; + + #binding = new ElementSelectorHostController(this, () => this.controller); + + #resizeObserver: ResizeObserver | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + + if (typeof ResizeObserver !== 'undefined') { + this.#resizeObserver = new ResizeObserver(([entry]) => { + if (entry) { + this.narrow = entry.contentRect.width < NARROW_THRESHOLD; + } + }); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#resizeObserver?.disconnect(); + } + + protected override willUpdate(changed: PropertyValues): void { + if (changed.has('controller')) { + this.#binding.resubscribe(); + } + + // One direction only: when bound, the controller is the source of truth for + // every one of these, and the properties are a reflection of it rather than + // an input to it. + const state = this.#binding.state; + + if (state) { + this.opened = state.open; + this.busy = state.busy; + this.loading = state.loading; + this.canSubmit = state.canSubmit; + this.label = state.title; + this.showTitle = state.showTitle; + this.selectLabel = state.selectLabel; + this.hasIndex = state.indexBody !== null; + } + + super.willUpdate(changed); + } + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + + const surface = this.shadowRoot?.querySelector('.surface'); + + if (surface) { + this.#resizeObserver?.observe(surface); + } + } + + /** Heading only — dismissal lives in the footer's Cancel button. */ + protected override renderHeader(): TemplateResult { + return html` +
+

+ ${this.label} +

+
+ `; + } + + protected override renderBody(): TemplateResult { + return html` +
+ ${this.loading && !this.hasIndex + ? html`
` + : nothing} + +
+ `; + } + + protected override renderFooter(): TemplateResult { + return html` +
+ + +
+ `; + } + + /** Accept the current selection. */ + submit(): void { + if (!this.canSubmit) { + return; + } + + this.dispatchEvent( + new CustomEvent('craft-select', {bubbles: true, composed: true}) + ); + + // The event is a notification for onlookers, not the mechanism — routing the + // action through it as well would give one intent two paths. + void this.controller?.submit(); + } + + /** + * Every dismissal — Cancel, Escape, the backdrop — arrives here through + * `CraftDialog`. When bound, the controller decides: it refuses while busy, + * which is what keeps the modal up mid-save. + */ + protected override requestClose(): void { + this.dispatchEvent( + new CustomEvent('craft-cancel', {bubbles: true, composed: true}) + ); + + if (this.controller) { + this.controller.cancel(); + return; + } + + super.requestClose(); + } + + #onSelectClick = (): void => { + this.submit(); + }; + + #onCancelClick = (): void => { + if (this.busy) { + return; + } + + this.requestClose(); + }; +} + +if (!customElements.get('craft-element-selector-modal')) { + customElements.define( + 'craft-element-selector-modal', + CraftElementSelectorModal + ); +} diff --git a/packages/craftcms-ui/src/core/element-selector/asset-selector-controller.test.ts b/packages/craftcms-ui/src/core/element-selector/asset-selector-controller.test.ts new file mode 100644 index 00000000000..d15eb1a8e3c --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/asset-selector-controller.test.ts @@ -0,0 +1,219 @@ +import {beforeEach, describe, expect, it, vi} from 'vite-plus/test'; +import {AssetSelectorController} from './asset-selector-controller.js'; +import type {AssetSelectorOptions} from './asset-selector-controller.js'; +import type {ElementInfo} from './types.js'; + +const ASSET = 'CraftCms\\Cms\\Asset\\Elements\\Asset'; + +const TRANSFORMS = [ + {handle: 'thumb', name: 'Thumbnail'}, + {handle: 'large', name: 'Large'}, +]; + +function asset(id: number): ElementInfo { + return { + id, + siteId: 1, + label: `Asset ${id}`, + status: null, + url: `/uploads/asset-${id}.jpg`, + hasThumb: true, + }; +} + +function create(options: Partial = {}) { + return new AssetSelectorController({ + elementType: ASSET, + transforms: TRANSFORMS, + hideOnSelect: false, + loadIndexBody: async () => ({html: '', props: {}}), + fetchTransformUrl: async (id, handle) => `/transformed/${handle}/${id}.jpg`, + ...options, + }); +} + +beforeEach(() => { + // Static by design (transform URLs are stable per asset and modals are built + // per relation field), so it has to be reset between tests. + AssetSelectorController.transformUrls = {}; +}); + +describe('canApplyTransform', () => { + it('is false with nothing selected', () => { + expect(create().canApplyTransform).toBe(false); + }); + + it('is true once assets are selected', () => { + const controller = create(); + controller.setSelection([asset(1)]); + + expect(controller.canApplyTransform).toBe(true); + }); + + it('is false when no transforms are configured', () => { + const controller = create({transforms: []}); + controller.setSelection([asset(1)]); + + expect(controller.canApplyTransform).toBe(false); + }); +}); + +describe('selectWithTransform', () => { + it('splices the transformed URL into the payload', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.setSelection([asset(1), asset(2)]); + + await controller.selectWithTransform('thumb'); + + expect(onSelect.mock.calls[0]![0].map((e: ElementInfo) => e.url)).toEqual([ + '/transformed/thumb/1.jpg', + '/transformed/thumb/2.jpg', + ]); + }); + + it('reports the transform in the select meta', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.setSelection([asset(1)]); + + await controller.selectWithTransform('large'); + + expect(onSelect.mock.calls[0]![1]).toEqual({transform: 'large'}); + }); + + it('leaves the transform unset for a plain submit', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.setSelection([asset(1)]); + + await controller.submit(); + + expect(onSelect.mock.calls[0]![1]).toEqual({transform: null}); + expect(onSelect.mock.calls[0]![0][0].url).toBe('/uploads/asset-1.jpg'); + }); + + it('resets the transform after submitting', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.setSelection([asset(1)]); + + await controller.selectWithTransform('thumb'); + await controller.submit(); + + expect(onSelect.mock.calls[1]![1]).toEqual({transform: null}); + }); + + it('holds busy across the fetches', async () => { + let release: (url: string) => void = () => {}; + const controller = create({ + fetchTransformUrl: () => + new Promise((resolve) => { + release = resolve; + }), + }); + controller.setSelection([asset(1)]); + + const pending = controller.selectWithTransform('thumb'); + expect(controller.state.busy).toBe(true); + + release('/transformed/thumb/1.jpg'); + await pending; + + expect(controller.state.busy).toBe(false); + }); +}); + +describe('the URL cache', () => { + it('fetches each asset once, sequentially', async () => { + const order: number[] = []; + const fetchTransformUrl = vi.fn(async (id: number, handle: string) => { + order.push(id); + return `/transformed/${handle}/${id}.jpg`; + }); + const controller = create({fetchTransformUrl}); + controller.setSelection([asset(1), asset(2)]); + + await controller.selectWithTransform('thumb'); + await controller.selectWithTransform('thumb'); + + expect(fetchTransformUrl).toHaveBeenCalledTimes(2); + expect(order).toEqual([1, 2]); + }); + + it('only fetches the assets it is missing', async () => { + const fetchTransformUrl = vi.fn( + async (id: number, handle: string) => `/transformed/${handle}/${id}.jpg` + ); + const controller = create({fetchTransformUrl}); + + controller.setSelection([asset(1)]); + await controller.selectWithTransform('thumb'); + fetchTransformUrl.mockClear(); + + controller.setSelection([asset(1), asset(2)]); + await controller.selectWithTransform('thumb'); + + expect(fetchTransformUrl.mock.calls.map(([id]) => id)).toEqual([2]); + }); + + it('keys the cache per transform', async () => { + const fetchTransformUrl = vi.fn( + async (id: number, handle: string) => `/transformed/${handle}/${id}.jpg` + ); + const controller = create({fetchTransformUrl}); + controller.setSelection([asset(1)]); + + await controller.selectWithTransform('thumb'); + await controller.selectWithTransform('large'); + + expect(fetchTransformUrl).toHaveBeenCalledTimes(2); + expect(Object.keys(AssetSelectorController.transformUrls)).toEqual([ + 'thumb', + 'large', + ]); + }); + + it('shares the cache across instances', async () => { + const fetchTransformUrl = vi.fn( + async (id: number, handle: string) => `/transformed/${handle}/${id}.jpg` + ); + + const first = create({fetchTransformUrl}); + first.setSelection([asset(1)]); + await first.selectWithTransform('thumb'); + + const second = create({fetchTransformUrl}); + second.setSelection([asset(1)]); + await second.selectWithTransform('thumb'); + + expect(fetchTransformUrl).toHaveBeenCalledTimes(1); + }); + + it('falls back to the untransformed URL when a transform fails', async () => { + const onSelect = vi.fn(); + const controller = create({ + onSelect, + fetchTransformUrl: async () => { + throw new Error('generate-transform blew up'); + }, + }); + controller.setSelection([asset(1)]); + + await controller.selectWithTransform('thumb'); + + expect(controller.state.busy).toBe(false); + expect(onSelect.mock.calls[0]![0][0].url).toBe('/uploads/asset-1.jpg'); + }); + + it('caches a false result so a failed transform is not retried', async () => { + const fetchTransformUrl = vi.fn(async () => false as const); + const controller = create({fetchTransformUrl}); + controller.setSelection([asset(1)]); + + await controller.selectWithTransform('thumb'); + await controller.selectWithTransform('thumb'); + + expect(fetchTransformUrl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/craftcms-ui/src/core/element-selector/asset-selector-controller.ts b/packages/craftcms-ui/src/core/element-selector/asset-selector-controller.ts new file mode 100644 index 00000000000..778e8e7fe81 --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/asset-selector-controller.ts @@ -0,0 +1,153 @@ +import {actionClient} from '@src/utilities/api/actionClient.js'; +import {ElementSelectorController} from './element-selector-controller.js'; +import type { + ElementIndexAdapter, + ElementInfo, + ElementSelectorOptions, + SelectMeta, +} from './types.js'; + +export interface AssetTransform { + handle: string; + name: string; +} + +/** Resolves an asset's URL under a transform, or `false` if it has none. */ +export type FetchTransformUrl = ( + assetId: number, + handle: string +) => Promise; + +export interface AssetSelectorOptions extends ElementSelectorOptions { + /** Transforms offered before selecting. Empty means no transform menu. */ + transforms?: AssetTransform[]; + /** Test seam; defaults to a POST to `assets/generate-transform`. */ + fetchTransformUrl?: FetchTransformUrl; +} + +const defaultFetchTransformUrl: FetchTransformUrl = async (assetId, handle) => { + const {data} = await actionClient.post('assets/generate-transform', { + assetId, + handle, + }); + + return (data?.url as string | undefined) || false; +}; + +/** + * Adds image transforms to the element selector. + * + * The user may pick a transform instead of plain Select; the chosen transform's + * URL is resolved for every selected asset and spliced into the payload. + * + * @example + * const controller = new AssetSelectorController({ + * elementType: 'CraftCms\\Cms\\Asset\\Elements\\Asset', + * transforms: [{handle: 'thumb', name: 'Thumbnail'}], + * onSelect: (elements, {transform}) => …, + * }); + * + * await controller.selectWithTransform('thumb'); + */ +export class AssetSelectorController< + A extends ElementIndexAdapter = ElementIndexAdapter, +> extends ElementSelectorController { + /** + * Cross-instance cache of resolved URLs, keyed `[handle][assetId]`. + * + * Static, as it was in the legacy modal: transform URLs are stable for an + * asset, and modals are constructed per relation field, so sharing the cache + * saves a request per field per asset. + */ + static transformUrls: Record> = {}; + + #selectedTransform: string | null = null; + + declare readonly options: ElementSelectorController['options'] & + AssetSelectorOptions; + + constructor(options: AssetSelectorOptions) { + super(options); + } + + get transforms(): readonly AssetTransform[] { + return this.options.transforms ?? []; + } + + /** Whether the transform menu should be available right now. */ + get canApplyTransform(): boolean { + return this.selection.length > 0 && this.transforms.length > 0; + } + + /** + * Resolve the transform's URL for every selected asset, then submit. + * + * Holds `busy` across the fetches, so the chrome shows a spinner and can't be + * clicked again mid-flight. + */ + async selectWithTransform(handle: string): Promise { + const cache = (AssetSelectorController.transformUrls[handle] ??= {}); + const missing = this.selection + .map((element) => Number(element.id)) + .filter((id) => cache[id] === undefined); + + if (missing.length > 0) { + const fetchUrl = + this.options.fetchTransformUrl ?? defaultFetchTransformUrl; + + this.setBusy(true); + + try { + // Sequential, matching the legacy modal: transform generation is + // expensive server-side and firing a burst of them was deliberate to + // avoid. + for (const id of missing) { + try { + cache[id] = await fetchUrl(id, handle); + } catch { + // A failed transform falls back to the untransformed URL rather + // than blocking the selection. + cache[id] = false; + } + } + } finally { + this.setBusy(false); + } + } + + this.#selectedTransform = handle; + + try { + await this.submit(); + } finally { + this.#selectedTransform = null; + } + } + + protected override buildElementInfo( + selection: readonly ElementInfo[] + ): ElementInfo[] { + const info = super.buildElementInfo(selection); + + if (!this.#selectedTransform) { + return info; + } + + const cache = + AssetSelectorController.transformUrls[this.#selectedTransform] ?? {}; + + for (const item of info) { + const url = cache[Number(item.id)]; + + if (url !== undefined && url !== false) { + item.url = url; + } + } + + return info; + } + + protected override selectMeta(): SelectMeta { + return {transform: this.#selectedTransform}; + } +} diff --git a/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts new file mode 100644 index 00000000000..15c6ba04f2f --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.test.ts @@ -0,0 +1,626 @@ +import {describe, expect, it, vi} from 'vite-plus/test'; +import {ElementSelectorController} from './element-selector-controller.js'; +import type { + ElementIndexAdapter, + ElementIndexBody, + ElementInfo, + ElementSelectorOptions, +} from './types.js'; + +const ENTRY = 'CraftCms\\Cms\\Entry\\Elements\\Entry'; + +function element(id: number, extra: Partial = {}): ElementInfo { + return { + id, + siteId: 1, + label: `Element ${id}`, + status: 'live', + url: `/entry-${id}`, + hasThumb: false, + ...extra, + }; +} + +function body(props: Record = {}): ElementIndexBody { + return {html: '
', props}; +} + +function create(options: Partial = {}) { + return new ElementSelectorController({ + elementType: ENTRY, + loadIndexBody: async () => body(), + ...options, + }); +} + +function stubIndex() { + // `satisfies` rather than an annotation: it checks the shape against the + // adapter contract while keeping the Mock types the assertions need. + return { + clearSelection: vi.fn(), + destroy: vi.fn(), + } satisfies ElementIndexAdapter; +} + +describe('options', () => { + it('falls back to defaults', () => { + const controller = create(); + + expect(controller.options.bodyAction).toBe('element-selector-modals/body'); + expect(controller.options.hideOnSelect).toBe(true); + expect(controller.options.multiSelect).toBe(false); + }); + + it('does not let explicitly-undefined keys shadow defaults', () => { + // Callers build settings by spreading partials, which routinely produces + // `{hideOnSelect: undefined}`. + const controller = create({ + hideOnSelect: undefined, + multiSelect: undefined, + }); + + expect(controller.options.hideOnSelect).toBe(true); + expect(controller.options.multiSelect).toBe(false); + }); + + it('resolves title and select label lazily, not at module load', () => { + const controller = create(); + + expect(controller.options.modalTitle).toBeTruthy(); + expect(controller.options.selectBtnLabel).toBeTruthy(); + expect(create({modalTitle: 'Choose'}).options.modalTitle).toBe('Choose'); + }); +}); + +describe('indexParams', () => { + it('identifies the index', () => { + const controller = create({sources: ['section:a'], condition: {x: 1}}); + + expect(controller.indexParams()).toEqual({ + context: 'modal', + elementType: ENTRY, + sources: ['section:a'], + condition: {x: 1}, + }); + }); + + it.each([ + ['null', null, false], + ['auto', 'auto' as const, false], + ])('omits showSiteMenu when %s', (_label, showSiteMenu, present) => { + expect('showSiteMenu' in create({showSiteMenu}).indexParams()).toBe( + present + ); + }); + + it.each([ + [true, '1'], + [false, '0'], + ])('serializes showSiteMenu %s as %s', (showSiteMenu, expected) => { + expect(create({showSiteMenu}).indexParams().showSiteMenu).toBe(expected); + }); + + it('includes siteIds only when set', () => { + expect('siteIds' in create().indexParams()).toBe(false); + expect(create({siteIds: [1, 2]}).indexParams().siteIds).toEqual([1, 2]); + }); +}); + +describe('selection and canSubmit', () => { + it('cannot submit with nothing selected', () => { + expect(create().state.canSubmit).toBe(false); + }); + + it('can submit once something is selected', () => { + const controller = create(); + controller.setSelection([element(1)]); + + expect(controller.state.canSubmit).toBe(true); + expect(controller.hasSelection).toBe(true); + }); + + it('cannot submit while loading', async () => { + let release: () => void = () => {}; + const controller = create({ + loadIndexBody: () => + new Promise((resolve) => { + release = () => resolve(body()); + }), + }); + + const pending = controller.reload(); + controller.setSelection([element(1)]); + expect(controller.state.loading).toBe(true); + expect(controller.state.canSubmit).toBe(false); + + release(); + await pending; + expect(controller.state.canSubmit).toBe(true); + }); + + it('emits change when the selection changes', () => { + const controller = create(); + const seen: number[] = []; + controller.on('change', (state) => seen.push(state.selection.length)); + + controller.setSelection([element(1)]); + controller.setSelection([]); + + expect(seen).toEqual([1, 0]); + }); + + it('hands out a frozen snapshot rather than live internals', () => { + const controller = create(); + controller.setSelection([element(1)]); + + const {state} = controller; + expect(Object.isFrozen(state)).toBe(true); + controller.setSelection([element(1), element(2)]); + expect(state.selection).toHaveLength(1); + expect(controller.state.selection).toHaveLength(2); + }); +}); + +describe('busy', () => { + it('is held for the duration of an async onSelect', async () => { + let release: () => void = () => {}; + const controller = create({ + hideOnSelect: false, + onSelect: () => + new Promise((resolve) => { + release = resolve; + }), + }); + controller.setSelection([element(1)]); + + const pending = controller.submit(); + expect(controller.state.busy).toBe(true); + expect(controller.state.canSubmit).toBe(false); + expect(controller.state.canCancel).toBe(false); + + release(); + await pending; + + expect(controller.state.busy).toBe(false); + expect(controller.state.canCancel).toBe(true); + }); + + it('is released when onSelect throws', async () => { + // The old modal disabled four things by hand and re-enabled them only on + // the success path, so a throw left the chrome stuck for good. + const controller = create({ + onSelect: () => Promise.reject(new Error('render-elements failed')), + }); + const errors: Error[] = []; + controller.on('error', (error) => errors.push(error)); + controller.setSelection([element(1)]); + + await controller.submit(); + + expect(controller.state.busy).toBe(false); + expect(controller.state.canCancel).toBe(true); + expect(errors.map((e) => e.message)).toEqual(['render-elements failed']); + expect(controller.state.error?.message).toBe('render-elements failed'); + }); + + it('ignores a second submit while one is in flight', async () => { + let release: () => void = () => {}; + const onSelect = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + const controller = create({hideOnSelect: false, onSelect}); + controller.setSelection([element(1)]); + + const first = controller.submit(); + await controller.submit(); + release(); + await first; + + expect(onSelect).toHaveBeenCalledTimes(1); + }); +}); + +describe('submit', () => { + it('does nothing with an empty selection', async () => { + const onSelect = vi.fn(); + await create({onSelect}).submit(); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('hands the selection to onSelect and emits select', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + const selected: ElementInfo[][] = []; + controller.on('select', ({elements}) => selected.push(elements)); + + controller.setSelection([element(1), element(2)]); + await controller.submit(); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0]![0].map((e: ElementInfo) => e.id)).toEqual([ + 1, 2, + ]); + expect(selected[0]!.map((e) => e.id)).toEqual([1, 2]); + }); + + it('preserves extra row data such as kind and alt', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.setSelection([element(1, {kind: 'image', alt: 'A cat'})]); + + await controller.submit(); + + expect(onSelect.mock.calls[0]![0][0]).toMatchObject({ + kind: 'image', + alt: 'A cat', + }); + }); + + it('copies elements rather than handing out the internal objects', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + const original = element(1); + controller.setSelection([original]); + + await controller.submit(); + + expect(onSelect.mock.calls[0]![0][0]).not.toBe(original); + }); + + it('closes when hideOnSelect is set', async () => { + const controller = create({hideOnSelect: true}); + await controller.open(); + controller.setSelection([element(1)]); + + await controller.submit(); + + expect(controller.state.open).toBe(false); + }); + + it('stays open when hideOnSelect is off', async () => { + const controller = create({hideOnSelect: false}); + await controller.open(); + controller.setSelection([element(1)]); + + await controller.submit(); + + expect(controller.state.open).toBe(true); + }); + + describe('disableElementsOnSelect', () => { + it('adds the selection to the disabled set and clears the index', async () => { + const controller = create({ + disableElementsOnSelect: true, + hideOnSelect: false, + }); + const index = stubIndex(); + controller.attachIndex(index); + controller.setSelection([element(1), element(2)]); + + await controller.submit(); + + expect(controller.state.disabledElementIds).toEqual([1, 2]); + expect(index.clearSelection).toHaveBeenCalled(); + }); + + it('accumulates across submissions without duplicating', async () => { + const controller = create({ + disableElementsOnSelect: true, + hideOnSelect: false, + disabledElementIds: [9], + }); + controller.setSelection([element(1)]); + await controller.submit(); + controller.setSelection([element(1), element(2)]); + await controller.submit(); + + expect(controller.state.disabledElementIds).toEqual([9, 1, 2]); + }); + + it('leaves the disabled set alone when off', async () => { + const controller = create({hideOnSelect: false}); + controller.setSelection([element(1)]); + + await controller.submit(); + + expect(controller.state.disabledElementIds).toEqual([]); + }); + }); +}); + +describe('cancel', () => { + it('fires onCancel and closes', async () => { + // The legacy modal declared `onCancel` and never called it. + const onCancel = vi.fn(); + const controller = create({onCancel}); + await controller.open(); + + controller.cancel(); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(controller.state.open).toBe(false); + }); + + it('emits cancel', async () => { + const controller = create(); + const seen = vi.fn(); + controller.on('cancel', seen); + await controller.open(); + + controller.cancel(); + + expect(seen).toHaveBeenCalledTimes(1); + }); + + it('is refused while busy', async () => { + const onCancel = vi.fn(); + const controller = create({ + onCancel, + hideOnSelect: false, + onSelect: () => new Promise(() => {}), + }); + await controller.open(); + controller.setSelection([element(1)]); + void controller.submit(); + + controller.cancel(); + + expect(onCancel).not.toHaveBeenCalled(); + expect(controller.state.open).toBe(true); + }); +}); + +describe('setDisabledElementIds', () => { + it('emits change, so a bound index re-reads it', () => { + // The old modal assigned to a settings array the index had already copied + // by value, so the index never saw the new set. + const controller = create(); + const seen: readonly number[][] = []; + controller.on('change', (state) => + (seen as number[][]).push([...state.disabledElementIds]) + ); + + controller.setDisabledElementIds([3, 4]); + + expect(seen).toEqual([[3, 4]]); + expect(controller.state.disabledElementIds).toEqual([3, 4]); + }); + + it('de-duplicates and coerces to numbers', () => { + const controller = create(); + controller.setDisabledElementIds([1, 1, 2, '3' as unknown as number]); + + expect(controller.state.disabledElementIds).toEqual([1, 2, 3]); + }); + + it('seeds from options', () => { + expect(create({disabledElementIds: [5]}).state.disabledElementIds).toEqual([ + 5, + ]); + }); + + it('does not mutate the caller’s array', async () => { + const ids = [1]; + const controller = create({ + disabledElementIds: ids, + disableElementsOnSelect: true, + hideOnSelect: false, + }); + controller.setSelection([element(2)]); + + await controller.submit(); + + expect(ids).toEqual([1]); + }); +}); + +describe('open and close', () => { + it('loads the index body once', async () => { + const loadIndexBody = vi.fn(async () => body({total: 3})); + const controller = create({loadIndexBody}); + + await controller.open(); + controller.close(); + await controller.open(); + + expect(loadIndexBody).toHaveBeenCalledTimes(1); + expect(controller.state.indexBody?.props).toEqual({total: 3}); + }); + + it('passes the action and params to the loader', async () => { + const loadIndexBody = vi.fn(async () => body()); + const controller = create({ + loadIndexBody, + bodyAction: 'custom/body', + sources: ['section:a'], + }); + + await controller.open(); + + expect(loadIndexBody).toHaveBeenCalledWith('custom/body', { + context: 'modal', + elementType: ENTRY, + sources: ['section:a'], + condition: undefined, + }); + }); + + it('clears the selection on reopen but keeps disabled ids', async () => { + // The relation field caches one instance and reopens it. A surviving + // selection would leave Select enabled before the user picked anything. + const controller = create({disabledElementIds: [7]}); + const index = stubIndex(); + controller.attachIndex(index); + await controller.open(); + controller.setSelection([element(1)]); + controller.close(); + + await controller.open(); + + expect(controller.state.selection).toEqual([]); + expect(controller.state.canSubmit).toBe(false); + expect(index.clearSelection).toHaveBeenCalled(); + expect(controller.state.disabledElementIds).toEqual([7]); + }); + + it('keeps the selection through close, so a submit handler can still read it', async () => { + const controller = create(); + await controller.open(); + controller.setSelection([element(1)]); + + controller.close(); + + expect(controller.state.selection).toHaveLength(1); + }); + + it('emits open and close once each', async () => { + const controller = create(); + const events: string[] = []; + controller.on('open', () => events.push('open')); + controller.on('close', () => events.push('close')); + + await controller.open(); + await controller.open(); + controller.close(); + controller.close(); + + expect(events).toEqual(['open', 'close']); + }); + + it('fires onClose when closing', async () => { + const onClose = vi.fn(); + const controller = create({onClose}); + await controller.open(); + + controller.close(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('records a load failure without throwing', async () => { + const controller = create({ + loadIndexBody: async () => { + throw new Error('nope'); + }, + }); + + await controller.open(); + + expect(controller.state.error?.message).toBe('nope'); + expect(controller.state.loading).toBe(false); + expect(controller.state.indexBody).toBeNull(); + }); +}); + +describe('destroy', () => { + it('leaves an idle state behind when destroyed mid-submit', async () => { + // The relation field destroys the modal from inside its own `onSelect`, + // which runs while `submit()` still holds `busy`. + let release: () => void = () => {}; + const controller: ElementSelectorController = create({ + hideOnSelect: false, + onSelect: () => + new Promise((resolve) => { + release = () => { + controller.destroy(); + resolve(); + }; + }), + }); + const states: boolean[] = []; + controller.on('change', (state) => states.push(state.busy)); + controller.setSelection([element(1)]); + + const pending = controller.submit(); + expect(controller.state.busy).toBe(true); + release(); + await pending; + + expect(controller.state.busy).toBe(false); + expect(states[states.length - 1]).toBe(false); + }); + + it('tears down the adapter and stops emitting', async () => { + const controller = create(); + const index = stubIndex(); + const seen = vi.fn(); + controller.attachIndex(index); + controller.on('change', seen); + await controller.open(); + seen.mockClear(); + + controller.destroy(); + const changesDuringDestroy = seen.mock.calls.length; + controller.setSelection([element(1)]); + + expect(index.destroy).toHaveBeenCalled(); + expect(controller.index).toBeNull(); + expect(controller.state.open).toBe(false); + expect(seen.mock.calls.length).toBe(changesDuringDestroy); + }); +}); + +describe('listeners', () => { + it('unsubscribes through the returned function', () => { + const controller = create(); + const seen = vi.fn(); + const off = controller.on('change', seen); + + controller.setSelection([element(1)]); + off(); + controller.setSelection([]); + + expect(seen).toHaveBeenCalledTimes(1); + }); + + it('survives a listener unsubscribing itself mid-emit', () => { + const controller = create(); + const order: string[] = []; + const off = controller.on('change', () => { + order.push('first'); + off(); + }); + controller.on('change', () => order.push('second')); + + controller.setSelection([element(1)]); + + expect(order).toEqual(['first', 'second']); + }); +}); + +describe('indexSettings', () => { + it('carries the query and selection configuration', () => { + const controller = create({ + storageKey: 'field-1', + criteria: {status: 'live'}, + multiSelect: true, + disabledElementIds: [4], + }); + + expect(controller.indexSettings()).toMatchObject({ + context: 'modal', + storageKey: 'field-1', + criteria: {status: 'live'}, + multiSelect: true, + selectable: true, + disabledElementIds: [4], + }); + }); + + it('lets indexSettings overrides win', () => { + const controller = create({indexSettings: {multiSelect: 'overridden'}}); + + expect(controller.indexSettings().multiSelect).toBe('overridden'); + }); + + it('reflects the current disabled set, not the initial one', () => { + const controller = create({disabledElementIds: [1]}); + controller.setDisabledElementIds([2, 3]); + + expect(controller.indexSettings().disabledElementIds).toEqual([2, 3]); + }); +}); diff --git a/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts new file mode 100644 index 00000000000..4cd0601da43 --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/element-selector-controller.ts @@ -0,0 +1,433 @@ +import {actionClient} from '@src/utilities/api/actionClient.js'; +import {t} from '@src/utilities/translate.js'; +import type { + ElementIndexAdapter, + ElementIndexBody, + ElementInfo, + ElementSelectorEvent, + ElementSelectorEventMap, + ElementSelectorListener, + ElementSelectorOptions, + ElementSelectorState, + LoadIndexBody, + ResolvedElementSelectorOptions, + SelectMeta, +} from './types.js'; + +const defaultLoadIndexBody: LoadIndexBody = async (action, params) => { + // A bare action path is fine: `actionClient`'s request interceptor expands it + // against the runtime CP trigger, so there is no `Craft.getActionUrl` here. + const {data} = await actionClient.post(action, params); + + return data as ElementIndexBody; +}; + +/** + * The element selector's business logic, with no rendering concerns. + * + * Owns selection state, the rules about what may be selected, the index request + * parameters, and the shape of the payload handed back to the opener. A + * presentation layer binds to it by subscribing to `change` and reading + * {@link state}; user intent goes back in through {@link submit}, {@link cancel} + * and {@link setSelection}. Nothing flows the other way, which is what keeps a + * web component and a Vue component from drifting apart. + * + * @example + * const controller = new ElementSelectorController({ + * elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', + * onSelect: (elements) => console.log(elements), + * }); + * + * controller.on('change', (state) => render(state)); + * await controller.open(); + */ +export class ElementSelectorController< + A extends ElementIndexAdapter = ElementIndexAdapter, +> { + static readonly defaults = { + multiSelect: false, + disabledElementIds: [] as number[], + disableElementsOnSelect: false, + hideOnSelect: true, + showTitle: false, + fullscreen: false, + bodyAction: 'element-selector-modals/body', + indexSettings: {} as Record, + preferStoredSource: false, + showSourcePath: true, + hideSidebar: false, + }; + + readonly options: ResolvedElementSelectorOptions; + + #index: A | null = null; + #selection: ElementInfo[] = []; + #disabledElementIds: number[] = []; + #indexBody: ElementIndexBody | null = null; + #open = false; + #loading = false; + #busy = false; + #error: Error | null = null; + #state: ElementSelectorState; + + #listeners = new Map void>>(); + + constructor(options: ElementSelectorOptions) { + this.options = { + ...ElementSelectorController.defaults, + // Resolved here rather than at module scope, where the translation + // catalogue may not have loaded yet. + modalTitle: t('Select element'), + selectBtnLabel: t('Select'), + ...stripUndefined(options), + } as ResolvedElementSelectorOptions; + + this.#disabledElementIds = [...(this.options.disabledElementIds ?? [])]; + this.#state = this.#buildState(); + } + + get elementType(): string { + return this.options.elementType; + } + + get state(): ElementSelectorState { + return this.#state; + } + + get index(): A | null { + return this.#index; + } + + get hasSelection(): boolean { + return this.#selection.length > 0; + } + + // ───────────────────────────── observation ───────────────────────────── + + /** Subscribe to an event. Returns an unsubscribe function. */ + on( + event: E, + listener: ElementSelectorListener + ): () => void { + let set = this.#listeners.get(event); + + if (!set) { + set = new Set(); + this.#listeners.set(event, set); + } + + set.add(listener as (data: never) => void); + + return () => { + this.#listeners.get(event)?.delete(listener as (data: never) => void); + }; + } + + // ───────────────────────────── lifecycle ────────────────────────────── + + /** + * Show the selector, loading the index body the first time. + * + * Reopening keeps the adapter, the loaded body and `disabledElementIds`, but + * clears the selection — openers such as the relation field's element select + * input cache one instance and reopen it, and a stale selection would leave + * the Select button enabled before the user has picked anything. + */ + async open(): Promise { + this.#selection = []; + this.#index?.clearSelection(); + + if (!this.#open) { + this.#open = true; + this.#emit('open', undefined); + } + + this.#update(); + + if (!this.#indexBody) { + await this.reload(); + } + } + + /** Hide the selector. Deliberately preserves selection and disabled ids. */ + close(): void { + if (!this.#open) { + return; + } + + this.#open = false; + this.#emit('close', undefined); + this.options.onClose?.(); + this.#update(); + } + + destroy(): void { + this.#index?.destroy?.(); + this.#index = null; + this.#selection = []; + this.#indexBody = null; + this.#open = false; + // Cleared too, so the last state anyone sees is idle. An opener that + // destroys the modal from inside `onSelect` gets here while `submit()` is + // still holding `busy`, and the release in its `finally` lands after + // `#listeners.clear()` — leaving a subscriber frozen mid-save. + this.#busy = false; + this.#loading = false; + this.#error = null; + this.#update(); + this.#listeners.clear(); + } + + /** (Re)fetch the index body. */ + async reload(): Promise { + const load = this.options.loadIndexBody ?? defaultLoadIndexBody; + + this.#loading = true; + this.#error = null; + this.#update(); + + try { + this.#indexBody = await load(this.options.bodyAction, this.indexParams()); + } catch (error) { + this.#fail(error); + } finally { + this.#loading = false; + this.#update(); + } + } + + // ─────────────────────────────── intent ─────────────────────────────── + + /** + * Hand the current selection to the opener. + * + * Holds `busy` until `onSelect` settles, so a slow or failing handler can't be + * raced by a second click and can't leave the chrome stuck. + */ + async submit(): Promise { + if (!this.#state.canSubmit) { + return; + } + + const elements = this.buildElementInfo(this.#selection); + const meta = this.selectMeta(); + + this.#busy = true; + this.#update(); + + try { + this.#emit('select', {elements, meta}); + await this.options.onSelect?.(elements, meta); + } catch (error) { + this.#fail(error); + return; + } finally { + this.#busy = false; + this.#update(); + } + + if (this.options.disableElementsOnSelect) { + this.setDisabledElementIds([ + ...this.#disabledElementIds, + ...elements.map((element) => Number(element.id)), + ]); + this.#index?.clearSelection(); + } + + if (this.options.hideOnSelect) { + this.close(); + } + } + + cancel(): void { + if (!this.#state.canCancel) { + return; + } + + this.#emit('cancel', undefined); + this.options.onCancel?.(); + this.close(); + } + + // ──────────────────────────── from the index ─────────────────────────── + + attachIndex(adapter: A): void { + this.#index = adapter; + this.#update(); + } + + detachIndex(): void { + this.#index = null; + this.#update(); + } + + setSelection(elements: ElementInfo[]): void { + this.#selection = [...elements]; + this.#update(); + } + + // ─────────────────────────── from the opener ─────────────────────────── + + /** + * Replace the set of elements that may not be selected. + * + * A whole-set assignment rather than add/remove calls, because that is how the + * relation field thinks about it — and because pushing the new set through + * `change` is what keeps the index in sync. The old modal mutated a settings + * array that had already been copied into the index by value, so the index + * never saw the update. + */ + setDisabledElementIds(ids: number[]): void { + this.#disabledElementIds = [...new Set(ids.map(Number))]; + this.#update(); + } + + /** Escape hatch for openers doing async work outside `onSelect`. */ + setBusy(busy: boolean): void { + this.#busy = busy; + this.#update(); + } + + // ─────────────────────────── index bootstrapping ─────────────────────── + + /** Identifies the index to the server. */ + indexParams(): Record { + const {options} = this; + + const params: Record = { + context: 'modal', + elementType: this.elementType, + sources: options.sources, + condition: options.condition, + }; + + // `null` and `'auto'` both mean "server decides", so the key is omitted. + if (options.showSiteMenu != null && options.showSiteMenu !== 'auto') { + params.showSiteMenu = options.showSiteMenu ? '1' : '0'; + } + + if (options.siteIds) { + params.siteIds = options.siteIds; + } + + return params; + } + + /** Configuration for the index itself, as opposed to the request that loads it. */ + indexSettings(): Record { + const {options} = this; + + return { + context: 'modal', + storageKey: options.storageKey, + condition: options.condition, + referenceElementId: options.referenceElementId, + referenceElementOwnerId: options.referenceElementOwnerId, + referenceElementSiteId: options.referenceElementSiteId, + criteria: {...options.criteria}, + disabledElementIds: [...this.#disabledElementIds], + selectable: true, + multiSelect: options.multiSelect, + waitForDoubleClicks: true, + hideSidebar: options.hideSidebar, + defaultSiteId: options.defaultSiteId, + defaultSource: options.defaultSource, + defaultSourcePath: options.defaultSourcePath, + preferStoredSource: options.preferStoredSource, + showSourcePath: options.showSourcePath, + ...options.indexSettings, + }; + } + + // ────────────────────────── subclass hooks ──────────────────────────── + + /** Whether the current selection may be submitted. */ + protected canSubmitSelection(): boolean { + return this.#selection.length > 0; + } + + /** Shapes the payload handed to `onSelect`. */ + protected buildElementInfo(selection: readonly ElementInfo[]): ElementInfo[] { + return selection.map((element) => ({...element})); + } + + /** Extra context accompanying a selection. */ + protected selectMeta(): SelectMeta { + return {}; + } + + /** For subclasses that keep their own derived state in sync. */ + protected notifyChange(): void { + this.#update(); + } + + /** Read access to the raw selection for subclasses. */ + protected get selection(): readonly ElementInfo[] { + return this.#selection; + } + + protected get disabledElementIds(): readonly number[] { + return this.#disabledElementIds; + } + + // ─────────────────────────────── internals ───────────────────────────── + + #buildState(): ElementSelectorState { + const busy = this.#busy; + const loading = this.#loading; + + return Object.freeze({ + open: this.#open, + loading, + busy, + selection: Object.freeze([...this.#selection]), + disabledElementIds: Object.freeze([...this.#disabledElementIds]), + indexBody: this.#indexBody, + error: this.#error, + title: this.options.modalTitle, + showTitle: this.options.showTitle, + selectLabel: this.options.selectBtnLabel, + canSubmit: !busy && !loading && this.canSubmitSelection(), + canCancel: !busy, + }); + } + + #update(): void { + this.#state = this.#buildState(); + this.#emit('change', this.#state); + } + + #fail(error: unknown): void { + this.#error = + error instanceof Error ? error : new Error(String(error ?? 'Unknown')); + this.#emit('error', this.#error); + } + + #emit( + event: E, + data: ElementSelectorEventMap[E] + ): void { + // Copied before iterating: a listener that unsubscribes itself (or another) + // would otherwise mutate the set mid-iteration. + const listeners = this.#listeners.get(event); + + if (listeners) { + [...listeners].forEach((listener) => + (listener as (value: ElementSelectorEventMap[E]) => void)(data) + ); + } + } +} + +/** + * Drops explicitly-`undefined` keys so they don't shadow defaults. + * + * Callers build settings blobs by spreading partial objects, which routinely + * yields `{hideOnSelect: undefined}`; a plain spread would overwrite the default + * with `undefined` rather than leaving it alone. + */ +function stripUndefined(source: T): Partial { + return Object.fromEntries( + Object.entries(source).filter(([, value]) => value !== undefined) + ) as Partial; +} diff --git a/packages/craftcms-ui/src/core/element-selector/index.ts b/packages/craftcms-ui/src/core/element-selector/index.ts new file mode 100644 index 00000000000..cb42792c708 --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/index.ts @@ -0,0 +1,36 @@ +export {ElementSelectorController} from './element-selector-controller.js'; +export { + AssetSelectorController, + type AssetSelectorOptions, + type AssetTransform, + type FetchTransformUrl, +} from './asset-selector-controller.js'; +export { + ASSET_ELEMENT_TYPE, + VolumeFolderSelectorController, + type SourcePathSegment, + type VolumeFolderIndexAdapter, + type VolumeFolderSelectorOptions, +} from './volume-folder-selector-controller.js'; +export { + adoptLegacyRegistrations, + createElementSelectorController, + elementSelectorControllerClass, + hasElementSelectorController, + registerElementSelectorController, + resetElementSelectorControllers, + type ElementSelectorControllerClass, +} from './registry.js'; +export type { + ElementIndexAdapter, + ElementIndexBody, + ElementInfo, + ElementSelectorEvent, + ElementSelectorEventMap, + ElementSelectorListener, + ElementSelectorOptions, + ElementSelectorState, + LoadIndexBody, + ResolvedElementSelectorOptions, + SelectMeta, +} from './types.js'; diff --git a/packages/craftcms-ui/src/core/element-selector/registry.test.ts b/packages/craftcms-ui/src/core/element-selector/registry.test.ts new file mode 100644 index 00000000000..69b2acb9d10 --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/registry.test.ts @@ -0,0 +1,105 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vite-plus/test'; +import {ElementSelectorController} from './element-selector-controller.js'; +import {AssetSelectorController} from './asset-selector-controller.js'; +import { + adoptLegacyRegistrations, + createElementSelectorController, + elementSelectorControllerClass, + hasElementSelectorController, + registerElementSelectorController, + resetElementSelectorControllers, +} from './registry.js'; + +const ENTRY = 'CraftCms\\Cms\\Entry\\Elements\\Entry'; +const ASSET = 'CraftCms\\Cms\\Asset\\Elements\\Asset'; + +class CustomController extends ElementSelectorController {} + +beforeEach(() => resetElementSelectorControllers()); +afterEach(() => { + resetElementSelectorControllers(); + delete (globalThis as any).window; +}); + +describe('registration', () => { + it('falls back to the base controller for an unregistered type', () => { + expect(elementSelectorControllerClass(ENTRY)).toBe( + ElementSelectorController + ); + expect(hasElementSelectorController(ENTRY)).toBe(false); + }); + + it('returns the registered class', () => { + registerElementSelectorController(ASSET, AssetSelectorController); + + expect(elementSelectorControllerClass(ASSET)).toBe(AssetSelectorController); + expect(hasElementSelectorController(ASSET)).toBe(true); + }); + + it('throws on a duplicate registration', () => { + registerElementSelectorController(ASSET, AssetSelectorController); + + expect(() => + registerElementSelectorController(ASSET, CustomController) + ).toThrow(/already been registered/); + }); +}); + +describe('createElementSelectorController', () => { + it('builds the registered class', () => { + registerElementSelectorController(ASSET, AssetSelectorController); + + expect( + createElementSelectorController({elementType: ASSET}) + ).toBeInstanceOf(AssetSelectorController); + }); + + it('builds the base controller otherwise', () => { + const controller = createElementSelectorController({elementType: ENTRY}); + + expect(controller).toBeInstanceOf(ElementSelectorController); + expect(controller.elementType).toBe(ENTRY); + }); + + it('passes the options through', () => { + const controller = createElementSelectorController({ + elementType: ENTRY, + multiSelect: true, + }); + + expect(controller.options.multiSelect).toBe(true); + }); +}); + +describe('adoptLegacyRegistrations', () => { + it('is a no-op without a window, so core stays node-testable', () => { + expect(() => adoptLegacyRegistrations()).not.toThrow(); + }); + + it('tolerates a missing legacy registry', () => { + (globalThis as any).window = {Craft: {}}; + + expect(() => adoptLegacyRegistrations()).not.toThrow(); + }); + + it('drains classes a plugin registered before this module loaded', () => { + (globalThis as any).window = { + Craft: {_elementSelectorModalClasses: {[ENTRY]: CustomController}}, + }; + + adoptLegacyRegistrations(); + + expect(elementSelectorControllerClass(ENTRY)).toBe(CustomController); + }); + + it('leaves an existing modern registration alone', () => { + registerElementSelectorController(ASSET, AssetSelectorController); + (globalThis as any).window = { + Craft: {_elementSelectorModalClasses: {[ASSET]: CustomController}}, + }; + + adoptLegacyRegistrations(); + + expect(elementSelectorControllerClass(ASSET)).toBe(AssetSelectorController); + }); +}); diff --git a/packages/craftcms-ui/src/core/element-selector/registry.ts b/packages/craftcms-ui/src/core/element-selector/registry.ts new file mode 100644 index 00000000000..1dae0b30ff6 --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/registry.ts @@ -0,0 +1,80 @@ +import {ElementSelectorController} from './element-selector-controller.js'; +import type {ElementSelectorOptions} from './types.js'; + +export type ElementSelectorControllerClass = new ( + options: ElementSelectorOptions +) => ElementSelectorController; + +const controllers = new Map(); + +/** + * Registers the controller to use for an element type. + * + * Throws on a second registration for the same type, as the legacy registry did + * — two plugins claiming one element type is a conflict worth surfacing, not one + * to resolve by last-write-wins. + */ +export function registerElementSelectorController( + elementType: string, + controllerClass: ElementSelectorControllerClass +): void { + if (controllers.has(elementType)) { + throw new Error( + `An element selector controller has already been registered for the element type “${elementType}”.` + ); + } + + controllers.set(elementType, controllerClass); +} + +export function hasElementSelectorController(elementType: string): boolean { + return controllers.has(elementType); +} + +/** The controller registered for an element type, or the base controller. */ +export function elementSelectorControllerClass( + elementType: string +): ElementSelectorControllerClass { + return controllers.get(elementType) ?? ElementSelectorController; +} + +export function createElementSelectorController( + options: ElementSelectorOptions +): ElementSelectorController { + return new (elementSelectorControllerClass(options.elementType))(options); +} + +/** + * Takes over anything sitting on the legacy `Craft._elementSelectorModalClasses`. + * + * The legacy bundle is a plain script that runs before this module, so a plugin + * can have registered before there was anywhere modern to put it. Existing + * modern entries win — a type registered here already is left alone. + * + * The `window` guard keeps this file importable under the `node` test + * environment, which is what enforces that the rest of `core/` stays DOM-free. + */ +export function adoptLegacyRegistrations(): void { + if (typeof window === 'undefined') { + return; + } + + const legacy = (window as any).Craft?._elementSelectorModalClasses as + | Record + | undefined; + + if (!legacy) { + return; + } + + for (const [elementType, controllerClass] of Object.entries(legacy)) { + if (!controllers.has(elementType)) { + controllers.set(elementType, controllerClass); + } + } +} + +/** Test seam. */ +export function resetElementSelectorControllers(): void { + controllers.clear(); +} diff --git a/packages/craftcms-ui/src/core/element-selector/types.ts b/packages/craftcms-ui/src/core/element-selector/types.ts new file mode 100644 index 00000000000..80de4f846a5 --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/types.ts @@ -0,0 +1,174 @@ +/** + * Types for the element selector core. + * + * This folder is deliberately free of `lit`, `vue`, `jquery` and the `Craft` + * global — it is the business layer both the web component and the Vue modal + * bind to, and third-party code can drive it directly. The `core` vitest project + * runs on the `node` environment to keep that honest. + */ + +/** + * What a selection hands back. + * + * The six named keys are the stable contract, produced by + * `ModalIndexViewModel::extraRowData()` on the server. The index signature + * carries whatever else that method emits for a given element type — `kind` and + * `alt` for assets, `folderId` for folders — so a consumer that needs more than + * the common six gets it without a change here. + */ +export interface ElementInfo { + id: number; + siteId: number | null; + label: string; + status: string | null; + url: string | null; + hasThumb: boolean; + [key: string]: unknown; +} + +/** The `element-selector-modals/body` response. */ +export interface ElementIndexBody { + /** Legacy `ElementIndexHtml` markup. Only the volume-folder path reads it. */ + html: string; + /** `ModalIndexViewModel::toArray()`. The Vue index reads it. */ + props: Record; +} + +/** Loads the index payload. Injectable so the core is testable without axios. */ +export type LoadIndexBody = ( + action: string, + params: Record +) => Promise; + +/** + * The seam between the controller and whatever is rendering the index. + * + * The adapter *pushes* selection in via {@link ElementSelectorController.setSelection}; + * the controller only ever asks it to clear or tear down. That direction is what + * lets one controller drive a Vue index, a legacy jQuery index, or a test stub + * without knowing which it has. + */ +export interface ElementIndexAdapter { + clearSelection(): void; + destroy?(): void; +} + +/** Extra information about a selection, beyond the elements themselves. */ +export interface SelectMeta { + /** Set by `AssetSelectorController` when a transform was chosen. */ + transform?: string | null; +} + +export interface ElementSelectorOptions { + /** Fully-qualified element class, e.g. `CraftCms\Cms\Entry\Elements\Entry`. */ + elementType: string; + + // — Query — + sources?: string[] | null; + condition?: unknown; + criteria?: Record | null; + referenceElementId?: number | null; + referenceElementOwnerId?: number | null; + referenceElementSiteId?: number | null; + /** `null` or `'auto'` leaves the decision to the server. */ + showSiteMenu?: boolean | 'auto' | null; + siteIds?: number[] | null; + + // — Selection rules — + multiSelect?: boolean; + disabledElementIds?: number[]; + /** Add each selection to the disabled set, so it can't be picked twice. */ + disableElementsOnSelect?: boolean; + hideOnSelect?: boolean; + + // — Copy the chrome renders — + modalTitle?: string | null; + showTitle?: boolean; + selectBtnLabel?: string | null; + fullscreen?: boolean; + + // — Index bootstrapping — + bodyAction?: string; + storageKey?: string | null; + indexSettings?: Record; + defaultSiteId?: number | null; + defaultSource?: string | null; + defaultSourcePath?: unknown[] | null; + preferStoredSource?: boolean; + showSourcePath?: boolean; + hideSidebar?: boolean; + + /** + * Called with the chosen elements. + * + * May return a promise. The controller awaits it and holds `busy` for its + * duration, which is what replaced the old `disable()` / `disableCancelBtn()` / + * `disableSelectBtn()` / `showFooterSpinner()` quartet — and, unlike that + * quartet, releases on a throw instead of stranding the buttons. + */ + onSelect?: ( + elements: ElementInfo[], + meta: SelectMeta + ) => void | Promise; + onCancel?: () => void; + onClose?: () => void; + + /** Test seam; defaults to a POST through `actionClient`. */ + loadIndexBody?: LoadIndexBody; +} + +/** Every option resolved against its default. */ +export type ResolvedElementSelectorOptions = ElementSelectorOptions & + Required< + Pick< + ElementSelectorOptions, + | 'multiSelect' + | 'disabledElementIds' + | 'disableElementsOnSelect' + | 'hideOnSelect' + | 'showTitle' + | 'fullscreen' + | 'bodyAction' + | 'indexSettings' + | 'preferStoredSource' + | 'showSourcePath' + | 'hideSidebar' + > + > & { + modalTitle: string; + selectBtnLabel: string; + }; + +/** An immutable snapshot. A fresh one is built for every `change` event. */ +export interface ElementSelectorState { + readonly open: boolean; + /** An index body request is in flight. */ + readonly loading: boolean; + /** A submit is in flight; both footer buttons are unavailable. */ + readonly busy: boolean; + readonly selection: readonly ElementInfo[]; + readonly disabledElementIds: readonly number[]; + readonly indexBody: ElementIndexBody | null; + readonly error: Error | null; + readonly title: string; + readonly showTitle: boolean; + readonly selectLabel: string; + readonly canSubmit: boolean; + readonly canCancel: boolean; +} + +export interface ElementSelectorEventMap { + /** Any state change. The only event a presentation layer needs. */ + change: ElementSelectorState; + open: void; + close: void; + cancel: void; + select: {elements: ElementInfo[]; meta: SelectMeta}; + error: Error; +} + +export type ElementSelectorEvent = keyof ElementSelectorEventMap; + +export type ElementSelectorListener = ( + data: ElementSelectorEventMap[E] +) => void; diff --git a/packages/craftcms-ui/src/core/element-selector/volume-folder-selector-controller.test.ts b/packages/craftcms-ui/src/core/element-selector/volume-folder-selector-controller.test.ts new file mode 100644 index 00000000000..5fb2a471b6f --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/volume-folder-selector-controller.test.ts @@ -0,0 +1,142 @@ +import {describe, expect, it, vi} from 'vite-plus/test'; +import { + ASSET_ELEMENT_TYPE, + VolumeFolderSelectorController, +} from './volume-folder-selector-controller.js'; +import type { + SourcePathSegment, + VolumeFolderIndexAdapter, + VolumeFolderSelectorOptions, +} from './volume-folder-selector-controller.js'; +import type {ElementInfo} from './types.js'; + +function create(options: Partial = {}) { + return new VolumeFolderSelectorController({ + hideOnSelect: false, + loadIndexBody: async () => ({html: '', props: {}}), + ...options, + }); +} + +/** Stands in for the legacy jQuery index, which owns the breadcrumb. */ +function folderIndex( + sourcePath: SourcePathSegment[] +): VolumeFolderIndexAdapter { + return { + sourcePath, + clearSelection: vi.fn(), + destroy: vi.fn(), + }; +} + +function folderRow(folderId: number): ElementInfo { + return { + id: 900 + folderId, + folderId, + siteId: null, + label: `Folder ${folderId}`, + status: null, + url: null, + hasThumb: false, + }; +} + +describe('configuration', () => { + it('is always the asset element type', () => { + expect(create().elementType).toBe(ASSET_ELEMENT_TYPE); + }); + + it('browses folders only', () => { + expect(create().indexParams().foldersOnly).toBe(true); + expect(create().indexSettings().foldersOnly).toBe(true); + }); + + it('never offers the site menu, since folders do not vary by site', () => { + expect(create({showSiteMenu: true}).indexParams().showSiteMenu).toBe('0'); + }); + + it('passes disabledFolderIds to the index', () => { + expect(create({disabledFolderIds: [3, 4]}).indexSettings()).toMatchObject({ + disabledFolderIds: [3, 4], + }); + }); +}); + +describe('selecting a highlighted folder', () => { + it('hands back the row’s folderId', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.setSelection([folderRow(12)]); + + await controller.submit(); + + expect(onSelect.mock.calls[0]![0]).toHaveLength(1); + expect(onSelect.mock.calls[0]![0][0].folderId).toBe(12); + }); +}); + +describe('selecting the open folder', () => { + it('can submit with nothing highlighted', () => { + // Unreachable in the legacy modal: it gated on + // `ev.currentTarget === this.$selectBtn[0]`, but the base bound the click as + // a zero-argument arrow, so `ev` was always undefined and Select did nothing. + const controller = create(); + controller.attachIndex(folderIndex([{folderId: 1}, {folderId: 7}])); + + expect(controller.hasSelection).toBe(false); + expect(controller.state.canSubmit).toBe(true); + }); + + it('hands back the deepest breadcrumb folder', async () => { + const onSelect = vi.fn(); + const controller = create({onSelect}); + controller.attachIndex( + folderIndex([ + {folderId: 1, label: 'Volume'}, + {folderId: 7, label: 'Nested'}, + ]) + ); + + await controller.submit(); + + expect(onSelect.mock.calls[0]![0][0]).toMatchObject({ + folderId: 7, + label: 'Nested', + }); + }); + + it('cannot submit with no index attached', () => { + expect(create().state.canSubmit).toBe(false); + }); + + it('cannot submit with an empty breadcrumb', () => { + const controller = create(); + controller.attachIndex(folderIndex([])); + + expect(controller.state.canSubmit).toBe(false); + }); + + it('cannot submit when the breadcrumb has no folder id', () => { + const controller = create(); + controller.attachIndex(folderIndex([{label: 'All volumes'}])); + + expect(controller.state.canSubmit).toBe(false); + }); + + it('refuses a folder in the disabled set', () => { + // Moving a folder into itself. + const controller = create({disabledFolderIds: [7]}); + controller.attachIndex(folderIndex([{folderId: 7}])); + + expect(controller.currentFolderId()).toBeNull(); + expect(controller.state.canSubmit).toBe(false); + }); + + it('still allows a highlighted row when the open folder is disabled', () => { + const controller = create({disabledFolderIds: [7]}); + controller.attachIndex(folderIndex([{folderId: 7}])); + controller.setSelection([folderRow(12)]); + + expect(controller.state.canSubmit).toBe(true); + }); +}); diff --git a/packages/craftcms-ui/src/core/element-selector/volume-folder-selector-controller.ts b/packages/craftcms-ui/src/core/element-selector/volume-folder-selector-controller.ts new file mode 100644 index 00000000000..d5f9c3ab7be --- /dev/null +++ b/packages/craftcms-ui/src/core/element-selector/volume-folder-selector-controller.ts @@ -0,0 +1,146 @@ +import {ElementSelectorController} from './element-selector-controller.js'; +import type { + ElementIndexAdapter, + ElementInfo, + ElementSelectorOptions, +} from './types.js'; + +export const ASSET_ELEMENT_TYPE = 'CraftCms\\Cms\\Asset\\Elements\\Asset'; + +/** One step of the breadcrumb into the folder currently open. */ +export interface SourcePathSegment { + folderId?: number; + label?: string; +} + +/** + * A folder index also exposes where it has navigated to. + * + * That is the whole reason this controller exists: "select the folder I'm + * looking at" is a selection the index itself has no row for. + */ +export interface VolumeFolderIndexAdapter extends ElementIndexAdapter { + readonly sourcePath: readonly SourcePathSegment[]; +} + +export interface VolumeFolderSelectorOptions extends Omit< + ElementSelectorOptions, + 'elementType' +> { + /** Folders that may not be chosen — typically the ones being moved. */ + disabledFolderIds?: number[]; +} + +/** + * Picks a volume folder rather than an element. + * + * Browses asset volumes folders-only, and — unlike every other selector — allows + * submitting with nothing highlighted, which means "the folder currently open". + * + * That fallback was unreachable in the legacy modal: it gated on + * `ev.currentTarget === this.$selectBtn[0]`, but the base class bound the click + * as a zero-argument arrow, so `ev` was always `undefined`. The Select button + * would enable and then do nothing. Here the rule lives in + * {@link canSubmitSelection} and {@link buildElementInfo}, so the inherited + * `submit()` handles both cases with no override and no event to inspect. + */ +export class VolumeFolderSelectorController extends ElementSelectorController { + readonly disabledFolderIds: number[]; + + constructor(options: VolumeFolderSelectorOptions = {}) { + const {disabledFolderIds = [], ...rest} = options; + + super({ + ...rest, + elementType: ASSET_ELEMENT_TYPE, + // Folders don't vary by site, so the menu is never useful here. + showSiteMenu: false, + }); + + this.disabledFolderIds = [...disabledFolderIds]; + } + + override indexParams(): Record { + return {...super.indexParams(), foldersOnly: true}; + } + + override indexSettings(): Record { + return { + ...super.indexSettings(), + foldersOnly: true, + disabledFolderIds: [...this.disabledFolderIds], + }; + } + + /** The open folder, if it may be selected. */ + currentFolderId(): number | null { + const sourcePath = this.index?.sourcePath; + + if (!sourcePath?.length) { + return null; + } + + const last = sourcePath[sourcePath.length - 1]!; + + // Coerced and range-checked rather than trusted: the breadcrumb comes from + // the index, which builds it from server HTML, so a missing or unparseable + // `folderId` has to read as "no folder" instead of leaking NaN into the + // selection payload. + const folderId = Number(last.folderId); + + if (!Number.isFinite(folderId)) { + return null; + } + + return this.disabledFolderIds.includes(folderId) ? null : folderId; + } + + protected override canSubmitSelection(): boolean { + return super.canSubmitSelection() || this.currentFolderId() !== null; + } + + /** + * Always `{folderId}`-bearing, whether a row was highlighted or the open + * folder is being chosen. + * + * The legacy modal read `data-folder-id` off each row's DOM; the id now + * arrives as extra row data from `ModalIndexViewModel::extraRowData()`. + */ + protected override buildElementInfo( + selection: readonly ElementInfo[] + ): ElementInfo[] { + if (selection.length > 0) { + return selection.map((element) => ({ + ...element, + folderId: Number(element.folderId ?? element.id), + })); + } + + const folderId = this.currentFolderId(); + + if (folderId === null) { + return []; + } + + // Shaped as a full ElementInfo so consumers reading `id`/`label` still work; + // `folderId` is the key `AssetIndex` and `MoveAssets` actually read. + return [ + { + id: folderId, + folderId, + siteId: null, + label: this.#currentFolderLabel(), + status: null, + url: null, + hasThumb: false, + }, + ]; + } + + #currentFolderLabel(): string { + const sourcePath = this.index?.sourcePath; + const last = sourcePath?.[sourcePath.length - 1]; + + return last?.label ?? ''; + } +} diff --git a/packages/craftcms-ui/src/index.ts b/packages/craftcms-ui/src/index.ts index 3229b000048..c82e300f1df 100644 --- a/packages/craftcms-ui/src/index.ts +++ b/packages/craftcms-ui/src/index.ts @@ -28,6 +28,8 @@ export {default as CraftCombobox} from './components/combobox/combobox.js'; export {default as CraftCopyAttribute} from './components/copy-attribute/copy-attribute.js'; export {default as CraftCopyButton} from './components/copy-button/copy-button.js'; export {default as CraftDialog} from './components/dialog/dialog.js'; +export {default as CraftElementSelectorModal} from './components/element-selector-modal/element-selector-modal.js'; +export {ElementSelectorHostController} from './components/element-selector-modal/controller-host.js'; export {default as CraftDisclosure} from './components/disclosure/disclosure.js'; export {default as CraftEmpty} from './components/empty/empty.js'; export {default as CraftField} from './components/field/field.js'; @@ -113,6 +115,8 @@ export * from './utilities/dom.js'; export * from './utilities/attrs.js'; export * from './utilities/thumbnail-loader.js'; export * from './utilities/create.js'; +export * from './utilities/focus-trap.js'; +export * from './core/element-selector/index.js'; // Services export {ConfigService} from './services/Config.js'; diff --git a/packages/craftcms-ui/src/utilities/focus-trap.test.ts b/packages/craftcms-ui/src/utilities/focus-trap.test.ts new file mode 100644 index 00000000000..47932c8b92a --- /dev/null +++ b/packages/craftcms-ui/src/utilities/focus-trap.test.ts @@ -0,0 +1,146 @@ +import {beforeEach, describe, expect, it} from 'vite-plus/test'; +import {focusableWithin, trapFocus} from './focus-trap.js'; + +/** A host with chrome in its shadow root and a slot in the middle, like a dialog. */ +class TrapHost extends HTMLElement { + connectedCallback() { + if (this.shadowRoot) { + return; + } + + const root = this.attachShadow({mode: 'open'}); + root.innerHTML = ``; + } +} + +if (!customElements.get('trap-host')) { + customElements.define('trap-host', TrapHost); +} + +function createHost(lightDom = ''): TrapHost { + const host = document.createElement('trap-host') as TrapHost; + host.innerHTML = lightDom; + document.body.append(host); + return host; +} + +function tab(host: HTMLElement, from: HTMLElement, shiftKey = false): void { + from.focus(); + from.dispatchEvent( + // `composed` matters: a non-composed event dispatched in a shadow tree + // never reaches the host, where the trap listens. + new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey, + bubbles: true, + composed: true, + }) + ); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('focusableWithin', () => { + it('collects across the shadow tree and slotted light DOM', () => { + const host = createHost( + '' + ); + + expect(focusableWithin(host).map((el) => el.className || el.id)).toEqual([ + 'close', + 'a', + 'b', + ]); + }); + + it('skips disabled, inert and aria-hidden elements', () => { + const host = createHost(` + + + + + `); + + expect(focusableWithin(host).map((el) => el.className || el.id)).toEqual([ + 'close', + 'd', + ]); + }); + + it('skips tabindex="-1" elements', () => { + const host = createHost( + '' + ); + + expect(focusableWithin(host).map((el) => el.className || el.id)).toEqual([ + 'close', + 'b', + ]); + }); +}); + +describe('trapFocus', () => { + it('wraps Tab from the last element to the first', () => { + const host = createHost( + '' + ); + trapFocus(host); + + const last = host.querySelector('#b')!; + tab(host, last); + + expect(host.shadowRoot!.activeElement).toBe( + host.shadowRoot!.querySelector('.close') + ); + }); + + it('wraps Shift-Tab from the first element to the last', () => { + const host = createHost( + '' + ); + trapFocus(host); + + const first = host.shadowRoot!.querySelector('.close')!; + tab(host, first, true); + + expect(document.activeElement).toBe(host.querySelector('#b')); + }); + + it('leaves interior Tab presses alone', () => { + const host = createHost( + '' + ); + trapFocus(host); + + const middle = host.querySelector('#a')!; + tab(host, middle); + + // No wrap: focus stays where the browser would take it from here. + expect(document.activeElement).toBe(middle); + }); + + it('stops trapping once released', () => { + const host = createHost( + '' + ); + const release = trapFocus(host); + release(); + + const last = host.querySelector('#b')!; + tab(host, last); + + expect(document.activeElement).toBe(last); + }); + + it('does nothing when there is nothing to focus', () => { + const host = document.createElement('div'); + document.body.append(host); + trapFocus(host); + + expect(() => + host.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'})) + ).not.toThrow(); + }); +}); diff --git a/packages/craftcms-ui/src/utilities/focus-trap.ts b/packages/craftcms-ui/src/utilities/focus-trap.ts new file mode 100644 index 00000000000..b4697a5e849 --- /dev/null +++ b/packages/craftcms-ui/src/utilities/focus-trap.ts @@ -0,0 +1,103 @@ +/** + * Focus containment for overlays that can't use the platform's own. + * + * A `` opened with `showModal()` gets focus containment from the + * browser. One opened with `show()` does not — it stays in the normal stacking + * context, which is the point (top-layer dialogs paint above ``-appended + * menus and make them unclickable), but the price is that Tab walks straight + * out of it. These helpers pay that price back. + */ + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button', + 'input', + 'select', + 'textarea', + 'details > summary', + 'iframe', + 'object', + 'embed', + 'audio[controls]', + 'video[controls]', + '[contenteditable]', + '[tabindex]', +].join(','); + +function isTabbable(el: HTMLElement): boolean { + if (el.hasAttribute('disabled') || el.hasAttribute('inert')) { + return false; + } + + if (el.getAttribute('aria-hidden') === 'true' || el.hidden) { + return false; + } + + return el.tabIndex >= 0; +} + +/** + * Tab-reachable elements inside a host, shadow tree first. + * + * A host's shadow tree and its slotted light-DOM children are separate trees, + * so this walks both. Concatenating them approximates document order — resolving + * it exactly would mean matching every ``'s assigned nodes against that + * slot's position in the shadow tree. The approximation holds for the shape + * these overlays use (chrome rendered in shadow, content slotted into the + * middle), and only the first and last entries are ever used. + * + * Deliberately does not filter on visibility: `isVisible()` depends on layout, + * which happy-dom does not compute, and a trap that finds nothing under test is + * worse than one that occasionally includes a hidden control. + */ +export function focusableWithin(host: HTMLElement): HTMLElement[] { + const roots: ParentNode[] = host.shadowRoot + ? [host.shadowRoot, host] + : [host]; + + return roots + .flatMap((root) => + Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)) + ) + .filter(isTabbable); +} + +/** + * Wrap Tab navigation around the focusable elements inside `host`. + * + * Returns a disposer; call it when the overlay closes. Installing a second trap + * on the same host without releasing the first leaves both listeners attached, + * so callers should hold the disposer rather than re-trapping. + */ +export function trapFocus(host: HTMLElement): () => void { + const onKeydown = (event: KeyboardEvent): void => { + if (event.key !== 'Tab') { + return; + } + + const focusable = focusableWithin(host); + + if (focusable.length === 0) { + return; + } + + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + // `composedPath()` rather than `target`, so a focused node inside a nested + // shadow root still matches the element we collected. + const active = event.composedPath()[0] as HTMLElement | undefined; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + host.addEventListener('keydown', onKeydown); + + return () => host.removeEventListener('keydown', onKeydown); +} diff --git a/packages/craftcms-ui/vitest.config.ts b/packages/craftcms-ui/vitest.config.ts index 95d158a9ed6..d20c50e6b75 100644 --- a/packages/craftcms-ui/vitest.config.ts +++ b/packages/craftcms-ui/vitest.config.ts @@ -25,6 +25,18 @@ export default defineConfig({ environment: 'happy-dom', }, }, + { + resolve: { + tsconfigPaths: true, + }, + test: { + name: 'core', + root: './src/core', + // `node`, not happy-dom: the core must not touch the DOM, and running + // it without one is what keeps that true. + environment: 'node', + }, + }, { resolve: { tsconfigPaths: true, diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts index 0b86c594b72..eda455f4bc6 100644 --- a/resources/js/common/types/globals.d.ts +++ b/resources/js/common/types/globals.d.ts @@ -105,22 +105,39 @@ interface SlideoutInstance { } interface ElementSelectorModalInstance { - show(): void; - on(event: string, callback: () => void): void; + show(): Promise; + hide(): void; + destroy(): void; + /** Returns an unsubscribe function. */ + on(event: string, callback: (data?: unknown) => void): () => void; + /** Republishes the whole set; the index re-reads it. */ + setDisabledElementIds(ids: number[]): void; + setBusy(busy: boolean): void; } type FieldLayoutDesignerInstance = any; interface ElementSelectorModalSettings { + /** Accepted for compatibility; the native dialog manages stacking itself. */ closeOtherModals?: boolean; criteria?: LegacyWidgetSettings; disabledElementIds?: number[]; + disableElementsOnSelect?: boolean; hideOnSelect?: boolean; modalTitle?: string; + showTitle?: boolean; + selectBtnLabel?: string; multiSelect?: boolean; - onSelect?: (elements: any[]) => void; - showSiteMenu?: boolean; + /** May return a promise; the modal stays busy until it settles. */ + onSelect?: (elements: any[], meta?: {transform?: string | null}) => unknown; + onCancel?: () => void; + onClose?: () => void; + showSiteMenu?: boolean | 'auto' | null; + siteIds?: number[] | null; sources?: string[] | null; + condition?: unknown; + storageKey?: string | null; + triggerElement?: HTMLElement | (() => HTMLElement | null) | null; } interface CraftStatic { @@ -139,7 +156,7 @@ interface CraftStatic { createElementSelectorModal( elementType: string, settings?: ElementSelectorModalSettings - ): ElementSelectorModalInstance; + ): Promise; expandPostArray(arr: FormData | URLSearchParams): LegacyWidgetSettings; escapeHtml(str: string); sites: Site[]; diff --git a/resources/js/modules/element-select-input/base-element-select-input.ts b/resources/js/modules/element-select-input/base-element-select-input.ts index daa1fd48b10..90f0e8843bb 100644 --- a/resources/js/modules/element-select-input/base-element-select-input.ts +++ b/resources/js/modules/element-select-input/base-element-select-input.ts @@ -1,15 +1,15 @@ import { + BACKSPACE_KEY, Base, - Select, - DragSort, - CustomSelect, bod, - firstFocusableElement, - hasAttr, - BACKSPACE_KEY, + CustomSelect, DELETE_KEY, DOWN_KEY, + DragSort, + firstFocusableElement, + hasAttr, RETURN_KEY, + Select, UP_KEY, type GarnishBaseSettings, } from '@craftcms/garnish'; @@ -170,6 +170,7 @@ export class BaseElementSelectInput extends Base modal: any = null; elementEditor: any = null; modalFirstOpen = true; + openingModal = false; $container: any = null; $form: any = null; @@ -266,8 +267,9 @@ export class BaseElementSelectInput extends Base if (this.$addElementBtn.length) { // activate is a jQuery synthetic event — must use jQuery .on(), not addListener. - $(this.$addElementBtn).on('activate.elementSelectInput', () => - this.showModal() + $(this.$addElementBtn).on( + 'activate.elementSelectInput', + () => void this.showModal() ); } @@ -361,7 +363,7 @@ export class BaseElementSelectInput extends Base } getAddElementsBtn(): any { - return this.$container.find('.btn.add:first'); + return this.$container.find('[command="--add-element"]'); } getSpinner(): any { @@ -923,16 +925,9 @@ export class BaseElementSelectInput extends Base this.elementSelect.removeItems($elements); } - if (this.modal) { - const ids: number[] = []; - for (let i = 0; i < $elements.length; i++) { - const id = $elements.eq(i).data('id'); - if (id) ids.push(id); - } - if (ids.length) { - this.modal.elementIndex.enableElementsById(ids); - } - } + // Removing a relation makes it selectable again, so the modal's disabled + // set has to shrink to match. + this.updateDisabledElementsInModal(); $elements.children('input').prop('disabled', true); @@ -1023,24 +1018,50 @@ export class BaseElementSelectInput extends Base callback?.(); } - showModal(): void { + async showModal(): Promise { if (!this._$replaceElement && !this.canAddMoreElements()) { return; } - if (!this.modal) { - this.modal = this.createModal(); + if (this.modal) { + void this.modal.show(); + + return; + } + + // Guards the await below: without it a double click opens two modals. + if (this.openingModal) { + return; + } + + this.openingModal = true; + + try { + this.modal = await this.createModal(); this.modalFirstOpen = false; - } else { - this.modal.show(); + } finally { + this.openingModal = false; } } - createModal(): any { - return Craft.createElementSelectorModal( - this.settings.elementType, - this.getModalSettings() - ); + /** + * The modal stack — Lit, Vue and the element index — is imported here rather + * than at module scope, so a page carrying a relation field doesn't load it + * unless a modal is actually opened. + */ + async createModal(): Promise { + const {elementType} = this.settings; + + if (!elementType) { + throw new Error( + 'An element select input needs an element type to open its selector.' + ); + } + + const {createElementSelectorModal} = + await import('@/modules/element-selector-modal/create-element-selector-modal'); + + return createElementSelectorModal(elementType, this.getModalSettings()); } getModalSettings(): any { @@ -1068,7 +1089,9 @@ export class BaseElementSelectInput extends Base siteIds: this.settings.siteIds, disabledElementIds: this.getDisabledElementIds(), onSelect: this.onModalSelect.bind(this), - onHide: this.onModalHide.bind(this), + // Was `onHide`; the controller calls this one whenever it closes, + // whether that came from Cancel, Escape or a completed selection. + onClose: this.onModalHide.bind(this), triggerElement: () => this.getNextLogicalFocusElement(), modalTitle: Craft.t('app', 'Choose'), }, @@ -1117,11 +1140,6 @@ export class BaseElementSelectInput extends Base } async onModalSelect(elements: any[]): Promise { - this.modal?.disable(); - this.modal?.disableCancelBtn(); - this.modal?.disableSelectBtn(); - this.modal?.showFooterSpinner(); - this.elementEditor?.pause(); const [inputUiType, inputUiSize] = (() => { @@ -1203,10 +1221,8 @@ export class BaseElementSelectInput extends Base this.updateDisabledElementsInModal(); } - this.modal?.enable(); - this.modal?.enableCancelBtn(); - this.modal?.enableSelectBtn(); - this.modal?.hideFooterSpinner(); + // `busy` is the controller's, held across this method because it is the + // awaited `onSelect`. Only the close is ours to ask for. this.modal?.hide(); await Craft.appendHeadHtml(data.headHtml); @@ -1369,10 +1385,14 @@ export class BaseElementSelectInput extends Base } } + /** + * Republishes which elements the modal may not select. + * + * The modal's index reads this as a whole set rather than being told to + * enable or disable individual ids, so both directions are one assignment. + */ updateDisabledElementsInModal(): void { - if (this.modal?.elementIndex) { - this.modal.elementIndex.disableElementsById(this.getDisabledElementIds()); - } + this.modal?.setDisabledElementIds(this.getDisabledElementIds()); } getElementById(id: number): any { diff --git a/resources/js/modules/element-selector-modal/ElementSelectorModal.test.ts b/resources/js/modules/element-selector-modal/ElementSelectorModal.test.ts new file mode 100644 index 00000000000..47bdd559f4e --- /dev/null +++ b/resources/js/modules/element-selector-modal/ElementSelectorModal.test.ts @@ -0,0 +1,309 @@ +import {createApp, defineComponent, h, nextTick} from 'vue'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vite-plus/test'; +import { + AssetSelectorController, + ElementSelectorController, + type AssetSelectorOptions, + type ElementInfo, +} from '@craftcms/ui'; + +/** + * The real index pulls the whole element-index component tree and its + * composables; this stub stands in for it so the test is about the wiring — + * controller to chrome, index to controller — and nothing else. + * + * It keeps the real component's contract: emits `selection-change` / `choose`, + * exposes `clearSelection`, takes `disabled-element-ids`. + */ +const stub = vi.hoisted(() => ({ + clearSelection: vi.fn(), + lastProps: null as Record | null, + emit: null as ((event: string, payload?: unknown) => void) | null, +})); + +const menu = vi.hoisted(() => ({ + actions: null as any[] | null, +})); + +vi.mock('@/common/components/ActionMenu.vue', async () => { + const {defineComponent: define, h: create} = await import('vue'); + + return { + default: define({ + name: 'ActionMenuStub', + props: ['actions', 'label'], + setup(props) { + // Read inside render so each re-render republishes the current list. + return () => { + menu.actions = props.actions as any[]; + return create('div', {class: 'action-menu-stub'}); + }; + }, + }), + }; +}); + +vi.mock('./ModalElementIndex.vue', async () => { + const {defineComponent: define, h: create} = await import('vue'); + + return { + default: define({ + name: 'ModalElementIndexStub', + props: ['action', 'initial', 'params', 'disabledElementIds'], + emits: ['selection-change', 'choose'], + setup(props, {emit, expose}) { + stub.lastProps = props as Record; + stub.emit = emit as (event: string, payload?: unknown) => void; + expose({clearSelection: stub.clearSelection}); + return () => create('div', {class: 'index-stub'}); + }, + }), + }; +}); + +function element(id: number): ElementInfo { + return { + id, + siteId: 1, + label: `Element ${id}`, + status: null, + url: null, + hasThumb: false, + }; +} + +function controller(options = {}) { + return new ElementSelectorController({ + elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', + modalTitle: 'Choose an entry', + hideOnSelect: false, + loadIndexBody: async () => ({html: '', props: {total: 2}}), + ...options, + }); +} + +async function mountModal(instance: ElementSelectorController) { + const {default: ElementSelectorModal} = + await import('./ElementSelectorModal.vue'); + + const app = createApp( + defineComponent({ + setup: () => () => h(ElementSelectorModal, {controller: instance}), + }) + ); + app.config.compilerOptions.isCustomElement = (tag: string) => + tag.includes('-'); + + const host = document.createElement('div'); + document.body.append(host); + app.mount(host); + await nextTick(); + + return { + host, + unmount: () => app.unmount(), + modal: () => + host.querySelector('craft-element-selector-modal') as HTMLElement & { + controller: ElementSelectorController | null; + }, + }; +} + +beforeEach(() => { + document.body.innerHTML = ''; + stub.clearSelection.mockClear(); + stub.lastProps = null; + stub.emit = null; + menu.actions = null; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('ElementSelectorModal', () => { + it('hands the controller to the web component as a property', async () => { + const instance = controller(); + const {modal, unmount} = await mountModal(instance); + + expect(modal().controller).toBe(instance); + unmount(); + }); + + it('holds the index back until the body has loaded', async () => { + const instance = controller(); + const {host, unmount} = await mountModal(instance); + + expect(host.querySelector('.index-stub')).toBeNull(); + + await instance.open(); + await nextTick(); + + expect(host.querySelector('.index-stub')).not.toBeNull(); + unmount(); + }); + + it('passes the action, payload and params to the index', async () => { + const instance = controller(); + const {unmount} = await mountModal(instance); + + await instance.open(); + await nextTick(); + + expect(stub.lastProps!.action).toBe('element-selector-modals/body'); + expect(stub.lastProps!.initial).toEqual({total: 2}); + expect(stub.lastProps!.params).toMatchObject({ + context: 'modal', + elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', + }); + unmount(); + }); + + it('routes the index’s selection into the controller', async () => { + const instance = controller(); + const {unmount} = await mountModal(instance); + await instance.open(); + await nextTick(); + + stub.emit!('selection-change', [element(1)]); + await nextTick(); + + expect(instance.state.selection.map((e) => e.id)).toEqual([1]); + expect(instance.state.canSubmit).toBe(true); + unmount(); + }); + + it('routes a double-click choose into a submit', async () => { + const onSelect = vi.fn(); + const instance = controller({onSelect}); + const {unmount} = await mountModal(instance); + await instance.open(); + await nextTick(); + + stub.emit!('selection-change', [element(1)]); + await nextTick(); + stub.emit!('choose'); + await nextTick(); + + expect(onSelect).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('republishes disabled ids to the index reactively', async () => { + // The bug this whole seam exists to fix: the old modal passed these once by + // value and the index never saw an update. + const instance = controller({disabledElementIds: [1]}); + const {unmount} = await mountModal(instance); + await instance.open(); + await nextTick(); + + expect(stub.lastProps!.disabledElementIds).toEqual([1]); + + instance.setDisabledElementIds([2, 3]); + await nextTick(); + + expect(stub.lastProps!.disabledElementIds).toEqual([2, 3]); + unmount(); + }); + + it('registers the index so the controller can clear it', async () => { + const instance = controller({disableElementsOnSelect: true}); + const {unmount} = await mountModal(instance); + await instance.open(); + await nextTick(); + + stub.emit!('selection-change', [element(1)]); + await nextTick(); + await instance.submit(); + + expect(stub.clearSelection).toHaveBeenCalled(); + expect(instance.state.disabledElementIds).toEqual([1]); + unmount(); + }); + + it('detaches the index on unmount', async () => { + const instance = controller(); + const {unmount} = await mountModal(instance); + await instance.open(); + await nextTick(); + expect(instance.index).not.toBeNull(); + + unmount(); + + expect(instance.index).toBeNull(); + }); +}); + +describe('the transform menu', () => { + function assetController( + transforms: {handle: string; name: string}[], + options: Partial = {} + ) { + return new AssetSelectorController({ + elementType: 'CraftCms\\Cms\\Asset\\Elements\\Asset', + transforms, + hideOnSelect: false, + loadIndexBody: async () => ({html: '', props: {}}), + fetchTransformUrl: async (id, handle) => `/t/${handle}/${id}.jpg`, + ...options, + }); + } + + it('is absent for a controller with no transforms', async () => { + const {host, unmount} = await mountModal(controller()); + + expect(host.querySelector('.action-menu-stub')).toBeNull(); + unmount(); + }); + + it('is absent for an asset controller that was given none', async () => { + const {host, unmount} = await mountModal(assetController([])); + + expect(host.querySelector('.action-menu-stub')).toBeNull(); + unmount(); + }); + + it('offers one item per transform', async () => { + const instance = assetController([ + {handle: 'thumb', name: 'Thumbnail'}, + {handle: 'large', name: 'Large'}, + ]); + const {host, unmount} = await mountModal(instance); + + expect(host.querySelector('.action-menu-stub')).not.toBeNull(); + expect(menu.actions!.map((a) => a.label)).toEqual(['Thumbnail', 'Large']); + unmount(); + }); + + it('disables the items until something is selected', async () => { + // The invoker is frozen under `v-once`, so this is where reactivity has to + // land — a `disabled` on the invoker would never update. + const instance = assetController([{handle: 'thumb', name: 'Thumbnail'}]); + const {unmount} = await mountModal(instance); + + expect(menu.actions!.every((a) => a.disabled)).toBe(true); + + instance.setSelection([element(1)]); + await nextTick(); + + expect(menu.actions!.every((a) => a.disabled)).toBe(false); + unmount(); + }); + + it('submits with the chosen transform applied', async () => { + const onSelect = vi.fn(); + const instance = assetController([{handle: 'thumb', name: 'Thumbnail'}], { + onSelect, + }); + const {unmount} = await mountModal(instance); + + instance.setSelection([element(1)]); + await nextTick(); + await menu.actions![0]!.onClick(); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect.mock.calls[0]![1]).toEqual({transform: 'thumb'}); + expect(onSelect.mock.calls[0]![0][0].url).toBe('/t/thumb/1.jpg'); + unmount(); + }); +}); diff --git a/resources/js/modules/element-selector-modal/ElementSelectorModal.vue b/resources/js/modules/element-selector-modal/ElementSelectorModal.vue new file mode 100644 index 00000000000..5d8107a4eb9 --- /dev/null +++ b/resources/js/modules/element-selector-modal/ElementSelectorModal.vue @@ -0,0 +1,115 @@ + + + diff --git a/resources/js/modules/element-selector-modal/ModalElementIndex.vue b/resources/js/modules/element-selector-modal/ModalElementIndex.vue new file mode 100644 index 00000000000..211028e9840 --- /dev/null +++ b/resources/js/modules/element-selector-modal/ModalElementIndex.vue @@ -0,0 +1,165 @@ + + + diff --git a/resources/js/modules/element-selector-modal/README.md b/resources/js/modules/element-selector-modal/README.md new file mode 100644 index 00000000000..46ecb2a68d1 --- /dev/null +++ b/resources/js/modules/element-selector-modal/README.md @@ -0,0 +1,115 @@ +# element-selector-modal + +The Vue presentation layer for the element selector. + +The business logic does **not** live here — it lives in +`@craftcms/ui`'s [`core/element-selector`](../../../../packages/craftcms-ui/src/core/element-selector/), +and the chrome lives in that package's `craft-element-selector-modal` component. +This folder is the third of the three layers: the Vue view, the Vue-mounting +factory, and the `Craft.*` shims. + +## The three layers + +``` +@craftcms/ui core/element-selector/ ← no DOM, no Vue, no Lit, no jQuery + │ + ┌─────────────┴──────────────┐ + ↓ ↓ +@craftcms/ui this folder + (chrome only) (renders the WC, slots the index) +``` + +**The controller is the seam — not the slot.** The web component and the index +never talk to each other; both talk to the controller. Every user intent goes +*into* the controller, every piece of state comes *out* of it. The web +component's Select button calls `controller.submit()` directly and *also* +dispatches `craft-select` for onlookers — the event is a notification, not the +mechanism. Wire it the other way and one intent ends up with two paths that +drift. + +Corollary: `ModalElementIndex.vue` emits `selection-change` / `choose` and never +imports the core. `ElementSelectorModal.vue` does the forwarding, which keeps the +index reusable and `useModalElementIndex` core-agnostic. + +## Files + +| File | Role | +| --- | --- | +| `ElementSelectorModal.vue` | The Vue view: renders `` with the index slotted in, plus the asset transform menu. | +| `useElementSelectorController.ts` | Mirrors the core's `change` event into a `shallowRef`. Deliberately shallow — nothing wraps the controller's frozen snapshots in a reactive proxy, so Vue and the web component read the same object. | +| `create-element-selector-modal.ts` | The imperative factory. Builds a controller from the registry, mounts the Vue view into a detached host, returns a handle. **Async**, and lazy-imports Vue so a page with a relation field doesn't pay for the index unless a modal opens. | +| `ModalElementIndex.vue` | The index itself. Unchanged in shape from the legacy modal's. | +| `useModalElementIndex.ts` | The composable behind it — the page's own `useElementIndex*` stack with a non-Inertia visitor. | +| `modal-index-visitor.ts` | That visitor. | +| `index.ts` | Registers the asset controller, drains legacy registrations, assigns the `Craft.*` shims. | +| `volume-folder-selector-modal.ts` | The folder picker. Binds the same web component and controller to the **legacy jQuery index** — no Vue at all. | + +## What the payload looks like + +`onSelect` receives `ElementInfo[]` — the six stable keys plus whatever +`ModalIndexViewModel::typeSpecificRowData()` adds for that element type +(`kind`/`alt` for assets, `folderId` for folders). + +Those arrive **nested** under an `elementInfo` key on each row, not merged into +it. A row is otherwise rendered column HTML keyed by attribute, and a flat merge +let a column silently overwrite anything sharing its name — `status` came back as +a `` element rather than `"pending"`, and `kind` would have come back +as `"Image"` rather than `"image"` whenever the File Kind column was visible. + +## The folder picker + +`volume-folder-selector-modal.ts` drives the **legacy jQuery element index** +rather than the Vue one, because folder picking keys off that index's +`sourcePath` — the breadcrumb of the folder you have navigated into, which is +what "select the folder I'm looking at" means when no row is highlighted. It is +the last thing keeping `ElementIndexHtml` and the HTML `element-indexes/*` +endpoints alive. + +It is also the proof that this split works: the same web component and the same +controller as the Vue modal, with server HTML slotted in instead of a component +tree, and a `VolumeFolderIndexAdapter` bridging the jQuery index to the core. + +It opens **`non-modal`** deliberately. `showModal()` puts a dialog in the top +layer, where it paints above every menu the legacy CP appends to `` — the +breadcrumb, status and site menus this index depends on — and makes them +unclickable. `show()` keeps it in the normal stacking context; the component +supplies the backdrop, Escape and focus containment the platform then stops +providing. + +The day a Vue folder index exists, this file collapses into +`ElementSelectorModal.vue`. + +### A rule this fixed + +"Select the folder I'm looking at" never actually worked. The legacy modal gated +it on `ev.currentTarget === this.$selectBtn[0]`, but the base class bound the +click as a zero-argument arrow, so `ev` was always `undefined`: the Select button +would enable and then do nothing. The rule now lives in the controller's +`canSubmitSelection()` and `buildElementInfo()`, so the inherited `submit()` +handles both cases with no override and no event to inspect. + +## Registry + +The map lives in the core (`@craftcms/ui`, `registry.ts`) and is keyed to +controller classes. `index.ts` registers `AssetSelectorController` for assets and +drains anything a plugin left on `Craft._elementSelectorModalClasses` before this +module evaluated — the legacy bundle is a plain `