mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-29 17:58:15 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user