import { type RefObject } from 'react'; import { type TPhase } from './use-animated-visibility'; /** * Moves focus into a popover element when it opens, based on its ARIA role. * * Follows the WAI-ARIA APG initial-focus patterns for each role: * - `dialog` / `alertdialog` → focus first focusable (or `[autofocus]`) * - `menu` → focus first menu item * - `listbox` → focus selected or first option * - `tooltip` / no role → no focus movement (focus must remain on trigger; * moving focus into the tooltip would dismiss it) * * Role is taken from the typed `role` prop - this is the consumer's contract * and is not subject to drift from a directly-mutated DOM attribute. * * Always call this hook unconditionally. Focus is moved on each transition * into a visible phase, the moment the host element first becomes visible * for that open intent: * * - Animated path: on `closed → entering`. The host has just been mounted * and the layout effect that calls `showPopover()` / `showModal()` has * already run, so the element is in the top layer. We focus before the * entry transition settles to match the WAI-ARIA APG expectation that * focus lands on the new menu / dialog the instant it opens, not after * the CSS finishes animating. * - Non-animated path: on `closed → open` (the entering phase is skipped). * - Reopen mid-exit: on `exiting → entering`. When a user dismisses the * popup (Escape, close button, light dismiss) the browser natively * restores focus to the trigger as part of `hidePopover()` / * `.close()`. If the consumer reopens before the exit transition * has settled, the phase machine jumps `exiting → entering` without * passing through `closed`, so without this branch the popup would be * visible again with focus stranded on the trigger - a keyboard usability * gap for menu/dialog/listbox roles. * * Note: there is a rare programmatic path where a consumer flips `isOpen` * false → true without any user-initiated close gesture and had moved focus * to a non-default target inside the popup. In that scenario this hook * re-focuses the role-appropriate default (e.g. first menu item) rather * than preserving the consumer-chosen target. This matches the documented * `closed → entering` behavior and is considered acceptable given how * abstract the scenario is. See `notes/architecture/focus.md`. * * Pure `entering → exiting → entering` stutters where the popup never * reaches `closed` and the consumer toggles in the same frame are still * collapsed: the `prevPhase` ref guard rejects any transition whose * previous phase was not `closed` or `exiting`. * * Tradeoff vs the previous `requestAnimationFrame` based approach: we no * longer wait one paint before focusing. The element is already attached * and in the top layer when this effect runs (the show / hide layout * effect runs first), so screen-reader announcement and visible focus * land together. If a regression appears that needs the extra paint * (e.g. browsers that require a layout pass before `focus()` works on a * freshly promoted top-layer element), wrap the `focus()` call in a * single `requestAnimationFrame` here and re-add a same-element ref * check inside the RAF callback. */ export declare function useInitialFocus({ elementRef, phase, role, }: { elementRef: RefObject; /** * Current visibility phase from `useAnimatedVisibility`. Initial * focus is moved on the transition out of `'closed'`: * * - Animated path: fires on `closed → entering`, immediately after * the host element mounts and `showPopover()` / `showModal()` has * run in the preceding layout effect. * - Non-animated path: fires on `closed → open` (entering phase is * skipped entirely when animation is disabled). */ phase: TPhase; role: string | undefined; }): void;