Add focus trap to <Modal>, use it in <DocumentsReopen> in Connect (#65227)

* Add focus trap to Modal

* Enable focus trap in DocumentsReopen

* Select focusable elements that are visible and not disabled

* Add fallbacks when nothing in modal is focused yet

* Remove deprecated MutableRefObject

* Clean up test components

* Reuse the same focusable logic in both handlers

* Reset lastTabKeyDirection in disableFocusTrap

* Handle trapFocus prop change while modal is open

* Guard against stale detached lastModalFocus in focus trap

* Suppress no-node-access lint in offsetParent mock

* `open={true}` -> `open`

* Replace offsetParent check with getComputedStyle for display:none filtering

offsetParent returns null for position:fixed elements regardless of visibility,
which would incorrectly exclude them from the focusable elements list.

* Handle lastModalFocus becoming unfocusable (e.g. dynamically disabled)

* Respect defaultPrevented on Tab and detect ancestor display:none in focus trap

* Use `Array.prototype.at`
This commit is contained in:
Maja
2026-04-02 15:39:44 +00:00
committed by GitHub
parent b2213b13ce
commit b7b648f394
5 changed files with 590 additions and 13 deletions
+3 -1
View File
@@ -94,7 +94,9 @@ test.describe('state restoration from disk', () => {
await using app = await launchApp(temp.path);
const { page } = app;
await expect(page.getByText('Reopen previous session')).toBeVisible();
await page.getByRole('button', { name: 'Reopen' }).click();
const reopenButton = page.getByRole('button', { name: 'Reopen' });
await expect(reopenButton).toBeFocused();
await reopenButton.click();
await expect(page.getByText('Reopen previous session')).not.toBeVisible();
// Both tabs should be restored.
@@ -36,6 +36,10 @@ export function DialogConfirmation(props: {
reason: 'escapeKeyDown' | 'backdropClick'
) => void;
dialogCss?: StyleFunction<ComponentProps<'div'>>;
/**
* If `true`, focus is trapped inside the dialog.
*/
trapFocus?: boolean;
}) {
return (
<Dialog
@@ -44,6 +48,7 @@ export function DialogConfirmation(props: {
onClose={props.onClose}
open={props.open}
keepInDOMAfterClose={props.keepInDOMAfterClose}
trapFocus={props.trapFocus}
>
{props.children}
</Dialog>
+417 -2
View File
@@ -26,7 +26,7 @@ import Modal, { ModalProps } from './Modal';
const renderModal = (props: Omit<ModalProps, 'open'>) => {
return render(
<Modal open={true} {...props}>
<Modal open {...props}>
<div>Hello</div>
</Modal>
);
@@ -160,7 +160,7 @@ test('restores focus on close', async () => {
test('closing dialog when attachCustomKeyEventHandler is set only hides it with DOM', async () => {
const { rerender } = render(
<Modal open={true} keepInDOMAfterClose>
<Modal open keepInDOMAfterClose>
<div>Hello</div>
</Modal>
);
@@ -176,6 +176,421 @@ test('closing dialog when attachCustomKeyEventHandler is set only hides it with
expect(screen.getByText('Hello')).not.toBeVisible();
});
describe('trapFocus', () => {
let originalOffsetParent: PropertyDescriptor | undefined;
beforeAll(() => {
// JSDOM doesn't implement layout, so offsetParent is always null. Mock it to return the
// parent element (non-null) for elements that aren't hidden via display:none on themselves or
// an ancestor.
originalOffsetParent = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetParent'
);
Object.defineProperty(HTMLElement.prototype, 'offsetParent', {
get(this: HTMLElement) {
// Walk up the ancestor chain — in real browsers, offsetParent is null when any
// ancestor has display:none.
let el: HTMLElement | null = this;
while (el) {
if (el.style.display === 'none') {
return null;
}
el = el.parentElement; // eslint-disable-line testing-library/no-node-access
}
return this.parentElement; // eslint-disable-line testing-library/no-node-access
},
configurable: true,
});
});
afterAll(() => {
if (originalOffsetParent) {
Object.defineProperty(
HTMLElement.prototype,
'offsetParent',
originalOffsetParent
);
}
});
// In the following describe group, the focused element is removed during a re-render, leaving
// lastModalFocus as a stale detached reference. Explicit keys ensure React unmounts the Removable
// button rather than reconciling it with Stable.
describe('when lastModalFocus was removed from the DOM', () => {
test('blurs programmatic focus theft', () => {
const { rerender } = render(
<>
<button>Outside</button>
<Modal open trapFocus>
<div>
<button key="removable">Removable</button>
<button key="stable">Stable</button>
</div>
</Modal>
</>
);
screen.getByRole('button', { name: 'Removable' }).focus();
rerender(
<>
<button>Outside</button>
<Modal open trapFocus>
<div>
<button key="stable">Stable</button>
</div>
</Modal>
</>
);
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(outsideButton).not.toHaveFocus();
});
test('Tab focuses into the modal', () => {
const { rerender } = render(
<>
<button>Outside</button>
<Modal open trapFocus>
<div>
<button key="removable">Removable</button>
<button key="stable">Stable</button>
</div>
</Modal>
</>
);
screen.getByRole('button', { name: 'Removable' }).focus();
rerender(
<>
<button>Outside</button>
<Modal open trapFocus>
<div>
<button key="stable">Stable</button>
</div>
</Modal>
</>
);
fireEvent.keyDown(document, { key: 'Tab' });
screen.getByRole('button', { name: 'Outside' }).focus();
expect(screen.getByRole('button', { name: 'Stable' })).toHaveFocus();
});
});
describe('when lastModalFocus becomes unfocusable', () => {
test('blurs programmatic focus theft', () => {
const { rerender } = render(
<ModalWithOutsideButton trapFocus>
<button>Target</button>
<button>Other</button>
</ModalWithOutsideButton>
);
screen.getByRole('button', { name: 'Target' }).focus();
// Disable the focused button — it stays in the DOM but can't receive focus.
rerender(
<ModalWithOutsideButton trapFocus>
<button disabled>Target</button>
<button>Other</button>
</ModalWithOutsideButton>
);
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(outsideButton).not.toHaveFocus();
});
test('Tab focuses into the modal', () => {
const { rerender } = render(
<ModalWithOutsideButton trapFocus>
<button>Target</button>
<button>Other</button>
</ModalWithOutsideButton>
);
screen.getByRole('button', { name: 'Target' }).focus();
rerender(
<ModalWithOutsideButton trapFocus>
<button disabled>Target</button>
<button>Other</button>
</ModalWithOutsideButton>
);
fireEvent.keyDown(document, { key: 'Tab' });
screen.getByRole('button', { name: 'Outside' }).focus();
expect(screen.getByRole('button', { name: 'Other' })).toHaveFocus();
});
});
test('returns focus to the modal when an outside element steals it', () => {
render(<ModalWithOutsideButton trapFocus />);
const insideButton = screen.getByRole('button', { name: 'Inside' });
insideButton.focus();
expect(insideButton).toHaveFocus();
// Simulate programmatic focus theft from outside the modal.
screen.getByRole('button', { name: 'Outside' }).focus();
expect(insideButton).toHaveFocus();
});
test('remembers autoFocus target and restores it after focus theft', () => {
render(
<>
<button>Outside</button>
<Modal open trapFocus>
<div>
<button>First</button>
<button autoFocus>Second</button>
</div>
</Modal>
</>
);
const secondButton = screen.getByRole('button', { name: 'Second' });
expect(secondButton).toHaveFocus();
screen.getByRole('button', { name: 'Outside' }).focus();
expect(secondButton).toHaveFocus();
});
test('wraps focus from last to first element on Tab', () => {
render(<TwoButtonModalWithOutsideButton />);
const lastButton = screen.getByRole('button', { name: 'Last' });
lastButton.focus();
fireEvent.keyDown(lastButton, { key: 'Tab' });
expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
});
test('does not interfere with Tab when defaultPrevented', () => {
render(<TwoButtonModalWithOutsideButton />);
const lastButton = screen.getByRole('button', { name: 'Last' });
lastButton.focus();
// Simulate a component inside the modal calling preventDefault on Tab (e.g., a rich-text
// editor using Tab for indentation). The handler runs before the modal's document-level
// listener because it's registered on the element itself.
const preventTab = (e: Event) => {
if ((e as KeyboardEvent).key === 'Tab') {
e.preventDefault();
}
};
lastButton.addEventListener('keydown', preventTab);
fireEvent.keyDown(lastButton, { key: 'Tab' });
lastButton.removeEventListener('keydown', preventTab);
// Focus should stay on Last — the trap should not have intercepted the event.
expect(lastButton).toHaveFocus();
});
test('wraps focus from first to last element on Shift+Tab', () => {
render(<TwoButtonModalWithOutsideButton />);
const firstButton = screen.getByRole('button', { name: 'First' });
firstButton.focus();
fireEvent.keyDown(firstButton, { key: 'Tab', shiftKey: true });
expect(screen.getByRole('button', { name: 'Last' })).toHaveFocus();
});
// In the following describe group, we simulate the browser Tab behavior manually: fire keyDown
// (which sets lastTabKeyDirection), then focus the outside element (which triggers focusin
// and lets handleFocusTrapFocusIn redirect focus into the modal).
describe('when nothing inside the modal was focused yet', () => {
test('Tab focuses the first element', () => {
render(<TwoButtonModalWithOutsideButton />);
fireEvent.keyDown(document, { key: 'Tab' });
screen.getByRole('button', { name: 'Outside' }).focus();
expect(screen.getByRole('button', { name: 'First' })).toHaveFocus();
});
test('Shift+Tab focuses the last element', () => {
render(<TwoButtonModalWithOutsideButton />);
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
screen.getByRole('button', { name: 'Outside' }).focus();
expect(screen.getByRole('button', { name: 'Last' })).toHaveFocus();
});
test('Tab skips hidden elements and focuses the first visible one', () => {
render(
<ModalWithOutsideButton trapFocus>
<button style={{ display: 'none' }}>Hidden</button>
<button>Visible</button>
</ModalWithOutsideButton>
);
fireEvent.keyDown(document, { key: 'Tab' });
screen.getByRole('button', { name: 'Outside' }).focus();
expect(screen.getByRole('button', { name: 'Visible' })).toHaveFocus();
});
test('Tab skips elements inside a hidden parent', () => {
render(
<>
<button>Outside</button>
<Modal open trapFocus>
<div>
<div style={{ display: 'none' }}>
<button>HiddenChild</button>
</div>
<button>Visible</button>
</div>
</Modal>
</>
);
fireEvent.keyDown(document, { key: 'Tab' });
screen.getByRole('button', { name: 'Outside' }).focus();
expect(screen.getByRole('button', { name: 'Visible' })).toHaveFocus();
});
test('blurs the thief when focus is stolen programmatically', () => {
render(<TwoButtonModalWithOutsideButton />);
// No element has autoFocus, so lastModalFocus is not pre-populated. The thief should be
// blurred rather than auto-focusing the first element inside the modal.
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(outsideButton).not.toHaveFocus();
// JSDOM sets document.body as activeElement when blurring any other element.
expect(document.body).toHaveFocus();
});
});
test('does not trap focus when trapFocus is not set', () => {
render(<ModalWithOutsideButton />);
screen.getByRole('button', { name: 'Inside' }).focus();
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(outsideButton).toHaveFocus();
});
test('enables focus trap when trapFocus changes to true while modal is open', () => {
const { rerender } = render(
<>
<button>Outside</button>
<Modal open trapFocus={false}>
<div>
<button>Inside</button>
</div>
</Modal>
</>
);
screen.getByRole('button', { name: 'Inside' }).focus();
rerender(
<>
<button>Outside</button>
<Modal open trapFocus={true}>
<div>
<button>Inside</button>
</div>
</Modal>
</>
);
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(screen.getByRole('button', { name: 'Inside' })).toHaveFocus();
});
test('disables focus trap when trapFocus changes to false while modal is open', () => {
const { rerender } = render(
<>
<button>Outside</button>
<Modal open trapFocus={true}>
<div>
<button>Inside</button>
</div>
</Modal>
</>
);
screen.getByRole('button', { name: 'Inside' }).focus();
rerender(
<>
<button>Outside</button>
<Modal open trapFocus={false}>
<div>
<button>Inside</button>
</div>
</Modal>
</>
);
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(outsideButton).toHaveFocus();
});
test('cleans up focus trap listeners on close', () => {
// Use keepInDOMAfterClose so that the modal stays in the DOM when closed. Without it, the modal
// is fully unmounted and the focusin handler would early-return due to modalEl being null,
// making the test pass trivially.
const { rerender } = render(
<>
<button>Outside</button>
<Modal open trapFocus keepInDOMAfterClose>
<div>
<button>Inside</button>
</div>
</Modal>
</>
);
screen.getByRole('button', { name: 'Inside' }).focus();
rerender(
<>
<button>Outside</button>
<Modal open={false} trapFocus keepInDOMAfterClose>
<div>
<button>Inside</button>
</div>
</Modal>
</>
);
const outsideButton = screen.getByRole('button', { name: 'Outside' });
outsideButton.focus();
expect(outsideButton).toHaveFocus();
});
});
const ModalWithOutsideButton = (
props: { trapFocus?: boolean } & Pick<ModalProps, 'keepInDOMAfterClose'> & {
children?: React.ReactNode;
}
) => (
<>
<button>Outside</button>
<Modal
open
trapFocus={props.trapFocus}
keepInDOMAfterClose={props.keepInDOMAfterClose}
>
<div>{props.children ?? <button>Inside</button>}</div>
</Modal>
</>
);
const TwoButtonModalWithOutsideButton = () => (
<ModalWithOutsideButton trapFocus>
<button>First</button>
<button>Last</button>
</ModalWithOutsideButton>
);
const ToggleableModal = () => {
const [isOpen, setIsOpen] = useState(false);
+164 -10
View File
@@ -16,12 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {
cloneElement,
ComponentProps,
MutableRefObject,
Ref,
} from 'react';
import React, { cloneElement, ComponentProps, Ref, RefObject } from 'react';
import { createPortal } from 'react-dom';
import styled, { StyleFunction } from 'styled-components';
@@ -101,12 +96,21 @@ export type ModalProps = {
* dialog itself.
*/
modalRef?: Ref<HTMLDivElement | null>;
/**
* If `true`, focus is trapped inside the modal. When something outside the modal tries to grab
* focus programmatically, focus is returned to the last focused element inside the modal.
* Tab/Shift+Tab cycling wraps around at the modal boundaries.
*/
trapFocus?: boolean;
};
export default class Modal extends React.Component<ModalProps> {
lastFocus: HTMLElement | undefined;
lastModalFocus: HTMLElement | undefined;
modalEl: HTMLDivElement | null = null;
mounted = false;
lastTabKeyDirection: 'forward' | 'backward' | null = null;
componentDidMount() {
this.mounted = true;
@@ -121,6 +125,16 @@ export default class Modal extends React.Component<ModalProps> {
} else if (!prevProps.open && this.props.open) {
this.lastFocus = document.activeElement as HTMLElement;
this.handleOpen();
} else if (
this.props.open &&
prevProps.trapFocus !== this.props.trapFocus
) {
// open didn't change but trapFocus did — toggle the trap without a full open/close cycle.
if (this.props.trapFocus) {
this.enableFocusTrap();
} else {
this.disableFocusTrap();
}
}
}
@@ -147,6 +161,7 @@ export default class Modal extends React.Component<ModalProps> {
handleOpen = () => {
document.addEventListener('keydown', this.handleDocumentKeyDown);
this.enableFocusTrap();
if (this.dialogEl()) {
this.handleOpened();
@@ -162,6 +177,7 @@ export default class Modal extends React.Component<ModalProps> {
handleClose = () => {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
this.disableFocusTrap();
this.restoreLastFocus();
};
@@ -181,10 +197,12 @@ export default class Modal extends React.Component<ModalProps> {
};
handleDocumentKeyDown = (event: KeyboardEvent) => {
const ESC = 'Escape';
if (event.key === 'Tab') {
this.handleFocusTrapTab(event);
}
// Ignore events that have been `event.preventDefault()` marked.
if (event.key !== ESC || event.defaultPrevented) {
if (event.key !== 'Escape' || event.defaultPrevented) {
return;
}
@@ -197,6 +215,143 @@ export default class Modal extends React.Component<ModalProps> {
}
};
// Focus trap methods.
//
// When trapFocus is enabled, focus is kept inside the modal. Programmatic focus theft from
// outside (e.g., useRefAutoFocus on a Document) is reverted to the last focused element inside
// the modal. Tab/Shift+Tab cycling wraps around at the modal boundaries.
enableFocusTrap = () => {
if (!this.props.trapFocus) {
return;
}
// If focus is already inside the modal (e.g., from autoFocus on a button during render),
// remember it before registering the listener, so that programmatic focus theft can be
// reverted.
const activeEl = document.activeElement as HTMLElement;
if (activeEl && this.modalEl?.contains(activeEl)) {
this.lastModalFocus = activeEl;
}
document.addEventListener('focusin', this.handleFocusTrapFocusIn);
};
disableFocusTrap = () => {
document.removeEventListener('focusin', this.handleFocusTrapFocusIn);
this.lastModalFocus = undefined;
this.lastTabKeyDirection = null;
};
handleFocusTrapFocusIn = (event: FocusEvent) => {
if (!this.modalEl) {
return;
}
const target = event.target as HTMLElement;
if (this.modalEl.contains(target)) {
this.lastModalFocus = target;
this.lastTabKeyDirection = null;
return;
}
// Focus escaped the modal. Try to pull it back to the last focused element. The element may
// have been removed from the DOM (e.g., during a re-render) or become unfocusable (e.g.,
// dynamically disabled), in which case we fall through to the Tab/blur branches below.
if (this.tryFocusLastModalFocus()) {
this.lastTabKeyDirection = null;
return;
}
if (this.lastTabKeyDirection) {
// Tab/Shift+Tab moved focus outside the modal before anything inside was focused.
// Direct focus to the appropriate element inside the modal.
const focusable = this.getFocusableElements();
const el =
this.lastTabKeyDirection === 'forward'
? focusable.at(0)
: focusable.at(-1);
el?.focus();
} else {
// Programmatic focus theft (Tab was not pressed) with nothing focused inside yet — just
// blur the thief.
target.blur();
}
this.lastTabKeyDirection = null;
};
/**
* Try to focus lastModalFocus. Returns true if focus was successfully moved. Clears the
* reference if the element is no longer in the DOM or is no longer focusable.
*/
tryFocusLastModalFocus = (): boolean => {
if (!this.lastModalFocus) {
return false;
}
// Avoid calling .focus() on a detached node. This is redundant with the activeElement check
// below in JSDOM (where .focus() on detached nodes is a no-op), so tests pass without it,
// but in real browsers it skips an unnecessary .focus() call on a detached node.
if (!this.modalEl?.contains(this.lastModalFocus)) {
this.lastModalFocus = undefined;
return false;
}
this.lastModalFocus.focus();
if (document.activeElement !== this.lastModalFocus) {
this.lastModalFocus = undefined;
return false;
}
return true;
};
handleFocusTrapTab = (event: KeyboardEvent) => {
if (!this.props.trapFocus || !this.modalEl || event.defaultPrevented) {
return;
}
// Record the Tab direction so that handleFocusTrapFocusIn can tell a Tab keypress apart from
// programmatic focus theft.
this.lastTabKeyDirection = event.shiftKey ? 'backward' : 'forward';
const focusable = this.getFocusableElements();
if (focusable.length === 0) {
return;
}
const first = focusable.at(0);
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
};
/** Return focusable elements within the modal that are visible and not disabled. */
getFocusableElements = (): HTMLElement[] => {
if (!this.modalEl) {
return [];
}
return [
...this.modalEl.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
),
].filter(el => {
const style = getComputedStyle(el);
// offsetParent is null for elements hidden via an ancestor's display:none (which
// getComputedStyle on the element itself won't catch, since display isn't inherited).
// However, offsetParent is also null for position:fixed elements, so exclude those from
// the check.
if (el.offsetParent === null && style.position !== 'fixed') {
return false;
}
return style.display !== 'none' && style.visibility !== 'hidden';
});
};
restoreLastFocus() {
if (this.props.disableRestoreFocus || !this.lastFocus) {
return;
@@ -239,8 +394,7 @@ export default class Modal extends React.Component<ModalProps> {
if (typeof modalRef === 'function') {
modalRef(el);
} else {
(modalRef as MutableRefObject<HTMLDivElement | null>).current =
el;
(modalRef as RefObject<HTMLDivElement | null>).current = el;
}
}
}}
@@ -42,6 +42,7 @@ export function DocumentsReopen(props: {
<DialogConfirmation
open={!props.hidden}
keepInDOMAfterClose
trapFocus
onClose={props.onDiscard}
dialogCss={() => ({
maxWidth: '400px',