Merge pull request #1074 from ing-bank/fix/focus

Fix/focus
This commit is contained in:
gerjanvangeest 2020-11-05 11:25:32 +01:00 committed by GitHub
commit 7f32fa9a23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 164 additions and 10 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/overlays': patch
---
Added a fix for focus not being restored to the root element when only a focusout event happens, without a subsequent focusin event. Added a fix to use getDeepActiveElement util instead of document.activeElement which fixes focus trap in elements with shadowRoots that contain focusable elements.

View file

@ -8,6 +8,7 @@
import { getDeepActiveElement } from './get-deep-active-element.js';
import { getFocusableElements } from './get-focusable-elements.js';
import { deepContains } from './deep-contains.js';
import { keyCodes } from './key-codes.js';
/**
@ -111,34 +112,62 @@ export function containFocus(rootElement) {
}
/**
* @param {Object} [opts]
* @param {boolean} [opts.resetToRoot]
* @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)) {
function setFocusInRootElement({ resetToRoot = false } = {}) {
if (deepContains(rootElement, /** @type {HTMLElement} */ (getDeepActiveElement()))) {
return;
}
const nextActive = focusableElements[isForwardTabInWindow() ? 0 : focusableElements.length - 1];
let nextActive;
if (resetToRoot) {
nextActive = rootElement;
} else {
nextActive = focusableElements[isForwardTabInWindow() ? 0 : focusableElements.length - 1];
}
if (nextActive) {
nextActive.focus();
}
}
function addFocusinListener() {
window.addEventListener('focusin', setFocusInRootElement);
function handleFocusin() {
window.removeEventListener('focusin', handleFocusin);
setFocusInRootElement();
}
function handleFocusout() {
/**
* There is a moment in time between focusout and focusin (when focus shifts)
* where the activeElement is reset to body first. So we use an async task to check
* a little bit later for activeElement, so we don't get a false positive.
*
* We used to check for focusin event for this, however,
* it can happen that focusout happens, but focusin never does, e.g. click outside but no focusable
* element is found to focus. If this happens, we should take the focus back to the rootElement.
*/
setTimeout(() => {
if (!deepContains(rootElement, /** @type {HTMLElement} */ (getDeepActiveElement()))) {
setFocusInRootElement({ resetToRoot: true });
}
});
window.addEventListener('focusin', handleFocusin);
}
function disconnect() {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('focusin', setFocusInRootElement);
window.removeEventListener('blur', addFocusinListener);
window.removeEventListener('focusin', handleFocusin);
window.removeEventListener('focusout', handleFocusout);
rootElement.removeChild(tabDetectionElement);
rootElement.style.removeProperty('outline');
}
window.addEventListener('keydown', handleKeydown);
window.addEventListener('blur', addFocusinListener);
window.addEventListener('focusout', handleFocusout);
createHelpersDetectingTabDirection();
return { disconnect };

View file

@ -0,0 +1,40 @@
/**
* Whether first element contains the second element, also goes through shadow roots
* @param {HTMLElement|ShadowRoot} el
* @param {HTMLElement} targetEl
* @returns {boolean}
*/
export function deepContains(el, targetEl) {
let containsTarget = el.contains(targetEl);
if (containsTarget) {
return true;
}
/** @param {HTMLElement} elem */
function checkChildren(elem) {
for (let i = 0; i < elem.children.length; i += 1) {
const child = /** @type {HTMLElement} */ (elem.children[i]);
if (child.shadowRoot) {
containsTarget = deepContains(child.shadowRoot, targetEl);
if (containsTarget) {
break;
}
}
if (child.children.length > 0) {
checkChildren(child);
}
}
}
// If element is not shadowRoot itself
if (el instanceof HTMLElement) {
if (el.shadowRoot) {
containsTarget = deepContains(el.shadowRoot, targetEl);
if (containsTarget) {
return true;
}
}
checkChildren(el);
}
return containsTarget;
}

View file

@ -358,7 +358,7 @@ describe('OverlayController', () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
contentNode,
trapsKeyboardFocus: true,
trapsKeyboardFocus: false,
});
// add element to dom to allow focus
/** @type {HTMLElement} */ (await fixture(html`${ctrl.content}`));

