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 = `
-
-
+
+
`;
-const lightDomAutofocusTemplate = `
-
-
-
-
-
-
-
-
-
-
-
+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();
+ });
});
});