Skip to content

Commit 65eee0b

Browse files
feat: manage focus for accessible click/context popups
1 parent 59b659d commit 65eee0b

File tree

6 files changed

+411
-13
lines changed

6 files changed

+411
-13
lines changed

src/Popup/index.tsx

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import PopupContent from './PopupContent';
1515
import useOffsetStyle from '../hooks/useOffsetStyle';
1616
import { useEvent } from '@rc-component/util';
1717
import type { PortalProps } from '@rc-component/portal';
18+
import {
19+
focusPopupRootOrFirst,
20+
handlePopupTabTrap,
21+
} from '../focusUtils';
1822

1923
export interface MobileConfig {
2024
mask?: boolean;
@@ -85,6 +89,12 @@ export interface PopupProps {
8589

8690
// Mobile
8791
mobile?: MobileConfig;
92+
93+
/**
94+
* Move focus into the popup when it opens and return it to `target` when it closes.
95+
* Tab cycles within the popup. Escape is handled by Portal `onEsc`.
96+
*/
97+
focusPopup?: boolean;
8898
}
8999

90100
const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
@@ -149,8 +159,13 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
149159
stretch,
150160
targetWidth,
151161
targetHeight,
162+
163+
focusPopup,
152164
} = props;
153165

166+
const rootRef = React.useRef<HTMLDivElement>(null);
167+
const prevOpenRef = React.useRef(false);
168+
154169
const popupContent = typeof popup === 'function' ? popup() : popup;
155170

156171
// We can not remove holder only when motion finished.
@@ -208,12 +223,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
208223
offsetY,
209224
);
210225

