Skip to content
Draft
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
21 changes: 21 additions & 0 deletions packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,27 @@ describe('<FocusTrap />', () => {
expect(screen.getByTestId('root')).toHaveFocus();
});

it('does not contain focus if the active element is body and the previous focus target is still mounted', async () => {
render(
<FocusTrap open>
<div tabIndex={-1} data-testid="root">
<div>Title</div>
</div>
</FocusTrap>,
);

const root = screen.getByTestId('root');
expect(root).toHaveFocus();

await act(async () => {
root.blur();
});
expect(document.activeElement).to.equal(document.body);

clock.tick(500); // wait for the interval check to kick in.
expect(document.activeElement).to.equal(document.body);
});

describe('prop: disableAutoFocus', () => {
it('should not trap', async () => {
render(
Expand Down
65 changes: 65 additions & 0 deletions packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,51 @@ function defaultIsEnabled(): boolean {
return true;
}

/**
* Determines whether a value is an HTMLElement in its owning window.
* This handles elements from iframes, where the global HTMLElement constructor differs.
*/
function isHTMLElement(element: unknown): element is HTMLElement {
if (typeof window === 'undefined' || element == null) {
return false;
}

const elementWindow = (element as Node).ownerDocument?.defaultView ?? window;
return element instanceof elementWindow.HTMLElement;
}

/**
* Checks whether the element itself is hidden from layout or visibility.
* Ancestors are checked separately by `isElementStillUsable`.
*/
function isElementHidden(element: HTMLElement): boolean {
if (element.hidden) {
return true;
}

const computedStyle = ownerDocument(element).defaultView?.getComputedStyle(element);
return computedStyle?.display === 'none' || computedStyle?.visibility === 'hidden';
}

/**
* Determines whether a previously focused element can still be treated as a valid focus target.
*/
function isElementStillUsable(element: HTMLElement | null): boolean {
if (!element?.isConnected || (element as HTMLInputElement).disabled) {
return false;
}

let currentElement: HTMLElement | null = element;
while (currentElement) {
if (isElementHidden(currentElement)) {
return false;
}
currentElement = currentElement.parentElement;
}

return true;
}

/**
* @ignore - internal component.
*/
Expand All @@ -144,6 +189,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
// This variable is useful when disableAutoFocus is true.
// It waits for the active element to move into the component to activate.
const activated = React.useRef(false);
const lastFocusedElement = React.useRef<HTMLElement>(null);

const rootRef = React.useRef<HTMLElement>(null);
const handleRef = useForkRef(getReactElementRef(children), rootRef);
Expand Down Expand Up @@ -191,6 +237,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {

if (activated.current) {
focusTarget.focus();
lastFocusedElement.current = focusTarget;
}
}

Expand Down Expand Up @@ -254,13 +301,28 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {

const activeEl = getActiveElement(doc);

// Avoid yanking focus back to the root when focus falls to <body> while
// the last focused element is still mounted. Some screen readers keep DOM
// focus there while moving a virtual cursor through the dialog content.
if (
activeEl === doc.body &&
isElementStillUsable(lastFocusedElement.current) &&
lastFocusedElement.current !== sentinelStart.current &&
lastFocusedElement.current !== sentinelEnd.current
) {
return;
}

if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) {
ignoreNextEnforceFocus.current = false;
return;
}

// The focus is already inside
if (contains(rootElement, activeEl)) {
if (isHTMLElement(activeEl)) {
lastFocusedElement.current = activeEl;
}
return;
}

Expand Down Expand Up @@ -341,6 +403,9 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
nodeToRestore.current = event.relatedTarget;
}
activated.current = true;
if (isHTMLElement(event.target)) {
lastFocusedElement.current = event.target;
}
reactFocusEventTarget.current = event.target;

const childrenPropsHandler = children.props.onFocus;
Expand Down
Loading