diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx index e779ecb554b754..8d6404bf8e544e 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -1,7 +1,15 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { expect } from 'chai'; -import { act, createRenderer, fireEvent, reactMajor, screen } from '@mui/internal-test-utils'; +import { + act, + createRenderer, + fireEvent, + isJsdom, + reactMajor, + screen, + within, +} from '@mui/internal-test-utils'; import FocusTrap from '@mui/material/Unstable_TrapFocus'; import Portal from '@mui/material/Portal'; import getActiveElement from '../utils/getActiveElement'; @@ -11,6 +19,27 @@ interface GenericProps { [index: string]: any; } +/** + * This file runs in both node and browser projects, so `vitest/browser` must be + * imported lazily from browser-only tests. + * + * Use the Vitest Browser userEvent provider for shadow-root keyboard interactions. + * The `user` returned by `render()` comes from @testing-library/user-event. + * Its Tab implementation computes the tab order from `document.querySelectorAll()`, + * which doesn't include focusable elements inside shadow roots. + * + * Keep the import specifier widened to `string` so package TypeScript checks + * don't load Vitest Browser's ambient matcher types. + */ +async function setupBrowser() { + const { page, userEvent: user } = await import('vitest/browser' as string); + + return { + page, + user, + }; +} + describe('', () => { const { clock, render } = createRenderer(); @@ -366,6 +395,177 @@ describe('', () => { expect(screen.getByRole('textbox')).toHaveFocus(); }); + describe.skipIf(isJsdom())('shadow DOM', () => { + it('loops focus when rendered in a shadow root', async () => { + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { user } = await setupBrowser(); + const firstButton = within(shadowContainer).getByRole('button', { name: 'first' }); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + await user.click(lastButton); + expect(shadowRoot.activeElement).to.equal(lastButton); + + await user.tab(); + expect(shadowRoot.activeElement).to.equal(firstButton); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(shadowHost); + } + }); + + it('loops backward when Shift+Tab starts from the shadow-root trap container', async () => { + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { user } = await setupBrowser(); + const root = within(shadowContainer).getByTestId('root'); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + expect(shadowRoot.activeElement).to.equal(root); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(shadowHost); + } + }); + + it('loops backward when Shift+Tab enters a lazy shadow-root trap from the document', async () => { + const shadowHost = document.createElement('div'); + const outsideAfter = document.createElement('input'); + outsideAfter.setAttribute('aria-label', 'after'); + document.body.appendChild(shadowHost); + document.body.appendChild(outsideAfter); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { page, user } = await setupBrowser(); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + // In Firefox, filling through a provider locator is the reliable way + // to move focus to this dynamically appended input before pressing Shift+Tab. + // eslint-disable-next-line testing-library/prefer-screen-queries -- `page` is a Vitest Browser locator provider, not a render result. + await user.fill(page.getByRole('textbox', { name: 'after' }), 'focused'); + expect(document.activeElement).to.equal(outsideAfter); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(shadowHost); + document.body.removeChild(outsideAfter); + } + }); + + it('loops backward when Shift+Tab enters a shadow-root trap from an iframe document', async () => { + const frame = document.createElement('iframe'); + frame.setAttribute('data-testid', 'focus-trap-shadow-root-iframe'); + // Firefox in the full no-isolate browser run needs the iframe to load + // a real same-origin document before Vitest Browser can query inside it reliably. + frame.srcdoc = ''; + const frameLoaded = new Promise((resolve) => { + frame.addEventListener('load', () => resolve(), { once: true }); + }); + document.body.appendChild(frame); + await frameLoaded; + const frameDocument = frame.contentDocument!; + const shadowHost = frameDocument.createElement('div'); + const outsideAfter = frameDocument.createElement('input'); + outsideAfter.setAttribute('aria-label', 'after'); + frameDocument.body.appendChild(shadowHost); + frameDocument.body.appendChild(outsideAfter); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + const shadowContainer = frameDocument.createElement('div'); + shadowRoot.appendChild(shadowContainer); + + let unmount: (() => void) | undefined; + + try { + ({ unmount } = render( + +
+ + +
+
, + { container: shadowContainer }, + )); + + const { page, user } = await setupBrowser(); + // Use a unique page locator instead of `page.elementLocator(frame)`. + // Vitest Browser serializes element locators for iframes to a broad `iframe` + // selector, which can bind to a stale iframe in the full no-isolate Firefox run. + // eslint-disable-next-line testing-library/prefer-screen-queries -- `page` is a Vitest Browser locator provider, not a render result. + const frameLocator = page.frameLocator(page.getByTestId('focus-trap-shadow-root-iframe')); + const lastButton = within(shadowContainer).getByRole('button', { name: 'last' }); + + // Passing the iframe-owned DOM element directly to `user.fill()` does + // not focus it reliably in Firefox. + // eslint-disable-next-line testing-library/prefer-screen-queries -- `frameLocator` is a Vitest Browser locator, not a render result. + await user.fill(frameLocator.getByLabelText('after'), 'focused'); + expect(frameDocument.activeElement).to.equal(outsideAfter); + + await user.tab({ shift: true }); + expect(shadowRoot.activeElement).to.equal(lastButton); + } finally { + unmount?.(); + document.body.removeChild(frame); + } + }); + }); + it('restores focus when closed', () => { function Test(props: GenericProps) { return ( diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx index 58a900b4bac312..48f405d6271d8f 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx @@ -123,6 +123,26 @@ function defaultIsEnabled(): boolean { return true; } +const DOCUMENT_FRAGMENT_NODE = 11; + +function isShadowRoot(node: Node): node is ShadowRoot { + return node.nodeType === DOCUMENT_FRAGMENT_NODE && 'host' in node && 'activeElement' in node; +} + +function isKeyboardEvent(event: Event): event is KeyboardEvent { + return 'key' in event && typeof event.key === 'string'; +} + +function getFocusRoot(node: HTMLElement): Document | ShadowRoot { + const rootNode = node.getRootNode(); + + if (isShadowRoot(rootNode)) { + return rootNode; + } + + return ownerDocument(node); +} + /** * @ignore - internal component. */ @@ -169,7 +189,8 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const doc = ownerDocument(rootRef.current); - const activeElement = getActiveElement(doc); + const focusRoot = getFocusRoot(rootRef.current); + const activeElement = getActiveElement(focusRoot) ?? getActiveElement(doc); // Prefer the explicitly marked focusable element. Fall back to the root // element for generic FocusTrap usage. @@ -214,8 +235,14 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const doc = ownerDocument(rootRef.current); + const focusRoot = getFocusRoot(rootRef.current); + const getRootActiveElement = () => getActiveElement(focusRoot) ?? getActiveElement(doc); + + const handleLoopFocus: EventListener = (nativeEvent) => { + if (!isKeyboardEvent(nativeEvent)) { + return; + } - const loopFocus = (nativeEvent: KeyboardEvent) => { lastKeydown.current = nativeEvent; if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') { @@ -223,7 +250,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const rootElement = rootRef.current; - const activeElement = getActiveElement(doc); + const activeElement = getRootActiveElement(); if (rootElement === null) { return; @@ -280,16 +307,18 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } }; - const contain = () => { + // Enforce the focus trap when focus moves outside the root or lands on + // one of the sentinel elements. + const handleFocusIn = () => { const rootElement = rootRef.current; // Cleanup functions are executed lazily in React 17. - // Contain can be called between the component being unmounted and its cleanup function being run. + // The focus-in handler can be called between the component being unmounted and its cleanup function being run. if (rootElement === null) { return; } - const activeEl = getActiveElement(doc); + const activeEl = getRootActiveElement(); if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) { ignoreNextEnforceFocus.current = false; @@ -349,8 +378,13 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } }; - doc.addEventListener('focusin', contain); - doc.addEventListener('keydown', loopFocus, true); + focusRoot.addEventListener('focusin', handleFocusIn); + focusRoot.addEventListener('keydown', handleLoopFocus, true); + + if (focusRoot !== doc) { + doc.addEventListener('focusin', handleFocusIn); + doc.addEventListener('keydown', handleLoopFocus, true); + } // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area. // for example https://bugzilla.mozilla.org/show_bug.cgi?id=559561. @@ -359,17 +393,22 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { // The whatwg spec defines how the browser should behave but does not explicitly mention any events: // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule. const interval = setInterval(() => { - const activeEl = getActiveElement(doc); + const activeEl = getRootActiveElement(); if (activeEl && activeEl.tagName === 'BODY') { - contain(); + handleFocusIn(); } }, 50); return () => { clearInterval(interval); - doc.removeEventListener('focusin', contain); - doc.removeEventListener('keydown', loopFocus, true); + focusRoot.removeEventListener('focusin', handleFocusIn); + focusRoot.removeEventListener('keydown', handleLoopFocus, true); + + if (focusRoot !== doc) { + doc.removeEventListener('focusin', handleFocusIn); + doc.removeEventListener('keydown', handleLoopFocus, true); + } }; }, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]);