diff --git a/client/src/controllers/DialogController.test.js b/client/src/controllers/DialogController.test.js new file mode 100644 index 0000000000..41ea2bdb95 --- /dev/null +++ b/client/src/controllers/DialogController.test.js @@ -0,0 +1,139 @@ +import { Application } from '@hotwired/stimulus'; + +import A11yDialog from 'a11y-dialog'; +import { DialogController } from './DialogController'; + +describe('DialogController', () => { + let application; + + describe('basic behaviour', () => { + beforeEach(() => { + application?.stop(); + + document.body.innerHTML = ` +
+ +
`; + + application = new Application(); + application.register('w-dialog', DialogController); + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); + }); + + it('should instantiate the controlled element with the A11y library', async () => { + const listener = jest.fn(); + document.addEventListener('w-dialog:ready', listener); + + expect(listener).not.toHaveBeenCalled(); + application.start(); + + await Promise.resolve(); + + expect(listener).toHaveBeenCalled(); + const { body, dialog } = listener.mock.calls[0][0].detail; + + expect(body).toEqual(document.getElementById('dialog-body')); + expect(dialog).toBeInstanceOf(A11yDialog); + expect(dialog.$el).toEqual(document.getElementById('dialog-container')); + }); + + it('should support the ability to show and hide the dialog', async () => { + const shownListener = jest.fn(); + document.addEventListener('w-dialog:shown', shownListener); + + const hiddenListener = jest.fn(); + document.addEventListener('w-dialog:hidden', hiddenListener); + + application.start(); + + await Promise.resolve(); + + expect(shownListener).not.toHaveBeenCalled(); + expect(hiddenListener).not.toHaveBeenCalled(); + + const dialog = document.getElementById('dialog-container'); + + // closed by default + expect(dialog.getAttribute('aria-hidden')).toEqual('true'); + expect(document.documentElement.style.overflowY).toBe(''); + + // show the dialog manually + dialog.dispatchEvent(new CustomEvent('w-dialog:show')); + + expect(dialog.getAttribute('aria-hidden')).toEqual(null); + expect(shownListener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + body: document.getElementById('dialog-body'), + dialog: expect.any(Object), + }), + }), + ); + expect(hiddenListener).not.toHaveBeenCalled(); + // add style to root element on shown by default + expect(document.documentElement.style.overflowY).toBe('hidden'); + + // hide the dialog manually + dialog.dispatchEvent(new CustomEvent('w-dialog:hide')); + + expect(dialog.getAttribute('aria-hidden')).toEqual('true'); + expect(shownListener).toHaveBeenCalledTimes(1); + expect(hiddenListener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + body: document.getElementById('dialog-body'), + dialog: expect.any(Object), + }), + }), + ); + // reset style on root element when hidden by default + expect(document.documentElement.style.overflowY).toBe(''); + }); + + it('should support the ability use a theme to avoid document style change', async () => { + const dialog = document.getElementById('dialog-container'); + + // adding a theme value + dialog.classList.add('w-dialog--floating'); + dialog.setAttribute('data-w-dialog-theme-value', 'floating'); + + application.start(); + await Promise.resolve(); + + // closed by default + expect(document.documentElement.style.overflowY).toBe(''); + expect(dialog.getAttribute('aria-hidden')).toEqual('true'); + + const shownListener = jest.fn(); + document.addEventListener('w-dialog:shown', shownListener); + + const hiddenListener = jest.fn(); + document.addEventListener('w-dialog:hidden', hiddenListener); + + dialog.dispatchEvent(new CustomEvent('w-dialog:show')); + + expect(dialog.getAttribute('aria-hidden')).toEqual(null); + expect(document.documentElement.style.overflowY).toBeFalsy(); + expect(shownListener).toHaveBeenCalled(); + + dialog.dispatchEvent(new CustomEvent('w-dialog:hide')); + + expect(dialog.getAttribute('aria-hidden')).toEqual('true'); + expect(document.documentElement.style.overflowY).toBeFalsy(); + expect(hiddenListener).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/controllers/DialogController.ts b/client/src/controllers/DialogController.ts index c9c516abc1..54e8b782f2 100644 --- a/client/src/controllers/DialogController.ts +++ b/client/src/controllers/DialogController.ts @@ -1,44 +1,54 @@ +import { Controller } from '@hotwired/stimulus'; import A11yDialog from 'a11y-dialog'; -export const dialog = ( - dialogTemplates = document.querySelectorAll('[data-wagtail-dialog]'), - rootElement = document.body, -) => { - const dialogs = Array.from(dialogTemplates).map((template) => { - const html = document.documentElement; - const templateContent = template.content.firstElementChild; +const FLOATING = 'floating'; - const { dialogRootSelector, theme } = templateContent.dataset; - const dialogRoot = - (dialogRootSelector && rootElement.querySelector(dialogRootSelector)) || - rootElement; - dialogRoot.appendChild(templateContent); +/** + * Instantiates an a11y dialog on the controlled element. + * Adds support for hide and show methods and blocking body + * scroll when the dialog is open. + * + * @example + *
+ *
+ *
+ */ +export class DialogController extends Controller { + static values = { + theme: { default: '', type: String }, + }; - const dialogTemplate = new A11yDialog(templateContent); + static targets = ['body']; - if (theme !== 'floating') { - // Prevent scrolling when dialog is open - dialogTemplate - .on('show', () => { - html.style.overflowY = 'hidden'; - }) - .on('hide', () => { - html.style.overflowY = ''; - }); - } + declare dialog: A11yDialog; + declare readonly bodyTarget: HTMLElement; + declare readonly themeValue: string; - // Attach event listeners to the dialog root (element with id), so it's - // possible to show/close the dialog somewhere else with no access to the - // A11yDialog instance. - templateContent.addEventListener('wagtail:show', () => - dialogTemplate.show(), - ); - templateContent.addEventListener('wagtail:hide', () => - dialogTemplate.hide(), - ); + connect() { + this.dialog = new A11yDialog(this.element); + const detail = { body: this.bodyTarget, dialog: this.dialog }; + const isFloating = this.themeValue === FLOATING; + this.dialog + .on('show', () => { + if (!isFloating) document.documentElement.style.overflowY = 'hidden'; + this.dispatch('shown', { detail }); + }) + .on('hide', () => { + if (!isFloating) document.documentElement.style.overflowY = ''; + this.dispatch('hidden', { detail }); + }); + this.dispatch('ready', { detail }); + return this.dialog; + } - return dialogTemplate; - }); + hide() { + this.dialog.hide(); + } - return dialogs; -}; + show() { + this.dialog.show(); + } +} diff --git a/client/src/controllers/TeleportController.test.js b/client/src/controllers/TeleportController.test.js new file mode 100644 index 0000000000..407277912b --- /dev/null +++ b/client/src/controllers/TeleportController.test.js @@ -0,0 +1,161 @@ +import { Application } from '@hotwired/stimulus'; + +import { TeleportController } from './TeleportController'; + +describe('TeleportController', () => { + let application; + + describe('basic behaviour', () => { + beforeEach(() => { + application?.stop(); + + document.body.innerHTML = ` +
+ +
`; + + application = new Application(); + application.register('w-teleport', TeleportController); + }); + + it('should move the Template element content to the body and remove the template by default', async () => { + expect(document.querySelectorAll('template')).toHaveLength(1); + expect(document.getElementById('content')).toBeNull(); + + const appendCallback = jest.fn(); + document.addEventListener('w-teleport:append', appendCallback); + const appendedCallback = jest.fn(); + document.addEventListener('w-teleport:appended', appendedCallback); + + application.start(); + + await Promise.resolve(); + + // updating the DOM + + expect(document.querySelectorAll('template')).toHaveLength(0); + expect(document.getElementById('content')).not.toBeNull(); + expect(document.getElementById('content').parentElement).toEqual( + document.body, + ); + + // dispatching events + + expect(appendCallback).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { complete: expect.any(Function), target: document.body }, + }), + ); + expect(appendedCallback).toHaveBeenCalledWith( + expect.objectContaining({ detail: { target: document.body } }), + ); + }); + + it('should allow a value to have the Template element kept', async () => { + document + .querySelector('template') + .setAttribute('data-w-teleport-keep-value', 'true'); + + expect(document.querySelectorAll('template')).toHaveLength(1); + expect(document.getElementById('content')).toBeNull(); + + application.start(); + + await Promise.resolve(); + + expect(document.querySelectorAll('template')).toHaveLength(1); + expect(document.getElementById('content')).not.toBeNull(); + expect(document.getElementById('content').parentElement).toEqual( + document.body, + ); + }); + + it('should allow the target container to be based on a provided selector value', async () => { + document.body.innerHTML += ` +
+ `; + + const template = document.querySelector('template'); + template.setAttribute( + 'data-w-teleport-target-value', + '#target-container', + ); + + expect(document.getElementById('target-container').innerHTML).toEqual(''); + + application.start(); + + await Promise.resolve(); + + expect(document.getElementById('target-container').innerHTML).toEqual( + '
Some content
', + ); + }); + + it('should allow for a default target container within the root element of a shadow DOM', async () => { + const shadowHost = document.createElement('div'); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + document.body.appendChild(shadowHost); + + const template = document.getElementById('template'); + const content = template.content.cloneNode(true); + + const targetContainer = document.createElement('div'); + targetContainer.setAttribute('id', 'target-container'); + targetContainer.appendChild(content); + shadowRoot.appendChild(targetContainer); + + application.start(); + + await Promise.resolve(); + + expect(shadowRoot.querySelector('#target-container').innerHTML).toContain( + '
Some content
', + ); + }); + + it('should throw an error if the template content is empty', async () => { + const errors = []; + + document.getElementById('template').innerHTML = ''; + + application.handleError = (error, message) => { + errors.push({ error, message }); + }; + + await Promise.resolve(application.start()); + + expect(errors).toEqual([ + { + error: new Error('Invalid template content.'), + message: 'Error connecting controller', + }, + ]); + }); + + it('should throw an error if a valid target container cannot be resolved', async () => { + const errors = []; + + document + .getElementById('template') + .setAttribute('data-w-teleport-target-value', '#missing-container'); + + application.handleError = (error, message) => { + errors.push({ error, message }); + }; + + await Promise.resolve(application.start()); + + expect(errors).toEqual([ + { + error: new Error( + "No valid target container found at '#missing-container'.", + ), + message: 'Error connecting controller', + }, + ]); + }); + }); +}); diff --git a/client/src/controllers/TeleportController.ts b/client/src/controllers/TeleportController.ts new file mode 100644 index 0000000000..7158f11379 --- /dev/null +++ b/client/src/controllers/TeleportController.ts @@ -0,0 +1,97 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Allows the controlled element's content to be copied and appended + * to another place in the DOM. Once copied, the original controlled + * element will be removed from the DOM unless `keep` is true. + * If a target selector isn't provided, a default target of + * `document.body` or the Shadow Root's first DOM node will be used. + * Depending on location of the controlled element. + * + * @example + * + */ +export class TeleportController extends Controller { + static values = { + keep: { default: false, type: Boolean }, + target: { default: '', type: String }, + }; + + /** If true, keep the original DOM element intact, otherwise remove it when cloned. */ + declare keepValue: boolean; + /** A selector to determine the target location to clone the element. */ + declare targetValue: string; + + connect() { + this.append(); + } + + append() { + const target = this.target; + let completed = false; + + const complete = () => { + if (completed) return; + target.append(this.templateElement); + this.dispatch('appended', { cancelable: false, detail: { target } }); + completed = true; + if (this.keepValue) return; + this.element.remove(); + }; + + const event = this.dispatch('append', { + cancelable: true, + detail: { complete, target }, + }); + + if (!event.defaultPrevented) complete(); + } + + /** + * Resolve a valid target element, defaulting to the document.body + * or the shadow root's first DOM node if no target selector provided. + */ + get target() { + let target: any; + + if (this.targetValue) { + target = document.querySelector(this.targetValue); + } else { + const rootNode = this.element.getRootNode(); + target = + rootNode instanceof Document ? rootNode.body : rootNode.firstChild; + } + + if (!(target instanceof Element)) { + throw new Error( + `No valid target container found at ${ + this.targetValue ? `'${this.targetValue}'` : 'the root node' + }.`, + ); + } + + return target; + } + + /** + * Resolve a valid HTMLElement from the controlled element's children. + */ + get templateElement() { + const templateElement = + this.element.content.firstElementChild?.cloneNode(true); + + if (!(templateElement instanceof HTMLElement)) { + throw new Error('Invalid template content.'); + } + + return templateElement; + } +} diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index 9e105a1e64..172ed650d2 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -5,6 +5,7 @@ import { ActionController } from './ActionController'; import { AutosizeController } from './AutosizeController'; import { BulkController } from './BulkController'; import { CountController } from './CountController'; +import { DialogController } from './DialogController'; import { DismissibleController } from './DismissibleController'; import { DropdownController } from './DropdownController'; import { MessagesController } from './MessagesController'; @@ -15,6 +16,7 @@ import { SubmitController } from './SubmitController'; import { SwapController } from './SwapController'; import { SyncController } from './SyncController'; import { TagController } from './TagController'; +import { TeleportController } from './TeleportController'; import { TooltipController } from './TooltipController'; import { UpgradeController } from './UpgradeController'; @@ -27,6 +29,7 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: AutosizeController, identifier: 'w-autosize' }, { controllerConstructor: BulkController, identifier: 'w-bulk' }, { controllerConstructor: CountController, identifier: 'w-count' }, + { controllerConstructor: DialogController, identifier: 'w-dialog' }, { controllerConstructor: DismissibleController, identifier: 'w-dismissible' }, { controllerConstructor: DropdownController, identifier: 'w-dropdown' }, { controllerConstructor: MessagesController, identifier: 'w-messages' }, @@ -37,6 +40,7 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: SwapController, identifier: 'w-swap' }, { controllerConstructor: SyncController, identifier: 'w-sync' }, { controllerConstructor: TagController, identifier: 'w-tag' }, + { controllerConstructor: TeleportController, identifier: 'w-teleport' }, { controllerConstructor: TooltipController, identifier: 'w-tooltip' }, { controllerConstructor: UpgradeController, identifier: 'w-upgrade' }, ]; diff --git a/client/src/entrypoints/admin/wagtailadmin.js b/client/src/entrypoints/admin/wagtailadmin.js index f076689c11..630965a9bc 100644 --- a/client/src/entrypoints/admin/wagtailadmin.js +++ b/client/src/entrypoints/admin/wagtailadmin.js @@ -1,7 +1,6 @@ import { Icon, Portal } from '../..'; import { initTooltips } from '../../includes/initTooltips'; import { initTabs } from '../../includes/tabs'; -import { dialog } from '../../includes/dialog'; import initCollapsibleBreadcrumbs from '../../includes/breadcrumbs'; import initSidePanel from '../../includes/sidePanel'; import { @@ -22,7 +21,6 @@ window.wagtail.components = { document.addEventListener('DOMContentLoaded', () => { initTooltips(); initTabs(); - dialog(); initCollapsibleBreadcrumbs(); initSidePanel(); initCollapsiblePanels();