Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"@rc-component/portal": "^2.2.0",
"@rc-component/resize-observer": "^1.1.1",
"@rc-component/util": "^1.2.1",
"clsx": "^2.1.1"
"clsx": "^2.1.1",
"focusable-selectors": "^0.8.4"
},
"devDependencies": {
"@rc-component/father-plugin": "^2.0.0",
Expand Down
69 changes: 62 additions & 7 deletions src/Popup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLDivElement, PopupProps>((props, ref) => {
Expand Down Expand Up @@ -149,8 +159,13 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
stretch,
targetWidth,
targetHeight,

focusPopup,
} = props;

const rootRef = React.useRef<HTMLDivElement>(null);
const prevOpenRef = React.useRef(false);

const popupContent = typeof popup === 'function' ? popup() : popup;

// We can not remove holder only when motion finished.
Expand Down Expand Up @@ -208,12 +223,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((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) {
Expand All @@ -232,6 +242,50 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((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;
// Only restore trigger focus if focus is still inside the popup (e.g. Escape).
// If the user dismissed by clicking elsewhere, activeElement may already be
// outside — avoid stealing focus from that target with target.focus().
if (
target?.isConnected &&
active &&
(root === active || root.contains(active))
) {
Comment on lines +258 to +266
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we check whether we have an active element before moving the focus to the target (which I assume is the previously focused element prior opening the popover)? I think the code can be simplified into: target?.focus(). Unless I’m missing something.

target.focus();
}
}
}, [open, focusPopup, isNodeVisible, target]);

const onPopupKeyDownCapture = useEvent(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!focusPopup || !open) {
return;
}
const root = rootRef.current;
if (root) {
handlePopupTabTrap(e, root);
}
},
);

// ========================= Render =========================
if (!show) {
return null;
}

return (
<Portal
open={forceRender || isNodeVisible}
Expand Down Expand Up @@ -276,7 +330,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {

return (
<div
ref={composeRef(resizeObserverRef, ref, motionRef)}
ref={composeRef(resizeObserverRef, ref, motionRef, rootRef)}
className={cls}
style={
{
Expand All @@ -295,6 +349,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
onPointerEnter={onPointerEnter}
onClick={onClick}
onPointerDownCapture={onPointerDownCapture}
onKeyDownCapture={onPopupKeyDownCapture}
>
{arrow && (
<Arrow
Expand Down
1 change: 1 addition & 0 deletions src/UniqueProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ const UniqueProvider = ({
motion={mergedOptions.popupMotion}
maskMotion={mergedOptions.maskMotion}
getPopupContainer={mergedOptions.getPopupContainer}
focusPopup={mergedOptions.focusPopup}
>
<UniqueContainer
prefixCls={prefixCls}
Expand Down
1 change: 1 addition & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface UniqueShowOptions {
getPopupContainer?: TriggerProps['getPopupContainer'];
getPopupClassNameFromAlign?: (align: AlignType) => string;
onEsc?: PortalProps['onEsc'];
focusPopup?: boolean;
}

export interface UniqueContextProps {
Expand Down
171 changes: 171 additions & 0 deletions src/focusUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type * as React from 'react';
import focusableSelectors from 'focusable-selectors';

const TABBABLE_SELECTOR =
focusableSelectors.join(',');

/**
* Subtree cannot contain tab stops the browser will use.
* @see https://github.com/KittyGiraudel/a11y-dialog/blob/4674ff3e4d626430a028a64969328e339c533ce8/src/dom-utils.ts
*/
function canHaveTabbableChildren(el: HTMLElement): boolean {
if (el.shadowRoot && el.getAttribute('tabindex') === '-1') {
return false;
}
return !el.matches(':disabled, [hidden], [inert]');
}

function isNonVisibleForInteraction(el: HTMLElement): boolean {
if (
el.matches('details:not([open]) *') &&
!el.matches('details > summary:first-of-type')
) {
return true;
}
return !(
el.offsetWidth ||
el.offsetHeight ||
el.getClientRects().length
);
}

function isTabbable(el: HTMLElement, win: Window): boolean {
if (el.shadowRoot?.delegatesFocus) {
return false;
}
if (!el.matches(TABBABLE_SELECTOR)) {
return false;
}
if (isNonVisibleForInteraction(el)) {
return false;
}
if (el.closest('[aria-hidden="true"]') || el.closest('[inert]')) {
return false;
}
const style = win.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Checking display and visibility on the element itself via getComputedStyle does not detect if the element is hidden because one of its ancestors has display: none. A more reliable check for layout visibility is el.getClientRects().length > 0 or checking el.offsetParent === null (though the latter has edge cases for fixed elements).

Suggested change
if (style.display === 'none' || style.visibility === 'hidden') {
if (el.getClientRects().length === 0) {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion from Gemini makes sense. You want to check for the element’s offset dimensions and bounding rects instead of every possible way of manually hiding an element with CSS.

function isVisible(element) {
  return Boolean(
    element.offsetWidth ||
      element.offsetHeight ||
      element.getClientRects().length
  )
}

Comment on lines +45 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is redundant with isNonVisibleForInteraction which already covers it: if an element has no dimensions, they’re hidden and non-focusable — regardless of their display or visibility properties.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNonVisibleForInteraction() catches elements that have no rendered box (offsetWidth/offsetHeight/getClientRects() all empty), which maps well to many display:none cases.

But visibility:hidden elements can still have layout dimensions and client rects, so they can slip past that check. The explicit style guard prevents those from being treated as tabbable.

So I'd keep this block, mainly for visibility:hidden correctness.

return false;
}
return true;
}

function getNextChildEl(parent: ParentNode, forward: boolean): Element | null {
return forward ? parent.firstElementChild : parent.lastElementChild;
}

function getNextSiblingEl(el: Element, forward: boolean): Element | null {
return forward ? el.nextElementSibling : el.previousElementSibling;
}

/**
* First or last tabbable descendant in tree order (light DOM, shadow roots, slots).
* @see https://github.com/KittyGiraudel/a11y-dialog/blob/4674ff3e4d626430a028a64969328e339c533ce8/src/dom-utils.ts
*/
function findTabbableEl(
el: HTMLElement,
forward: boolean,
win: Window,
): HTMLElement | null {
if (forward && isTabbable(el, win)) {
return el;
}

if (canHaveTabbableChildren(el)) {
if (el.shadowRoot) {
let next = getNextChildEl(el.shadowRoot, forward);
while (next) {
const hit = findTabbableEl(next as HTMLElement, forward, win);
if (hit) {
return hit;
}
next = getNextSiblingEl(next, forward);
}
} else if (el.localName === 'slot') {
const assigned = (el as HTMLSlotElement).assignedElements({
flatten: true,
}) as HTMLElement[];
const ordered = forward ? assigned : [...assigned].reverse();
for (let i = 0; i < ordered.length; i += 1) {
const hit = findTabbableEl(ordered[i], forward, win);
if (hit) {
return hit;
}
}
Comment on lines +83 to +93
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

插槽的 fallback 内容现在不会参与遍历。

<slot> 没有分配节点时,这里直接结束了分支,导致默认插槽内容里的可聚焦元素永远找不到;打开时首个聚焦和 Tab 环都可能失效。

🔧 建议修改
     } else if (el.localName === 'slot') {
       const assigned = (el as HTMLSlotElement).assignedElements({
         flatten: true,
       }) as HTMLElement[];
-      const ordered = forward ? assigned : [...assigned].reverse();
+      const candidates =
+        assigned.length > 0
+          ? assigned
+          : (Array.from(el.children) as HTMLElement[]);
+      const ordered = forward ? candidates : [...candidates].reverse();
       for (let i = 0; i < ordered.length; i += 1) {
         const hit = findTabbableEl(ordered[i], forward, win);
         if (hit) {
           return hit;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (el.localName === 'slot') {
const assigned = (el as HTMLSlotElement).assignedElements({
flatten: true,
}) as HTMLElement[];
const ordered = forward ? assigned : [...assigned].reverse();
for (let i = 0; i < ordered.length; i += 1) {
const hit = findTabbableEl(ordered[i], forward, win);
if (hit) {
return hit;
}
}
} else if (el.localName === 'slot') {
const assigned = (el as HTMLSlotElement).assignedElements({
flatten: true,
}) as HTMLElement[];
const candidates =
assigned.length > 0
? assigned
: (Array.from(el.children) as HTMLElement[]);
const ordered = forward ? candidates : [...candidates].reverse();
for (let i = 0; i < ordered.length; i += 1) {
const hit = findTabbableEl(ordered[i], forward, win);
if (hit) {
return hit;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/focusUtils.ts` around lines 83 - 93, The slot branch in findTabbableEl
currently ignores fallback content when (el as
HTMLSlotElement).assignedElements() returns empty; change it so when
assigned.length === 0 you fall back to the slot's own child elements (e.g.
Array.from((el as HTMLSlotElement).children) or el.childNodes filtered to
elements), assign that to ordered (respecting the forward ? order : reverse),
and then iterate/recursively call findTabbableEl as you do for assigned elements
so default slot/fallback content is included in traversal.

} else {
let next = getNextChildEl(el, forward);
while (next) {
const hit = findTabbableEl(next as HTMLElement, forward, win);
if (hit) {
return hit;
}
next = getNextSiblingEl(next, forward);
}
}
}

if (!forward && isTabbable(el, win)) {
return el;
}

return null;
}

/** First and last tabbable nodes inside `container` (inclusive). `last === first` if only one. */
export function getTabbableEdges(
container: HTMLElement,
): readonly [HTMLElement | null, HTMLElement | null] {
const win = container.ownerDocument.defaultView!;
const first = findTabbableEl(container, true, win);
const last = first
? findTabbableEl(container, false, win) || first
: null;
return [first, last] as const;
}

export function focusPopupRootOrFirst(
container: HTMLElement,
): HTMLElement | null {
const [first] = getTabbableEdges(container);
if (first) {
first.focus();
return first;
}
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 [first, last] = getTabbableEdges(container);
const active = document.activeElement as HTMLElement | null;

if (!active || !container.contains(active)) {
return;
}
Comment on lines +151 to +153
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand this: if the container doesn’t contain the active element, then the trap has failed, hasn’t it? Isn’t the whole idea to ensure the container keeps containing the active element while the trap is up?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep this guard.
container.contains(active) is intentional so we only cycle focus when the active element is currently inside the popup. If focus has already moved outside (click outside, programmatic focus shift, close timing, portal/shadow edge cases), forcing wrap would steal focus unexpectedly.
So this check is a defensive no-op when the trap is no longer applicable, not a trap failure we should “fix” inside this handler.


if (!first || !last) {
if (active === container) {
e.preventDefault();
}
return;
}

if (!e.shiftKey) {
if (active === last || active === container) {
e.preventDefault();
first.focus();
}
} else if (active === first || active === container) {
e.preventDefault();
last.focus();
}
}
36 changes: 30 additions & 6 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -211,6 +219,8 @@ export function generateTrigger(
// Private
mobile,

focusPopup: focusPopupProp,

...restProps
} = props;

Expand Down Expand Up @@ -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,
Expand All @@ -354,6 +382,7 @@ export function generateTrigger(
getPopupClassNameFromAlign,
id,
onEsc,
focusPopup: mergedFocusPopup,
}));

// Handle controlled state changes for UniqueProvider
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -838,6 +861,7 @@ export function generateTrigger(
autoDestroy={mergedAutoDestroy}
getPopupContainer={getPopupContainer}
onEsc={onEsc}
focusPopup={mergedFocusPopup}
// Arrow
align={alignInfo}
arrow={innerArrow}
Expand Down
Loading