diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js index 200abbe83..9c6958772 100644 --- a/packages/overlays/src/OverlayController.js +++ b/packages/overlays/src/OverlayController.js @@ -610,7 +610,7 @@ export class OverlayController { _handleTrapsKeyboardFocus({ phase }) { if (phase === 'show') { this.enableTrapsKeyboardFocus(); - } else if (phase === 'hide') { + } else if (phase === 'hide' || phase === 'teardown') { this.disableTrapsKeyboardFocus(); } } diff --git a/packages/overlays/src/utils/contain-focus.js b/packages/overlays/src/utils/contain-focus.js index fb74ca851..0a06f7a70 100644 --- a/packages/overlays/src/utils/contain-focus.js +++ b/packages/overlays/src/utils/contain-focus.js @@ -74,6 +74,7 @@ export function containFocus(rootElement) { const focusableElements = getFocusableElements(rootElement); // Initial focus goes to first element with autofocus, or the root element const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement; + let /** @type {HTMLElement} */ tabDetectionElement; // If root element will receive focus, it should have a tabindex of -1. // This makes it focusable through js, but it won't appear in the tab order @@ -95,12 +96,47 @@ export function containFocus(rootElement) { } } + function createHelpersDetectingTabDirection() { + tabDetectionElement = document.createElement('div'); + tabDetectionElement.style.display = 'none'; + rootElement.insertBefore(tabDetectionElement, rootElement.children[0]); + } + + function isForwardTabInWindow() { + const compareMask = tabDetectionElement.compareDocumentPosition(document.activeElement); + return compareMask === Node.DOCUMENT_POSITION_PRECEDING; + } + + /** + * @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)) { + return; + } + const nextActive = focusableElements[isForwardTabInWindow() ? 0 : focusableElements.length - 1]; + if (nextActive) { + nextActive.focus(); + } + } + + function addFocusinListener() { + window.addEventListener('focusin', setFocusInRootElement); + } + function disconnect() { window.removeEventListener('keydown', handleKeydown); + window.removeEventListener('focusin', setFocusInRootElement); + window.removeEventListener('blur', addFocusinListener); + rootElement.removeChild(tabDetectionElement); rootElement.style.removeProperty('outline'); } window.addEventListener('keydown', handleKeydown); + window.addEventListener('blur', addFocusinListener); + createHelpersDetectingTabDirection(); return { disconnect }; } diff --git a/packages/overlays/test/utils-tests/contain-focus.test.js b/packages/overlays/test/utils-tests/contain-focus.test.js index 679d03da7..53ebd6fe5 100644 --- a/packages/overlays/test/utils-tests/contain-focus.test.js +++ b/packages/overlays/test/utils-tests/contain-focus.test.js @@ -1,9 +1,9 @@ -import { expect, fixture } from '@open-wc/testing'; +import { expect, fixture, html } from '@open-wc/testing'; +import { renderLitAsNode } from '@lion/helpers'; import { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js'; import { getFocusableElements } from '../../src/utils/get-focusable-elements.js'; import { keyCodes } from '../../src/utils/key-codes.js'; - import { containFocus } from '../../src/utils/contain-focus.js'; function simulateTabWithinContainFocus() { @@ -12,7 +12,37 @@ function simulateTabWithinContainFocus() { window.dispatchEvent(event); } -const lightDomTemplate = ` +function simulateTabInWindow(elToRecieveFocus) { + window.dispatchEvent(new Event('blur')); + elToRecieveFocus.focus(); + window.dispatchEvent(new Event('focusin')); +} + +const interactionElementsNode = renderLitAsNode(html` +
+ + +
+ +
+ + +
+`); + +const lightDomTemplate = html` +
+ +
+ ${interactionElementsNode} +
+ +
+`; + +const lightDomAutofocusTemplate = html`
@@ -20,8 +50,8 @@ const lightDomTemplate = `
- -
+ +
-
- - +function createShadowDomNode() { + const shadowDomNode = renderLitAsNode(html` +
+ +
+
- - -
-`; + `); + const rootElementShadow = shadowDomNode.querySelector('#rootElementShadow'); + rootElementShadow.attachShadow({ mode: 'open' }); + rootElementShadow.shadowRoot.appendChild(interactionElementsNode); + return shadowDomNode; +} describe('containFocus()', () => { it('starts focus at the root element when there is no element with [autofocus]', async () => { await fixture(lightDomTemplate); const root = document.getElementById('rootElement'); - containFocus(root); + const { disconnect } = containFocus(root); expect(getDeepActiveElement()).to.equal(root); expect(root.getAttribute('tabindex')).to.equal('-1'); expect(root.style.getPropertyValue('outline-style')).to.equal('none'); + + disconnect(); }); it('starts focus at the element with [autofocus] attribute', async () => { await fixture(lightDomAutofocusTemplate); const el = document.querySelector('input[autofocus]'); - containFocus(el); + const { disconnect } = containFocus(el); expect(getDeepActiveElement()).to.equal(el); + + disconnect(); }); it('on tab, focuses first focusable element if focus was on element outside root element', async () => { await fixture(lightDomTemplate); const root = document.getElementById('rootElement'); const focusableElements = getFocusableElements(root); + const { disconnect } = containFocus(root); - containFocus(root); document.getElementById('outside-1').focus(); simulateTabWithinContainFocus(); expect(getDeepActiveElement()).to.equal(focusableElements[0]); + + disconnect(); }); it('on tab, focuses first focusable element if focus was on the last focusable element', async () => { await fixture(lightDomTemplate); const root = document.getElementById('rootElement'); const focusableElements = getFocusableElements(root); + const { disconnect } = containFocus(root); - containFocus(root); focusableElements[focusableElements.length - 1].focus(); simulateTabWithinContainFocus(); expect(getDeepActiveElement()).to.equal(focusableElements[0]); + + disconnect(); }); it('on tab, does not interfere if focus remains within the root element', async () => { await fixture(lightDomTemplate); const root = document.getElementById('rootElement'); const focusableElements = getFocusableElements(root); + const { disconnect } = containFocus(root); - containFocus(root); focusableElements[2].focus(); simulateTabWithinContainFocus(); @@ -110,5 +142,60 @@ describe('containFocus()', () => { * to the first element. */ expect(getDeepActiveElement()).to.equal(focusableElements[2]); + + disconnect(); + }); + + describe('Tabbing into window', () => { + it('restores focus within root element', async () => { + await fixture(lightDomTemplate); + const root = document.getElementById('rootElement'); + const focusableElements = getFocusableElements(root); + const { disconnect } = containFocus(root); + + // Simulate tab in window + simulateTabInWindow(document.getElementById('outside-1')); + expect(getDeepActiveElement()).to.equal(focusableElements[0]); + + // Simulate shift+tab in window + simulateTabInWindow(document.getElementById('outside-2')); + expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]); + + disconnect(); + }); + + it('restores focus within root element with shadow dom', async () => { + const el = await fixture(html`${createShadowDomNode()}`); + const root = el.querySelector('#rootElementShadow'); + const focusableElements = getFocusableElements(root); + const { disconnect } = containFocus(root); + + // Simulate tab in window + simulateTabInWindow(document.getElementById('outside-1')); + expect(getDeepActiveElement()).to.equal(focusableElements[0]); + + // Simulate shift+tab in window + simulateTabInWindow(document.getElementById('outside-2')); + expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]); + + disconnect(); + }); + + it('keeps focus if already in rootElement', async () => { + const el = await fixture(html`${createShadowDomNode()}`); + const root = el.querySelector('#rootElementShadow'); + const focusableElements = getFocusableElements(root); + const { disconnect } = containFocus(root); + + // Simulate tab in window + simulateTabInWindow(focusableElements[0]); + expect(getDeepActiveElement()).to.equal(focusableElements[0]); + + // Simulate shift+tab in window + simulateTabInWindow(focusableElements[focusableElements.length - 1]); + expect(getDeepActiveElement()).to.equal(focusableElements[focusableElements.length - 1]); + + disconnect(); + }); }); });