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 = `
+
+
+ Some content
+
+ `;
+
+ 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();