Merge pull request #716 from ing-bank/fix/rotatingTabsOutsideWindow

Fix/rotating tabs outside window
This commit is contained in:
Thijs Louisse 2020-05-20 12:46:28 +02:00 committed by GitHub
commit 82e9c360c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 152 additions and 29 deletions

View file

@ -610,7 +610,7 @@ export class OverlayController {
_handleTrapsKeyboardFocus({ phase }) {
if (phase === 'show') {
this.enableTrapsKeyboardFocus();
} else if (phase === 'hide') {
} else if (phase === 'hide' || phase === 'teardown') {
this.disableTrapsKeyboardFocus();
}
}

View file

@ -74,6 +74,7 @@ export function containFocus(rootElement) {
const focusableElements = getFocusableElements(rootElement);
// Initial focus goes to first element with autofocus, or the root element
const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement;
let /** @type {HTMLElement} */ tabDetectionElement;
// If root element will receive focus, it should have a tabindex of -1.
// This makes it focusable through js, but it won't appear in the tab order
@ -95,12 +96,47 @@ export function containFocus(rootElement) {
}
}
function createHelpersDetectingTabDirection() {
tabDetectionElement = document.createElement('div');
tabDetectionElement.style.display = 'none';
rootElement.insertBefore(tabDetectionElement, rootElement.children[0]);
}
function isForwardTabInWindow() {
const compareMask = tabDetectionElement.compareDocumentPosition(document.activeElement);
return compareMask === Node.DOCUMENT_POSITION_PRECEDING;
}
/**
* @desc When we simulate a modal dialog, we need to restore the focus to the first or last
* element of the rootElement
*/
function setFocusInRootElement() {
window.removeEventListener('focusin', setFocusInRootElement);
if (rootElement.contains(document.activeElement)) {
return;
}
const nextActive = focusableElements[isForwardTabInWindow() ? 0 : focusableElements.length - 1];
if (nextActive) {
nextActive.focus();
}
}
function addFocusinListener() {
window.addEventListener('focusin', setFocusInRootElement);
}
function disconnect() {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('focusin', setFocusInRootElement);
window.removeEventListener('blur', addFocusinListener);
rootElement.removeChild(tabDetectionElement);
rootElement.style.removeProperty('outline');
}
window.addEventListener('keydown', handleKeydown);
window.addEventListener('blur', addFocusinListener);
createHelpersDetectingTabDirection();
return { disconnect };
}

View file

