// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useListFocus.ts * @input Uses React useCallback, useRef, useIsomorphicLayoutEffect, * isRtlElement * @output Exports useListFocus hook for linear list keyboard navigation * @position Core hook; used by TabMenu for dropdown menu navigation, Toolbar * for roving tabindex, ButtonGroup, ContextMenu, NavHeadingMenu, and more. * Auto-detects RTL from the container's computed direction so horizontal * arrow keys follow visual direction (WCAG 1.3.2). * * SYNC: When modified, update: * - /packages/core/src/hooks/index.ts * - /packages/core/src/hooks/useListFocus.doc.mjs * - /packages/core/src/hooks/useListFocus.test.tsx */ import { useCallback, useRef } from 'react'; import { isRtlElement } from "./isRtlElement.js"; import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect.js"; /** * Navigation orientation for a linear list. * - `'horizontal'`: ArrowLeft/ArrowRight move between items. * - `'vertical'`: ArrowUp/ArrowDown move between items. * - `'both'`: all four arrows move between items (in linear DOM order). */ export type ListFocusOrientation = 'horizontal' | 'vertical' | 'both'; /** * Configuration for list focus behavior */ export interface UseListFocusOptions { /** * Selector for focusable items within the list. * @default '[role="menuitem"]' */ itemSelector?: string; /** * Selector identifying a list boundary — used to scope a list that contains * *nested* lists of the same kind (e.g. a menu with submenu flyouts). * * Overlays like submenu flyouts render inline (native popover, not a * portal), so a nested list's items are DOM descendants of the parent list. * Without scoping, the parent's `querySelectorAll` sweeps the nested items * into its roving order (leaving hidden items `.focus()` can't land on), and * key events from the nested list bubble into the parent's handler (moving * focus twice). When set, `useListFocus`: * - counts an item as its own only when the item's nearest * `boundarySelector` ancestor is this list's container, and * - ignores key events whose nearest `boundarySelector` ancestor is not * this list's container (i.e. they originated in a nested list). * * Typically `'[role="menu"]'` for menus. Omit for flat lists. */ boundarySelector?: string; /** * Whether arrow navigation wraps around at the ends. * @default true */ wrap?: boolean; /** * Callback when Escape key is pressed. Supplying it also makes the list * consume the key (`preventDefault`); without it Escape passes through to * the surrounding layer. */ onEscape?: () => void; /** * Navigation orientation. `'horizontal'` uses ArrowLeft/ArrowRight, * `'vertical'` uses ArrowUp/ArrowDown, `'both'` accepts all four arrows. * @default 'vertical' */ orientation?: ListFocusOrientation; /** * Whether Home/End jump to the first/last enabled item. * @default true */ hasHomeEnd?: boolean; /** * Roving-tabindex ownership. When true, the hook manages a single tab stop * across the items: exactly one enabled item carries `tabindex="0"` and the * rest `tabindex="-1"`. The tab stop is stamped on mount and repaired * whenever items mount/unmount or toggle disabled, and moves with arrow * navigation. Attach the returned {@link UseListFocusReturn.handleFocus} to * the container's `onFocus` to keep the stop in sync after clicks or * programmatic focus. * * When false (the default), the hook only *moves* focus (`.focus()`) and * never touches `tabindex` — the caller owns tab-stop management. * @default false */ hasRovingTabIndex?: boolean; /** * When true, arrow keys are not stolen from a nested text input/textarea * whose caret is not at the boundary in the direction of travel (or that has * a non-collapsed selection), and are never stolen from a nested * `contenteditable` (rich-text editor / chat composer). This preserves * normal caret movement while the user is editing inline within the list * (e.g. a toolbar search field or composer). * @default false */ hasCaretGuard?: boolean; } /** * Return type for useListFocus hook */ export interface UseListFocusReturn { /** * Ref to attach to the list container element. */ listRef: React.RefObject; /** * Key down handler to attach to the list container. */ handleKeyDown: (e: React.KeyboardEvent) => void; /** * Focus handler to attach to the container's `onFocus`. Keeps the roving tab * stop in sync when `hasRovingTabIndex` is enabled; a no-op otherwise, so it is * always safe to attach. */ handleFocus: (e: React.FocusEvent) => void; /** * Focus a specific item by index. */ focusItem: (index: number) => void; /** * Focus the first enabled item. Returns true when an item was focused. */ focusFirst: () => boolean; /** * Focus the last enabled item. Returns true when an item was focused. */ focusLast: () => boolean; /** * Whether a key event belongs to this list level (vs. a nested list that * shares the same `boundarySelector`). Useful when a consumer wraps * `handleKeyDown` with extra behavior (Enter/Space activation, typeahead) * and must apply it only to events this level owns. Always true when no * `boundarySelector` is configured. */ ownsEvent: (e: React.KeyboardEvent) => boolean; /** * The current, in-DOM-order list of this level's focusable items (already * scoped by `boundarySelector`). Exposed so consumers can build typeahead * targets from the same source of truth as roving focus, rather than * re-querying and re-filtering. */ getItems: () => HTMLElement[]; } // Text-editing inputs whose caret must not be hijacked by arrow navigation. const TEXT_INPUT_TYPES = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number']); /** * The nearest `contenteditable` root for `el`, or null when `el` is not inside * an editable region. Prefers the browser's `isContentEditable` property and * falls back to the closest `[contenteditable]` ancestor whose value is not * `"false"` (so environments without `isContentEditable` still work). */ function getContentEditableRoot(el: HTMLElement): HTMLElement | null { if (el.isContentEditable) { return el; } const candidate = el.closest('[contenteditable]'); if (candidate && candidate.getAttribute('contenteditable') !== 'false') { return candidate; } return null; } /** * Whether an arrow/Home/End key should be left to the browser because the * event target is a text-editing element whose caret is not yet at the boundary * in the direction of travel (or a selection is present). Returns true when the * list should NOT steal the key. * * Covers three editing surfaces: * - `