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
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
176 changes: 176 additions & 0 deletions src/focusUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type * as React from 'react';

const TABBABLE_SELECTOR =
'a[href], button, input, select, textarea, [tabindex]:not([tabindex^="-"])';

/**
* 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;
}
if ('disabled' in el && (el as HTMLButtonElement).disabled) {
return false;
}
if (el instanceof HTMLInputElement && el.type === 'hidden') {
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
  )
}

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;
}
}
} 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;
}

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