{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-roving-tabindex",
  "title": "useRovingTabindex",
  "description": "A roving-tabindex hook that gives a control group one Tab stop and arrow-key navigation inside it — horizontal / vertical / grid lanes, disabled-item skipping, loop or stop, Home/End and typeahead.",
  "files": [
    {
      "path": "src/registry/hooks/use-roving-tabindex.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport type RovingOrientation = \"horizontal\" | \"vertical\" | \"grid\"\n\nexport interface UseRovingTabindexOptions {\n  /** How many focusable items the group has. Non-integers / negatives / NaN / Infinity are clamped to an integer `>= 0`. */\n  count: number\n  /**\n   * Which pair of arrow keys this group owns:\n   * - `\"horizontal\"` (default) — ←/→ act, ↑/↓ are **not intercepted** and stay with page scrolling;\n   * - `\"vertical\"` — ↑/↓ act, ←/→ pass through;\n   * - `\"grid\"` — all four act, ↑/↓ cross rows by `columns` and ←/→ walk inside the row.\n   */\n  orientation?: RovingOrientation\n  /** Column count, meaningful only for `orientation=\"grid\"`; missing / invalid values mean 1 column (a vertical list). */\n  columns?: number\n  /** Wrap at the ends (default) or stop. In a grid, \"the end\" means **the current row / column**, not the whole list. */\n  loop?: boolean\n  /** Starting index in uncontrolled mode, read once on mount (same meaning as the initial value of `useState`). */\n  defaultIndex?: number\n  /** Passing it (even `0`) switches to **controlled** mode: no internal index, changes only go out via `onIndexChange`. */\n  index?: number\n  /** Called when the index really changes. **Clamping and skipping disabled items are silent repairs** and never fire it. */\n  onIndexChange?: (index: number) => void\n  /** Indexes that take no part in keyboard navigation and can never become the Tab stop. */\n  disabledIndexes?: Iterable<number>\n  /** Enables typeahead when supplied: return the item's readable text and typed letters jump to the match. */\n  getItemText?: (index: number) => string\n  /** How long, in ms, before the typeahead buffer resets. Default 500. */\n  typeaheadTimeout?: number\n}\n\nexport interface RovingItemProps {\n  /** Callback ref that collects the DOM node — focus can only land on an element we hold. */\n  ref: (node: HTMLElement | null) => void\n  /** Only `activeIndex` gets `0`, so the whole group costs **one** Tab stop from the outside. */\n  tabIndex: 0 | -1\n  onKeyDown: React.KeyboardEventHandler<HTMLElement>\n  onFocus: React.FocusEventHandler<HTMLElement>\n  onClick: React.MouseEventHandler<HTMLElement>\n  \"data-index\": number\n}\n\nexport interface RovingContainerProps {\n  /**\n   * Fallback: when focus lands on the container itself (a press on the padding, or someone\n   * calling `container.focus()`) it is handed to the active item. No `role` on purpose —\n   * only the consumer knows whether this group is a toolbar or a radiogroup.\n   */\n  onFocus: React.FocusEventHandler<HTMLElement>\n}\n\nexport interface UseRovingTabindexResult {\n  /** The current Tab stop. Already clamped to `[0, count-1]` and never on a disabled item; `-1` when `count === 0`. */\n  activeIndex: number\n  /** The whole prop set to spread onto item `index`. Out-of-range indexes get a safe no-op set. */\n  getItemProps: (index: number) => RovingItemProps\n  /**\n   * Moves the Tab stop imperatively. By default it **also moves DOM focus**; pass\n   * `{ focus: false }` to move the stop without stealing focus. Out-of-range targets are\n   * clamped, and a disabled target makes the whole call a no-op.\n   */\n  setActiveIndex: (index: number, options?: { focus?: boolean }) => void\n  containerProps: RovingContainerProps\n}\n\nconst NOOP = () => {}\n\nfunction clamp(value: number, min: number, max: number): number {\n  if (value < min) return min\n  if (value > max) return max\n  return value\n}\n\n/**\n * A \"lane\": the one-dimensional sequence the arrow keys walk. The flat list is one lane, and\n * each row and each column of a grid is one too — so all three orientations share a single\n * searcher that skips disabled items and wraps.\n */\ninterface Lane {\n  size: number\n  /** Flat index of position `pos` in this lane. */\n  at: (pos: number) => number\n  /** Position of a flat index inside this lane. */\n  posOf: (index: number) => number\n}\n\n/**\n * From `fromPos`, walk by `delta` to the next position `skip` does not reject.\n *\n * The loop is hard-capped at `lane.size`: one lap covers every position exactly once, so a\n * **fully disabled lane returns `null` instead of spinning forever**. With `loop` off, stepping\n * out of range returns `null` (stop at the end). `fromPos` may be `-1` / `lane.size` — that is\n * exactly \"first step in from outside the lane\", which is why Home / End need no separate search.\n */\nfunction seek(\n  lane: Lane,\n  fromPos: number,\n  delta: 1 | -1,\n  loop: boolean,\n  skip: (index: number) => boolean,\n): number | null {\n  if (lane.size <= 0) return null\n  let pos = fromPos\n  for (let step = 0; step < lane.size; step += 1) {\n    pos += delta\n    if (pos < 0 || pos >= lane.size) {\n      if (!loop) return null\n      pos = ((pos % lane.size) + lane.size) % lane.size\n    }\n    const index = lane.at(pos)\n    if (!skip(index)) return index\n  }\n  return null\n}\n\nfunction flatLane(total: number): Lane {\n  return { size: total, at: pos => pos, posOf: index => index }\n}\n\n/** The row `index` sits in. When the last row is short, `size` is how many cells it really has. */\nfunction rowLane(index: number, total: number, cols: number): Lane {\n  const row = Math.floor(index / cols)\n  return {\n    size: Math.min(cols, total - row * cols),\n    at: pos => row * cols + pos,\n    posOf: i => i % cols,\n  }\n}\n\n/**\n * The column `index` sits in. Its length has to account for the remainder — with 14 items in 5\n * columns, column 4 holds only 2 cells (indexes 4 and 9), so ↓ from 9 must wrap to 4 rather\n * than step onto a 14 that does not exist.\n */\nfunction colLane(index: number, total: number, cols: number): Lane {\n  const col = index % cols\n  const fullRows = Math.floor(total / cols)\n  return {\n    size: fullRows + (col < total % cols ? 1 : 0),\n    at: pos => pos * cols + col,\n    posOf: i => Math.floor(i / cols),\n  }\n}\n\n/** Clamp into range; if that lands on a disabled item, take the next enabled one (wrapping). All disabled keeps the clamped value. */\nfunction resolveActive(raw: number, total: number, isDisabled: (index: number) => boolean): number {\n  if (total <= 0) return -1\n  const base = Number.isFinite(raw) ? clamp(Math.floor(raw), 0, total - 1) : 0\n  if (!isDisabled(base)) return base\n  return seek(flatLane(total), base, 1, true, isDisabled) ?? base\n}\n\ninterface RovingLatest {\n  total: number\n  cols: number\n  orientation: RovingOrientation\n  loop: boolean\n  activeIndex: number\n  isControlled: boolean\n  isDisabled: (index: number) => boolean\n  onIndexChange: ((index: number) => void) | undefined\n  getItemText: ((index: number) => string) | undefined\n  typeaheadTimeout: number\n}\n\ninterface ItemHandlers {\n  ref: (node: HTMLElement | null) => void\n  onKeyDown: React.KeyboardEventHandler<HTMLElement>\n  onFocus: React.FocusEventHandler<HTMLElement>\n  onClick: React.MouseEventHandler<HTMLElement>\n}\n\n/**\n * The WAI-ARIA roving tabindex pattern: a group of sibling controls costs **one Tab stop** from\n * the outside and is navigated with the arrow keys inside — toolbars, radiogroups, tablists,\n * emoji grids and chip groups all want this, and a dozen components here each had a copy.\n *\n * ```tsx\n * const roving = useRovingTabindex({ count: tools.length })\n * <div role=\"toolbar\" aria-orientation=\"horizontal\" {...roving.containerProps}>\n *   {tools.map((tool, i) => <button key={tool.id} {...roving.getItemProps(i)}>{tool.label}</button>)}\n * </div>\n * ```\n *\n * - **One Tab stop only**: in `getItemProps(i).tabIndex` only the active item is `0`, every other\n *   is `-1`. Tab enters the group from the control before it and leaves for the one after it.\n * - **Arrow keys split by `orientation`**: a horizontal group never touches ↑/↓, a vertical one\n *   never touches ←/→ — **keys it does not own are not `preventDefault`ed**, or the page could\n *   not be scrolled from inside the group. Keys it does own are swallowed even at the end.\n * - **Grid**: ↑/↓ walk **the current column**, ←/→ **the current row**, both sized by the real\n *   cell count, so a short last row has no holes to fall into. `Home` / `End` are the ends of\n *   the row; ⌘·Ctrl + `Home` / `End` reach the ends of the whole grid (as the ARIA grid pattern\n *   specifies).\n * - **Disabled items**: indexes in `disabledIndexes` are skipped by the arrow keys and can never\n *   become the Tab stop; with `loop` off the walk stops at the end, with it on it wraps; **even\n *   a fully disabled group converges within one lap** instead of looping forever, and the group\n *   still keeps one `tabIndex=0` (without it the group would drop out of the Tab sequence).\n * - **Focus really follows**: `setActiveIndex` moves DOM focus to the target **synchronously**.\n *   It cannot hang off an effect — focusing the item that is already the current index is a\n *   no-change setState, React skips the render and the effect never runs (imperative focus also\n *   gets one frame of retry, guarded by `isConnected`). The focus event that move fires is not\n *   reported back, so one arrow press calls `onIndexChange` exactly once.\n * - **When `count` changes**: an out-of-range index is clamped back by adjust-state **during\n *   render** (not a setState in an effect body), so the frame where the list shrank already\n *   reads a valid value.\n * - **Controlled / uncontrolled**: passing `index` makes it controlled and the hook holds no\n *   state. Clamping and skipping disabled items are repairs, not user actions, and **do not**\n *   fire `onIndexChange`; clamp yourself if you persist the index.\n * - **typeahead** (only with `getItemText`): typed letters prefix-match and jump; repeating one\n *   letter cycles through every item starting with it, and the buffer clears after\n *   `typeaheadTimeout`.\n */\nexport function useRovingTabindex({\n  count,\n  orientation = \"horizontal\",\n  columns,\n  loop = true,\n  defaultIndex = 0,\n  index: controlledIndex,\n  onIndexChange,\n  disabledIndexes,\n  getItemText,\n  typeaheadTimeout = 500,\n}: UseRovingTabindexOptions): UseRovingTabindexResult {\n  // Clamp: consumers compute count / columns themselves, and `items.length - 1`, `0` and NaN\n  // have all shown up. Unclamped, seek's loop bound becomes NaN and one keypress hangs the tab.\n  const total = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0\n  const cols =\n    orientation === \"grid\" && typeof columns === \"number\" && Number.isFinite(columns)\n      ? Math.max(1, Math.floor(columns))\n      : 1\n\n  // Rebuilt every render. `disabledIndexes` is almost always an inline literal, so memoising on\n  // it memoises nothing; and this Set never escapes, so a fresh identity updates nothing.\n  const disabledSet = new Set<number>()\n  for (const disabledIndex of disabledIndexes ?? []) disabledSet.add(disabledIndex)\n  const isDisabled = (index: number) => disabledSet.has(index)\n\n  const isControlled = controlledIndex !== undefined\n  const [uncontrolledIndex, setUncontrolledIndex] = React.useState(defaultIndex)\n  const rawIndex = isControlled ? controlledIndex : uncontrolledIndex\n  const activeIndex = resolveActive(rawIndex, total, isDisabled)\n\n  // Adjust-state during render: when the list shrank, or the current item was just disabled,\n  // clamp the stored index back in one go. In an effect this trips react-hooks/set-state-in-effect\n  // and flashes the out-of-range value for a frame. It is idempotent (activeIndex is already\n  // valid), so it converges after at most one extra render.\n  if (!isControlled && total > 0 && uncontrolledIndex !== activeIndex) {\n    setUncontrolledIndex(activeIndex)\n  }\n\n  // Latest-ref: every callback reads current values from here, so the returned functions keep a\n  // stable identity forever. `onIndexChange` especially — consumers pass inline arrows, and a\n  // fresh identity every render would re-fire downstream effects that depend on it.\n  const latest = React.useRef<RovingLatest>({\n    total,\n    cols,\n    orientation,\n    loop,\n    activeIndex,\n    isControlled,\n    isDisabled,\n    onIndexChange,\n    getItemText,\n    typeaheadTimeout,\n  })\n  // The insertion effect is React's earliest commit-phase hook (before layout effects, before\n  // paint), so any handler firing after this commit already reads the new values.\n  React.useInsertionEffect(() => {\n    latest.current = {\n      total,\n      cols,\n      orientation,\n      loop,\n      activeIndex,\n      isControlled,\n      isDisabled,\n      onIndexChange,\n      getItemText,\n      typeaheadTimeout,\n    }\n  })\n\n  const itemsRef = React.useRef(new Map<number, HTMLElement>())\n  const pendingFocusRef = React.useRef<number | null>(null)\n  /** True while `applyPendingFocus` is inside `element.focus()`: that focus event is our own echo. */\n  const focusingRef = React.useRef(false)\n\n  const applyPendingFocus = React.useCallback(() => {\n    const target = pendingFocusRef.current\n    if (target === null) return\n    const element = itemsRef.current.get(target)\n    // The target may not be mounted yet (a virtual list, or a list that grew in this same\n    // frame): keep the request and retry on the next commit. It may also have just been\n    // unmounted — isConnected covers that, because focus() on a detached node fails silently\n    // and dumps focus back onto <body>.\n    if (!element || !element.isConnected) return\n    pendingFocusRef.current = null\n    if (typeof document !== \"undefined\" && document.activeElement === element) return\n    // focus() dispatches the item's focus event synchronously, before the caller below has\n    // committed the new index. Reported through handleItemFocus it would fire onIndexChange a\n    // second time for the same move, so it is flagged as ours and ignored there.\n    focusingRef.current = true\n    try {\n      element.focus()\n    } finally {\n      focusingRef.current = false\n    }\n  }, [])\n\n  React.useEffect(() => {\n    applyPendingFocus()\n    // One frame of retry only. Kept around, a long-stale request would steal focus seconds later\n    // during some unrelated re-render.\n    pendingFocusRef.current = null\n  })\n\n  const setActiveIndex = React.useCallback(\n    (next: number, options?: { focus?: boolean }) => {\n      const ctx = latest.current\n      if (ctx.total <= 0 || !Number.isFinite(next)) return\n      const target = clamp(Math.floor(next), 0, ctx.total - 1)\n      // A disabled item is never the Tab stop. Ignore the call rather than quietly sliding to a\n      // neighbour — that would turn setActiveIndex(3) into 4, which no caller can predict.\n      if (ctx.isDisabled(target)) return\n      if (options?.focus !== false) {\n        pendingFocusRef.current = target\n        // Synchronous, not deferred to an effect: when target === activeIndex the setState\n        // changes nothing, React skips the render, and a \"setState → focus in an effect\"\n        // approach would never run.\n        applyPendingFocus()\n      }\n      if (target === ctx.activeIndex) return\n      if (!ctx.isControlled) setUncontrolledIndex(target)\n      ctx.onIndexChange?.(target)\n    },\n    [applyPendingFocus],\n  )\n\n  const typeaheadRef = React.useRef<{ query: string; timer: ReturnType<typeof setTimeout> | null }>({\n    query: \"\",\n    timer: null,\n  })\n  React.useEffect(\n    () => () => {\n      const { timer } = typeaheadRef.current\n      if (timer !== null) clearTimeout(timer)\n    },\n    [],\n  )\n\n  const handleTypeahead = React.useCallback(\n    (index: number, event: React.KeyboardEvent<HTMLElement>) => {\n      const ctx = latest.current\n      const getText = ctx.getItemText\n      if (!getText) return\n      // Single printable characters only. Space belongs to \"activate the current item\", and\n      // modifier combos belong to the app's own shortcuts.\n      if (event.key.length !== 1 || event.key === \" \") return\n      if (event.metaKey || event.ctrlKey || event.altKey) return\n\n      const state = typeaheadRef.current\n      if (state.timer !== null) clearTimeout(state.timer)\n      state.query += event.key.toLowerCase()\n      const timeout = Number.isFinite(ctx.typeaheadTimeout) ? Math.max(0, ctx.typeaheadTimeout) : 500\n      state.timer = setTimeout(() => {\n        typeaheadRef.current.query = \"\"\n        typeaheadRef.current.timer = null\n      }, timeout)\n\n      // Repeating one letter means \"cycle through everything starting with it\" (the ARIA\n      // convention), so search on that single character. Otherwise treat the buffer as a prefix\n      // and back the start up by one, so the current item is a candidate too (\"s\" lands on\n      // Settings, then \"e\" should stay on Settings rather than jump to the next se… item).\n      const query = state.query\n      const repeated = query.length > 1 && query.split(\"\").every(char => char === query[0])\n      const needle = repeated ? query[0] : query\n      const from = repeated || query.length === 1 ? index : index - 1\n\n      const lane = flatLane(ctx.total)\n      const next = seek(lane, from, 1, true, candidate => {\n        if (ctx.isDisabled(candidate)) return true\n        const text = getText(candidate)\n        return !(typeof text === \"string\" ? text : \"\").trim().toLowerCase().startsWith(needle)\n      })\n      if (next === null) return\n      // Swallow the key only on a hit; a miss goes back to the page (Firefox's find-as-you-type).\n      event.preventDefault()\n      setActiveIndex(next)\n    },\n    [setActiveIndex],\n  )\n\n  const handleKeyDown = React.useCallback(\n    (index: number, event: React.KeyboardEvent<HTMLElement>) => {\n      const ctx = latest.current\n      if (event.defaultPrevented) return\n      if (ctx.total <= 0 || index < 0 || index >= ctx.total) return\n      const mod = event.metaKey || event.ctrlKey\n      const key = event.key\n\n      if (key.startsWith(\"Arrow\")) {\n        // ⌘/Ctrl/⌥ + arrow are system-level actions (page up, start of line, move by word) — hands off.\n        if (mod || event.altKey) return\n      } else if (key !== \"Home\" && key !== \"End\") {\n        handleTypeahead(index, event)\n        return\n      } else if (event.altKey) {\n        return\n      }\n\n      const flat = flatLane(ctx.total)\n      const grid = ctx.orientation === \"grid\"\n      let next: number | null = null\n\n      switch (key) {\n        case \"ArrowRight\":\n        case \"ArrowLeft\": {\n          // A vertical group does not own ←/→: pass them on, so horizontal scrolling and caret movement still work.\n          if (ctx.orientation === \"vertical\") return\n          const delta = key === \"ArrowRight\" ? 1 : -1\n          const lane = grid ? rowLane(index, ctx.total, ctx.cols) : flat\n          next = seek(lane, lane.posOf(index), delta, ctx.loop, ctx.isDisabled)\n          break\n        }\n        case \"ArrowDown\":\n        case \"ArrowUp\": {\n          // A horizontal group does not own ↑/↓: it must pass them on, or the page cannot be scrolled from the toolbar.\n          if (ctx.orientation === \"horizontal\") return\n          const delta = key === \"ArrowDown\" ? 1 : -1\n          const lane = grid ? colLane(index, ctx.total, ctx.cols) : flat\n          next = seek(lane, lane.posOf(index), delta, ctx.loop, ctx.isDisabled)\n          break\n        }\n        case \"Home\": {\n          // In a grid, Home is the start of the row; ⌘/Ctrl+Home is the first cell of the grid (ARIA grid pattern).\n          const lane = grid && !mod ? rowLane(index, ctx.total, ctx.cols) : flat\n          next = seek(lane, -1, 1, false, ctx.isDisabled)\n          break\n        }\n        default: {\n          const lane = grid && !mod ? rowLane(index, ctx.total, ctx.cols) : flat\n          next = seek(lane, lane.size, -1, false, ctx.isDisabled)\n          break\n        }\n      }\n\n      // Always swallow a key this group owns, even when the walk hit the end and nothing moved —\n      // otherwise \"stop at the end\" turns into \"scroll the page at the end\".\n      event.preventDefault()\n      if (next !== null) setActiveIndex(next)\n    },\n    [handleTypeahead, setActiveIndex],\n  )\n\n  const handleItemFocus = React.useCallback(\n    (index: number) => {\n      // Our own programmatic focus: the call that moved it reports the change itself.\n      if (focusingRef.current) return\n      // Focus is already on this item; a second focus() would fight the browser's own focus flow.\n      setActiveIndex(index, { focus: false })\n    },\n    [setActiveIndex],\n  )\n\n  const handleItemClick = React.useCallback(\n    (index: number, event: React.MouseEvent<HTMLElement>) => {\n      if (event.defaultPrevented) return\n      // Safari does not focus a <button> on click, so syncing from onFocus alone leaves the Tab\n      // stop on the previous item after a mouse press — hence this explicit one\n      // (applyPendingFocus already dedupes).\n      setActiveIndex(index)\n    },\n    [setActiveIndex],\n  )\n\n  const registerItem = React.useCallback(\n    (index: number) => (node: HTMLElement | null) => {\n      // Not a concise arrow body: React 19 treats a ref callback's return value as a cleanup function.\n      if (node) itemsRef.current.set(index, node)\n      else itemsRef.current.delete(index)\n    },\n    [],\n  )\n\n  // One stable handler set per item, whose identity only tracks count. Rebuilt every render, the\n  // ref callbacks would detach / attach each frame and the handlers would defeat downstream React.memo.\n  const itemHandlers = React.useMemo(() => {\n    const handlers: ItemHandlers[] = []\n    for (let index = 0; index < total; index += 1) {\n      handlers[index] = {\n        ref: registerItem(index),\n        onKeyDown: event => handleKeyDown(index, event),\n        onFocus: () => handleItemFocus(index),\n        onClick: event => handleItemClick(index, event),\n      }\n    }\n    return handlers\n  }, [total, registerItem, handleKeyDown, handleItemFocus, handleItemClick])\n\n  const getItemProps = React.useCallback(\n    (index: number): RovingItemProps => {\n      const handlers = itemHandlers[index]\n      return {\n        ref: handlers?.ref ?? NOOP,\n        tabIndex: index === activeIndex ? 0 : -1,\n        onKeyDown: handlers?.onKeyDown ?? NOOP,\n        onFocus: handlers?.onFocus ?? NOOP,\n        onClick: handlers?.onClick ?? NOOP,\n        \"data-index\": index,\n      }\n    },\n    [itemHandlers, activeIndex],\n  )\n\n  const handleContainerFocus = React.useCallback(\n    (event: React.FocusEvent<HTMLElement>) => {\n      // Only the \"focus landed on the container itself\" case: focus bubbling up from an item is that item's onFocus.\n      if (event.target !== event.currentTarget) return\n      const ctx = latest.current\n      if (ctx.activeIndex < 0) return\n      pendingFocusRef.current = ctx.activeIndex\n      applyPendingFocus()\n    },\n    [applyPendingFocus],\n  )\n\n  const containerProps = React.useMemo(\n    () => ({ onFocus: handleContainerFocus }),\n    [handleContainerFocus],\n  )\n\n  return { activeIndex, getItemProps, setActiveIndex, containerProps }\n}\n\nexport default useRovingTabindex\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}