import { ApproxStructure, Assertions, FocusTools, Keyboard, Keys, Mouse, type StructAssert, type TestStore, UiFinder } from '@ephox/agar'; import { after, afterEach, before, beforeEach, describe, it } from '@ephox/bedrock-client'; import { Result, Type } from '@ephox/katamari'; import { PlatformDetection } from '@ephox/sand'; import { Attribute, Class, Compare, SugarBody, SugarDocument, TextContent } from '@ephox/sugar'; import * as AddEventsBehaviour from 'ephox/alloy/api/behaviour/AddEventsBehaviour'; import * as Behaviour from 'ephox/alloy/api/behaviour/Behaviour'; import { Focusing } from 'ephox/alloy/api/behaviour/Focusing'; import { Tabstopping } from 'ephox/alloy/api/behaviour/Tabstopping'; import { Toggling } from 'ephox/alloy/api/behaviour/Toggling'; import type { AlloyComponent } from 'ephox/alloy/api/component/ComponentApi'; import * as GuiFactory from 'ephox/alloy/api/component/GuiFactory'; import * as AlloyEvents from 'ephox/alloy/api/events/AlloyEvents'; import * as SystemEvents from 'ephox/alloy/api/events/SystemEvents'; import * as Attachment from 'ephox/alloy/api/system/Attachment'; import * as Gui from 'ephox/alloy/api/system/Gui'; import { Button } from 'ephox/alloy/api/ui/Button'; import { Container } from 'ephox/alloy/api/ui/Container'; import { ModalDialog } from 'ephox/alloy/api/ui/ModalDialog'; import * as GuiSetup from 'ephox/alloy/test/GuiSetup'; import * as Sinks from 'ephox/alloy/test/Sinks'; describe('browser.alloy.ui.dialog.ModalDialogTest', () => { const focusAndTab = Behaviour.derive([ Focusing.config({ }), Tabstopping.config({ }) ]); const pDraghandle = ModalDialog.parts.draghandle({ dom: { tag: 'div', styles: { width: '100px', height: '40px', background: 'black' } } }); const pTitle = ModalDialog.parts.title({ dom: { tag: 'div', innerHtml: 'Title', classes: [ 'test-dialog-title' ] }, components: [ ] }); const pClose = ModalDialog.parts.close( Button.sketch({ dom: { tag: 'button', classes: [ 'test-dialog-close' ], innerHtml: 'X', attributes: { 'type': 'button', 'title': 'Close', 'aria-label': 'Close' } }, buttonBehaviours: Behaviour.derive([ Tabstopping.config({}) ]), components: [ ] }) ); const pBody = ModalDialog.parts.body({ dom: { tag: 'div', classes: [ 'test-dialog-body' ] }, behaviours: Behaviour.derive([ Tabstopping.config({ }), Focusing.config({ }), Toggling.config({ toggleClass: 'untabbable' }) ]), components: [ Container.sketch({ dom: { innerHtml: '

This is something else

' } }) ] }); const pFooter = ModalDialog.parts.footer({ dom: { tag: 'div', classes: [ 'test-dialog-footer' ], styles: { // Needs size to get focus. height: '10px', border: '1px solid green' } }, behaviours: focusAndTab, components: [ ] }); const makeDialog = (store: TestStore, sink: AlloyComponent) => { const dialog = GuiFactory.build( ModalDialog.sketch({ dom: { tag: 'div', classes: [ 'test-dialog' ], styles: { background: 'white' } }, components: [ pDraghandle, pTitle, pClose, pBody, pFooter ], dragBlockClass: 'drag-blocker', lazySink: (comp: AlloyComponent) => { Assertions.assertEq('Checking dialog passed through to lazySink', true, Compare.eq(comp.element, dialog.element)); return Result.value(sink); }, useTabstopAt: (elem) => { return !Class.has(elem, 'untabbable'); }, firstTabstop: 1, modalBehaviours: Behaviour.derive([ AddEventsBehaviour.config('modal-events-1', [ AlloyEvents.runOnAttached(store.adder('modal.attached.1')) ]), AddEventsBehaviour.config('modal-events-2', [ AlloyEvents.runOnAttached(store.adder('modal.attached.2')) ]) ]), eventOrder: { [SystemEvents.attachedToDom()]: [ 'modal-events-1', 'modal-events-2' ] }, onEscape: store.adderH('dialog.escape'), onExecute: store.adderH('dialog.execute'), parts: { blocker: { dom: { tag: 'div', styles: { 'z-index': '1000000000', 'background': 'rgba(0, 0, 100, 0.5)' }, classes: [ 'test-dialog-blocker' ] } } } }) ); return dialog; }; const otherMothership = Gui.create(); const sink = Sinks.relativeSink(); otherMothership.add(sink); const hook = GuiSetup.bddSetup( (store) => makeDialog(store, sink) ); const dialogSelectors = { dialog: '.test-dialog', title: '.test-dialog-title', body: '.test-dialog-body', footer: '.test-dialog-footer', close: '.test-dialog-close' }; const checkDialogStructure = (label: string, expected: StructAssert) => { const dialog = UiFinder.findIn(SugarBody.body(), dialogSelectors.dialog).getOrDie(); Assertions.assertStructure(label, expected, dialog); }; before(() => { Attachment.attachSystem(hook.body(), otherMothership); }); beforeEach(() => { hook.store().clear(); const dialog = hook.component(); ModalDialog.show(dialog); }); after(() => { Attachment.detachSystem(otherMothership); }); afterEach(() => { ModalDialog.hide(hook.component()); }); it('TINY-9520: Attached event should have fired ', () => { const store = hook.store(); const doc = SugarDocument.getDocument(); store.assertEq('Should equal to attach event', [ 'modal.attached.1', 'modal.attached.2' ]); store.clear(); store.assertEq('Should be clear before and ', [ ]); Keyboard.activeKeydown(doc, Keys.enter(), { }); store.assertEq('After pressing ', [ 'dialog.execute' ]); store.clear(); Keyboard.activeKeyup(doc, Keys.escape(), { }); store.assertEq('After pressing ', [ 'dialog.escape' ]); }); it('TINY-9520: Check dialog structure', () => { const dialog = hook.component(); checkDialogStructure('After showing', ApproxStructure.build((s, str, arr) => s.element('div', { attrs: { 'aria-modal': str.is('true'), 'role': str.is('dialog') }, classes: [ arr.has('test-dialog') ], children: [ s.element('div', { }), s.element('div', { html: str.is('Title'), classes: [ arr.has('test-dialog-title') ] }), s.element('button', { html: str.is('X') }), s.element('div', { classes: [ arr.has('test-dialog-body') ], children: [ s.element('div', { children: [ s.element('p', { html: str.is('This is something else') }) ] }) ] }), s.element('div', { classes: [ arr.has('test-dialog-footer') ] }) ] }))); const body = ModalDialog.getBody(dialog); Assertions.assertStructure('Checking body of dialog', ApproxStructure.build((s, _str, arr) => s.element('div', { classes: [ arr.has('test-dialog-body') ] })), body.element); const footer = ModalDialog.getFooter(dialog); Assertions.assertStructure('Checking footer of dialog', ApproxStructure.build((s, _str, arr) => s.element('div', { classes: [ arr.has('test-dialog-footer') ] })), footer.getOrDie().element); }); it('TINY-9520: Checking aria attribute of dialog', () => { const os = PlatformDetection.detect().os; const dialog = hook.component(); const dialogTitle = UiFinder.findIn(dialog.element, dialogSelectors.title).getOrDie(); if (os.isMacOS()) { const ariaLabel = TextContent.get(dialogTitle); Assertions.assertEq('Dialog aria-labelledby should not be set for MacOS', true, !Attribute.has(dialog.element, 'aria-labelledby')); Assertions.assertEq('aria-label should not be empty', true, Type.isNonNullable(ariaLabel) && ariaLabel.length > 0); Assertions.assertEq('Dialog aria-label should be the same as header title', Attribute.get(dialog.element, 'aria-label'), ariaLabel); } else { const titleId = Attribute.getOpt(dialogTitle, 'id').getOr(''); Assertions.assertEq('titleId should be set', true, Attribute.has(dialogTitle, 'id')); Assertions.assertEq('titleId should not be empty', true, titleId.length > 0); const dialogLabelledBy = Attribute.get(dialog.element, 'aria-labelledby'); Assertions.assertEq('Dialog aria-labelledby should be equal to title id', titleId, dialogLabelledBy); } }); it('TINY-9520: Focus testing', async () => { const dialog = hook.component(); const doc = SugarDocument.getDocument(); await FocusTools.pTryOnSelector('Focus should be on body now', doc, dialogSelectors.body); Keyboard.activeKeydown(doc, Keys.tab(), { }); await FocusTools.pTryOnSelector('Focus should be on footer now', doc, dialogSelectors.footer); Keyboard.activeKeydown(doc, Keys.tab(), { }); await FocusTools.pTryOnSelector('Focus should be on X close button now', doc, dialogSelectors.close); Keyboard.activeKeydown(doc, Keys.tab(), { shift: true }); await FocusTools.pTryOnSelector('Focus should be back to footer now', doc, dialogSelectors.footer); Keyboard.activeKeydown(doc, Keys.tab(), { shift: true }); await FocusTools.pTryOnSelector('Focus should be back to body now', doc, dialogSelectors.body); Keyboard.activeKeydown(doc, Keys.tab(), { shift: true }); await FocusTools.pTryOnSelector('Focus should be back to X close button now', doc, dialogSelectors.close); const dialogBody = ModalDialog.getBody(dialog); Toggling.on(dialogBody); Keyboard.activeKeydown(doc, Keys.tab(), { }); // Skipping body, jumping directly to footer await FocusTools.pTryOnSelector('Focus should skip untabbable body', doc, dialogSelectors.footer); }); it('TINY-9520: Clicking on blocker', async () => { const doc = SugarDocument.getDocument(); Mouse.clickOn(doc, '.test-dialog-blocker'); await FocusTools.pTryOnSelector('Focus should move to first focusable element when clicking the blocker', doc, dialogSelectors.body); }); it('TINY-10056: Clicking on blocker shouldn\'t focus first focusable when dialog is blocked', async () => { const dialog = hook.component(); const doc = SugarDocument.getDocument(); ModalDialog.setBusy(dialog, (_d, bs) => ({ dom: { tag: 'div', classes: [ 'test-busy-class' ], innerHtml: 'Loading', }, behaviours: bs })); Mouse.clickOn(doc, '.test-dialog-blocker'); await FocusTools.pTryOnSelector('Focus should move to blocker element when clicking the blocker', doc, '.test-busy-class'); Mouse.clickOn(doc, '.test-dialog'); await FocusTools.pTryOnSelector('Focus should move to blocker element when clicking the blocker', doc, '.test-busy-class'); ModalDialog.setIdle(dialog); }); it('TINY-9520: Dialog busy test', async () => { const dialog = hook.component(); const doc = SugarDocument.getDocument(); checkDialogStructure('Checking initial structure after showing (not busy)', ApproxStructure.build((s, str, arr) => s.element('div', { attrs: { 'aria-modal': str.is('true'), 'role': str.is('dialog') }, classes: [ arr.has('test-dialog') ], children: [ s.element('div', { }), s.element('div', { html: str.is('Title'), classes: [ arr.has('test-dialog-title') ] }), s.element('button', { html: str.is('X') }), s.element('div', { classes: [ arr.has('test-dialog-body') ], children: [ s.element('div', { children: [ s.element('p', { html: str.is('This is something else') }) ] }) ] }), s.element('div', { classes: [ arr.has('test-dialog-footer') ] }) ] }))); ModalDialog.setBusy(dialog, (_d, bs) => ({ dom: { tag: 'div', classes: [ 'test-busy-class' ], innerHtml: 'Loading', }, behaviours: bs })); checkDialogStructure( 'Checking setBusy structure', ApproxStructure.build((s, str, arr) => s.element('div', { classes: [ arr.has('test-dialog') ], attrs: { 'aria-busy': str.is('true') }, children: [ s.anything(), // Draghandle s.anything(), // Title s.anything(), // X s.element('div', { classes: [ arr.has('test-dialog-body') ] }), s.element('div', { classes: [ arr.has('test-dialog-footer') ] }), s.element('div', { classes: [ arr.has('test-busy-class') ], html: str.is('Loading') }) ] })) ); await FocusTools.pTryOnSelector('Focus should be on loading message', doc, '.test-busy-class'); // NOTE: Without real key testing ... this isn't really that useful. Keyboard.sKeydown(doc, Keys.tab(), { }); await FocusTools.pTryOnSelector('Focus should STILL be on loading message', doc, '.test-busy-class'); ModalDialog.setBusy(dialog, (_bs) => ({ dom: { tag: 'div', classes: [ 'test-busy-second-class' ], innerHtml: 'Still loading' } })); checkDialogStructure( 'Checking second setBusy structure', ApproxStructure.build((s, str, arr) => s.element('div', { classes: [ arr.has('test-dialog') ], attrs: { 'aria-busy': str.is('true') }, children: [ s.anything(), // Draghandle s.anything(), // Title s.anything(), // X s.element('div', { classes: [ arr.has('test-dialog-body') ] }), s.element('div', { classes: [ arr.has('test-dialog-footer') ] }), s.element('div', { classes: [ arr.has('test-busy-second-class') ], html: str.is('Still loading') }) ] })) ); ModalDialog.setIdle(dialog); checkDialogStructure( 'Checking setIdle structure', ApproxStructure.build((s, str, arr) => s.element('div', { classes: [ arr.has('test-dialog') ], attrs: { 'aria-busy': str.none() }, children: [ s.anything(), // Draghandle s.anything(), // Title s.anything(), // X s.element('div', { classes: [ arr.has('test-dialog-body') ] }), s.element('div', { classes: [ arr.has('test-dialog-footer') ] }) ] })) ); }); });