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`Content
`, invokerTemplate: () => html` `, }); const el = await fixture(html`Content
`, invokerTemplate: () => html` `, }); await fixture(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(`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`Content
`, placementConfig: { placement: 'left-start', }, }); await fixture(html`Content
`, invokerTemplate: () => html` `, placementConfig: { placement: 'top-start', }, }); await fixture(`Content
`, invokerTemplate: () => html` `, }); await fixture(html`Content
`, invokerTemplate: () => html` `, placementConfig: { placement: 'top', }, }); await fixture(html`Content
`, invokerTemplate: () => html` `, placementConfig: { placement: 'top', }, }); await fixture(html`Content
`, invokerTemplate: () => html` `, placementConfig: { placement: 'top', }, }); await fixture(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` `, 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(` `); 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`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(`Content
`, invokerTemplate: () => html` `, }); const { content, invoker } = ctrl; const dom = await fixture(`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); }); }); });