diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 51cff879..40d612dc 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,50 @@ 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; + // 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)) + ) { + 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..da05b7e7 --- /dev/null +++ b/src/focusUtils.ts @@ -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') { + 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(); + } +} 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(); + }); +});