import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing'; import Popper from 'popper.js/dist/popper.min.js'; import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js'; import { LionLitElement } from '@lion/core/src/LionLitElement.js'; import { keyCodes } from '../src/utils/key-codes.js'; import { simulateTab } from '../src/utils/simulate-tab.js'; import { LocalOverlayController } from '../src/LocalOverlayController.js'; import { overlays } from '../src/overlays.js'; describe('LocalOverlayController', () => { describe('templates', () => { it('creates a controller with methods: show, hide, sync and syncInvoker', () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); expect(controller.show).to.be.a('function'); expect(controller.hide).to.be.a('function'); expect(controller.sync).to.be.a('function'); expect(controller.syncInvoker).to.be.a('function'); }); it('will render holders for invoker and content', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); const el = await fixture(html`
${controller.invoker} ${controller.content}
`); expect(el.querySelectorAll('div')[0].textContent.trim()).to.equal('Invoker'); controller.show(); expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('Content'); }); it('will add/remove the content on show/hide', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); const el = await fixture(html`
${controller.invoker} ${controller.content}
`); expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal(''); controller.show(); expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('Content'); controller.hide(); expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal(''); }); it('will hide and show html nodes provided to overlay', async () => { const tagString = defineCE( class extends LionLitElement { render() { return html` `; } }, ); const element = unsafeStatic(tagString); const elem = await fixture(html` <${element}>
content
<${element}> `); const controller = overlays.add( new LocalOverlayController({ hidesOnEsc: true, hidesOnOutsideClick: true, contentNode: elem.querySelector('[slot="content"]'), invokerNode: elem.querySelector('[slot="invoker"]'), }), ); expect(elem.querySelector('[slot="content"]').style.display).to.equal('none'); controller.show(); expect(elem.querySelector('[slot="content"]').style.display).to.equal('inline-block'); controller.hide(); expect(elem.querySelector('[slot="content"]').style.display).to.equal('none'); }); it('exposes isShown state for reading', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await fixture(html`
${controller.invoker} ${controller.content}
`); expect(controller.isShown).to.equal(false); controller.show(); expect(controller.isShown).to.equal(true); controller.hide(); expect(controller.isShown).to.equal(false); }); it('can update the invoker data', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: (data = { text: 'foo' }) => html` `, }); expect(controller.invoker.textContent.trim()).to.equal('foo'); controller.syncInvoker({ data: { text: 'bar' } }); expect(controller.invoker.textContent.trim()).to.equal('bar'); }); it('can synchronize the content data', async () => { const controller = new LocalOverlayController({ contentTemplate: data => html`

${data.text}

`, invokerTemplate: () => html` `, }); await controller.show(); controller.sync({ data: { text: 'foo' } }); expect(controller.content.textContent.trim()).to.equal('foo'); controller.sync({ data: { text: 'bar' } }); expect(controller.content.textContent.trim()).to.equal('bar'); }); it.skip('can reuse an existing node for the invoker (disables syncInvoker())', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerReference: null, // TODO: invokerReference }); await fixture(`
${controller.content}
`); expect(controller.invoker.textContent.trim()).to.equal('Invoker'); controller.show(); expect(controller.content.textContent.trim()).to.equal('Content'); }); }); // Please use absolute positions in the tests below to prevent the HTML generated by // the test runner from interfering. describe('positioning', () => { it('creates a popper instance on the controller when shown, keeps it when hidden', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await controller.show(); expect(controller._popper) .to.be.an.instanceof(Popper) .and.have.property('modifiers'); controller.hide(); expect(controller._popper) .to.be.an.instanceof(Popper) .and.have.property('modifiers'); }); it('positions correctly', async () => { // smoke test for integration of popper const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await controller.show(); // 16px displacement due to default 16px viewport margin both horizontal and vertical expect(controller.content.firstElementChild.style.transform).to.equal( 'translate3d(16px, 16px, 0px)', ); }); it('uses top as the default placement', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await fixture(html`
${controller.invoker} ${controller.content}
`); await controller.show(); const contentChild = controller.content.firstElementChild; expect(contentChild.getAttribute('x-placement')).to.equal('top'); }); it('positions to preferred place if placement is set and space is available', async () => { const controller = new LocalOverlayController({ invokerTemplate: () => html` `, contentTemplate: () => html`

Content

`, placementConfig: { placement: 'left-start', }, }); await fixture(html`
${controller.invoker} ${controller.content}
`); await controller.show(); const contentChild = controller.content.firstElementChild; expect(contentChild.getAttribute('x-placement')).to.equal('left-start'); }); it('positions to different place if placement is set and no space is available', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, placementConfig: { placement: 'top-start', }, }); await fixture(`
${controller.invoker} ${controller.content}
`); await controller.show(); const contentChild = controller.content.firstElementChild; expect(contentChild.getAttribute('x-placement')).to.equal('bottom-start'); }); it('allows the user to override default Popper modifiers', async () => { const controller = new LocalOverlayController({ placementConfig: { modifiers: { keepTogether: { enabled: false, }, offset: { enabled: true, offset: `0, 16px`, }, }, }, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await fixture(html`
${controller.invoker} ${controller.content}
`); await controller.show(); const keepTogether = controller._popper.modifiers.find(item => item.name === 'keepTogether'); const offset = controller._popper.modifiers.find(item => item.name === 'offset'); expect(keepTogether.enabled).to.be.false; expect(offset.enabled).to.be.true; expect(offset.offset).to.equal('0, 16px'); }); it('updates placementConfig even when overlay is closed', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, placementConfig: { placement: 'top', }, }); await fixture(html`
${controller.invoker} ${controller.content}
`); await controller.show(); const contentChild = controller.content.firstElementChild; expect(contentChild.getAttribute('x-placement')).to.equal('top'); controller.hide(); await controller.updatePlacementConfig({ placement: 'bottom' }); await controller.show(); expect(controller._popper.options.placement).to.equal('bottom'); }); it('positions the popper element correctly on show', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, placementConfig: { placement: 'top', }, }); await fixture(html`
${controller.invoker} ${controller.content}
`); await controller.show(); let contentChild = controller.content.firstElementChild; expect(contentChild.style.transform).to.equal('translate3d(14px, -58px, 0px)'); controller.hide(); await controller.show(); contentChild = controller.content.firstElementChild; expect(contentChild.style.transform).to.equal('translate3d(14px, -58px, 0px)'); }); it('updates placement properly even during hidden state', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, placementConfig: { placement: 'top', }, }); await fixture(html`
${controller.invoker} ${controller.content}
`); await controller.show(); let contentChild = controller.content.firstElementChild; expect(contentChild.style.transform).to.equal('translate3d(14px, -58px, 0px)'); controller.hide(); await controller.updatePlacementConfig({ modifiers: { offset: { enabled: true, offset: '0, 32px', }, }, }); await controller.show(); contentChild = controller.content.firstElementChild; expect(controller._popper.options.modifiers.offset.offset).to.equal('0, 32px'); expect(contentChild.style.transform).to.equal('translate3d(14px, -82px, 0px)'); }); }); describe('a11y', () => { it('adds and removes aria-expanded on invoker', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); expect(controller.invokerNode.getAttribute('aria-controls')).to.contain( controller.content.id, ); expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false'); controller.show(); expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('true'); controller.hide(); expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false'); }); it('traps the focus via option { trapsKeyboardFocus: true }', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`
Anchor
`, invokerTemplate: () => html` `, trapsKeyboardFocus: true, }); // make sure we're connected to the dom await fixture(html` ${controller.invoker}${controller.content} `); controller.show(); const elOutside = await fixture(``); const [el1, el2] = [].slice.call(controller.contentNode.querySelectorAll('[id]')); el2.focus(); // this mimics a tab within the contain-focus system used const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); event.keyCode = keyCodes.tab; window.dispatchEvent(event); expect(elOutside).to.not.equal(document.activeElement); expect(el1).to.equal(document.activeElement); }); it('traps the focus via option { trapsKeyboardFocus: true } when using contentNode', async () => { const invokerNode = await fixture(''); const contentNode = await fixture(`
Anchor
`); const controller = new LocalOverlayController({ contentNode, invokerNode, trapsKeyboardFocus: true, }); // make sure we're connected to the dom await fixture(html` ${controller.invoker}${controller.content} `); controller.show(); const elOutside = await fixture(``); const [el1, el2] = [].slice.call(controller.contentNode.querySelectorAll('[id]')); el2.focus(); // this mimics a tab within the contain-focus system used const event = new CustomEvent('keydown', { detail: 0, bubbles: true }); event.keyCode = keyCodes.tab; window.dispatchEvent(event); expect(elOutside).to.not.equal(document.activeElement); expect(el1).to.equal(document.activeElement); }); it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => { const controller = new LocalOverlayController({ contentTemplate: () => html`
`, invokerTemplate: () => html` `, trapsKeyboardFocus: false, }); // make sure we're connected to the dom await fixture(html` ${controller.invoker}${controller.content} `); const elOutside = await fixture(``); controller.show(); const el1 = controller.content.querySelector('button'); el1.focus(); simulateTab(); expect(elOutside).to.equal(document.activeElement); }); }); describe('hidesOnEsc', () => { it('hides when [escape] is pressed', async () => { const ctrl = new LocalOverlayController({ hidesOnEsc: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await fixture( html` ${ctrl.invoker}${ctrl.content} `, ); ctrl.show(); keyUpOn(ctrl.contentNode, keyCodes.escape); ctrl.updateComplete; expect(ctrl.isShown).to.equal(false); }); it('stays shown when [escape] is pressed on outside element', async () => { const ctrl = new LocalOverlayController({ hidesOnEsc: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await fixture( html` ${ctrl.invoker}${ctrl.content} `, ); ctrl.show(); keyUpOn(document, keyCodes.escape); ctrl.updateComplete; expect(ctrl.isShown).to.equal(true); }); }); describe('hidesOnOutsideClick', () => { it('hides on outside click', async () => { const controller = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); await fixture( html` ${controller.invoker}${controller.content} `, ); const { content } = controller; controller.show(); expect(content.textContent.trim()).to.equal('Content'); document.body.click(); await aTimeout(); expect(content.textContent.trim()).to.equal(''); }); it('doesn\'t hide on "inside" click', async () => { const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); const { content, invoker } = ctrl; await fixture(html` ${invoker}${content} `); // Don't hide on first invoker click ctrl.invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Don't hide on inside (content) click ctrl.contentNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Don't hide on invoker click when shown ctrl.invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Works as well when clicked content element lives in shadow dom ctrl.show(); await aTimeout(); const tag = defineCE( class extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.shadowRoot.innerHTML = '
'; } }, ); const shadowEl = document.createElement(tag); content.appendChild(shadowEl); shadowEl.shadowRoot.querySelector('button').click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Important to check if it can be still shown after, because we do some hacks inside ctrl.hide(); expect(ctrl.isShown).to.equal(true); ctrl.show(); expect(ctrl.isShown).to.equal(true); }); it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => { const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); const { content, invoker } = ctrl; const dom = await fixture(`
This element prevents our handlers from reaching the document click handler.
`); ctrl.show(); expect(ctrl.isShown).to.equal(true); dom.querySelector('third-party-noise').click(); await aTimeout(); expect(ctrl.isShown).to.equal(false); // Important to check if it can be still shown after, because we do some hacks inside ctrl.show(); expect(ctrl.isShown).to.equal(true); }); it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => { const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); const { content, invoker } = ctrl; const dom = await fixture(`
This element prevents our handlers from reaching the document click handler.
`); dom.querySelector('third-party-noise').addEventListener( 'click', event => { event.stopPropagation(); }, true, ); ctrl.show(); expect(ctrl.isShown).to.equal(true); dom.querySelector('third-party-noise').click(); await aTimeout(); expect(ctrl.isShown).to.equal(false); // Important to check if it can be still shown after, because we do some hacks inside ctrl.show(); expect(ctrl.isShown).to.equal(true); }); }); describe('toggles', () => { it('toggles on clicks', async () => { const ctrl = new LocalOverlayController({ hidesOnOutsideClick: true, contentTemplate: () => html`

Content

`, invokerTemplate: () => html` `, }); const { content, invoker, invokerNode } = ctrl; await fixture( html` ${invoker}${content} `, ); // Show content on first invoker click invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Hide content on click when shown invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(false); // Show contnet on invoker click when hidden invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); }); }); });