@ -1,9 +1,9 @@
import { expect, fixture } from '@open-wc/testing';
import { expect, fixture, html } from '@open-wc/testing';
import { renderLitAsNode } from '@lion/helpers';
import { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js';
import { getFocusableElements } from '../../src/utils/get-focusable-elements.js';
import { keyCodes } from '../../src/utils/key-codes.js';
import { containFocus } from '../../src/utils/contain-focus.js';
function simulateTabWithinContainFocus() {
@ -12,7 +12,37 @@ function simulateTabWithinContainFocus() {
window.dispatchEvent(event);
}
const lightDomTemplate = `
function simulateTabInWindow(elToRecieveFocus) {
window.dispatchEvent(new Event('blur'));
elToRecieveFocus.focus();
window.dispatchEvent(new Event('focusin'));
}
const interactionElementsNode = renderLitAsNode(html`
<div>
<button id="el1"></button>
<a id="el2" href="#"></a>
<div id="el3" tabindex="0"></div>
<input id="el4" />
<div id="el5" contenteditable="true"></div>
<textarea id="el6"></textarea>
<select id="el7">
<option>1</option>
</select>
</div>
`);
const lightDomTemplate = html`
<div>
<button id="outside-1">outside 1</button>
<div id="rootElement">
${interactionElementsNode}
</div>
<button id="outside-2">outside 2</button>
</div>
`;
const lightDomAutofocusTemplate = html`
<div>
<button id="outside-1">outside 1</button>
@ -20,8 +50,8 @@ const lightDomTemplate = `
<button id="el1"></button>
<a id="el2" href="#"></a>
<div id="el3" tabindex="0"></div>
<input id="el4">
<div id="el5" contenteditable></div>
<input id="el4" autofocus />
<div id="el5" contenteditable="true"></div>
<textarea id="el6"></textarea>
<select id="el7">
<option>1</option>
@ -32,75 +62,77 @@ const lightDomTemplate = `
</div>
`;
const lightDomAutofocusTemplate = `
function createShadowDomNode() {
const shadowDomNode = renderLitAsNode(html`
<div>
<button id="outside-1">outside 1</button>
<div id="rootElement">
<button id="el1"></button>
<a id="el2" href="#"></a>
<div id="el3" tabindex="0"></div>
<input id="el4" autofocus>
<div id="el5" contenteditable></div>
<textarea id="el6"></textarea>
<select id="el7">
<option>1</option>
</select>
</div>
<div id="rootElementShadow"></div>
<button id="outside-2">outside 2</button>
</div>
`;
`);
const rootElementShadow = shadowDomNode.querySelector('#rootElementShadow');
rootElementShadow.attachShadow({ mode: 'open' });
rootElementShadow.shadowRoot.appendChild(interactionElementsNode);
return shadowDomNode;
}
describe('containFocus()', () => {
it('starts focus at the root element when there is no element with [autofocus]', async () => {
await fixture(lightDomTemplate);
const root = document.getElementById('rootElement');
containFocus(root);
const { disconnect } = containFocus(root);
expect(getDeepActiveElement()).to.equal(root);
expect(root.getAttribute('tabindex')).to.equal('-1');
expect(root.style.getPropertyValue('outline-style')).to.equal('none');
disconnect();
});
it('starts focus at the element with [autofocus] attribute', async () => {
await fixture(lightDomAutofocusTemplate);
const el = document.querySelector('input[autofocus]');
containFocus(el);
const { disconnect } = containFocus(el);
expect(getDeepActiveElement()).to.equal(el);
disconnect();
});
it('on tab, focuses first focusable element if focus was on element outside root element', async () => {
await fixture(lightDomTemplate);
const root = document.getElementById('rootElement');
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
containFocus(root);
document.getElementById('outside-1').focus();
simulateTabWithinContainFocus();
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
disconnect();
});
it('on tab, focuses first focusable element if focus was on the last focusable element', async () => {
await fixture(lightDomTemplate);
const root = document.getElementById('rootElement');
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
containFocus(root);
focusableElements[focusableElements.length - 1].focus();
simulateTabWithinContainFocus();
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
disconnect();
});
it('on tab, does not interfere if focus remains within the root element', async () => {
await fixture(lightDomTemplate);
const root = document.getElementById('rootElement');
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
containFocus(root);
focusableElements[2].focus();
simulateTabWithinContainFocus();
@ -110,5 +142,60 @@ describe('containFocus()', () => {
* to the first element.
*/
expect(getDeepActiveElement()).to.equal(focusableElements[2]);
disconnect();
});
describe('Tabbing into window', () => {
it('restores focus within root element', async () => {
await fixture(lightDomTemplate);
const root = document.getElementById('rootElement');
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
// Simulate tab in window
simulateTabInWindow(document.getElementById('outside-1'));
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
// Simulate shift+tab in window
simulateTabInWindow(document.getElementById('outside-2'));
expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]);
disconnect();
});
it('restores focus within root element with shadow dom', async () => {
const el = await fixture(html`${createShadowDomNode()}`);
const root = el.querySelector('#rootElementShadow');
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
// Simulate tab in window
simulateTabInWindow(document.getElementById('outside-1'));
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
// Simulate shift+tab in window
simulateTabInWindow(document.getElementById('outside-2'));
expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]);
disconnect();
});
it('keeps focus if already in rootElement', async () => {
const el = await fixture(html`${createShadowDomNode()}`);
const root = el.querySelector('#rootElementShadow');
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
// Simulate tab in window
simulateTabInWindow(focusableElements[0]);
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
// Simulate shift+tab in window
simulateTabInWindow(focusableElements[focusableElements.length - 1]);
expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]);
disconnect();
});
});
});