commit
7f32fa9a23
6 changed files with 164 additions and 10 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 { 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 };
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
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