{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-focus-trap",
  "title": "useFocusTrap",
  "description": "A hook that keeps keyboard focus inside a container: moves focus in, cycles Tab both ways, only the innermost trap reacts, and focus returns to the trigger on release.",
  "files": [
    {
      "path": "src/registry/hooks/use-focus-trap.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * Selector for focusable candidates. Only elements Tab can stop on:\n * `[tabindex=\"-1\"]` is programmatically focusable but out of the tab order and\n * must be excluded, or the cycle parks on somewhere the user can never reach by\n * pressing Tab. `details > summary` is the disclosure triangle itself (natively\n * focusable), and the control bar of `audio/video[controls]` takes Tab too. The\n * real visibility / inert / aria-hidden filtering happens below; this selector\n * is only a coarse pass.\n */\nconst FOCUSABLE_SELECTOR = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n  '[contenteditable]:not([contenteditable=\"false\"])',\n  \"audio[controls]\",\n  \"video[controls]\",\n  \"details > summary\",\n].join(\", \")\n\n/**\n * The registry of nested traps lives on the DOM, not in a module-level variable.\n *\n * This hook can be installed standalone: dialog A on a page ships one copy,\n * drawer B ships another, and each copy has its own module scope. A module-level\n * stack would then be counted twice — with two layers nested, both sides believe\n * they are innermost, one Tab gets handled by both traps (the outer yanks focus\n * back to itself, the inner yanks it back again, focus ping-pongs between the\n * layers), and one Esc closes both layers at once.\n *\n * The serial lives in an attribute on the container element and the counter on\n * `document.body`; both are globally unique, so independent copies cooperate\n * correctly: the highest serial is the innermost trap.\n */\nconst TRAP_ATTR = \"data-zy-focus-trap\"\nconst SERIAL_KEY = \"zyFocusTrapSerial\"\n\nfunction claimTrapSlot(container: HTMLElement) {\n  const serial = Number(document.body.dataset[SERIAL_KEY] ?? \"0\") + 1\n  document.body.dataset[SERIAL_KEY] = String(serial)\n  container.setAttribute(TRAP_ATTR, String(serial))\n}\n\nfunction releaseTrapSlot(container: HTMLElement) {\n  container.removeAttribute(TRAP_ATTR)\n  // Reset the counter once every trap is gone so serials do not grow forever.\n  if (!document.querySelector(`[${TRAP_ATTR}]`)) delete document.body.dataset[SERIAL_KEY]\n}\n\n/** Innermost = the highest serial in the document. Only it answers Tab and Escape. */\nfunction isInnermost(container: HTMLElement) {\n  const own = Number(container.getAttribute(TRAP_ATTR) ?? \"0\")\n  if (own === 0) return false\n  for (const other of document.querySelectorAll(`[${TRAP_ATTR}]`)) {\n    if (Number(other.getAttribute(TRAP_ATTR) ?? \"0\") > own) return false\n  }\n  return true\n}\n\n/**\n * Whether an element can actually take focus. **Browsers refuse to focus\n * invisible elements, and `focus()` fails silently** — no throw, no return value,\n * focus just stays put. Leave such an element in the candidate set and pressing\n * Tab appears to do nothing at all: the trap looks completely broken.\n *\n * - `getClientRects().length === 0`: the element or any ancestor is\n *   `display:none` (this also covers collapsed `<details>` content and\n *   `content-visibility: hidden` subtrees).\n * - `visibility` is inherited, so the computed value already accounts for\n *   ancestors — one check rules out both \"hidden itself\" and \"hidden ancestor\"\n *   without wrongly excluding an element that is explicitly visible under a\n *   hidden ancestor.\n * - Zero-size elements (0×0 placeholders, collapsed rows) do not exist visually\n *   and have no business in the tab order.\n */\nfunction isVisible(element: HTMLElement) {\n  const rects = element.getClientRects()\n  if (rects.length === 0) return false\n  let hasArea = false\n  for (const rect of rects) {\n    if (rect.width > 0 && rect.height > 0) {\n      hasArea = true\n      break\n    }\n  }\n  if (!hasArea) return false\n  return window.getComputedStyle(element).visibility === \"visible\"\n}\n\n/**\n * `inert` / `aria-hidden=\"true\"` subtrees should not exist for the keyboard or a\n * screen reader. Walk up only as far as the container: host apps routinely put\n * `aria-hidden` on the whole `#root` when a modal opens, and a `closest()` all\n * the way to document would mark every element inside the trap unreachable.\n */\nfunction isSuppressed(element: HTMLElement, root: HTMLElement) {\n  let node: HTMLElement | null = element\n  while (node) {\n    if (node.hasAttribute(\"inert\")) return true\n    if (node.getAttribute(\"aria-hidden\") === \"true\") return true\n    if (node === root) return false\n    node = node.parentElement\n  }\n  return false\n}\n\n/**\n * The elements Tab can currently reach inside the container, in **native tab\n * order**: positive tabindex first in ascending order, everything else in DOM\n * order. Since the Tab handler below takes over the whole cycle, it has to\n * follow the browser's own rules, or a page using positive tabindex changes\n * order the moment it enters the trap. Recomputed on every use, never cached.\n */\nfunction focusablesIn(root: HTMLElement): HTMLElement[] {\n  const found: { element: HTMLElement; order: number; position: number }[] = []\n  for (const element of root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)) {\n    if (isSuppressed(element, root)) continue\n    if (!isVisible(element)) continue\n    found.push({ element, order: Math.max(0, element.tabIndex), position: found.length })\n  }\n  found.sort((a, b) => {\n    if (a.order === b.order) return a.position - b.position\n    if (a.order === 0) return 1\n    if (b.order === 0) return -1\n    return a.order - b.order\n  })\n  return found.map(entry => entry.element)\n}\n\n/** Retry budget (frames) while the container has not settled. ~0.5s, then we give up on moving focus — the trap stays live. */\nconst MAX_ENTER_FRAMES = 30\n\n/** Initial focus target: a ref, or a CSS selector resolved inside the container. */\nexport type FocusTrapTarget = React.RefObject<HTMLElement | null> | string\n\nexport interface UseFocusTrapOptions {\n  /**\n   * Whether the trap is live. Flipping true moves focus into the container;\n   * flipping false removes the listeners and restores focus.\n   */\n  active: boolean\n  /**\n   * Where to send focus on activation. A ref or a selector; if it does not\n   * resolve (or that element is not focusable yet) it falls back to the first\n   * focusable element in the container, then to the container itself. Defaults\n   * to null.\n   */\n  initialFocus?: FocusTrapTarget | null\n  /** Give focus back to the element that had it before activation. Defaults to true. */\n  returnFocus?: boolean\n  /**\n   * Called when the innermost trap receives Escape. **The hook closes nothing on\n   * its own** — whether and how to close (discard the draft? ask again?) is the\n   * consumer's call.\n   */\n  onEscape?: (event: KeyboardEvent) => void\n}\n\nexport interface UseFocusTrapResult {\n  /** Callback ref for the container element. Live on mount, torn down on unmount. */\n  ref: (node: HTMLElement | null) => void\n}\n\n/**\n * Confine keyboard focus to a container: move focus in, wrap Tab / Shift+Tab at\n * both ends, tell the consumer about Escape, and hand focus back to whatever\n * triggered it on teardown.\n *\n * Three design choices worth calling out:\n *\n * 1. **A callback ref, not `useState` + effect.** Focus is imperative and has to\n *    happen synchronously the moment the container hits the DOM. The \"setState\n *    to remember the node → focus in an effect\" route has a subtle dead spot:\n *    setting state to a value **equal to the current one** makes React skip the\n *    render entirely, the effect never re-runs, and focus is stranded. There is\n *    no state here at all.\n * 2. **The retry path.** The container may be mounted but not settled yet (a\n *    positioned overlay has no coordinates on its first frame, an entrance\n *    animation starts at `visibility:hidden`, content is still loading). `focus()`\n *    on an invisible element fails silently, so moving focus in retries on a\n *    ResizeObserver + rAF until the container is really visible.\n * 3. **Nesting via a DOM-level registry** (see the `TRAP_ATTR` note above): a\n *    module-level stack is counted separately by each independent copy on the\n *    page, and two nested traps fight over focus.\n *\n * SSR-safe: no browser API is touched during render; `document` appears only in\n * the ref callback and in effects.\n */\nexport function useFocusTrap({\n  active,\n  initialFocus = null,\n  returnFocus = true,\n  onEscape,\n}: UseFocusTrapOptions): UseFocusTrapResult {\n  const containerRef = React.useRef<HTMLElement | null>(null)\n  const trappedRef = React.useRef(false)\n  const previousFocusRef = React.useRef<HTMLElement | null>(null)\n  /** Only remove the tabindex if we added it; never touch a value the consumer wrote. */\n  const ownsTabIndexRef = React.useRef(false)\n  const retryRef = React.useRef<{ frame: number; observer: ResizeObserver | null; attempts: number } | null>(null)\n\n  // latest-ref: these are only read from events/callbacks, and putting them in a\n  // dependency array would make a consumer's inline arrow function reinstall the\n  // listeners on every render. `active` is seeded with useRef(active) so the ref\n  // callback on the first frame (it runs before effects) already sees the right\n  // value.\n  const activeRef = React.useRef(active)\n  const initialFocusRef = React.useRef(initialFocus)\n  const returnFocusRef = React.useRef(returnFocus)\n  const onEscapeRef = React.useRef(onEscape)\n  React.useEffect(() => {\n    initialFocusRef.current = initialFocus\n    returnFocusRef.current = returnFocus\n    onEscapeRef.current = onEscape\n  })\n\n  const clearRetry = React.useCallback(() => {\n    const retry = retryRef.current\n    if (!retry) return\n    retryRef.current = null\n    if (retry.frame !== 0) cancelAnimationFrame(retry.frame)\n    retry.observer?.disconnect()\n  }, [])\n\n  /** Move focus into the container; true on success, false if it has not settled yet (left to the retry path). */\n  const enter = React.useCallback(() => {\n    const container = containerRef.current\n    if (!container) return true\n\n    const wanted = initialFocusRef.current\n    const explicit =\n      typeof wanted === \"string\" ? container.querySelector<HTMLElement>(wanted) : (wanted?.current ?? null)\n    if (explicit) {\n      if (document.activeElement === explicit) return true\n      explicit.focus()\n      if (document.activeElement === explicit) return true\n    }\n\n    // If the container is not painted yet, nothing inside it can take focus\n    // either. Do not fall back to \"first focusable element\" here — that would let\n    // a not-yet-ready timing silently void initialFocus.\n    if (!isVisible(container)) return false\n\n    // The consumer may have put focus inside already (activation triggered from a\n    // control within the container, say); do not steal it.\n    if (container.contains(document.activeElement)) return true\n\n    // Container fallback: with no focusable element there is nowhere else for\n    // focus to go. The ref callback guarantees the container has a tabindex, so\n    // this always succeeds.\n    const target = focusablesIn(container)[0] ?? container\n    if (target !== document.activeElement) target.focus()\n    return container.contains(document.activeElement)\n  }, [])\n\n  const scheduleEnter = React.useCallback(() => {\n    clearRetry()\n    if (enter()) return\n    const container = containerRef.current\n    if (!container) return\n\n    const retry: { frame: number; observer: ResizeObserver | null; attempts: number } = {\n      attempts: 0,\n      frame: 0,\n      observer: null,\n    }\n    retryRef.current = retry\n\n    const tick = () => {\n      retry.frame = 0\n      if (!trappedRef.current) return clearRetry()\n      retry.attempts += 1\n      if (enter() || retry.attempts >= MAX_ENTER_FRAMES) return clearRetry()\n      retry.frame = requestAnimationFrame(tick)\n    }\n    retry.frame = requestAnimationFrame(tick)\n\n    // ResizeObserver fires once right after observe(), then again on the frame the\n    // container gets a size — it pinpoints \"the panel just settled\" better than\n    // polling. The rAF path covers the case where the size never changes and only\n    // visibility flips (RO says nothing about that).\n    if (typeof ResizeObserver !== \"undefined\") {\n      retry.observer = new ResizeObserver(() => {\n        if (!trappedRef.current) return clearRetry()\n        if (enter()) clearRetry()\n      })\n      retry.observer.observe(container)\n    }\n  }, [clearRetry, enter])\n\n  const handleKeyDown = React.useCallback((event: KeyboardEvent) => {\n    if (event.defaultPrevented) return\n    if (event.key !== \"Tab\" && event.key !== \"Escape\") return\n    const container = containerRef.current\n    if (!container || !trappedRef.current) return\n    // Nesting: with both layers active only the innermost responds, so one Esc\n    // does not blow away both layers.\n    if (!isInnermost(container)) return\n\n    if (event.key === \"Escape\") {\n      onEscapeRef.current?.(event)\n      return\n    }\n\n    // The candidate set is recomputed on every Tab. Content inside a trap changes\n    // all the time (a form that finished loading, an expanded section, a button\n    // just disabled); a cached copy either leaves new elements out of the cycle or\n    // focuses an already-unmounted node as the boundary — a silent failure.\n    const items = focusablesIn(container)\n    if (items.length === 0) {\n      // Empty trap: focus rests on the container itself instead of being tabbed\n      // back out to the page behind it.\n      event.preventDefault()\n      container.focus({ preventScroll: true })\n      return\n    }\n\n    // Drive the whole cycle ourselves rather than \"guard the ends, let the browser\n    // handle the middle\". Measured: an element the browser accepts but we exclude\n    // (a button inside an `aria-hidden` subtree, an `inert` block) gets picked by\n    // native Tab, focus lands somewhere outside the candidate set, and the\n    // fallback branch drags it back to the first one — focus ping-pongs between\n    // those two and everything after it is unreachable.\n    const forward = !event.shiftKey\n    const current = document.activeElement instanceof HTMLElement ? document.activeElement : null\n    const index = current ? items.indexOf(current) : -1\n\n    let next: HTMLElement\n    if (index !== -1) {\n      next = items[(index + (forward ? 1 : -1) + items.length) % items.length]\n    } else if (!current) {\n      next = forward ? items[0] : items[items.length - 1]\n    } else {\n      // Focus is outside the candidate set: the container itself (tabIndex=-1), an\n      // element just excluded, or the user clicked the background with the mouse.\n      // Find the nearest candidate before/after it in document order so the cycle\n      // does not lose its place. Between the container and its descendants the\n      // relation is CONTAINED_BY|FOLLOWING, so \"forward from the container\" lands\n      // on the first candidate naturally.\n      const following = items.filter(\n        item => (current.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,\n      )\n      const preceding = items.filter(\n        item => (current.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_PRECEDING) !== 0,\n      )\n      next = forward\n        ? (following[0] ?? items[0])\n        : (preceding[preceding.length - 1] ?? items[items.length - 1])\n    }\n\n    event.preventDefault()\n    next.focus()\n  }, [])\n\n  const deactivate = React.useCallback(() => {\n    if (!trappedRef.current) return\n    trappedRef.current = false\n    clearRetry()\n\n    const container = containerRef.current\n    document.removeEventListener(\"keydown\", handleKeyDown, true)\n    if (container) releaseTrapSlot(container)\n\n    const previous = previousFocusRef.current\n    previousFocusRef.current = null\n    if (!returnFocusRef.current || !previous) return\n\n    // Only restore if focus is still inside the trap (or already fell to body).\n    // The user may have clicked elsewhere on the page in the meantime, and\n    // yanking them back to the trigger from there is worse than not restoring.\n    const insideTrap = container?.contains(document.activeElement) === true\n    const onBody = document.activeElement === null || document.activeElement === document.body\n    if (!insideTrap && !onBody) return\n\n    // isConnected: the trigger is often unmounted by this very interaction (it\n    // lived inside the UI that just closed, its list row was deleted). focus() on\n    // a detached node does not throw — it silently drops focus on <body>, leaving\n    // screen-reader users with nowhere to stand. preventScroll keeps the page from\n    // jumping to a trigger that may be far off screen.\n    if (previous.isConnected) previous.focus({ preventScroll: true })\n  }, [clearRetry, handleKeyDown])\n\n  const activate = React.useCallback(() => {\n    const container = containerRef.current\n    if (!container || trappedRef.current) return\n    trappedRef.current = true\n    // Record where we came from before moving focus.\n    previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null\n    claimTrapSlot(container)\n    // Capture phase: handlers inside the container routinely call stopPropagation\n    // on Escape (standard practice for overlay components), and a bubble-phase\n    // listener would miss those keys entirely, leaving the trap useless.\n    document.addEventListener(\"keydown\", handleKeyDown, true)\n    scheduleEnter()\n  }, [handleKeyDown, scheduleEnter])\n\n  const reconcile = React.useCallback(() => {\n    if (activeRef.current && containerRef.current) activate()\n    else deactivate()\n  }, [activate, deactivate])\n\n  const ref = React.useCallback(\n    (node: HTMLElement | null) => {\n      const previous = containerRef.current\n      if (previous === node) return\n      if (previous) {\n        deactivate()\n        if (ownsTabIndexRef.current) {\n          previous.removeAttribute(\"tabindex\")\n          ownsTabIndexRef.current = false\n        }\n      }\n      containerRef.current = node\n      if (node && !node.hasAttribute(\"tabindex\")) {\n        // The container has to be able to take focus itself: with no focusable\n        // element in the trap (a text-only confirm, content still loading) focus\n        // has nowhere else to go, and focus() on a non-focusable element fails\n        // silently — focus stays on the page behind and the trap does nothing.\n        node.setAttribute(\"tabindex\", \"-1\")\n        ownsTabIndexRef.current = true\n      }\n      reconcile()\n    },\n    [deactivate, reconcile],\n  )\n\n  React.useEffect(() => {\n    activeRef.current = active\n    reconcile()\n    // Unmount / active flipping false: drop the listeners, stop the retries, give\n    // focus back.\n    return () => {\n      activeRef.current = false\n      deactivate()\n    }\n  }, [active, deactivate, reconcile])\n\n  return React.useMemo(() => ({ ref }), [ref])\n}\n\nexport default useFocusTrap\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}