diff --git a/packages/overlays/src/LocalOverlayController.js b/packages/overlays/src/LocalOverlayController.js index d1bd507c8..dd433f504 100644 --- a/packages/overlays/src/LocalOverlayController.js +++ b/packages/overlays/src/LocalOverlayController.js @@ -15,24 +15,48 @@ export class LocalOverlayController { this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus; this.placement = finalParams.placement; this.position = finalParams.position; + /** + * A wrapper to render into the invokerTemplate + * + * @property {HTMLElement} + */ this.invoker = document.createElement('div'); this.invoker.style.display = 'inline-block'; + this.invokerTemplate = finalParams.invokerTemplate; + /** + * The actual invoker element we work with - it get's all the events and a11y + * + * @property {HTMLElement} + */ + this.invokerNode = this.invoker; + if (finalParams.invokerNode) { + this.invokerNode = finalParams.invokerNode; + this.invoker = this.invokerNode; + } + + /** + * A wrapper the contentTemplate renders into + * + * @property {HTMLElement} + */ this.content = document.createElement('div'); this.content.style.display = 'inline-block'; + this.contentTemplate = finalParams.contentTemplate; + this.contentNode = this.content; + if (finalParams.contentNode) { + this.contentNode = finalParams.contentNode; + this.content = this.contentNode; + } + this.contentId = `overlay-content-${Math.random() .toString(36) .substr(2, 10)}`; this._contentData = {}; - this.invokerTemplate = finalParams.invokerTemplate; - this.invokerNode = finalParams.invokerNode; - this.contentTemplate = finalParams.contentTemplate; - this.contentNode = finalParams.contentNode; this.syncInvoker(); this._updateContent(); this._prevShown = false; this._prevData = {}; - - if (this.hidesOnEsc) this._setupHidesOnEsc(); + this.__boundEscKeyHandler = this.__escKeyHandler.bind(this); } get isShown() { @@ -109,10 +133,12 @@ export class LocalOverlayController { if (this.trapsKeyboardFocus) this._setupTrapsKeyboardFocus(); if (this.hidesOnOutsideClick) this._setupHidesOnOutsideClick(); + if (this.hidesOnEsc) this._setupHidesOnEsc(); } else { this._updateContent(); this.invokerNode.setAttribute('aria-expanded', false); if (this.hidesOnOutsideClick) this._teardownHidesOnOutsideClick(); + if (this.hidesOnEsc) this._teardownHidesOnEsc(); } this._prevShown = shown; this._prevData = data; @@ -127,15 +153,15 @@ export class LocalOverlayController { this._containFocusHandler.disconnect(); this._containFocusHandler = undefined; // eslint-disable-line no-param-reassign } - this._containFocusHandler = containFocus(this.content.firstElementChild); + this._containFocusHandler = containFocus(this.contentNode); } _setupHidesOnEsc() { - this.content.addEventListener('keyup', event => { - if (event.keyCode === keyCodes.escape) { - this.hide(); - } - }); + this.contentNode.addEventListener('keyup', this.__boundEscKeyHandler); + } + + _teardownHidesOnEsc() { + this.contentNode.removeEventListener('keyup', this.__boundEscKeyHandler); } _setupHidesOnOutsideClick() { @@ -162,14 +188,14 @@ export class LocalOverlayController { }); }; - this.content.addEventListener('click', this.__preventCloseOutsideClick, true); - this.invoker.addEventListener('click', this.__preventCloseOutsideClick, true); + this.contentNode.addEventListener('click', this.__preventCloseOutsideClick, true); + this.invokerNode.addEventListener('click', this.__preventCloseOutsideClick, true); document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true); } _teardownHidesOnOutsideClick() { - this.content.removeEventListener('click', this.__preventCloseOutsideClick, true); - this.invoker.removeEventListener('click', this.__preventCloseOutsideClick, true); + this.contentNode.removeEventListener('click', this.__preventCloseOutsideClick, true); + this.invokerNode.removeEventListener('click', this.__preventCloseOutsideClick, true); document.documentElement.removeEventListener('click', this.__onCaptureHtmlClick, true); this.__preventCloseOutsideClick = null; this.__onCaptureHtmlClick = null; @@ -182,4 +208,10 @@ export class LocalOverlayController { this.contentNode.style.display = 'none'; } } + + __escKeyHandler(e) { + if (e.keyCode === keyCodes.escape) { + this.hide(); + } + } } diff --git a/packages/overlays/stories/local-overlay.stories.js b/packages/overlays/stories/local-overlay.stories.js index a59996ff9..324694d32 100644 --- a/packages/overlays/stories/local-overlay.stories.js +++ b/packages/overlays/stories/local-overlay.stories.js @@ -37,7 +37,7 @@ storiesOf('Local Overlay System|Local Overlay', module) `, invokerTemplate: () => html` - + `, }), ); @@ -62,7 +62,7 @@ storiesOf('Local Overlay System|Local Overlay', module) `, invokerTemplate: () => html` - + `, }), ); @@ -88,7 +88,7 @@ storiesOf('Local Overlay System|Local Overlay', module) `, invokerTemplate: () => html` - + `, }), ); @@ -153,30 +153,6 @@ storiesOf('Local Overlay System|Local Overlay', module) `; }) - .add('On toggle', () => { - const popup = overlays.add( - new LocalOverlayController({ - hidesOnEsc: true, - hidesOnOutsideClick: true, - contentTemplate: () => - html` -
United Kingdom
- `, - invokerTemplate: () => - html` - - `, - }), - ); - return html` - -
- -
- `; - }) .add('trapsKeyboardFocus', () => { const popup = overlays.add( new LocalOverlayController({ @@ -198,7 +174,7 @@ storiesOf('Local Overlay System|Local Overlay', module) `, invokerTemplate: () => html` - + `, }), ); @@ -210,4 +186,38 @@ storiesOf('Local Overlay System|Local Overlay', module) ${popup.invoker}${popup.content} `; + }) + .add('trapsKeyboardFocus with nodes', () => { + const invokerNode = document.createElement('button'); + invokerNode.innerHTML = 'Invoker Button'; + + const contentNode = document.createElement('div'); + contentNode.classList.add('demo-popup'); + const contentButton = document.createElement('button'); + contentButton.innerHTML = 'Content Button'; + const contentInput = document.createElement('input'); + contentNode.appendChild(contentButton); + contentNode.appendChild(contentInput); + + const popup = overlays.add( + new LocalOverlayController({ + hidesOnEsc: true, + hidesOnOutsideClick: true, + trapsKeyboardFocus: true, + contentNode, + invokerNode, + }), + ); + + invokerNode.addEventListener('click', () => { + popup.toggle(); + }); + return html` + +
+ ${popup.invoker}${popup.content} +
+ `; }); diff --git a/packages/overlays/test/LocalOverlayController.test.js b/packages/overlays/test/LocalOverlayController.test.js index 5a2154925..311991a50 100644 --- a/packages/overlays/test/LocalOverlayController.test.js +++ b/packages/overlays/test/LocalOverlayController.test.js @@ -208,7 +208,7 @@ describe('LocalOverlayController', () => { }); controller.show(); - expect(controller.content.firstElementChild.style.top).to.equal('8px'); + expect(controller.contentNode.style.top).to.equal('8px'); }); it('uses top as the default placement', async () => { @@ -218,7 +218,9 @@ describe('LocalOverlayController', () => {

Content

`, invokerTemplate: () => html` - + `, }); await fixture(html` @@ -227,15 +229,17 @@ describe('LocalOverlayController', () => { `); controller.show(); - const invokerChild = controller.content.firstElementChild; - expect(invokerChild.getAttribute('js-positioning-vertical')).to.equal('top'); - expect(invokerChild.getAttribute('js-positioning-horizontal')).to.equal('centerHorizontal'); + const { contentNode } = controller; + expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('top'); + expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('centerHorizontal'); }); it('positions to preferred place if placement is set and space is available', async () => { const controller = new LocalOverlayController({ invokerTemplate: () => html` - + `, contentTemplate: () => html` @@ -250,9 +254,9 @@ describe('LocalOverlayController', () => { `); controller.show(); - const contentChild = controller.content.firstElementChild; - expect(contentChild.getAttribute('js-positioning-vertical')).to.equal('top'); - expect(contentChild.getAttribute('js-positioning-horizontal')).to.equal('right'); + const { contentNode } = controller; + expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('top'); + expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('right'); }); it('positions to different place if placement is set and no space is available', async () => { @@ -262,7 +266,9 @@ describe('LocalOverlayController', () => {

Content

`, invokerTemplate: () => html` - + `, placement: 'top right', }); @@ -273,9 +279,9 @@ describe('LocalOverlayController', () => { `); controller.show(); - const invokerChild = controller.content.firstElementChild; - expect(invokerChild.getAttribute('js-positioning-vertical')).to.equal('bottom'); - expect(invokerChild.getAttribute('js-positioning-horizontal')).to.equal('right'); + const { contentNode } = controller; + expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('bottom'); + expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('right'); }); }); @@ -292,14 +298,14 @@ describe('LocalOverlayController', () => { `, }); - expect(controller.invoker.firstElementChild.getAttribute('aria-controls')).to.contain( + expect(controller.invokerNode.getAttribute('aria-controls')).to.contain( controller.content.id, ); - expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('false'); + expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false'); controller.show(); - expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('true'); + expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('true'); controller.hide(); - expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('false'); + expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false'); }); it('traps the focus via option { trapsKeyboardFocus: true }', async () => { @@ -317,17 +323,45 @@ describe('LocalOverlayController', () => { trapsKeyboardFocus: true, }); // make sure we're connected to the dom - await fixture( - html` - ${controller.invoker}${controller.content} - `, - ); + await fixture(html` + ${controller.invoker}${controller.content} + `); controller.show(); const elOutside = await fixture(``); - const [el1, el2] = [].slice.call( - controller.content.firstElementChild.querySelectorAll('[id]'), - ); + 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 @@ -360,7 +394,7 @@ describe('LocalOverlayController', () => { ); const elOutside = await fixture(``); controller.show(); - const el1 = controller.content.firstElementChild.querySelector('button'); + const el1 = controller.content.querySelector('button'); el1.focus(); simulateTab(); @@ -388,7 +422,7 @@ describe('LocalOverlayController', () => { ); ctrl.show(); - keyUpOn(ctrl.content, keyCodes.escape); + keyUpOn(ctrl.contentNode, keyCodes.escape); ctrl.updateComplete; expect(ctrl.isShown).to.equal(false); }); @@ -457,25 +491,23 @@ describe('LocalOverlayController', () => { `, }); - const { content, invoker, invokerNode } = ctrl; - await fixture( - html` - ${invoker}${content} - `, - ); + const { content, invoker } = ctrl; + await fixture(html` + ${invoker}${content} + `); // Don't hide on first invoker click - invokerNode.click(); + ctrl.invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Don't hide on inside (content) click - content.click(); + ctrl.contentNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true); // Don't hide on invoker click when shown - invokerNode.click(); + ctrl.invokerNode.click(); await aTimeout(); expect(ctrl.isShown).to.equal(true);