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` -
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(` +