fix: use deep active element, reset to root on focusout without focusin

This commit is contained in:
Joren Broekema 2020-10-28 14:30:46 +01:00
parent bc97030fed
commit fff79915f9
5 changed files with 163 additions and 9 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 { getDeepActiveElement } from './get-deep-active-element.js';
import { getFocusableElements } from './get-focusable-elements.js'; import { getFocusableElements } from './get-focusable-elements.js';
import { deepContains } from './deep-contains.js';
import { keyCodes } from './key-codes.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 * @desc When we simulate a modal dialog, we need to restore the focus to the first or last
* element of the rootElement * element of the rootElement
*/ */
function setFocusInRootElement() { function setFocusInRootElement({ resetToRoot = false } = {}) {
window.removeEventListener('focusin', setFocusInRootElement); if (deepContains(rootElement, /** @type {HTMLElement} */ (getDeepActiveElement()))) {
if (rootElement.contains(document.activeElement)) {
return; 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) { if (nextActive) {
nextActive.focus(); nextActive.focus();
} }
} }
function addFocusinListener() { function handleFocusin() {
window.addEventListener('focusin', setFocusInRootElement); 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() { function disconnect() {
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('focusin', setFocusInRootElement); window.removeEventListener('focusin', handleFocusin);
window.removeEventListener('focusout', addFocusinListener); window.removeEventListener('focusout', handleFocusout);
rootElement.removeChild(tabDetectionElement); rootElement.removeChild(tabDetectionElement);
rootElement.style.removeProperty('outline'); rootElement.style.removeProperty('outline');
} }
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
window.addEventListener('focusout', addFocusinListener); window.addEventListener('focusout', handleFocusout);
createHelpersDetectingTabDirection(); createHelpersDetectingTabDirection();
return { disconnect }; 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

@ -148,6 +148,20 @@ describe('containFocus()', () => {
disconnect(); 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', () => { describe('Tabbing into window', () => {
it('restores focus within root element', async () => { it('restores focus within root element', async () => {
await fixture(lightDomTemplate); 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;
});
});