0
0
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:
Lovelyfin00 2023-05-02 10:04:53 +01:00 committed by LB (Ben Johnston)
parent a15f7d188b
commit c556acef35
6 changed files with 447 additions and 38 deletions

View 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();
});
});
});

View File

@ -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();
}
}

View 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',
},
]);
});
});
});

View 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;
}
}

View File

@ -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' },
];

View File

@ -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();