From 65eee0bfbef7d817407c4147bdd4dee38b6b37ac Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 3 Apr 2026 04:37:15 -0500 Subject: [PATCH] feat: manage focus for accessible click/context popups --- src/Popup/index.tsx | 68 +++++++++-- src/UniqueProvider/index.tsx | 1 + src/context.ts | 1 + src/focusUtils.ts | 91 ++++++++++++++ src/index.tsx | 36 +++++- tests/focus.test.tsx | 227 +++++++++++++++++++++++++++++++++++ 6 files changed, 411 insertions(+), 13 deletions(-) create mode 100644 src/focusUtils.ts create mode 100644 tests/focus.test.tsx diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 51cff879..e4776cf7 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -15,6 +15,10 @@ import PopupContent from './PopupContent'; import useOffsetStyle from '../hooks/useOffsetStyle'; import { useEvent } from '@rc-component/util'; import type { PortalProps } from '@rc-component/portal'; +import { + focusPopupRootOrFirst, + handlePopupTabTrap, +} from '../focusUtils'; export interface MobileConfig { mask?: boolean; @@ -85,6 +89,12 @@ export interface PopupProps { // Mobile mobile?: MobileConfig; + + /** + * Move focus into the popup when it opens and return it to `target` when it closes. + * Tab cycles within the popup. Escape is handled by Portal `onEsc`. + */ + focusPopup?: boolean; } const Popup = React.forwardRef((props, ref) => { @@ -149,8 +159,13 @@ const Popup = React.forwardRef((props, ref) => { stretch, targetWidth, targetHeight, + + focusPopup, } = props; + const rootRef = React.useRef(null); + const prevOpenRef = React.useRef(false); + const popupContent = typeof popup === 'function' ? popup() : popup; // We can not remove holder only when motion finished. @@ -208,12 +223,7 @@ const Popup = React.forwardRef((props, ref) => { offsetY, ); - // ========================= Render ========================= - if (!show) { - return null; - } - - // >>>>> Misc + // >>>>> Misc (computed before conditional return; hooks must run every render) const miscStyle: React.CSSProperties = {}; if (stretch) { if (stretch.includes('height') && targetHeight) { @@ -232,6 +242,49 @@ const Popup = React.forwardRef((props, ref) => { miscStyle.pointerEvents = 'none'; } + useLayoutEffect(() => { + if (!focusPopup) { + prevOpenRef.current = open; + return; + } + + const root = rootRef.current; + const wasOpen = prevOpenRef.current; + prevOpenRef.current = open; + + if (open && !wasOpen && root && isNodeVisible) { + focusPopupRootOrFirst(root); + } else if (!open && wasOpen && root) { + const active = document.activeElement as HTMLElement | null; + if ( + target && + active && + (root === active || root.contains(active)) + ) { + if (target.isConnected) { + target.focus(); + } + } + } + }, [open, focusPopup, isNodeVisible, target]); + + const onPopupKeyDownCapture = useEvent( + (e: React.KeyboardEvent) => { + if (!focusPopup || !open) { + return; + } + const root = rootRef.current; + if (root) { + handlePopupTabTrap(e, root); + } + }, + ); + + // ========================= Render ========================= + if (!show) { + return null; + } + return ( ((props, ref) => { return (
((props, ref) => { onPointerEnter={onPointerEnter} onClick={onClick} onPointerDownCapture={onPointerDownCapture} + onKeyDownCapture={onPopupKeyDownCapture} > {arrow && ( string; onEsc?: PortalProps['onEsc']; + focusPopup?: boolean; } export interface UniqueContextProps { diff --git a/src/focusUtils.ts b/src/focusUtils.ts new file mode 100644 index 00000000..c59d9420 --- /dev/null +++ b/src/focusUtils.ts @@ -0,0 +1,91 @@ +import type * as React from 'react'; + +const TABBABLE_SELECTOR = + 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +function isTabbable(el: HTMLElement, win: Window): boolean { + if (el.closest('[aria-hidden="true"]')) { + return false; + } + if ('disabled' in el && (el as HTMLButtonElement).disabled) { + return false; + } + if (el instanceof HTMLInputElement && el.type === 'hidden') { + return false; + } + const ti = el.getAttribute('tabindex'); + if (ti !== null && Number(ti) < 0) { + return false; + } + const style = win.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') { + return false; + } + return true; +} + +/** Visible, tabbable descendants inside `container` (in DOM order). */ +export function getTabbableElements(container: HTMLElement): HTMLElement[] { + const doc = container.ownerDocument; + const win = doc.defaultView!; + const nodeList = container.querySelectorAll(TABBABLE_SELECTOR); + const list: HTMLElement[] = []; + for (let i = 0; i < nodeList.length; i += 1) { + const el = nodeList[i]; + if (isTabbable(el, win)) { + list.push(el); + } + } + return list; +} + +export function focusPopupRootOrFirst( + container: HTMLElement, +): HTMLElement | null { + const tabbables = getTabbableElements(container); + if (tabbables.length) { + tabbables[0].focus(); + return tabbables[0]; + } + if (!container.hasAttribute('tabindex')) { + container.setAttribute('tabindex', '-1'); + } + container.focus(); + return container; +} + +export function handlePopupTabTrap( + e: React.KeyboardEvent, + container: HTMLElement, +): void { + if (e.key !== 'Tab' || e.defaultPrevented) { + return; + } + + const list = getTabbableElements(container); + const active = document.activeElement as HTMLElement | null; + + if (!active || !container.contains(active)) { + return; + } + + if (list.length === 0) { + if (active === container) { + e.preventDefault(); + } + return; + } + + const first = list[0]; + const last = list[list.length - 1]; + + if (!e.shiftKey) { + if (active === last || active === container) { + e.preventDefault(); + first.focus(); + } + } else if (active === first || active === container) { + e.preventDefault(); + last.focus(); + } +} diff --git a/src/index.tsx b/src/index.tsx index 4cad2655..60d7041a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -130,6 +130,14 @@ export interface TriggerProps { */ unique?: boolean; + /** + * When true, moves focus into the popup on open (first tabbable node or the popup root with + * `tabIndex={-1}`), restores focus to the trigger on close, and keeps Tab cycling inside the + * popup. When undefined, enabled for click / contextMenu / focus triggers unless `hover` is also + * a show action (so hover-only tooltips are unchanged). + */ + focusPopup?: boolean; + // ==================== Arrow ==================== arrow?: boolean | ArrowTypeOuter; @@ -211,6 +219,8 @@ export function generateTrigger( // Private mobile, + focusPopup: focusPopupProp, + ...restProps } = props; @@ -331,6 +341,24 @@ export function generateTrigger( // Support ref const isOpen = useEvent(() => mergedOpen); + const [showActions, hideActions] = useAction( + action, + showAction, + hideAction, + ); + + const mergedFocusPopup = React.useMemo(() => { + if (focusPopupProp !== undefined) { + return focusPopupProp; + } + return ( + !showActions.has('hover') && + (showActions.has('click') || + showActions.has('contextMenu') || + showActions.has('focus')) + ); + }, [focusPopupProp, showActions]); + // Extract common options for UniqueProvider const getUniqueOptions = useEvent((delay: number = 0) => ({ popup, @@ -354,6 +382,7 @@ export function generateTrigger( getPopupClassNameFromAlign, id, onEsc, + focusPopup: mergedFocusPopup, })); // Handle controlled state changes for UniqueProvider @@ -472,12 +501,6 @@ export function generateTrigger( isMobile, ); - const [showActions, hideActions] = useAction( - action, - showAction, - hideAction, - ); - const clickToShow = showActions.has('click'); const clickToHide = hideActions.has('click') || hideActions.has('contextMenu'); @@ -838,6 +861,7 @@ export function generateTrigger( autoDestroy={mergedAutoDestroy} getPopupContainer={getPopupContainer} onEsc={onEsc} + focusPopup={mergedFocusPopup} // Arrow align={alignInfo} arrow={innerArrow} diff --git a/tests/focus.test.tsx b/tests/focus.test.tsx new file mode 100644 index 00000000..a78b2ff0 --- /dev/null +++ b/tests/focus.test.tsx @@ -0,0 +1,227 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; +import * as React from 'react'; +import Trigger from '../src'; +import { placementAlignMap } from './util'; + +const flush = async () => { + for (let i = 0; i < 10; i += 1) { + act(() => { + jest.runAllTimers(); + }); + + await act(async () => { + await Promise.resolve(); + }); + } +}; + +describe('Trigger focus management', () => { + let eleRect = { width: 100, height: 100 }; + let popupRect = { + x: 0, + y: 0, + left: 0, + top: 0, + width: 100, + height: 100, + }; + + beforeAll(() => { + spyElementPrototypes(HTMLElement, { + clientWidth: { get: () => eleRect.width }, + clientHeight: { get: () => eleRect.height }, + offsetWidth: { get: () => eleRect.width }, + offsetHeight: { get: () => eleRect.height }, + offsetParent: { get: () => document.body }, + }); + + spyElementPrototypes(HTMLDivElement, { + getBoundingClientRect() { + return popupRect; + }, + }); + + spyElementPrototypes(HTMLButtonElement, { + getBoundingClientRect() { + return popupRect; + }, + }); + }); + + beforeEach(() => { + eleRect = { width: 100, height: 100 }; + popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 }; + jest.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + jest.useRealTimers(); + }); + + it('moves focus to first tabbable in popup when opened by click (default)', async () => { + render( + + + +
+ } + > + + , + ); + + const trigger = document.querySelectorAll('button')[0]; + const innerOne = () => + Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'inner-one', + )!; + + act(() => { + fireEvent.click(trigger); + }); + + await flush(); + + expect(document.activeElement).toBe(innerOne()); + }); + + it('does not auto-focus when hover is a show action', async () => { + render( + inner} + > + + , + ); + + const trigger = document.querySelector('button')!; + + act(() => { + fireEvent.click(trigger); + }); + + await flush(); + + const inner = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'inner', + )!; + expect(inner).toBeTruthy(); + expect(inner).not.toHaveFocus(); + }); + + it('returns focus to trigger when popup closes', async () => { + render( + inner} + > + + , + ); + + const trigger = document.querySelector('button')!; + + act(() => { + fireEvent.click(trigger); + }); + + await flush(); + + const inner = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'inner', + )!; + expect(document.activeElement).toBe(inner); + + act(() => { + fireEvent.click(trigger); + }); + + await flush(); + + expect(document.activeElement).toBe(trigger); + }); + + it('traps Tab within the popup', async () => { + render( + + + + + } + > + + , + ); + + const trigger = document.querySelectorAll('button')[0]; + + act(() => { + fireEvent.click(trigger); + }); + + await flush(); + + const btnA = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'a', + )!; + const btnB = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'b', + )!; + + act(() => { + btnB.focus(); + }); + + fireEvent.keyDown(btnB, { key: 'Tab', shiftKey: false }); + + expect(document.activeElement).toBe(btnA); + + fireEvent.keyDown(btnA, { key: 'Tab', shiftKey: true }); + + expect(document.activeElement).toBe(btnB); + }); + + it('respects focusPopup={false}', async () => { + render( + inner} + > + + , + ); + + const trigger = document.querySelector('button')!; + + act(() => { + fireEvent.click(trigger); + }); + + await flush(); + + const inner = Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent === 'inner', + )!; + expect(inner).not.toHaveFocus(); + }); +});