diff --git a/packages/mui-material/src/MenuList/MenuList.js b/packages/mui-material/src/MenuList/MenuList.js index 6774b003ad9f00..6ca8f4e8db2d3c 100644 --- a/packages/mui-material/src/MenuList/MenuList.js +++ b/packages/mui-material/src/MenuList/MenuList.js @@ -2,7 +2,7 @@ import * as React from 'react'; import { isFragment } from 'react-is'; import PropTypes from 'prop-types'; -import ownerDocument from '../utils/ownerDocument'; +import getActiveElement from '../utils/getActiveElement'; import List from '../List'; import getScrollbarSize from '../utils/getScrollbarSize'; import useForkRef from '../utils/useForkRef'; @@ -161,7 +161,7 @@ const MenuList = React.forwardRef(function MenuList(props, ref) { * or document.body or document.documentElement. Only the first case will * trigger this specific handler. */ - const currentFocus = ownerDocument(list).activeElement; + const currentFocus = getActiveElement(list.ownerDocument); if (key === 'ArrowDown') { // Prevent scroll of the page diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx index 62f11b1325b292..369ffb6383b573 100644 --- a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx @@ -7,6 +7,7 @@ import ownerDocument from '@mui/utils/ownerDocument'; import getReactElementRef from '@mui/utils/getReactElementRef'; import exactProp from '@mui/utils/exactProp'; import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; +import getActiveElement from '../utils/getActiveElement'; import { FocusTrapProps } from './FocusTrap.types'; // Inspired by https://github.com/focus-trap/tabbable @@ -162,15 +163,16 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const doc = ownerDocument(rootRef.current); + const activeElement = getActiveElement(doc); - if (!rootRef.current.contains(doc.activeElement)) { + if (!rootRef.current.contains(activeElement)) { if (!rootRef.current.hasAttribute('tabIndex')) { if (process.env.NODE_ENV !== 'production') { console.error( [ 'MUI: The modal content node does not accept focus.', 'For the benefit of assistive technologies, ' + - 'the tabIndex of the node is being set to "-1".', + 'the tabIndex of the node is being set to "-1".', ].join('\n'), ); } @@ -209,6 +211,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { } const doc = ownerDocument(rootRef.current); + const activeElement = getActiveElement(doc); const loopFocus = (nativeEvent: KeyboardEvent) => { lastKeydown.current = nativeEvent; @@ -219,7 +222,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { // Make sure the next tab starts from the right place. // doc.activeElement refers to the origin. - if (doc.activeElement === rootRef.current && nativeEvent.shiftKey) { + if (activeElement === rootRef.current && nativeEvent.shiftKey) { // We need to ignore the next contain as // it will try to move the focus back to the rootRef element. ignoreNextEnforceFocus.current = true; @@ -238,27 +241,29 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element { return; } + const activeEl = getActiveElement(doc); + if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) { ignoreNextEnforceFocus.current = false; return; } // The focus is already inside - if (rootElement.contains(doc.activeElement)) { + if (rootElement.contains(activeEl)) { return; } // The disableEnforceFocus is set and the focus is outside of the focus trap (and sentinel nodes) if ( disableEnforceFocus && - doc.activeElement !== sentinelStart.current && - doc.activeElement !== sentinelEnd.current + activeEl !== sentinelStart.current && + activeEl !== sentinelEnd.current ) { return; } // if the focus event is not coming from inside the children's react tree, reset the refs - if (doc.activeElement !== reactFocusEventTarget.current) { + if (activeEl !== reactFocusEventTarget.current) { reactFocusEventTarget.current = null; } else if (reactFocusEventTarget.current !== null) { return; @@ -309,7 +314,8 @@ 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(() => { - if (doc.activeElement && doc.activeElement.tagName === 'BODY') { + const activeEl = getActiveElement(doc); + if (activeEl && activeEl.tagName === 'BODY') { contain(); } }, 50); diff --git a/packages/mui-material/src/utils/getActiveElement.d.ts b/packages/mui-material/src/utils/getActiveElement.d.ts new file mode 100644 index 00000000000000..5dc195ba7779d1 --- /dev/null +++ b/packages/mui-material/src/utils/getActiveElement.d.ts @@ -0,0 +1,8 @@ +/** + * Gets the currently active element, traversing shadow DOM boundaries. + * @param root - The root document or shadow root to search from (defaults to document) + * @returns The active element or null if none found + */ +declare const getActiveElement: (root?: Document | ShadowRoot) => Element | null; + +export default getActiveElement; \ No newline at end of file diff --git a/packages/mui-material/src/utils/getActiveElement.test.tsx b/packages/mui-material/src/utils/getActiveElement.test.tsx new file mode 100644 index 00000000000000..ff132090cc34f2 --- /dev/null +++ b/packages/mui-material/src/utils/getActiveElement.test.tsx @@ -0,0 +1,122 @@ +import { expect } from 'chai'; +import getActiveElement from './getActiveElement'; + +describe('getActiveElement', () => { + let mockElement: Element; + + beforeEach(() => { + mockElement = document.createElement('div'); + }); + + describe('basic functionality', () => { + it('should return active element from document when no root provided', () => { + Object.defineProperty(document, 'activeElement', { + value: mockElement, + writable: true, + }); + + const result = getActiveElement(); + expect(result).to.equal(mockElement); + }); + + it('should return active element from provided root', () => { + const customRoot = { activeElement: mockElement } as Document; + const result = getActiveElement(customRoot); + expect(result).to.equal(mockElement); + }); + + it('should return null when no active element exists', () => { + const customRoot = { activeElement: null } as Document; + const result = getActiveElement(customRoot); + expect(result).to.equal(null); + }); + }); + + describe('SSR compatibility', () => { + let originalDocument: Document; + + beforeEach(() => { + originalDocument = global.document; + Object.defineProperty(global, 'document', { + value: undefined, + writable: true, + }); + }); + + afterEach(() => { + global.document = originalDocument; + }); + + it('should return null when document is undefined', () => { + const result = getActiveElement(); + expect(result).to.equal(null); + }); + }); + + describe('shadow DOM traversal', () => { + it('should traverse into shadow root to find active element', () => { + const shadowElement = document.createElement('div'); + const shadowRoot = shadowElement.attachShadow({ mode: 'open' }); + const activeElement = document.createElement('span'); + + shadowRoot.appendChild(activeElement); + + Object.defineProperty(shadowRoot, 'activeElement', { + value: activeElement, + writable: true, + }); + + const result = getActiveElement(shadowRoot); + expect(result).to.equal(activeElement); + }); + + it('should handle nested shadow DOM', () => { + const outerDiv = document.createElement('div'); + const outerShadow = outerDiv.attachShadow({ mode: 'open' }); + + const innerDiv = document.createElement('div'); + const innerShadow = innerDiv.attachShadow({ mode: 'open' }); + const activeElement = document.createElement('span'); + + innerShadow.appendChild(activeElement); + outerShadow.appendChild(innerDiv); + + // Set active element in outer shadow to be the inner div + Object.defineProperty(outerShadow, 'activeElement', { + value: innerDiv, + writable: true, + }); + + // Set active element in inner shadow to be the span + Object.defineProperty(innerShadow, 'activeElement', { + value: activeElement, + writable: true, + }); + + const result = getActiveElement(outerShadow); + expect(result).to.equal(activeElement); + }); + }); + + describe('edge cases', () => { + it('should handle undefined parameter and fallback to document', () => { + Object.defineProperty(document, 'activeElement', { + value: mockElement, + writable: true, + }); + + const result = getActiveElement(undefined); + expect(result).to.equal(mockElement); + }); + + it('should work with different element types', () => { + const buttonElement = document.createElement('button'); + Object.defineProperty(document, 'activeElement', { + value: buttonElement, + writable: true, + }); + + expect(getActiveElement()).to.equal(buttonElement); + }); + }); +}); \ No newline at end of file diff --git a/packages/mui-material/src/utils/getActiveElement.ts b/packages/mui-material/src/utils/getActiveElement.ts new file mode 100644 index 00000000000000..056cd0096aa67d --- /dev/null +++ b/packages/mui-material/src/utils/getActiveElement.ts @@ -0,0 +1,20 @@ +export default function getActiveElement(root: Document | ShadowRoot = document) { + // Check if document is defined (for SSR compatibility) + if (typeof document === 'undefined') { + return null; + } + + const doc = root || document; + const activeEl = doc.activeElement; + + if (!activeEl) { + return null; + } + + if (activeEl.shadowRoot) { + + return getActiveElement(activeEl.shadowRoot); + } + + return activeEl; +}; \ No newline at end of file diff --git a/packages/mui-material/src/utils/index.js b/packages/mui-material/src/utils/index.js index 7ad3c2d3504f8e..6db67803932eba 100644 --- a/packages/mui-material/src/utils/index.js +++ b/packages/mui-material/src/utils/index.js @@ -6,6 +6,7 @@ export { default as createChainedFunction } from './createChainedFunction'; export { default as createSvgIcon } from './createSvgIcon'; export { default as debounce } from './debounce'; export { default as deprecatedPropType } from './deprecatedPropType'; +export { default as getActiveElement } from './getActiveElement'; export { default as isMuiElement } from './isMuiElement'; export { default as unstable_memoTheme } from './memoTheme'; export { default as ownerDocument } from './ownerDocument'; diff --git a/packages/mui-utils/src/getScrollbarSize/getScrollbarSize.ts b/packages/mui-utils/src/getScrollbarSize/getScrollbarSize.ts index 2cc378ca44a124..6f23ffca6f1a59 100644 --- a/packages/mui-utils/src/getScrollbarSize/getScrollbarSize.ts +++ b/packages/mui-utils/src/getScrollbarSize/getScrollbarSize.ts @@ -1,6 +1,9 @@ // A change of the browser zoom change the scrollbar size. // Credit https://github.com/twbs/bootstrap/blob/488fd8afc535ca3a6ad4dc581f5e89217b6a36ac/js/src/util/scrollbar.js#L14-L18 export default function getScrollbarSize(win: Window = window): number { + if (typeof win === 'undefined') { + return 0; + } // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes const documentWidth = win.document.documentElement.clientWidth; return win.innerWidth - documentWidth;