Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/mui-material/src/MenuList/MenuList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
22 changes: 14 additions & 8 deletions packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/mui-material/src/utils/getActiveElement.d.ts
Original file line number Diff line number Diff line change
@@ -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;
122 changes: 122 additions & 0 deletions packages/mui-material/src/utils/getActiveElement.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
20 changes: 20 additions & 0 deletions packages/mui-material/src/utils/getActiveElement.ts
Original file line number Diff line number Diff line change
@@ -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;
};
1 change: 1 addition & 0 deletions packages/mui-material/src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions packages/mui-utils/src/getScrollbarSize/getScrollbarSize.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading