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 }) {
|
||||
if (phase === 'show') {
|
||||
this.enableTrapsKeyboardFocus();
|
||||
} else if (phase === 'hide') {
|
||||
} else if (phase === 'hide' || phase === 'teardown') {
|
||||
this.disableTrapsKeyboardFocus();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue