diff --git a/e2e/tests/connect/stateRestoration.spec.ts b/e2e/tests/connect/stateRestoration.spec.ts index 54ab19317d8..3f5253843f7 100644 --- a/e2e/tests/connect/stateRestoration.spec.ts +++ b/e2e/tests/connect/stateRestoration.spec.ts @@ -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. diff --git a/web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx b/web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx index dec91a4e2ee..85d3bfe3f07 100644 --- a/web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx +++ b/web/packages/design/src/DialogConfirmation/DialogConfirmation.tsx @@ -36,6 +36,10 @@ export function DialogConfirmation(props: { reason: 'escapeKeyDown' | 'backdropClick' ) => void; dialogCss?: StyleFunction>; + /** + * If `true`, focus is trapped inside the dialog. + */ + trapFocus?: boolean; }) { return ( {props.children} diff --git a/web/packages/design/src/Modal/Modal.test.tsx b/web/packages/design/src/Modal/Modal.test.tsx index 6e1c86eef2c..57e63f14c6a 100644 --- a/web/packages/design/src/Modal/Modal.test.tsx +++ b/web/packages/design/src/Modal/Modal.test.tsx @@ -26,7 +26,7 @@ import Modal, { ModalProps } from './Modal'; const renderModal = (props: Omit) => { return render( - +
Hello
); @@ -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( - +
Hello
); @@ -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( + <> + + +
+ + +
+
+ + ); + + screen.getByRole('button', { name: 'Removable' }).focus(); + rerender( + <> + + +
+ +
+
+ + ); + + const outsideButton = screen.getByRole('button', { name: 'Outside' }); + outsideButton.focus(); + expect(outsideButton).not.toHaveFocus(); + }); + + test('Tab focuses into the modal', () => { + const { rerender } = render( + <> + + +
+ + +
+
+ + ); + + screen.getByRole('button', { name: 'Removable' }).focus(); + rerender( + <> + + +
+ +
+
+ + ); + + 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( + + + + + ); + + screen.getByRole('button', { name: 'Target' }).focus(); + + // Disable the focused button — it stays in the DOM but can't receive focus. + rerender( + + + + + ); + + const outsideButton = screen.getByRole('button', { name: 'Outside' }); + outsideButton.focus(); + expect(outsideButton).not.toHaveFocus(); + }); + + test('Tab focuses into the modal', () => { + const { rerender } = render( + + + + + ); + + screen.getByRole('button', { name: 'Target' }).focus(); + + rerender( + + + + + ); + + 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(); + + 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( + <> + + +
+ + +
+
+ + ); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + + + + + ); + + 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( + <> + + +
+
+ +
+ +
+
+ + ); + + 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(); + + // 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(); + + 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( + <> + + +
+ +
+
+ + ); + + screen.getByRole('button', { name: 'Inside' }).focus(); + + rerender( + <> + + +
+ +
+
+ + ); + + 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( + <> + + +
+ +
+
+ + ); + + screen.getByRole('button', { name: 'Inside' }).focus(); + + rerender( + <> + + +
+ +
+
+ + ); + + 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( + <> + + +
+ +
+
+ + ); + + screen.getByRole('button', { name: 'Inside' }).focus(); + + rerender( + <> + + +
+ +
+
+ + ); + + const outsideButton = screen.getByRole('button', { name: 'Outside' }); + outsideButton.focus(); + expect(outsideButton).toHaveFocus(); + }); +}); + +const ModalWithOutsideButton = ( + props: { trapFocus?: boolean } & Pick & { + children?: React.ReactNode; + } +) => ( + <> + + +
{props.children ?? }
+
+ +); + +const TwoButtonModalWithOutsideButton = () => ( + + + + +); + const ToggleableModal = () => { const [isOpen, setIsOpen] = useState(false); diff --git a/web/packages/design/src/Modal/Modal.tsx b/web/packages/design/src/Modal/Modal.tsx index 78f6c937446..bdd9c08f28b 100644 --- a/web/packages/design/src/Modal/Modal.tsx +++ b/web/packages/design/src/Modal/Modal.tsx @@ -16,12 +16,7 @@ * along with this program. If not, see . */ -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; + + /** + * 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 { 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 { } 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 { handleOpen = () => { document.addEventListener('keydown', this.handleDocumentKeyDown); + this.enableFocusTrap(); if (this.dialogEl()) { this.handleOpened(); @@ -162,6 +177,7 @@ export default class Modal extends React.Component { handleClose = () => { document.removeEventListener('keydown', this.handleDocumentKeyDown); + this.disableFocusTrap(); this.restoreLastFocus(); }; @@ -181,10 +197,12 @@ export default class Modal extends React.Component { }; 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 { } }; + // 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( + '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 { if (typeof modalRef === 'function') { modalRef(el); } else { - (modalRef as MutableRefObject).current = - el; + (modalRef as RefObject).current = el; } } }} diff --git a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx index 348af0fc0eb..0ef3630a09d 100644 --- a/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx +++ b/web/packages/teleterm/src/ui/DocumentsReopen/DocumentsReopen.tsx @@ -42,6 +42,7 @@ export function DocumentsReopen(props: { ({ maxWidth: '400px',