UNPKG

@lion/ui

Version:

A package of extendable web components

1,358 lines (1,179 loc) 76.1 kB
/* eslint-disable no-new */ import { OverlayController, overlays } from '@lion/ui/overlays.js'; import { mimicClick } from '@lion/ui/overlays-test-helpers.js'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import { unsafeStatic, fixtureSync, defineCE, aTimeout, fixture, expect, html, } from '@open-wc/testing'; import { isActiveElement } from '../../core/test-helpers/isActiveElement.js'; import { createShadowHost } from '../test-helpers/createShadowHost.js'; import { _adoptStyleUtils } from '../src/utils/adopt-styles.js'; import { simulateTab } from '../src/utils/simulate-tab.js'; import { keyCodes } from '../src/utils/key-codes.js'; /** * @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig */ const wrappingDialogNodeStyle = 'display: none; z-index: 9999; padding: 0px;'; /** * A small wrapper function that closely mimics an escape press from a user * (prevents common mistakes like no bubbling or keydown) * @param {HTMLElement|Document} element */ async function mimicEscapePress(element) { // Make sure that the element inside the dialog is focusable (and cleanup after) if (element instanceof HTMLElement) { const { tabIndex: tabIndexBefore } = element; // eslint-disable-next-line no-param-reassign element.tabIndex = -1; element.focus(); // eslint-disable-next-line no-param-reassign element.tabIndex = tabIndexBefore; // make sure element is focusable } // Send the event await sendKeys({ press: 'Escape' }); // Wait for at least a microtask, so that possible property effects are performed await aTimeout(0); } /** * @param {OverlayController} ctrlToFind * @returns {boolean} */ function isRegisteredOnManager(ctrlToFind) { return Boolean(ctrlToFind.manager?.list?.find(ctrl => ctrlToFind === ctrl)); } /** * Make sure that all browsers serialize html in a similar way * (Firefox tends to output empty style attrs) * @param {HTMLElement} node */ function normalizeOverlayContentWapper(node) { if (node.hasAttribute('style') && !node.style.cssText) { node.removeAttribute('style'); } } /** * @param {OverlayController} overlayControllerEl */ function getProtectedMembers(overlayControllerEl) { // @ts-ignore const { _contentId: contentId, _renderTarget: renderTarget } = overlayControllerEl; return { contentId, renderTarget, }; } /** * @param {HTMLElement} element * */ const isInViewport = element => { const rect = element.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); }; const withGlobalTestConfig = () => /** @type {OverlayConfig} */ ({ placementMode: 'global', contentNode: /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`)), }); const withLocalTestConfig = () => /** @type {OverlayConfig} */ ({ placementMode: 'local', contentNode: /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`)), invokerNode: /** @type {HTMLElement} */ ( fixtureSync(html` <div role="button" style="width: 100px; height: 20px;">Invoker</div> `) ), }); /** * @param {HTMLDivElement} parentContent * @returns {Promise<{parentOverlay: OverlayController; childOverlay: OverlayController}>} */ async function createNestedEscControllers(parentContent) { const childContent = /** @type {HTMLDivElement} */ (parentContent.querySelector('div[id]')); // Assert valid fixure const isValidFixture = (parentContent.id.startsWith('parent-overlay--hidesOnEsc') || parentContent.id.startsWith('parent-overlay--hidesOnOutsideEsc')) && (childContent.id.startsWith('child-overlay--hidesOnEsc') || childContent.id.startsWith('child-overlay--hidesOnOutsideEsc')); if (!isValidFixture) { throw new Error('Provide a valid fixture'); } if (parentContent.hasAttribute('data-convert-to-shadow-root')) { const shadowRootParent = parentContent.attachShadow({ mode: 'open' }); shadowRootParent.appendChild(childContent); } const parentHasOutsideOnEsc = parentContent.id.startsWith('parent-overlay--hidesOnOutsideEsc'); const childHasOutsideOnEsc = childContent.id.startsWith('child-overlay--hidesOnOutsideEsc'); const parentConfig = parentHasOutsideOnEsc ? { hidesOnOutsideEsc: true } : { hidesOnEsc: true }; const childConfig = childHasOutsideOnEsc ? { hidesOnOutsideEsc: true } : { hidesOnEsc: true }; const parentOverlay = new OverlayController({ ...withGlobalTestConfig(), contentNode: parentContent, ...parentConfig, }); const childOverlay = new OverlayController({ ...withGlobalTestConfig(), contentNode: childContent, ...childConfig, }); await parentOverlay.show(); await childOverlay.show(); return { parentOverlay, childOverlay }; } afterEach(() => { overlays.teardown(); }); describe('OverlayController', () => { describe('Init', () => { it('adds OverlayController instance to OverlayManager', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); expect(ctrl.manager).to.equal(overlays); expect(overlays.list).to.include(ctrl); }); it('prepares a content node wrapper', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); expect(ctrl.content).not.to.be.undefined; expect(ctrl.contentNode.parentElement).to.equal(ctrl.contentWrapperNode); }); describe('Stylesheets', () => { it('calls adoptStyles', async () => { const spy = sinon.spy(_adoptStyleUtils, 'adoptStyle'); const { shadowHost, cleanupShadowHost } = createShadowHost(); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>contentful</div>')); shadowHost.appendChild(contentNode); new OverlayController({ ...withLocalTestConfig(), contentNode, }); expect(spy).to.have.been.called; cleanupShadowHost(); }); }); describe('Z-index on local overlays', () => { /** @type {HTMLElement} */ let contentNode; /** * @param {string} zIndexVal * @param {{ mode?: string }} options */ async function createZNode(zIndexVal, { mode } = {}) { if (mode === 'global') { contentNode = /** @type {HTMLElement} */ ( await fixture(html` <div class="z-index--${zIndexVal}"> <style> .z-index--${zIndexVal} { z-index: ${zIndexVal}; } </style> I should be on top </div> `) ); } if (mode === 'inline') { contentNode = /** @type {HTMLElement} */ ( await fixture(html` <div>I should be on top</div> `) ); contentNode.style.zIndex = zIndexVal; } return contentNode; } it('sets a z-index to make sure overlay is painted on top of siblings', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode: await createZNode('auto', { mode: 'global' }), }); await ctrl.show(); // @ts-expect-error find out why config would/could be undfined expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) }); await ctrl.show(); // @ts-expect-error find out why config would/could be undfined expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'global' }) }); await ctrl.show(); // @ts-expect-error find out why config would/could be undfined expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'inline' }) }); await ctrl.show(); // @ts-expect-error find out why config would/could be undfined expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); }); it.skip("doesn't set a z-index when contentNode already has >= 1", async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode: await createZNode('1', { mode: 'global' }), }); await ctrl.show(); expect(ctrl.content.style.zIndex).to.equal(''); ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) }); await ctrl.show(); expect(ctrl.content.style.zIndex).to.equal(''); ctrl.updateConfig({ contentNode: await createZNode('2', { mode: 'global' }) }); await ctrl.show(); expect(ctrl.content.style.zIndex).to.equal(''); ctrl.updateConfig({ contentNode: await createZNode('2', { mode: 'inline' }) }); await ctrl.show(); expect(ctrl.content.style.zIndex).to.equal(''); }); it("doesn't touch the value of .contentNode", async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode: await createZNode('auto', { mode: 'global' }), }); expect(ctrl.contentNode.style.zIndex).to.equal(''); }); }); describe('Offline content', () => { it('throws when passing a content node that was created "offline"', async () => { const contentNode = document.createElement('div'); const createOverlayController = () => { new OverlayController({ ...withLocalTestConfig(), contentNode, }); }; expect(createOverlayController).to.throw( '[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.', ); }); it('succeeds when passing a content node that was created "online"', async () => { const contentNode = /** @type {HTMLElement} */ (fixtureSync('<div>')); const overlay = new OverlayController({ ...withLocalTestConfig(), contentNode, }); expect(overlay.contentNode.isConnected).to.be.true; }); }); }); // TODO: Add more teardown feature tests describe('Teardown', () => { it('unregisters itself from overlayManager', async () => { const ctrl = new OverlayController(withGlobalTestConfig()); expect(isRegisteredOnManager(ctrl)).to.be.true; ctrl.teardown(); expect(isRegisteredOnManager(ctrl)).to.be.false; }); }); describe('Node Configuration', () => { describe('Content', async () => { it('accepts a .contentNode for displaying content of the overlay', async () => { const myContentNode = /** @type {HTMLElement} */ (fixtureSync('<p>direct node</p>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), contentNode: myContentNode, }); expect(ctrl.contentNode).to.have.trimmed.text('direct node'); expect(ctrl.contentNode).to.equal(myContentNode); }); describe('Embedded dom structure', async () => { describe('When projected in shadow dom', async () => { it('wraps a .contentWrapperNode for style application and a <dialog role="none"> for top layer paints', async () => { const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = '<slot name="content"></slot>'; this.innerHTML = '<div slot="content">projected</div>'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`)); const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), contentNode: myContentNode, }); expect(ctrl.contentNode.assignedSlot?.parentElement).to.equal(ctrl.contentWrapperNode); expect(ctrl.contentWrapperNode.parentElement?.tagName).to.equal('DIALOG'); normalizeOverlayContentWapper(ctrl.contentWrapperNode); // The total dom structure created... expect(el).shadowDom.to.equal(` <dialog data-overlay-outer-wrapper="" open="" role="none" style="${wrappingDialogNodeStyle}"> <div data-id="content-wrapper"> <slot name="content"> </slot> </div> </dialog> `); expect(el).lightDom.to.equal(`<div slot="content">projected</div>`); }); }); describe('When in light dom', async () => { it('wraps a .contentWrapperNode for style application and a <dialog role="none"> for top layer paints', async () => { const el = fixtureSync('<section><div id="content">non projected</div></section>'); const myContentNode = /** @type {HTMLElement} */ (el.querySelector('#content')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), contentNode: myContentNode, }); expect(ctrl.contentNode.parentElement).to.equal(ctrl.contentWrapperNode); expect(ctrl.contentWrapperNode.parentElement?.tagName).to.equal('DIALOG'); normalizeOverlayContentWapper(ctrl.contentWrapperNode); // The total dom structure created... expect(el).lightDom.to.equal(` <dialog data-overlay-outer-wrapper="" open="" role="none" style="${wrappingDialogNodeStyle}"> <div data-id="content-wrapper"> <div id="content">non projected</div> </div> </dialog> `); }); }); describe('When .contenWrapperNode provided', async () => { it('keeps the .contentWrapperNode for style application and wraps a <dialog role="none"> for top layer paints', async () => { const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = '<div><slot name="content"></slot></div>'; this.innerHTML = '<div slot="content">projected</div>'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`)); const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); const myContentWrapper = /** @type {HTMLElement} */ ( el.shadowRoot?.querySelector('div') ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), contentNode: myContentNode, contentWrapperNode: myContentWrapper, }); normalizeOverlayContentWapper(ctrl.contentWrapperNode); // The total dom structure created... expect(el).shadowDom.to.equal(` <dialog data-overlay-outer-wrapper="" open="" role="none" style="${wrappingDialogNodeStyle}"> <div data-id="content-wrapper"> <slot name="content"></slot> </div> </dialog> `); }); it("uses the .contentWrapperNode as container for Popper's arrow", async () => { const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = ` <div> <div id="arrow"></div> <slot name="content"></slot> </div>`; this.innerHTML = '<div slot="content">projected</div>'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`)); const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); const myContentWrapper = /** @type {HTMLElement} */ ( el.shadowRoot?.querySelector('div') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode: myContentNode, contentWrapperNode: myContentWrapper, }); normalizeOverlayContentWapper(ctrl.contentWrapperNode); // The total dom structure created... expect(el).shadowDom.to.equal(` <dialog data-overlay-outer-wrapper="" open="" role="none" style="${wrappingDialogNodeStyle}"> <div data-id="content-wrapper"> <div id="arrow"></div> <slot name="content"></slot> </div> </dialog> `); }); }); }); }); describe('Invoker / Reference', async () => { it('accepts a .invokerNode to directly set invoker', async () => { const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('<button>invoke</button>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), invokerNode: myInvokerNode, }); expect(ctrl.invokerNode).to.equal(myInvokerNode); expect(ctrl.referenceNode).to.equal(undefined); }); it('accepts a .referenceNode as positioning anchor different from .invokerNode', async () => { const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('<button>invoke</button>')); const myReferenceNode = /** @type {HTMLElement} */ (fixtureSync('<div>anchor</div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), invokerNode: myInvokerNode, referenceNode: myReferenceNode, }); expect(ctrl.referenceNode).to.equal(myReferenceNode); expect(ctrl.invokerNode).to.not.equal(ctrl.referenceNode); }); }); describe('Backdrop', () => { it('creates a .backdropNode inside <dialog> for guaranteed top layer paints and positioning opportunities', async () => { const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = '<slot name="content"></slot>'; this.innerHTML = '<div slot="content">projected</div>'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}></${tagString}>`)); const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), contentNode: myContentNode, hasBackdrop: true, }); normalizeOverlayContentWapper(ctrl.contentWrapperNode); // The total dom structure created... expect(el).shadowDom.to.equal( ` <dialog data-overlay-outer-wrapper="" open="" role="none" style="${wrappingDialogNodeStyle}"> <div class="overlays__backdrop"></div> <div data-id="content-wrapper"> <slot name="content"> </slot> </div> </dialog> `, ); }); }); describe('When contentWrapperNode needs to be provided for correct arrow positioning', () => { it('uses contentWrapperNode as provided for local positioning', async () => { const el = /** @type {HTMLElement} */ ( await fixture(html` <div id="contentWrapperNode"> <div id="contentNode"></div> <my-arrow></my-arrow> </div> `) ); const contentNode = /** @type {HTMLElement} */ (el.querySelector('#contentNode')); const contentWrapperNode = el; const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode, contentWrapperNode, }); expect(ctrl.contentWrapperNode).to.equal(contentWrapperNode); }); }); }); describe('Feature Configuration', () => { describe('trapsKeyboardFocus', () => { it('offers an hasActiveTrapsKeyboardFocus flag', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, }); expect(ctrl.hasActiveTrapsKeyboardFocus).to.be.false; await ctrl.show(); expect(ctrl.hasActiveTrapsKeyboardFocus).to.be.true; }); it('focuses the overlay on show', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, }); await ctrl.show(); expect(isActiveElement(ctrl.contentNode)).to.be.true; }); it('keeps focus within the overlay e.g. you can not tab out by accident', async () => { const contentNode = /** @type {HTMLElement} */ ( await fixture(html` <div><input id="input1" /><input id="input2" /></div> `) ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, contentNode, }); await ctrl.show(); const elOutside = /** @type {HTMLElement} */ ( await fixture(html`<button>click me</button>`) ); const input1 = ctrl.contentNode.querySelectorAll('input')[0]; const input2 = ctrl.contentNode.querySelectorAll('input')[1]; input2.focus(); // this mimics a tab within the contain-focus system used const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); // @ts-ignore override private key event.keyCode = keyCodes.tab; window.dispatchEvent(event); expect(isActiveElement(elOutside)).to.be.false; expect(isActiveElement(input1)).to.be.true; }); it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture(html`<div><input /></div>`)); const ctrl = new OverlayController({ ...withGlobalTestConfig(), contentNode, trapsKeyboardFocus: false, }); // add element to dom to allow focus /** @type {HTMLElement} */ (await fixture(html`${ctrl.content}`)); await ctrl.show(); const elOutside = /** @type {HTMLElement} */ (await fixture(html`<input />`)); const input = /** @type {HTMLInputElement} */ (ctrl.contentNode.querySelector('input')); input.focus(); simulateTab(); expect(isActiveElement(elOutside)).to.be.true; }); it('keeps focus within overlay with multiple overlays with all traps on true', async () => { const ctrl0 = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, }); const ctrl1 = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, }); await ctrl0.show(); await ctrl1.show(); expect(ctrl0.hasActiveTrapsKeyboardFocus).to.be.false; expect(ctrl1.hasActiveTrapsKeyboardFocus).to.be.true; await ctrl1.hide(); expect(ctrl0.hasActiveTrapsKeyboardFocus).to.be.true; expect(ctrl1.hasActiveTrapsKeyboardFocus).to.be.false; }); it('warns when contentNode is a host for a shadowRoot', async () => { const warnSpy = sinon.spy(console, 'warn'); const contentNode = /** @type {HTMLDivElement} */ (await fixture(html` <div></div> `)); contentNode.attachShadow({ mode: 'open' }); const shadowRootContentNode = /** @type {HTMLElement} */ ( await fixture(html` <div><input id="input1" /><input id="input2" /></div> `) ); contentNode.shadowRoot?.appendChild(shadowRootContentNode); const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, contentNode, }); await ctrl.show(); // @ts-expect-error expect(console.warn.args[0][0]).to.equal( '[overlays]: For best accessibility (compatibility with Safari + VoiceOver), provide a contentNode that is not a host for a shadow root', ); warnSpy.restore(); }); }); describe('hidesOnEsc', () => { it('hides on [Escape] press', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnEsc: true, }); await ctrl.show(); await mimicEscapePress(ctrl.contentNode); expect(ctrl.isShown).to.be.false; }); it('stays shown on [Escape] press with `.hidesOnEsc` set to false', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnEsc: false, }); await ctrl.show(); await mimicEscapePress(ctrl.contentNode); expect(ctrl.isShown).to.be.true; }); it('stays shown on [Escape] press on outside element', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnEsc: true, }); await ctrl.show(); await mimicEscapePress(document); expect(ctrl.isShown).to.be.true; }); it('stays shown on [Escape] press with modal <dialog> and `.hidesOnEsc` is false', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, hidesOnEsc: false, }); await ctrl.show(); await mimicEscapePress(ctrl.contentNode); expect(ctrl.isShown).to.be.true; }); it('parent stays shown on [Escape] press in a nested overlay', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- --> <div id="parent-overlay--hidesOnEsc"> <div id="child-overlay--hidesOnEsc">we press [Escape] here</div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(childOverlay.contentNode); expect(parentOverlay.isShown).to.be.true; expect(childOverlay.isShown).to.be.false; }); }); describe('hidesOnOutsideEsc', () => { it('hides on [Escape] press on outside element', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideEsc: true, }); await ctrl.show(); await mimicEscapePress(document); expect(ctrl.isShown).to.be.false; }); it('stays shown on [Escape] press on inside element', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideEsc: true, }); await ctrl.show(); await mimicEscapePress(ctrl.contentNode); expect(ctrl.isShown).to.be.true; }); it('stays shown on [Escape] press in a nested overlay', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- --> <div id="parent-overlay--hidesOnOutsideEsc"> <div id="child-overlay--hidesOnOutsideEsc">we press [Escape] here</div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); mimicEscapePress(childOverlay.contentNode); expect(parentOverlay.isShown).to.be.true; expect(childOverlay.isShown).to.be.true; }); }); describe('Nested hidesOnEsc / hidesOnOutsideEsc', () => { describe('Parent has hidesOnEsc and child has hidesOnOutsideEsc', () => { it('on [Escape] press in child overlay: parent hides, child stays shown', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- --> <div id="parent-overlay--hidesOnEsc"> <div id="child-overlay--hidesOnOutsideEsc">we press [Escape] here</div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(childOverlay.contentNode); expect(parentOverlay.isShown).to.be.false; expect(childOverlay.isShown).to.be.true; await childOverlay.teardown(); await parentOverlay.teardown(); }); it('on [Escape] press outside overlays: parent stays shown, child hides', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- we press [Escape] here --> <div id="parent-overlay--hidesOnEsc"> <div id="child-overlay--hidesOnOutsideEsc"></div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(document); expect(parentOverlay.isShown).to.be.true; expect(childOverlay.isShown).to.be.false; }); it('on [Escape] press in parent overlay: parent is hidden, child is hidden', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- --> <div id="parent-overlay--hidesOnEsc"> we press [Escape] here <div id="child-overlay--hidesOnOutsideEsc"></div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(parentContent); expect(parentOverlay.isShown).to.be.false; expect(childOverlay.isShown).to.be.false; }); }); describe('Parent has hidesOnOutsideEsc and child has hidesOnEsc', () => { it('on [Escape] press in child overlay: parent stays shown, child hides', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- --> <div id="parent-overlay--hidesOnOutsideEsc"> <div id="child-overlay--hidesOnEsc">we press [Escape] here</div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(childOverlay.contentNode); expect(parentOverlay.isShown).to.be.true; expect(childOverlay.isShown).to.be.false; }); it('on [Escape] press outside overlays: parent hides, child stays shown', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- we press [Escape] here --> <div id="parent-overlay--hidesOnOutsideEsc"> <div id="child-overlay--hidesOnEsc"></div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(document); expect(parentOverlay.isShown).to.be.false; expect(childOverlay.isShown).to.be.true; }); it('on [Escape] press in parent overlay: parent hides, child stays shown', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- we press [Escape] here --> <div id="parent-overlay--hidesOnOutsideEsc"> <div id="child-overlay--hidesOnEsc"></div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(document); expect(parentOverlay.isShown).to.be.false; expect(childOverlay.isShown).to.be.true; }); }); describe('With shadow dom', () => { it('on [Escape] press in child overlay in shadow root: parent hides, child stays shown', async () => { const parentContent = /** @type {HTMLDivElement} */ ( await fixture( html` <!-- --> <div id="parent-overlay--hidesOnEsc" data-convert-to-shadow-root> <div id="child-overlay--hidesOnOutsideEsc">we press [Escape] here</div> </div>`, ) ); const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent); await mimicEscapePress(childOverlay.contentNode); expect(parentOverlay.isShown).to.be.false; expect(childOverlay.isShown).to.be.true; }); }); }); describe('hidesOnOutsideClick', () => { it('hides on outside click', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, contentNode, }); await ctrl.show(); mimicClick(document.body); await aTimeout(0); expect(ctrl.isShown).to.be.false; await ctrl.show(); await mimicClick(document.body, { isAsync: true }); await aTimeout(0); expect(ctrl.isShown).to.be.false; }); it('doesn\'t hide on "inside" click', async () => { const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>')); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); await ctrl.show(); // Don't hide on invoker click ctrl.invokerNode?.click(); await aTimeout(0); expect(ctrl.isShown).to.be.true; // Don't hide on inside (content) click ctrl.contentNode.click(); await aTimeout(0); expect(ctrl.isShown).to.be.true; // Don't hide on inside mousedown & outside mouseup await mimicClick(ctrl.contentNode, { releaseElement: document.body, isAsync: true }); await aTimeout(0); expect(ctrl.isShown).to.be.true; // Important to check if it can be still shown after, because we do some hacks inside await ctrl.hide(); expect(ctrl.isShown).to.be.false; await ctrl.show(); expect(ctrl.isShown).to.be.true; }); it('only hides when both mousedown and mouseup events are outside', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode: /** @type {HTMLElement} */ ( fixtureSync(html` <div role="button" style="width: 100px; height: 20px;">Invoker</div> `) ), }); await ctrl.show(); mimicClick(document.body, { releaseElement: contentNode }); await aTimeout(0); expect(ctrl.isShown).to.be.true; mimicClick(contentNode, { releaseElement: document.body }); await aTimeout(0); expect(ctrl.isShown).to.be.true; mimicClick(document.body, { releaseElement: /** @type {HTMLElement} */ (ctrl.invokerNode), }); await aTimeout(0); expect(ctrl.isShown).to.be.true; mimicClick(/** @type {HTMLElement} */ (ctrl.invokerNode), { releaseElement: document.body, }); await aTimeout(0); expect(ctrl.isShown).to.be.true; mimicClick(document.body); await aTimeout(0); expect(ctrl.isShown).to.be.false; }); it('doesn\'t hide on "inside sub shadow dom" click', async () => { const invokerNode = /** @type {HTMLElement} */ (await fixture('<button>Invoker</button>')); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); await ctrl.show(); // Works as well when clicked content element lives in shadow dom const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = '<div><button>click me</button></div>'; } }, ); const tag = unsafeStatic(tagString); ctrl.updateConfig({ contentNode: /** @type {HTMLElement} */ ( await fixture(html` <div> <div>Content</div> <${tag}></${tag}> </div> `) ), }); await ctrl.show(); // Don't hide on inside shadowDom click /** @type {ShadowRoot} */ // @ts-expect-error (ctrl.contentNode.querySelector(tagString).shadowRoot).querySelector('button').click(); await aTimeout(0); expect(ctrl.isShown).to.be.true; // Important to check if it can be still shown after, because we do some hacks inside await ctrl.hide(); expect(ctrl.isShown).to.be.false; await ctrl.show(); expect(ctrl.isShown).to.be.true; }); it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('<div role="button">Invoker</div>') ); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>')); const ctrl = new OverlayController({ ...withLocalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); const stopProp = (/** @type {Event} */ e) => e.stopPropagation(); const dom = await fixture( ` <div> <div id="popup">${invokerNode}${contentNode}</div> <div id="third-party-noise" @click="${stopProp}" @mousedown="${stopProp}" @mouseup="${stopProp}"> This element prevents our handlers from reaching the document click handler. </div> </div> `, ); await ctrl.show(); expect(ctrl.isShown).to.equal(true); const noiseEl = /** @type {HTMLElement} */ (dom.querySelector('#third-party-noise')); mimicClick(noiseEl); await aTimeout(0); expect(ctrl.isShown).to.equal(false); // Important to check if it can be still shown after, because we do some hacks inside await ctrl.show(); expect(ctrl.isShown).to.equal(true); }); it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture(html`<div role="button">Invoker</div>`) ); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>')); const ctrl = new OverlayController({ ...withLocalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); const stopProp = (/** @type {Event} */ e) => e.stopPropagation(); const dom = /** @type {HTMLElement} */ ( await fixture(` <div> <div id="popup">${invokerNode}${ctrl.content}</div> <div id="third-party-noise"> This element prevents our handlers from reaching the document click handler. </div> </div> `) ); const noiseEl = /** @type {HTMLElement} */ (dom.querySelector('#third-party-noise')); noiseEl.addEventListener('click', stopProp, true); noiseEl.addEventListener('mousedown', stopProp, true); noiseEl.addEventListener('mouseup', stopProp, true); await ctrl.show(); expect(ctrl.isShown).to.equal(true); mimicClick(noiseEl); await aTimeout(0); expect(ctrl.isShown).to.equal(false); // Important to check if it can be still shown after, because we do some hacks inside await ctrl.show(); expect(ctrl.isShown).to.equal(true); }); it('doesn\'t hide on "inside label" click', async () => { const contentNode = /** @type {HTMLElement} */ ( await fixture(` <div> <label for="test">test</label> <input id="test"> Content </div>`) ); const labelNode = /** @type {HTMLElement} */ (contentNode.querySelector('label[for=test]')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, contentNode, }); await ctrl.show(); // Don't hide on label click labelNode.click(); await aTimeout(0); expect(ctrl.isShown).to.be.true; }); it('hides when window is blurred (useful for iframes)', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, }); await ctrl.show(); window.dispatchEvent(new Event('blur')); await aTimeout(0); expect(ctrl.isShown).to.be.false; }); }); describe('elementToFocusAfterHide', () => { it('focuses body when hiding by default', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture('<div><input /></div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), viewportConfig: { placement: 'top-left', }, contentNode, }); await ctrl.show(); const input = /** @type {HTMLInputElement} */ (contentNode.querySelector('input')); input.focus(); expect(isActiveElement(input)).to.be.true; await ctrl.hide(); expect(isActiveElement(document.body)).to.be.true; }); it('supports elementToFocusAfterHide option to focus it when hiding', async () => { const input = /** @type {HTMLElement} */ (await fixture('<input />')); const contentNode = /** @type {HTMLElement} */ ( await fixture('<div><textarea></textarea></div>') ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), elementToFocusAfterHide: input, contentNode, }); await ctrl.show(); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea')); textarea.focus(); expect(isActiveElement(textarea)).to.be.true; await ctrl.hide(); expect(isActiveElement(input)).to.be.true; expect(isInViewport(input)).to.be.true; }); it('supports elementToFocusAfterHide option when shadowRoot involved', async () => { const input = /** @type {HTMLElement} */ (await fixture('<input />')); const contentNode = /** @type {HTMLElement} */ ( await fixture('<div><textarea></textarea></div>') ); const shadowHost = document.createElement('div'); shadowHost.attachShadow({ mode: 'open' }); /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `<slot></slot>`; shadowHost.appendChild(contentNode); document.body.appendChild(shadowHost); const ctrl = new OverlayController({ ...withGlobalTestConfig(), elementToFocusAfterHide: input, contentNode, }); await ctrl.show(); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea')); textarea.focus(); expect(isActiveElement(textarea)).to.be.true; await ctrl.hide(); expect(isActiveElement(input)).to.be.true; document.body.removeChild(shadowHost); }); it(`only sets focus when outside world didn't take over already`, async () => { const input = /** @type {HTMLElement} */ (await fixture('<input />')); const outsideButton = /** @type {HTMLButtonElement} */ (await fixture('<button></button>')); const contentNode = /** @type {HTMLElement} */ (await fixture('<div>/div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), elementToFocusAfterHide: input, contentNode, }); await ctrl.show(); // an outside element has taken over focus outsideButton.focus(); expect(isActiveElement(outsideButton)).to.be.true; await ctrl.hide(); expect(isActiveElement(outsideButton)).to.be.true; }); it('allows to set elementToFocusAfterHide on show', async () => { const input = /** @type {HTMLElement} */ (await fixture('<input />')); const contentNode = /** @type {HTMLElement} */ ( await fixture('<div><textarea></textarea></div>') ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), viewportConfig: { placement: 'top-left', }, contentNode, }); await ctrl.show(input); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea')); textarea.focus(); expect(isActiveElement(textarea)).to.be.true; await ctrl.hide(); expect(isActiveElement(input)).to.be.true; }); }); describe('preventsScroll', () => { it('prevent scrolling the background', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), preventsScroll: true, }); await ctrl.show(); expect(Array.from(document.body.classList)).to.contain('overlays-scroll-lock'); await ctrl.hide(); expect(Array.from(document.body.classList)).to.not.contain('overlays-scroll-lock'); }); it('keeps preventing of scrolling when multiple overlays are opened and closed', async () => { const ctrl0 = new OverlayController({ ...withGlobalTestConfig(), preventsScroll: true, }); const ctrl1 = new OverlayController({ ...withGlobalTestConfig(), preventsScroll: true, }); await ctrl0.show(); await ctrl1.show(); await ctrl1.hide(); expect(Array.from(document.body.classList)).to.contain('overlays-scroll-lock'); }); }); describe('hasBackdrop', () => { it('has no backdrop by default', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); await ctrl.show(); expect(ctrl.backdropNode).to.be.undefined; }); it('supports a backdrop option', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hasBackdrop: false, }); await ctrl.show(); expect(ctrl.backdropNode).to.be.undefined; await ctrl.hide(); const controllerWithBackdrop = new OverlayController({ ...withGlobalTestConfig(), hasBackdrop: true, }); await controllerWithBackdrop.show(); expect(controllerWithBackdrop.backdropNode).to.have.class('overlays__backdrop'); }); it('reenables the backdrop when shown/hidden/shown', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hasBackdrop: true, }); await ctrl.show(); expect(ctrl.backdropNode).to.have.class('overlays__backdrop'); await ctrl.hide(); await ctrl.show(); expect(ctrl.backdropNode).to.have.class('overlays__backdrop'); }); it('adds and stacks backdrops if .hasBackdrop is enabled', async () => { const ctrl0 = new OverlayController({ ...withGlobalTestConfig(), hasBackdrop: true, }); await ctrl0.show(); expect(ctrl0.backdropNode).to.have.class('overlays__backdrop');