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.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();
}
}
}

View file

@ -37,7 +37,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`,
invokerTemplate: () =>
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: () =>
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: () =>
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>
`;
})
.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', () => {
const popup = overlays.add(
new LocalOverlayController({
@ -198,7 +174,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`,
invokerTemplate: () =>
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}
</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();
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', () => {
<p>Content</p>
`,
invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button>
<button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`,
});
await fixture(html`
@ -227,15 +229,17 @@ describe('LocalOverlayController', () => {
</div>
`);
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`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button>
<button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`,
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', () => {
<p>Content</p>
`,
invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button>
<button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`,
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(`<button>click me</button>`);
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('<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();
// this mimics a tab within the contain-focus system used
@ -360,7 +394,7 @@ describe('LocalOverlayController', () => {
);
const elOutside = await fixture(`<button>click me</button>`);
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', () => {
<button @click="${() => ctrl.show()}">Invoker</button>
`,
});
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);