View file

@ -148,6 +148,20 @@ describe('containFocus()', () => {
disconnect();
});
it.skip('restores focus to root element if focusout event happens where activeElement goes outside', async () => {
await fixture(lightDomTemplate);
const root = /** @type {HTMLElement} */ (document.getElementById('rootElement'));
const focusableElements = getFocusableElements(root);
const { disconnect } = containFocus(root);
focusableElements[2].focus();
expect(getDeepActiveElement()).to.equal(focusableElements[2]);
document.body.click(); // this does not cause focusout event :( doesn't seem possible to mock
expect(getDeepActiveElement()).to.equal(root);
disconnect();
});
describe('Tabbing into window', () => {
it('restores focus within root element', async () => {
await fixture(lightDomTemplate);

View file

@ -0,0 +1,66 @@
import { expect, fixture } from '@open-wc/testing';
import { html } from '@lion/core';
import { deepContains } from '../../src/utils/deep-contains.js';
describe('deepContains()', () => {
it('returns true if element contains a target element with a shadow boundary in between', async () => {
const shadowElement = /** @type {HTMLElement} */ (await fixture('<div id="shadow"></div>'));
const shadowRoot = shadowElement.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<button id="el-1">Button</button>
<a id="el-2" href="#foo">Href</a>
<input id="el-3">
`;
const shadowElementChild = /** @type {HTMLElement} */ (shadowRoot.querySelector('#el-1'));
const element = /** @type {HTMLElement} */ (await fixture(html`
<div id="light">
${shadowElement}
<button id="light-el-1"></button>
</div>
`));
const lightEl = /** @type {HTMLElement} */ (element.querySelector('#light-el-1'));
expect(deepContains(element, shadowElement)).to.be.true;
expect(deepContains(element, shadowElementChild)).to.be.true;
expect(deepContains(element, lightEl)).to.be.true;
expect(deepContains(shadowElement, shadowElement)).to.be.true;
expect(deepContains(shadowElement, shadowElementChild)).to.be.true;
expect(deepContains(shadowRoot, shadowElementChild)).to.be.true;
});
it('returns true if element contains a target element with a shadow boundary in between, for multiple shadowroots', async () => {
const shadowElement = /** @type {HTMLElement} */ (await fixture('<div id="shadow"></div>'));
const shadowRoot = shadowElement.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<button id="el-1">Button</button>
<a id="el-2" href="#foo">Href</a>
<input id="el-3">
`;
const shadowElement2 = /** @type {HTMLElement} */ (await fixture('<div id="shadow2"></div>'));
const shadowRoot2 = shadowElement2.attachShadow({ mode: 'open' });
shadowRoot2.innerHTML = `
<button id="el-1">Button</button>
<a id="el-2" href="#foo">Href</a>
<input id="el-3">
`;
const shadowElementChild = /** @type {HTMLElement} */ (shadowRoot.querySelector('#el-2'));
const shadowElementChild2 = /** @type {HTMLElement} */ (shadowRoot2.querySelector('#el-2'));
const element = /** @type {HTMLElement} */ (await fixture(html`
<div id="light">
${shadowElement} ${shadowElement2}
<button id="light-el-1"></button>
</div>
`));
expect(deepContains(element, shadowElementChild)).to.be.true;
expect(deepContains(shadowElement, shadowElementChild)).to.be.true;
expect(deepContains(shadowRoot, shadowElementChild)).to.be.true;
expect(deepContains(element, shadowElementChild2)).to.be.true;
expect(deepContains(shadowElement2, shadowElementChild2)).to.be.true;
expect(deepContains(shadowRoot2, shadowElementChild2)).to.be.true;
});
});