fix: use deep active element, reset to root on focusout without focusin
This commit is contained in:
parent
bc97030fed
commit
fff79915f9
5 changed files with 163 additions and 9 deletions
5
.changeset/hot-roses-smell.md
Normal file
5
.changeset/hot-roses-smell.md
Normal 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.
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
40
packages/overlays/src/utils/deep-contains.js
Normal file
40
packages/overlays/src/utils/deep-contains.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
66
packages/overlays/test/utils-tests/deep-contains.test.js
Normal file
66
packages/overlays/test/utils-tests/deep-contains.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue