diff --git a/.changeset/hot-roses-smell.md b/.changeset/hot-roses-smell.md new file mode 100644 index 000000000..6d0bf9c8a --- /dev/null +++ b/.changeset/hot-roses-smell.md @@ -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. diff --git a/packages/overlays/src/utils/contain-focus.js b/packages/overlays/src/utils/contain-focus.js index e670cb24c..f100d1bf7 100644 --- a/packages/overlays/src/utils/contain-focus.js +++ b/packages/overlays/src/utils/contain-focus.js @@ -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('focusout', addFocusinListener); + window.removeEventListener('focusin', handleFocusin); + window.removeEventListener('focusout', handleFocusout); rootElement.removeChild(tabDetectionElement); rootElement.style.removeProperty('outline'); } window.addEventListener('keydown', handleKeydown); - window.addEventListener('focusout', addFocusinListener); + window.addEventListener('focusout', handleFocusout); createHelpersDetectingTabDirection(); return { disconnect }; diff --git a/packages/overlays/src/utils/deep-contains.js b/packages/overlays/src/utils/deep-contains.js new file mode 100644 index 000000000..b5730ab68 --- /dev/null +++ b/packages/overlays/src/utils/deep-contains.js @@ -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; +} diff --git a/packages/overlays/test/utils-tests/contain-focus.test.js b/packages/overlays/test/utils-tests/contain-focus.test.js index 7cab868ad..a45843acd 100644 --- a/packages/overlays/test/utils-tests/contain-focus.test.js +++ b/packages/overlays/test/utils-tests/contain-focus.test.js @@ -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); diff --git a/packages/overlays/test/utils-tests/deep-contains.test.js b/packages/overlays/test/utils-tests/deep-contains.test.js new file mode 100644 index 000000000..60b9382a2 --- /dev/null +++ b/packages/overlays/test/utils-tests/deep-contains.test.js @@ -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('
')); + const shadowRoot = shadowElement.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ` + + Href + + `; + const shadowElementChild = /** @type {HTMLElement} */ (shadowRoot.querySelector('#el-1')); + const element = /** @type {HTMLElement} */ (await fixture(html` +