mirror of
https://github.com/wagtail/wagtail.git
synced 2024-11-25 05:02:57 +01:00
Create DialogController (w-dialog) & TeleportController (w-teleport)
This commit is contained in:
parent
a15f7d188b
commit
c556acef35
139
client/src/controllers/DialogController.test.js
Normal file
139
client/src/controllers/DialogController.test.js
Normal file
@ -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 = `
|
||||
<section>
|
||||
<div
|
||||
id="dialog-container"
|
||||
aria-hidden="true"
|
||||
data-controller="w-dialog"
|
||||
data-action="w-dialog:hide->w-dialog#hide w-dialog:show->w-dialog#show"
|
||||
>
|
||||
<div role="document">
|
||||
<div id="dialog-body" data-w-dialog-target="body">CONTENT</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
* <div
|
||||
* data-controller="w-dialog"
|
||||
* data-w-dialog-theme-value="floating"
|
||||
* >
|
||||
* <div data-w-dialog-target="body"></div>
|
||||
* </div>
|
||||
*/
|
||||
export class DialogController extends Controller<HTMLElement> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
161
client/src/controllers/TeleportController.test.js
Normal file
161
client/src/controllers/TeleportController.test.js
Normal file
@ -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 = `
|
||||
<main>
|
||||
<template id="template" data-controller="w-teleport">
|
||||
<div id="content">Some content</div>
|
||||
</template>
|
||||
</main>`;
|
||||
|
||||
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 += `
|
||||
<div id="target-container"></div>
|
||||
`;
|
||||
|
||||
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(
|
||||
'<div id="content">Some content</div>',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<div id="content">Some content</div>',
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
97
client/src/controllers/TeleportController.ts
Normal file
97
client/src/controllers/TeleportController.ts
Normal file
@ -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
|
||||
* <aside>
|
||||
* <template
|
||||
* data-controller="w-teleport"
|
||||
* data-w-teleport-target-value="#other-location"
|
||||
* >
|
||||
* <div class="content-to-clone">Some content</div>
|
||||
* </template>
|
||||
* <div id="other-location"></div>
|
||||
* </aside>
|
||||
*/
|
||||
export class TeleportController extends Controller<HTMLTemplateElement> {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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' },
|
||||
];
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user