Merge pull request #109 from ing-bank/fix/containFocusForContentNode

fix(overlays): trapsKeyboardFocus should work with contentNode
This commit is contained in:
gerjanvangeest 2019-06-19 17:02:25 +02:00 committed by GitHub
commit d00439a24c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 154 additions and 80 deletions

View file

@ -15,24 +15,48 @@ export class LocalOverlayController {
this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus; this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus;
this.placement = finalParams.placement; this.placement = finalParams.placement;
this.position = finalParams.position; this.position = finalParams.position;
/**
* A wrapper to render into the invokerTemplate
*
* @property {HTMLElement}
*/
this.invoker = document.createElement('div'); this.invoker = document.createElement('div');
this.invoker.style.display = 'inline-block'; 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 = document.createElement('div');
this.content.style.display = 'inline-block'; 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() this.contentId = `overlay-content-${Math.random()
.toString(36) .toString(36)
.substr(2, 10)}`; .substr(2, 10)}`;
this._contentData = {}; this._contentData = {};
this.invokerTemplate = finalParams.invokerTemplate;
this.invokerNode = finalParams.invokerNode;
this.contentTemplate = finalParams.contentTemplate;
this.contentNode = finalParams.contentNode;
this.syncInvoker(); this.syncInvoker();
this._updateContent(); this._updateContent();
this._prevShown = false; this._prevShown = false;
this._prevData = {}; this._prevData = {};
this.__boundEscKeyHandler = this.__escKeyHandler.bind(this);
if (this.hidesOnEsc) this._setupHidesOnEsc();
} }
get isShown() { get isShown() {
@ -109,10 +133,12 @@ export class LocalOverlayController {
if (this.trapsKeyboardFocus) this._setupTrapsKeyboardFocus(); if (this.trapsKeyboardFocus) this._setupTrapsKeyboardFocus();
if (this.hidesOnOutsideClick) this._setupHidesOnOutsideClick(); if (this.hidesOnOutsideClick) this._setupHidesOnOutsideClick();
if (this.hidesOnEsc) this._setupHidesOnEsc();
} else { } else {
this._updateContent(); this._updateContent();
this.invokerNode.setAttribute('aria-expanded', false); this.invokerNode.setAttribute('aria-expanded', false);
if (this.hidesOnOutsideClick) this._teardownHidesOnOutsideClick(); if (this.hidesOnOutsideClick) this._teardownHidesOnOutsideClick();
if (this.hidesOnEsc) this._teardownHidesOnEsc();
} }
this._prevShown = shown; this._prevShown = shown;
this._prevData = data; this._prevData = data;
@ -127,15 +153,15 @@ export class LocalOverlayController {
this._containFocusHandler.disconnect(); this._containFocusHandler.disconnect();
this._containFocusHandler = undefined; // eslint-disable-line no-param-reassign this._containFocusHandler = undefined; // eslint-disable-line no-param-reassign
} }
this._containFocusHandler = containFocus(this.content.firstElementChild); this._containFocusHandler = containFocus(this.contentNode);
} }
_setupHidesOnEsc() { _setupHidesOnEsc() {
this.content.addEventListener('keyup', event => { this.contentNode.addEventListener('keyup', this.__boundEscKeyHandler);
if (event.keyCode === keyCodes.escape) { }
this.hide();
} _teardownHidesOnEsc() {
}); this.contentNode.removeEventListener('keyup', this.__boundEscKeyHandler);
} }
_setupHidesOnOutsideClick() { _setupHidesOnOutsideClick() {
@ -162,14 +188,14 @@ export class LocalOverlayController {
}); });
}; };
this.content.addEventListener('click', this.__preventCloseOutsideClick, true); this.contentNode.addEventListener('click', this.__preventCloseOutsideClick, true);
this.invoker.addEventListener('click', this.__preventCloseOutsideClick, true); this.invokerNode.addEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true); document.documentElement.addEventListener('click', this.__onCaptureHtmlClick, true);
} }
_teardownHidesOnOutsideClick() { _teardownHidesOnOutsideClick() {
this.content.removeEventListener('click', this.__preventCloseOutsideClick, true); this.contentNode.removeEventListener('click', this.__preventCloseOutsideClick, true);
this.invoker.removeEventListener('click', this.__preventCloseOutsideClick, true); this.invokerNode.removeEventListener('click', this.__preventCloseOutsideClick, true);
document.documentElement.removeEventListener('click', this.__onCaptureHtmlClick, true); document.documentElement.removeEventListener('click', this.__onCaptureHtmlClick, true);
this.__preventCloseOutsideClick = null; this.__preventCloseOutsideClick = null;
this.__onCaptureHtmlClick = null; this.__onCaptureHtmlClick = null;
@ -182,4 +208,10 @@ export class LocalOverlayController {
this.contentNode.style.display = 'none'; this.contentNode.style.display = 'none';
} }
} }
__escKeyHandler(e) {
if (e.keyCode === keyCodes.escape) {
this.hide();
}
}
} }

View file

@ -37,7 +37,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`, `,
invokerTemplate: () => invokerTemplate: () =>
html` html`
<button @click=${() => popup.show()}>UK</button> <button @click=${() => popup.toggle()}>UK</button>
`, `,
}), }),
); );
@ -62,7 +62,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`, `,
invokerTemplate: () => invokerTemplate: () =>
html` html`
<button @click=${() => popup.show()}>UK</button> <button @click=${() => popup.toggle()}>UK</button>
`, `,
}), }),
); );
@ -88,7 +88,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`, `,
invokerTemplate: () => invokerTemplate: () =>
html` html`
<button @click=${() => popup.show()}>Click me</button> <button @click=${() => popup.toggle()}>Click me</button>
`, `,
}), }),
); );
@ -153,30 +153,6 @@ storiesOf('Local Overlay System|Local Overlay', module)
</div> </div>
`; `;
}) })
.add('On toggle', () => {
const popup = overlays.add(
new LocalOverlayController({
hidesOnEsc: true,
hidesOnOutsideClick: true,
contentTemplate: () =>
html`
<div class="demo-popup">United Kingdom</div>
`,
invokerTemplate: () =>
html`
<button @click=${() => popup.toggle()}>UK</button>
`,
}),
);
return html`
<style>
${popupDemoStyle}
</style>
<div class="demo-box">
<label for="input">Weather in ${popup.invoker}${popup.content} toggles.</label>
</div>
`;
})
.add('trapsKeyboardFocus', () => { .add('trapsKeyboardFocus', () => {
const popup = overlays.add( const popup = overlays.add(
new LocalOverlayController({ new LocalOverlayController({
@ -198,7 +174,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`, `,
invokerTemplate: () => invokerTemplate: () =>
html` html`
<button @click=${() => popup.show()}>UK</button> <button @click=${() => popup.toggle()}>UK</button>
`, `,
}), }),
); );
@ -210,4 +186,38 @@ storiesOf('Local Overlay System|Local Overlay', module)
${popup.invoker}${popup.content} ${popup.invoker}${popup.content}
</div> </div>
`; `;
})
.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`
<style>
${popupDemoStyle}
</style>
<div class="demo-box">
${popup.invoker}${popup.content}
</div>
`;
}); });

View file

@ -208,7 +208,7 @@ describe('LocalOverlayController', () => {
}); });
controller.show(); 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 () => { it('uses top as the default placement', async () => {
@ -218,7 +218,9 @@ describe('LocalOverlayController', () => {
<p>Content</p> <p>Content</p>
`, `,
invokerTemplate: () => html` invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button> <button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`, `,
}); });
await fixture(html` await fixture(html`
@ -227,15 +229,17 @@ describe('LocalOverlayController', () => {
</div> </div>
`); `);
controller.show(); controller.show();
const invokerChild = controller.content.firstElementChild; const { contentNode } = controller;
expect(invokerChild.getAttribute('js-positioning-vertical')).to.equal('top'); expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('top');
expect(invokerChild.getAttribute('js-positioning-horizontal')).to.equal('centerHorizontal'); expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('centerHorizontal');
}); });
it('positions to preferred place if placement is set and space is available', async () => { it('positions to preferred place if placement is set and space is available', async () => {
const controller = new LocalOverlayController({ const controller = new LocalOverlayController({
invokerTemplate: () => html` invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button> <button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`, `,
contentTemplate: () => contentTemplate: () =>
html` html`
@ -250,9 +254,9 @@ describe('LocalOverlayController', () => {
`); `);
controller.show(); controller.show();
const contentChild = controller.content.firstElementChild; const { contentNode } = controller;
expect(contentChild.getAttribute('js-positioning-vertical')).to.equal('top'); expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('top');
expect(contentChild.getAttribute('js-positioning-horizontal')).to.equal('right'); expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('right');
}); });
it('positions to different place if placement is set and no space is available', async () => { it('positions to different place if placement is set and no space is available', async () => {
@ -262,7 +266,9 @@ describe('LocalOverlayController', () => {
<p>Content</p> <p>Content</p>
`, `,
invokerTemplate: () => html` invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button> <button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`, `,
placement: 'top right', placement: 'top right',
}); });
@ -273,9 +279,9 @@ describe('LocalOverlayController', () => {
`); `);
controller.show(); controller.show();
const invokerChild = controller.content.firstElementChild; const { contentNode } = controller;
expect(invokerChild.getAttribute('js-positioning-vertical')).to.equal('bottom'); expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('bottom');
expect(invokerChild.getAttribute('js-positioning-horizontal')).to.equal('right'); 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, controller.content.id,
); );
expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('false'); expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('false');
controller.show(); controller.show();
expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('true'); expect(controller.invokerNode.getAttribute('aria-expanded')).to.equal('true');
controller.hide(); 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 () => { it('traps the focus via option { trapsKeyboardFocus: true }', async () => {
@ -317,17 +323,45 @@ describe('LocalOverlayController', () => {
trapsKeyboardFocus: true, trapsKeyboardFocus: true,
}); });
// make sure we're connected to the dom // make sure we're connected to the dom
await fixture( await fixture(html`
html` ${controller.invoker}${controller.content}
${controller.invoker}${controller.content} `);
`,
);
controller.show(); controller.show();
const elOutside = await fixture(`<button>click me</button>`); const elOutside = await fixture(`<button>click me</button>`);
const [el1, el2] = [].slice.call( const [el1, el2] = [].slice.call(controller.contentNode.querySelectorAll('[id]'));
controller.content.firstElementChild.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('<button>Invoker</button>');
const contentNode = await fixture(`
<div>
<button id="el1">Button</button>
<a id="el2" href="#">Anchor</a>
</div>
`);
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(`<button>click me</button>`);
const [el1, el2] = [].slice.call(controller.contentNode.querySelectorAll('[id]'));
el2.focus(); el2.focus();
// this mimics a tab within the contain-focus system used // this mimics a tab within the contain-focus system used
@ -360,7 +394,7 @@ describe('LocalOverlayController', () => {
); );
const elOutside = await fixture(`<button>click me</button>`); const elOutside = await fixture(`<button>click me</button>`);
controller.show(); controller.show();
const el1 = controller.content.firstElementChild.querySelector('button'); const el1 = controller.content.querySelector('button');
el1.focus(); el1.focus();
simulateTab(); simulateTab();
@ -388,7 +422,7 @@ describe('LocalOverlayController', () => {
); );
ctrl.show(); ctrl.show();
keyUpOn(ctrl.content, keyCodes.escape); keyUpOn(ctrl.contentNode, keyCodes.escape);
ctrl.updateComplete; ctrl.updateComplete;
expect(ctrl.isShown).to.equal(false); expect(ctrl.isShown).to.equal(false);
}); });
@ -457,25 +491,23 @@ describe('LocalOverlayController', () => {
<button @click="${() => ctrl.show()}">Invoker</button> <button @click="${() => ctrl.show()}">Invoker</button>
`, `,
}); });
const { content, invoker, invokerNode } = ctrl; const { content, invoker } = ctrl;
await fixture( await fixture(html`
html` ${invoker}${content}
${invoker}${content} `);
`,
);
// Don't hide on first invoker click // Don't hide on first invoker click
invokerNode.click(); ctrl.invokerNode.click();
await aTimeout(); await aTimeout();
expect(ctrl.isShown).to.equal(true); expect(ctrl.isShown).to.equal(true);
// Don't hide on inside (content) click // Don't hide on inside (content) click
content.click(); ctrl.contentNode.click();
await aTimeout(); await aTimeout();
expect(ctrl.isShown).to.equal(true); expect(ctrl.isShown).to.equal(true);
// Don't hide on invoker click when shown // Don't hide on invoker click when shown
invokerNode.click(); ctrl.invokerNode.click();
await aTimeout(); await aTimeout();
expect(ctrl.isShown).to.equal(true); expect(ctrl.isShown).to.equal(true);