Merge pull request #716 from ing-bank/fix/rotatingTabsOutsideWindow
Fix/rotating tabs outside window
This commit is contained in:
commit
82e9c360c6
3 changed files with 152 additions and 29 deletions
|
|
@ -610,7 +610,7 @@ export class OverlayController {
|
||||||
_handleTrapsKeyboardFocus({ phase }) {
|
_handleTrapsKeyboardFocus({ phase }) {
|
||||||
if (phase === 'show') {
|
if (phase === 'show') {
|
||||||
this.enableTrapsKeyboardFocus();
|
this.enableTrapsKeyboardFocus();
|
||||||
} else if (phase === 'hide') {
|
} else if (phase === 'hide' || phase === 'teardown') {
|
||||||
this.disableTrapsKeyboardFocus();
|
this.disableTrapsKeyboardFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export function containFocus(rootElement) {
|
||||||
const focusableElements = getFocusableElements(rootElement);
|
const focusableElements = getFocusableElements(rootElement);
|
||||||
// Initial focus goes to first element with autofocus, or the root element
|
// Initial focus goes to first element with autofocus, or the root element
|
||||||
const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement;
|
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.
|
// 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
|
// 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() {
|
function disconnect() {
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
window.removeEventListener('focusin', setFocusInRootElement);
|
||||||
|
window.removeEventListener('blur', addFocusinListener);
|
||||||
|
rootElement.removeChild(tabDetectionElement);
|
||||||
rootElement.style.removeProperty('outline');
|
rootElement.style.removeProperty('outline');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
window.addEventListener('blur', addFocusinListener);
|
||||||
|
createHelpersDetectingTabDirection();
|
||||||
|
|
||||||
return { disconnect };
|
return { disconnect };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js';
|
||||||
import { getFocusableElements } from '../../src/utils/get-focusable-elements.js';
|
import { getFocusableElements } from '../../src/utils/get-focusable-elements.js';
|
||||||
import { keyCodes } from '../../src/utils/key-codes.js';
|
import { keyCodes } from '../../src/utils/key-codes.js';
|
||||||
|
|
||||||
import { containFocus } from '../../src/utils/contain-focus.js';
|
import { containFocus } from '../../src/utils/contain-focus.js';
|
||||||
|
|
||||||
function simulateTabWithinContainFocus() {
|
function simulateTabWithinContainFocus() {
|
||||||
|
|
@ -12,7 +12,37 @@ function simulateTabWithinContainFocus() {
|
||||||
window.dispatchEvent(event);
|
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>
|
<div>
|
||||||
<button id="outside-1">outside 1</button>
|
<button id="outside-1">outside 1</button>
|
||||||
|
|
||||||
|
|
@ -20,8 +50,8 @@ const lightDomTemplate = `
|
||||||
<button id="el1"></button>
|
<button id="el1"></button>
|
||||||
<a id="el2" href="#"></a>
|
<a id="el2" href="#"></a>
|
||||||
<div id="el3" tabindex="0"></div>
|
<div id="el3" tabindex="0"></div>
|
||||||
<input id="el4">
|
<input id="el4" autofocus />
|
||||||
<div id="el5" contenteditable></div>
|
<div id="el5" contenteditable="true"></div>
|
||||||
<textarea id="el6"></textarea>
|
<textarea id="el6"></textarea>
|
||||||
<select id="el7">
|
<select id="el7">
|
||||||
<option>1</option>
|
<option>1</option>
|
||||||
|
|
@ -32,75 +62,77 @@ const lightDomTemplate = `
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const lightDomAutofocusTemplate = `
|
function createShadowDomNode() {
|
||||||
|
const shadowDomNode = renderLitAsNode(html`
|
||||||
<div>
|
<div>
|
||||||
<button id="outside-1">outside 1</button>
|
<button id="outside-1">outside 1</button>
|
||||||
|
<div id="rootElementShadow"></div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<button id="outside-2">outside 2</button>
|
<button id="outside-2">outside 2</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
|
const rootElementShadow = shadowDomNode.querySelector('#rootElementShadow');
|
||||||
|
rootElementShadow.attachShadow({ mode: 'open' });
|
||||||
|
rootElementShadow.shadowRoot.appendChild(interactionElementsNode);
|
||||||
|
return shadowDomNode;
|
||||||
|
}
|
||||||
|
|
||||||
describe('containFocus()', () => {
|
describe('containFocus()', () => {
|
||||||
it('starts focus at the root element when there is no element with [autofocus]', async () => {
|
it('starts focus at the root element when there is no element with [autofocus]', async () => {
|
||||||
await fixture(lightDomTemplate);
|
await fixture(lightDomTemplate);
|
||||||
const root = document.getElementById('rootElement');
|
const root = document.getElementById('rootElement');
|
||||||
containFocus(root);
|
const { disconnect } = containFocus(root);
|
||||||
|
|
||||||
expect(getDeepActiveElement()).to.equal(root);
|
expect(getDeepActiveElement()).to.equal(root);
|
||||||
expect(root.getAttribute('tabindex')).to.equal('-1');
|
expect(root.getAttribute('tabindex')).to.equal('-1');
|
||||||
expect(root.style.getPropertyValue('outline-style')).to.equal('none');
|
expect(root.style.getPropertyValue('outline-style')).to.equal('none');
|
||||||
|
|
||||||
|
disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts focus at the element with [autofocus] attribute', async () => {
|
it('starts focus at the element with [autofocus] attribute', async () => {
|
||||||
await fixture(lightDomAutofocusTemplate);
|
await fixture(lightDomAutofocusTemplate);
|
||||||
const el = document.querySelector('input[autofocus]');
|
const el = document.querySelector('input[autofocus]');
|
||||||
containFocus(el);
|
const { disconnect } = containFocus(el);
|
||||||
|
|
||||||
expect(getDeepActiveElement()).to.equal(el);
|
expect(getDeepActiveElement()).to.equal(el);
|
||||||
|
|
||||||
|
disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on tab, focuses first focusable element if focus was on element outside root element', async () => {
|
it('on tab, focuses first focusable element if focus was on element outside root element', async () => {
|
||||||
await fixture(lightDomTemplate);
|
await fixture(lightDomTemplate);
|
||||||
const root = document.getElementById('rootElement');
|
const root = document.getElementById('rootElement');
|
||||||
const focusableElements = getFocusableElements(root);
|
const focusableElements = getFocusableElements(root);
|
||||||
|
const { disconnect } = containFocus(root);
|
||||||
|
|
||||||
containFocus(root);
|
|
||||||
document.getElementById('outside-1').focus();
|
document.getElementById('outside-1').focus();
|
||||||
|
|
||||||
simulateTabWithinContainFocus();
|
simulateTabWithinContainFocus();
|
||||||
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
|
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
|
||||||
|
|
||||||
|
disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on tab, focuses first focusable element if focus was on the last focusable element', async () => {
|
it('on tab, focuses first focusable element if focus was on the last focusable element', async () => {
|
||||||
await fixture(lightDomTemplate);
|
await fixture(lightDomTemplate);
|
||||||
const root = document.getElementById('rootElement');
|
const root = document.getElementById('rootElement');
|
||||||
const focusableElements = getFocusableElements(root);
|
const focusableElements = getFocusableElements(root);
|
||||||
|
const { disconnect } = containFocus(root);
|
||||||
|
|
||||||
containFocus(root);
|
|
||||||
focusableElements[focusableElements.length - 1].focus();
|
focusableElements[focusableElements.length - 1].focus();
|
||||||
|
|
||||||
simulateTabWithinContainFocus();
|
simulateTabWithinContainFocus();
|
||||||
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
|
expect(getDeepActiveElement()).to.equal(focusableElements[0]);
|
||||||
|
|
||||||
|
disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on tab, does not interfere if focus remains within the root element', async () => {
|
it('on tab, does not interfere if focus remains within the root element', async () => {
|
||||||
await fixture(lightDomTemplate);
|
await fixture(lightDomTemplate);
|
||||||
const root = document.getElementById('rootElement');
|
const root = document.getElementById('rootElement');
|
||||||
const focusableElements = getFocusableElements(root);
|
const focusableElements = getFocusableElements(root);
|
||||||
|
const { disconnect } = containFocus(root);
|
||||||
|
|
||||||
containFocus(root);
|
|
||||||
focusableElements[2].focus();
|
focusableElements[2].focus();
|
||||||
|
|
||||||
simulateTabWithinContainFocus();
|
simulateTabWithinContainFocus();
|
||||||
|
|
@ -110,5 +142,60 @@ describe('containFocus()', () => {
|
||||||
* to the first element.
|
* to the first element.
|
||||||
*/
|
*/
|
||||||
expect(getDeepActiveElement()).to.equal(focusableElements[2]);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue