/* eslint-disable no-new */ import { aTimeout, defineCE, expect, fixture, html, unsafeStatic, fixtureSync, } from '@open-wc/testing'; import sinon from 'sinon'; import { OverlayController, overlays } from '@lion/ui/overlays.js'; import { mimicClick } from '@lion/ui/overlays-test-helpers.js'; import { keyCodes } from '../src/utils/key-codes.js'; import { simulateTab } from '../src/utils/simulate-tab.js'; import { _adoptStyleUtils } from '../src/utils/adopt-styles.js'; import { createShadowHost } from '../test-helpers/createShadowHost.js'; /** * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig * @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement */ const wrappingDialogNodeStyle = 'display: none; z-index: 9999; padding: 0px;'; /** * 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`
my content
`)), }); const withLocalTestConfig = () => /** @type {OverlayConfig} */ ({ placementMode: 'local', contentNode: /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)), invokerNode: /** @type {HTMLElement} */ ( fixtureSync(html`
Invoker
`) ), }); 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('
contentful
')); 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`
I should be on top
`) ); } if (mode === 'inline') { contentNode = /** @type {HTMLElement} */ ( await fixture(html`
I should be on top
`) ); 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('
')); const overlay = new OverlayController({ ...withLocalTestConfig(), contentNode, }); expect(overlay.contentNode.isConnected).to.be.true; }); }); }); // TODO: Add teardown feature tests describe('Teardown', () => {}); describe('Node Configuration', () => { describe('Content', async () => { it('accepts a .contentNode for displaying content of the overlay', async () => { const myContentNode = /** @type {HTMLElement} */ (fixtureSync('

direct node

')); 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 for top layer paints', async () => { const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = ''; this.innerHTML = '
projected
'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${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(`
`); expect(el).lightDom.to.equal(`
projected
`); }); }); describe('When in light dom', async () => { it('wraps a .contentWrapperNode for style application and a for top layer paints', async () => { const el = fixtureSync('
non projected
'); 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(`
non projected
`); }); }); describe('When .contenWrapperNode provided', async () => { it('keeps the .contentWrapperNode for style application and wraps a for top layer paints', async () => { const tagString = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { /** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = '
'; this.innerHTML = '
projected
'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${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(`
`); }); 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 = `
`; this.innerHTML = '
projected
'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${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(`
`); }); }); }); }); describe('Invoker / Reference', async () => { it('accepts a .invokerNode to directly set invoker', async () => { const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('')); 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('')); const myReferenceNode = /** @type {HTMLElement} */ (fixtureSync('
anchor
')); 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 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 = ''; this.innerHTML = '
projected
'; } }, ); const el = /** @type {HTMLElement} */ (fixtureSync(`<${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( `
`, ); }); }); 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`
`) ); 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(ctrl.contentNode).to.equal(document.activeElement); }); it('keeps focus within the overlay e.g. you can not tab out by accident', async () => { const contentNode = /** @type {HTMLElement} */ ( await fixture(html`
`) ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, contentNode, }); await ctrl.show(); const elOutside = /** @type {HTMLElement} */ ( await fixture(html``) ); 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(elOutside).to.not.equal(document.activeElement); expect(input1).to.equal(document.activeElement); }); it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture(html`
`)); 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``)); const input = /** @type {HTMLInputElement} */ (ctrl.contentNode.querySelector('input')); input.focus(); simulateTab(); expect(elOutside).to.equal(document.activeElement); }); 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; }); }); describe('hidesOnEsc', () => { it('hides when [escape] is pressed', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnEsc: true, }); await ctrl.show(); ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); await aTimeout(0); expect(ctrl.isShown).to.be.false; }); it("doesn't hide when [escape] is pressed and hidesOnEsc is set to false", async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnEsc: false, }); await ctrl.show(); ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); await aTimeout(0); expect(ctrl.isShown).to.be.true; }); it('stays shown when [escape] is pressed on outside element', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnEsc: true, }); await ctrl.show(); document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); expect(ctrl.isShown).to.be.true; }); it('does not hide when [escape] is pressed with modal and "hidesOnEsc" is false', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), trapsKeyboardFocus: true, hidesOnEsc: false, }); await ctrl.show(); ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); await aTimeout(0); expect(ctrl.isShown).to.be.true; }); }); describe('hidesOnOutsideEsc', () => { it('hides when [escape] is pressed on outside element', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideEsc: true, }); await ctrl.show(); document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); await aTimeout(0); expect(ctrl.isShown).to.be.false; }); it('stays shown when [escape] is pressed on inside element', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideEsc: true, }); await ctrl.show(); ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); expect(ctrl.isShown).to.be.true; }); }); describe('hidesOnOutsideClick', () => { it('hides on outside click', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); 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('')); const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); 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 ctrl.contentNode.dispatchEvent(new MouseEvent('mousedown')); await aTimeout(0); document.body.dispatchEvent(new MouseEvent('mouseup')); 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('
Content
')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode: /** @type {HTMLElement} */ ( fixtureSync(html`
Invoker
`) ), }); 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('')); const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); 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 = '
'; } }, ); const tag = unsafeStatic(tagString); ctrl.updateConfig({ contentNode: /** @type {HTMLElement} */ ( await fixture(html`
Content
<${tag}>
`) ), }); 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('
Invoker
') ); const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); const ctrl = new OverlayController({ ...withLocalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); const stopProp = (/** @type {Event} */ e) => e.stopPropagation(); const dom = await fixture( `
This element prevents our handlers from reaching the document click handler.
`, ); 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`
Invoker
`) ); const contentNode = /** @type {HTMLElement} */ (await fixture('
Content
')); const ctrl = new OverlayController({ ...withLocalTestConfig(), hidesOnOutsideClick: true, contentNode, invokerNode, }); const stopProp = (/** @type {Event} */ e) => e.stopPropagation(); const dom = /** @type {HTMLElement} */ ( await fixture(`
This element prevents our handlers from reaching the document click handler.
`) ); 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(`
Content
`) ); 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; }); }); describe('elementToFocusAfterHide', () => { it('focuses body when hiding by default', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture('
')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), viewportConfig: { placement: 'top-left', }, contentNode, }); await ctrl.show(); const input = /** @type {HTMLInputElement} */ (contentNode.querySelector('input')); input.focus(); expect(document.activeElement).to.equal(input); await ctrl.hide(); expect(document.activeElement).to.equal(document.body); }); it('supports elementToFocusAfterHide option to focus it when hiding', async () => { const input = /** @type {HTMLElement} */ (await fixture('')); const contentNode = /** @type {HTMLElement} */ ( await fixture('
') ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), elementToFocusAfterHide: input, contentNode, }); await ctrl.show(); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea')); textarea.focus(); expect(document.activeElement).to.equal(textarea); await ctrl.hide(); expect(document.activeElement).to.equal(input); expect(isInViewport(input)).to.be.true; }); it('supports elementToFocusAfterHide option when shadowRoot involved', async () => { const input = /** @type {HTMLElement} */ (await fixture('')); const contentNode = /** @type {HTMLElement} */ ( await fixture('
') ); const shadowHost = document.createElement('div'); shadowHost.attachShadow({ mode: 'open' }); /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = ``; 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(document.activeElement).to.equal(textarea); await ctrl.hide(); expect(document.activeElement).to.equal(input); document.body.removeChild(shadowHost); }); it(`only sets focus when outside world didn't take over already`, async () => { const input = /** @type {HTMLElement} */ (await fixture('')); const outsideButton = /** @type {HTMLButtonElement} */ (await fixture('')); const contentNode = /** @type {HTMLElement} */ (await fixture('
/div>')); const ctrl = new OverlayController({ ...withGlobalTestConfig(), elementToFocusAfterHide: input, contentNode, }); await ctrl.show(); // an outside element has taken over focus outsideButton.focus(); expect(document.activeElement).to.equal(outsideButton); await ctrl.hide(); expect(document.activeElement).to.equal(outsideButton); }); it('allows to set elementToFocusAfterHide on show', async () => { const input = /** @type {HTMLElement} */ (await fixture('')); const contentNode = /** @type {HTMLElement} */ ( await fixture('
') ); const ctrl = new OverlayController({ ...withGlobalTestConfig(), viewportConfig: { placement: 'top-left', }, contentNode, }); await ctrl.show(input); const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea')); textarea.focus(); expect(document.activeElement).to.equal(textarea); await ctrl.hide(); expect(document.activeElement).to.equal(input); }); }); 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'); const ctrl1 = new OverlayController({ ...withGlobalTestConfig(), hasBackdrop: false, }); await ctrl1.show(); expect(ctrl0.backdropNode).to.have.class('overlays__backdrop'); expect(ctrl1.backdropNode).to.be.undefined; const ctrl2 = new OverlayController({ ...withGlobalTestConfig(), hasBackdrop: true, }); await ctrl2.show(); expect(ctrl0.backdropNode).to.have.class('overlays__backdrop'); expect(ctrl1.backdropNode).to.be.undefined; expect(ctrl2.backdropNode).to.have.class('overlays__backdrop'); }); }); describe('locally placed overlay with hasBackdrop', () => { it('has no backdrop by default', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), }); await ctrl.show(); expect(ctrl.backdropNode).to.be.undefined; }); it('supports a backdrop option', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: false, }); await ctrl.show(); expect(ctrl.backdropNode).to.be.undefined; await ctrl.hide(); const backdropNode = document.createElement('div'); backdropNode.classList.add('custom-backdrop'); const controllerWithBackdrop = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: true, backdropNode, }); await controllerWithBackdrop.show(); expect(controllerWithBackdrop.backdropNode).to.have.class('custom-backdrop'); }); it('reenables the backdrop when shown/hidden/shown', async () => { const backdropNode = document.createElement('div'); backdropNode.classList.add('custom-backdrop'); const ctrl = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: true, backdropNode, }); await ctrl.show(); expect(ctrl.backdropNode).to.have.class('custom-backdrop'); await ctrl.hide(); await ctrl.show(); expect(ctrl.backdropNode).to.have.class('custom-backdrop'); }); it('adds and stacks backdrops if .hasBackdrop is enabled', async () => { const backdropNode = document.createElement('div'); backdropNode.classList.add('custom-backdrop-zero'); const ctrl0 = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: true, backdropNode, }); await ctrl0.show(); expect(ctrl0.backdropNode).to.have.class('custom-backdrop-zero'); const ctrl1 = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: false, }); await ctrl1.show(); expect(ctrl0.backdropNode).to.have.class('custom-backdrop-zero'); expect(ctrl1.backdropNode).to.be.undefined; const anotherBackdropNode = document.createElement('div'); anotherBackdropNode.classList.add('custom-backdrop-two'); const ctrl2 = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: true, backdropNode: anotherBackdropNode, }); await ctrl2.show(); expect(ctrl0.backdropNode).to.have.class('custom-backdrop-zero'); expect(ctrl1.backdropNode).to.be.undefined; expect(ctrl2.backdropNode).to.have.class('custom-backdrop-two'); }); }); describe('isBlocking', () => { it('prevents showing of other overlays', async () => { const ctrl0 = new OverlayController({ ...withGlobalTestConfig(), isBlocking: false, }); const ctrl1 = new OverlayController({ ...withGlobalTestConfig(), isBlocking: false, }); const ctrl2 = new OverlayController({ ...withGlobalTestConfig(), isBlocking: true, }); const ctrl3 = new OverlayController({ ...withGlobalTestConfig(), isBlocking: false, }); await ctrl0.show(); await ctrl1.show(); await ctrl2.show(); // blocking expect(ctrl0.__wrappingDialogNode).to.not.be.displayed; expect(ctrl1.__wrappingDialogNode).to.not.be.displayed; expect(ctrl2.__wrappingDialogNode).to.be.displayed; await ctrl3.show(); await ctrl3._showComplete; expect(ctrl3.__wrappingDialogNode).to.be.displayed; await ctrl2.hide(); await ctrl2._hideComplete; expect(ctrl0.__wrappingDialogNode).to.be.displayed; expect(ctrl1.__wrappingDialogNode).to.be.displayed; await ctrl2.show(); // blocking expect(ctrl0.__wrappingDialogNode).to.not.be.displayed; expect(ctrl1.__wrappingDialogNode).to.not.be.displayed; expect(ctrl2.__wrappingDialogNode).to.be.displayed; expect(ctrl3.__wrappingDialogNode).to.not.be.displayed; }); it('keeps backdrop status when used in combination with blocking', async () => { const ctrl0 = new OverlayController({ ...withGlobalTestConfig(), isBlocking: false, hasBackdrop: true, }); await ctrl0.show(); const ctrl1 = new OverlayController({ ...withGlobalTestConfig(), isBlocking: false, hasBackdrop: true, }); await ctrl1.show(); await ctrl1.hide(); expect(ctrl0.hasActiveBackdrop).to.be.true; expect(ctrl1.hasActiveBackdrop).to.be.false; await ctrl1.show(); expect(ctrl0.hasActiveBackdrop).to.be.true; expect(ctrl1.hasActiveBackdrop).to.be.true; }); }); }); describe('Show / Hide / Toggle', () => { it('has .isShown which defaults to false', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); expect(ctrl.isShown).to.be.false; }); it('has async show() which shows the overlay', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); await ctrl.show(); expect(ctrl.isShown).to.be.true; expect(ctrl.show()).to.be.instanceOf(Promise); }); it('has async hide() which hides the overlay', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); await ctrl.hide(); expect(ctrl.isShown).to.be.false; expect(ctrl.hide()).to.be.instanceOf(Promise); }); it('fires "show" event once overlay becomes shown', async () => { const showSpy = sinon.spy(); const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); ctrl.addEventListener('show', showSpy); await ctrl.show(); expect(showSpy.callCount).to.equal(1); await ctrl.show(); expect(showSpy.callCount).to.equal(1); }); it('fires "before-show" event right before overlay becomes shown', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); const eventSpy = sinon.spy(); ctrl.addEventListener('before-show', eventSpy); ctrl.addEventListener('show', eventSpy); await ctrl.show(); expect(eventSpy.getCall(0).args[0].type).to.equal('before-show'); expect(eventSpy.getCall(1).args[0].type).to.equal('show'); expect(eventSpy.callCount).to.equal(2); await ctrl.show(); expect(eventSpy.callCount).to.equal(2); }); it('fires "hide" event once overlay becomes hidden', async () => { const hideSpy = sinon.spy(); const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); ctrl.addEventListener('hide', hideSpy); await ctrl.show(); await ctrl.hide(); expect(hideSpy.callCount).to.equal(1); await ctrl.hide(); expect(hideSpy.callCount).to.equal(1); }); it('fires "before-hide" event right before overlay becomes hidden', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); const eventSpy = sinon.spy(); ctrl.addEventListener('before-hide', eventSpy); ctrl.addEventListener('hide', eventSpy); await ctrl.show(); await ctrl.hide(); expect(eventSpy.getCall(0).args[0].type).to.equal('before-hide'); expect(eventSpy.getCall(1).args[0].type).to.equal('hide'); expect(eventSpy.callCount).to.equal(2); await ctrl.hide(); expect(eventSpy.callCount).to.equal(2); }); it('can be toggled', async () => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); await ctrl.toggle(); expect(ctrl.isShown).to.be.true; await ctrl.toggle(); expect(ctrl.isShown).to.be.false; await ctrl.toggle(); expect(ctrl.isShown).to.be.true; // check for hide expect(ctrl.toggle()).to.be.instanceOf(Promise); // check for show expect(ctrl.toggle()).to.be.instanceOf(Promise); }); it('makes sure the latest shown overlay is visible', async () => { const ctrl0 = new OverlayController({ ...withGlobalTestConfig(), }); const ctrl1 = new OverlayController({ ...withGlobalTestConfig(), }); await ctrl0.show(); const rect = ctrl0.contentNode.getBoundingClientRect(); const getTopEl = () => document.elementFromPoint(Math.ceil(rect.left), Math.ceil(rect.top)); await ctrl0.show(); expect(getTopEl()).to.equal(ctrl0.contentNode); await ctrl1.show(); expect(getTopEl()).to.equal(ctrl1.contentNode); await ctrl0.show(); expect(getTopEl()).to.equal(ctrl0.contentNode); }); it('awaits a "transitionHide" hook before hiding for real', done => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); ctrl.show(); /** @type {{ (): void; (value?: void | PromiseLike | undefined): void; }} */ let hideTransitionFinished; ctrl.transitionHide = () => new Promise(resolve => { hideTransitionFinished = resolve; }); ctrl.hide(); expect(getComputedStyle(ctrl.content).display).to.equal('block'); setTimeout(() => { hideTransitionFinished(); setTimeout(() => { expect(getComputedStyle(ctrl.content).display).to.equal('none'); done(); }, 0); }, 0); }); it('awaits a "transitionShow" hook before finishing the show method', done => { const ctrl = new OverlayController({ ...withGlobalTestConfig(), }); /** @type {{ (): void; (value?: void | PromiseLike | undefined): void; }} */ let showTransitionFinished; ctrl.transitionShow = () => new Promise(resolve => { showTransitionFinished = resolve; }); ctrl.show(); let showIsDone = false; /** @type {Promise} */ (ctrl._showComplete).then(() => { showIsDone = true; }); expect(showIsDone).to.be.false; setTimeout(() => { showTransitionFinished(); setTimeout(() => { expect(showIsDone).to.be.true; done(); }, 0); }, 0); }); }); describe('Update Configuration', () => { it('reinitializes content', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode: /** @type {HTMLElement} */ (await fixture(html`
content1
`)), }); await ctrl.show(); // Popper adds inline styles expect(ctrl.content.style.transform).not.to.be.undefined; expect(ctrl.contentNode.textContent).to.include('content1'); ctrl.updateConfig({ placementMode: 'local', contentNode: /** @type {HTMLElement} */ (await fixture(html`
content2
`)), }); expect(ctrl.contentNode.textContent).to.include('content2'); }); it('respects the initial config provided to new OverlayController(initialConfig)', async () => { const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)); const ctrl = new OverlayController({ // This is the shared config placementMode: 'global', handlesAccessibility: true, contentNode, }); ctrl.updateConfig({ // This is the added config placementMode: 'local', hidesOnEsc: true, }); expect(ctrl.placementMode).to.equal('local'); expect(ctrl.handlesAccessibility).to.equal(true); expect(ctrl.contentNode).to.equal(contentNode); }); // Currently not working, enable again when we fix updateConfig it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => { const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`
my content
`)); const ctrl = new OverlayController({ // This is the shared config placementMode: 'global', handlesAccessibility: true, contentNode, }); ctrl.show(); expect(ctrl.contentWrapperNode.classList.contains('overlays__overlay-container--center')); expect(ctrl.isShown).to.be.true; ctrl.updateConfig({ viewportConfig: { placement: 'top-right' } }); expect(ctrl.contentWrapperNode.classList.contains('overlays__overlay-container--top-right')); expect(ctrl.isShown).to.be.true; }); it('disables backdrop when switching to hasBackrop "false"', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), contentNode: /** @type {HTMLElement} */ (await fixture(html`
content1
`)), hasBackdrop: true, }); await ctrl.show(); // Popper adds inline styles expect(ctrl.backdropNode).not.to.be.undefined; expect(Array.from(ctrl.backdropNode.classList)).to.include('overlays__backdrop--visible'); ctrl.updateConfig({ hasBackdrop: false, }); expect(Array.from(ctrl.backdropNode.classList)).to.not.include('overlays__backdrop--visible'); }); }); describe('Accessibility', () => { it('synchronizes [aria-expanded] on invoker', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, invokerNode, }); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('false'); await ctrl.show(); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('true'); await ctrl.hide(); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal('false'); }); it('does not synchronize [aria-expanded] on invoker when the overlay is modal', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), hasBackdrop: true, handlesAccessibility: true, invokerNode, }); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal(null); await ctrl.show(); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal(null); await ctrl.hide(); expect(ctrl.invokerNode?.getAttribute('aria-expanded')).to.equal(null); }); it('creates unique id for content', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, }); const { contentId } = getProtectedMembers(ctrl); expect(ctrl.contentNode.id).to.contain(contentId); }); it('preserves content id when present', async () => { const contentNode = /** @type {HTMLElement} */ ( await fixture('
content
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, contentNode, }); expect(ctrl.contentNode.id).to.contain('preserved'); }); it('adds [role=dialog] on content', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, invokerNode, }); expect(ctrl.contentNode.getAttribute('role')).to.equal('dialog'); }); it('preserves [role] on content when present', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const contentNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, invokerNode, contentNode, }); expect(ctrl.contentNode.getAttribute('role')).to.equal('menu'); }); it('allows to not provide an invokerNode', async () => { let properlyInstantiated = false; try { new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, invokerNode: undefined, }); properlyInstantiated = true; } catch (e) { throw new Error(/** @type {Error} */ (e).message); } expect(properlyInstantiated).to.be.true; }); // TODO: check if we covered all functionality. "Inertness" should be handled by the platform with a modal overlay... it.skip('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => {}); it.skip('disables pointer events and selection on inert elements', async () => {}); describe('Tooltip', () => { it('adds [aria-describedby] on invoker', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, isTooltip: true, invokerNode, }); const { contentId } = getProtectedMembers(ctrl); expect(ctrl.invokerNode?.getAttribute('aria-describedby')).to.equal(contentId); }); it('adds [aria-labelledby] on invoker when invokerRelation is label', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, isTooltip: true, invokerRelation: 'label', invokerNode, }); const { contentId } = getProtectedMembers(ctrl); expect(ctrl.invokerNode?.getAttribute('aria-describedby')).to.equal(null); expect(ctrl.invokerNode?.getAttribute('aria-labelledby')).to.equal(contentId); }); it('adds [role=tooltip] on content', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, isTooltip: true, invokerNode, }); expect(ctrl.contentNode.getAttribute('role')).to.equal('tooltip'); }); describe('Teardown', () => { it('restores [role] on dialog content', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, invokerNode, }); expect(ctrl.contentNode.getAttribute('role')).to.equal('dialog'); ctrl.teardown(); expect(ctrl.contentNode.getAttribute('role')).to.equal(null); }); it('restores [role] on tooltip content', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const contentNode = /** @type {HTMLElement} */ ( await fixture('
content
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, isTooltip: true, invokerNode, contentNode, }); expect(contentNode.getAttribute('role')).to.equal('tooltip'); ctrl.teardown(); expect(contentNode.getAttribute('role')).to.equal('presentation'); }); it('restores [aria-describedby] on content', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const contentNode = /** @type {HTMLElement} */ ( await fixture('
content
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, isTooltip: true, invokerNode, contentNode, }); expect(invokerNode.getAttribute('aria-describedby')).to.equal(contentNode.id); ctrl.teardown(); expect(invokerNode.getAttribute('aria-describedby')).to.equal(null); }); it('restores [aria-labelledby] on content', async () => { const invokerNode = /** @type {HTMLElement} */ ( await fixture('
invoker
') ); const contentNode = /** @type {HTMLElement} */ ( await fixture('
content
') ); const ctrl = new OverlayController({ ...withLocalTestConfig(), handlesAccessibility: true, isTooltip: true, invokerNode, contentNode, invokerRelation: 'label', }); expect(invokerNode.getAttribute('aria-labelledby')).to.equal(contentNode.id); ctrl.teardown(); expect(invokerNode.getAttribute('aria-labelledby')).to.equal(null); }); }); }); }); describe('Exception handling', () => { it('throws if no .placementMode gets passed on', async () => { const contentNode = document.createElement('div'); // Ensure the contentNode is connected to DOM document.body.appendChild(contentNode); expect(() => { new OverlayController({ contentNode, }); }).to.throw('[OverlayController] You need to provide a .placementMode ("global"|"local")'); }); it('throws if invalid .placementMode gets passed on', async () => { expect(() => { new OverlayController({ // @ts-ignore placementMode: 'invalid', }); }).to.throw( '[OverlayController] "invalid" is not a valid .placementMode, use ("global"|"local")', ); }); it('throws if no .contentNode gets passed on', async () => { expect(() => { new OverlayController({ placementMode: 'global', }); }).to.throw('[OverlayController] You need to provide a .contentNode'); }); it('throws if handlesAccessibility is false for a tooltip', async () => { const contentNode = document.createElement('div'); document.body.appendChild(contentNode); expect(() => { new OverlayController({ placementMode: 'local', contentNode, isTooltip: true, handlesAccessibility: false, }); }).to.throw( '[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled', ); }); }); });