211-
// ========================= Render =========================
212-
if (!show) {
213-
return null;
214-
}
215-
216-
// >>>>> Misc
226+
// >>>>> Misc (computed before conditional return; hooks must run every render)
217227
const miscStyle: React.CSSProperties = {};
218228
if (stretch) {
219229
if (stretch.includes('height') && targetHeight) {
@@ -232,6 +242,49 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
232242
miscStyle.pointerEvents = 'none';
233243
}
234244

245+
useLayoutEffect(() => {
246+
if (!focusPopup) {
247+
prevOpenRef.current = open;
248+
return;
249+
}
250+
251+
const root = rootRef.current;
252+
const wasOpen = prevOpenRef.current;
253+
prevOpenRef.current = open;
254+
255+
if (open && !wasOpen && root && isNodeVisible) {
256+
focusPopupRootOrFirst(root);
257+
} else if (!open && wasOpen && root) {
258+
const active = document.activeElement as HTMLElement | null;
259+
if (
260+
target &&
261+
active &&
262+
(root === active || root.contains(active))
263+
) {
264+
if (target.isConnected) {
265+
target.focus();
266+
}
267+
}
268+
}
269+
}, [open, focusPopup, isNodeVisible, target]);
270+
271+
const onPopupKeyDownCapture = useEvent(
272+
(e: React.KeyboardEvent<HTMLDivElement>) => {
273+
if (!focusPopup || !open) {
274+
return;
275+
}
276+
const root = rootRef.current;
277+
if (root) {
278+
handlePopupTabTrap(e, root);
279+
}
280+
},
281+
);
282+
283+
// ========================= Render =========================
284+
if (!show) {
285+
return null;
286+
}
287+
235288
return (
236289
<Portal
237290
open={forceRender || isNodeVisible}
@@ -276,7 +329,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
276329

277330
return (
278331
<div
279-
ref={composeRef(resizeObserverRef, ref, motionRef)}
332+
ref={composeRef(resizeObserverRef, ref, motionRef, rootRef)}
280333
className={cls}
281334
style={
282335
{
@@ -295,6 +348,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
295348
onPointerEnter={onPointerEnter}
296349
onClick={onClick}
297350
onPointerDownCapture={onPointerDownCapture}
351+
onKeyDownCapture={onPopupKeyDownCapture}
298352
>
299353
{arrow && (
300354
<Arrow

src/UniqueProvider/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ const UniqueProvider = ({
223223
motion={mergedOptions.popupMotion}
224224
maskMotion={mergedOptions.maskMotion}
225225
getPopupContainer={mergedOptions.getPopupContainer}
226+
focusPopup={mergedOptions.focusPopup}
226227
>
227228
<UniqueContainer
228229
prefixCls={prefixCls}

src/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface UniqueShowOptions {
3636
getPopupContainer?: TriggerProps['getPopupContainer'];
3737
getPopupClassNameFromAlign?: (align: AlignType) => string;
3838
onEsc?: PortalProps['onEsc'];
39+
focusPopup?: boolean;
3940
}
4041

4142
export interface UniqueContextProps {

src/focusUtils.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type * as React from 'react';
2+
3+
const TABBABLE_SELECTOR =
4+
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
5+
6+
function isTabbable(el: HTMLElement, win: Window): boolean {
7+
if (el.closest('[aria-hidden="true"]')) {
8+
return false;
9+
}
10+
if ('disabled' in el && (el as HTMLButtonElement).disabled) {
11+
return false;
12+
}
13+
if (el instanceof HTMLInputElement && el.type === 'hidden') {
14+
return false;
15+
}
16+
const ti = el.getAttribute('tabindex');
17+
if (ti !== null && Number(ti) < 0) {
18+
return false;
19+
}
20+
const style = win.getComputedStyle(el);
21+
if (style.display === 'none' || style.visibility === 'hidden') {
22+
return false;
23+
}
24+
return true;
25+
}
26+
27+
/** Visible, tabbable descendants inside `container` (in DOM order). */
28+
export function getTabbableElements(container: HTMLElement): HTMLElement[] {
29+
const doc = container.ownerDocument;
30+
const win = doc.defaultView!;
31+
const nodeList = container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR);
32+
const list: HTMLElement[] = [];
33+
for (let i = 0; i < nodeList.length; i += 1) {
34+
const el = nodeList[i];
35+
if (isTabbable(el, win)) {
36+
list.push(el);
37+
}
38+
}
39+
return list;
40+
}
41+
42+
export function focusPopupRootOrFirst(
43+
container: HTMLElement,
44+
): HTMLElement | null {
45+
const tabbables = getTabbableElements(container);
46+
if (tabbables.length) {
47+
tabbables[0].focus();
48+
return tabbables[0];
49+
}
50+
if (!container.hasAttribute('tabindex')) {
51+
container.setAttribute('tabindex', '-1');
52+
}
53+
container.focus();
54+
return container;
55+
}
56+
57+
export function handlePopupTabTrap(
58+
e: React.KeyboardEvent,
59+
container: HTMLElement,
60+
): void {
61+
if (e.key !== 'Tab' || e.defaultPrevented) {
62+
return;
63+
}
64+
65+
const list = getTabbableElements(container);
66+
const active = document.activeElement as HTMLElement | null;
67+
68+
if (!active || !container.contains(active)) {
69+
return;
70+
}
71+
72+
if (list.length === 0) {
73+
if (active === container) {
74+
e.preventDefault();
75+
}
76+
return;
77+
}
78+
79+
const first = list[0];
80+
const last = list[list.length - 1];
81+
82+
if (!e.shiftKey) {
83+
if (active === last || active === container) {
84+
e.preventDefault();
85+
first.focus();
86+
}
87+
} else if (active === first || active === container) {
88+
e.preventDefault();
89+
last.focus();
90+
}
91+
}

src/index.tsx

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ export interface TriggerProps {
130130
*/
131131
unique?: boolean;
132132

133+
/**
134+
* When true, moves focus into the popup on open (first tabbable node or the popup root with
135+
* `tabIndex={-1}`), restores focus to the trigger on close, and keeps Tab cycling inside the
136+
* popup. When undefined, enabled for click / contextMenu / focus triggers unless `hover` is also
137+
* a show action (so hover-only tooltips are unchanged).
138+
*/
139+
focusPopup?: boolean;
140+
133141
// ==================== Arrow ====================
134142
arrow?: boolean | ArrowTypeOuter;
135143

@@ -211,6 +219,8 @@ export function generateTrigger(
211219
// Private
212220
mobile,
213221

222+
focusPopup: focusPopupProp,
223+
214224
...restProps
215225
} = props;
216226

@@ -331,6 +341,24 @@ export function generateTrigger(
331341
// Support ref
332342
const isOpen = useEvent(() => mergedOpen);
333343

344+
const [showActions, hideActions] = useAction(
345+
action,
346+
showAction,
347+
hideAction,
348+
);
349+
350+
const mergedFocusPopup = React.useMemo(() => {
351+
if (focusPopupProp !== undefined) {
352+
return focusPopupProp;
353+
}
354+
return (
355+
!showActions.has('hover') &&
356+
(showActions.has('click') ||
357+
showActions.has('contextMenu') ||
358+
showActions.has('focus'))
359+
);
360+
}, [focusPopupProp, showActions]);
361+
334362
// Extract common options for UniqueProvider
335363
const getUniqueOptions = useEvent((delay: number = 0) => ({
336364
popup,
@@ -354,6 +382,7 @@ export function generateTrigger(
354382
getPopupClassNameFromAlign,
355383
id,
356384
onEsc,
385+
focusPopup: mergedFocusPopup,
357386
}));
358387

359388
// Handle controlled state changes for UniqueProvider
@@ -472,12 +501,6 @@ export function generateTrigger(
472501
isMobile,
473502
);
474503

475-
const [showActions, hideActions] = useAction(
476-
action,
477-
showAction,
478-
hideAction,
479-
);
480-
481504
const clickToShow = showActions.has('click');
482505
const clickToHide =
483506
hideActions.has('click') || hideActions.has('contextMenu');
@@ -838,6 +861,7 @@ export function generateTrigger(
838861
autoDestroy={mergedAutoDestroy}
839862
getPopupContainer={getPopupContainer}
840863
onEsc={onEsc}
864+
focusPopup={mergedFocusPopup}
841865
// Arrow
842866
align={alignInfo}
843867
arrow={innerArrow}

0 commit comments

Comments
 (0)