{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"select","type":"registry:component","title":"Select Select","description":"Composable primitives (Select, SelectTrigger, SelectValue, SelectContent, SelectItem); the panel pinches off the trigger and separates, with a short, shared row reveal. Position-aware (opens upward when needed).","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/select.tsx","type":"registry:component","target":"@components/motion/select.tsx","content":"\"use client\";\n// beui.dev/components/motion/select\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { type ListboxRow, useListbox } from \"@/lib/hooks/use-listbox\";\nimport { cn } from \"@/lib/utils\";\n\n// One progress value drives the surface, gap, corners and chevron. This short\n// spring suits the full reveal distance; each reader clamps its small internal\n// overshoot, so all visible endpoints settle together. Rows share a short\n// reveal so long lists have no late entries.\nconst UNFOLD_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.2,\n  bounce: 0.12,\n} as const;\n// Exits are faster than entrances: the panel folds back on a short ease.\nconst UNFOLD_EXIT = { duration: 0.2, ease: EASE_OUT } as const;\nconst RADIUS = 12;\n// How far the open panel sits from the trigger once it has pulled away.\nconst GAP = 8;\n// The surface starts as a thin lip flush against the trigger's edge.\nconst LIP = 8;\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst ITEM_VARIANTS: Variants = {\n  // A shared short reveal keeps late rows in long, scrolled lists ready with\n  // the surface. Per-index staggering can delay visible rows by over a second.\n  hidden: { opacity: 0, y: -4, transition: { duration: 0.08, ease: EASE_OUT } },\n  show: { opacity: 1, y: 0, transition: { duration: 0.12, ease: EASE_OUT } },\n};\n\ntype Placement = \"bottom\" | \"top\";\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string, disabled: boolean) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  rows: ListboxRow[];\n  /** Closes the panel; focus inside it returns to the trigger. */\n  close: () => void;\n  reduce: boolean;\n  progress: MotionValue<number>;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n  placement: Placement;\n  setPlacement: (p: Placement) => void;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n// The option keyboard focus is on, resolved during render by the content.\nconst ActiveOptionContext = createContext<string | undefined>(undefined);\n\nfunction useSelectContext(component: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${component} must be used within <Select>`);\n  return ctx;\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Controlled open state of the panel. A layout that stacks selects can hold\n   * this to keep exactly one panel open — the panel is absolutely positioned\n   * inside its field, so two open at once paint over each other's options.\n   */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /**\n   * Fires whenever the panel opens or closes. The panel is absolutely\n   * positioned inside the field, so a layout that stacks selects has to know\n   * which one is open to paint it above its neighbours.\n   */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internal, setInternal] = useState(defaultValue);\n  const [labels, setLabels] = useState<\n    Map<string, { label: string; disabled: boolean }>\n  >(new Map());\n  const [placement, setPlacement] = useState<Placement>(\"bottom\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const openControlled = openProp !== undefined;\n  const open = openControlled ? openProp : internalOpen;\n  const progress = useMotionValue(open ? 1 : 0);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  // The panel turns inert as it closes, which would drop focus held inside it\n  // on the page body. Hand it back to the trigger before that happens.\n  const close = useCallback(() => {\n    const trigger = document.getElementById(`${baseId}-trigger`);\n    if (\n      rootRef.current?.contains(document.activeElement) &&\n      document.activeElement !== trigger\n    )\n      trigger?.focus();\n    setOpen(false);\n  }, [baseId, setOpen]);\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      close();\n    },\n    [controlled, onValueChange, close],\n  );\n\n  const register = useCallback((v: string, label: string, disabled: boolean) => {\n    setLabels((m) => {\n      const entry = m.get(v);\n      if (entry?.label === label && entry.disabled === disabled) return m;\n      return new Map(m).set(v, { label, disabled });\n    });\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      if (!m.has(v)) return m;\n      const next = new Map(m);\n      next.delete(v);\n      return next;\n    });\n  }, []);\n\n  // Reduced motion has no travel, so the progress only carries a short fade.\n  useEffect(() => {\n    const animation = animate(\n      progress,\n      open ? 1 : 0,\n      reduce ? { duration: 0.12 } : open ? UNFOLD_SPRING : UNFOLD_EXIT,\n    );\n    return () => animation.stop();\n  }, [open, progress, reduce]);\n\n  // close on outside pointer / escape\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen, close]);\n\n  const rows = useMemo(\n    () =>\n      Array.from(labels, ([id, entry]) => ({\n        id,\n        label: entry.label,\n        disabled: entry.disabled,\n      })),\n    [labels],\n  );\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)?.label),\n      rows,\n      close,\n      reduce,\n      progress,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n      placement,\n      setPlacement,\n    }),\n    [\n      current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labels,\n      rows,\n      close,\n      reduce,\n      progress,\n      baseId,\n      disabled,\n      placement,\n    ],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport interface SelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectTrigger({ className, children }: SelectTriggerProps) {\n  const ctx = useSelectContext(\"SelectTrigger\");\n  const isTop = ctx.placement === \"top\";\n  // The edge facing the panel flattens while the panel is attached to it and\n  // rounds back as the panel pulls away — the two pinch apart — on the same\n  // curve the panel moves on. The far edge stays rounded.\n  const nearRadius = useTransform(\n    ctx.progress,\n    [0, 0.2, 0.4, 1],\n    [RADIUS, 0, 0, RADIUS],\n  );\n  const rotate = useTransform(ctx.progress, (p) =>\n    ctx.reduce ? (ctx.open ? 180 : 0) : Math.min(1, Math.max(0, p)) * 180,\n  );\n  return (\n    <motion.button\n      type=\"button\"\n      id={ctx.triggerId}\n      disabled={ctx.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={ctx.open}\n      aria-controls={ctx.listId}\n      onClick={() => ctx.setOpen(!ctx.open)}\n      onKeyDown={(event) => {\n        if (\n          !ctx.open &&\n          (event.key === \"ArrowDown\" || event.key === \"ArrowUp\")\n        ) {\n          event.preventDefault();\n          ctx.setOpen(true);\n        }\n      }}\n      style={{\n        borderTopLeftRadius: ctx.reduce || !isTop ? RADIUS : nearRadius,\n        borderTopRightRadius: ctx.reduce || !isTop ? RADIUS : nearRadius,\n        borderBottomLeftRadius: ctx.reduce || isTop ? RADIUS : nearRadius,\n        borderBottomRightRadius: ctx.reduce || isTop ? RADIUS : nearRadius,\n      }}\n      className={cn(\n        \"relative z-10 flex w-full items-center justify-between gap-2 border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors\",\n        \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        style={{ rotate }}\n        className=\"text-muted-foreground\"\n      >\n        <ChevronDown className=\"h-4 w-4\" />\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport interface SelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function SelectValue({ placeholder, className }: SelectValueProps) {\n  const ctx = useSelectContext(\"SelectValue\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface SelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectContent({ className, children }: SelectContentProps) {\n  const ctx = useSelectContext(\"SelectContent\");\n  const panelRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const { open, progress, reduce, setPlacement } = ctx;\n  const { activeId, onKeyDown } = useListbox({\n    open,\n    rows: ctx.rows,\n    selected: ctx.value,\n    list: panelRef,\n  });\n\n  // Keep descendants laid out while hidden: display:none resets nested\n  // scrollers in Chromium. Collapse only the hidden outer footprint after\n  // exit so it cannot extend page scrolling. Opening restores the full box\n  // before measurement or focus; neither layout change is animated.\n  const [exiting, setExiting] = useState(false);\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (!open) setExiting(true);\n  }\n  const present = open || exiting;\n  useMotionValueEvent(progress, \"animationComplete\", () => {\n    if (progress.get() === 0) setExiting(false);\n  });\n\n  useLayoutEffect(() => {\n    const node = panelRef.current;\n    if (!node || !present) return;\n    const measure = () => setHeight(node.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [present]);\n\n  // On open, flip upward when there isn't room below and there's more above.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = document.getElementById(ctx.triggerId);\n    const node = panelRef.current;\n    if (!trigger || !node) return;\n    const rect = trigger.getBoundingClientRect();\n    const h = node.offsetHeight;\n    const below = window.innerHeight - rect.bottom;\n    const above = rect.top;\n    setPlacement(below < h + 16 && above > below ? \"top\" : \"bottom\");\n  }, [open, ctx.triggerId, setPlacement]);\n\n  const isTop = ctx.placement === \"top\";\n\n  // Reveal a full-size panel from its near edge. No ancestor scales the list,\n  // so rows, borders and shadow keep their natural thickness. The clip and\n  // corner writes can paint; only the gap uses a transform. All read one\n  // progress value, with no animated layout properties.\n  const ready = height > 0 && !reduce;\n  const fromY = ready ? (isTop ? GAP : -GAP) : 0;\n  const detach = useTransform(progress, [0.4, 1], [0, 1]);\n  const y = useTransform(detach, (d) => lerp(fromY, 0, d));\n  const nearRadius = useTransform(detach, (d) => (ready ? RADIUS * d : RADIUS));\n  const borderRadius = useTransform(nearRadius, (near) =>\n    isTop\n      ? `${RADIUS}px ${RADIUS}px ${near}px ${near}px`\n      : `${near}px ${near}px ${RADIUS}px ${RADIUS}px`,\n  );\n  const clipPath = useTransform([progress, nearRadius], ([p, near]: number[]) => {\n    if (!ready) return \"none\";\n    // Clamp overshoot so the clip never extends into the trigger or exposes\n    // overflowing children. At rest remove it so the native shadow is whole.\n    const reveal = Math.min(1, Math.max(0, p));\n    if (reveal === 1) return \"none\";\n    const hidden = Math.max(0, (height - LIP) * (1 - reveal));\n    const inset = isTop ? `${hidden}px 0 0 0` : `0 0 ${hidden}px 0`;\n    const corners = isTop\n      ? `${RADIUS}px ${RADIUS}px ${near}px ${near}px`\n      : `${near}px ${near}px ${RADIUS}px ${RADIUS}px`;\n    return `inset(${inset} round ${corners})`;\n  });\n  // The surface materialises over its first frames rather than popping in as\n  // a lip; with reduced motion the fade is the whole entrance.\n  const opacity = useTransform(progress, reduce ? [0, 1] : [0, 0.12], [0, 1]);\n\n  return (\n    <motion.div\n      ref={panelRef}\n      id={ctx.listId}\n      role=\"listbox\"\n      aria-labelledby={ctx.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      onKeyDown={(event) => {\n        // Tab leaves the list from the trigger, so focus moves on to the\n        // control after the select rather than into the closing panel.\n        if (event.key === \"Tab\") ctx.close();\n        else onKeyDown(event);\n      }}\n      style={{\n        visibility: present ? undefined : \"hidden\",\n        maxHeight: present ? undefined : 0,\n        borderWidth: present ? undefined : 0,\n        y,\n        clipPath,\n        borderRadius,\n        opacity,\n        pointerEvents: open ? \"auto\" : \"none\",\n        marginTop: isTop ? 0 : GAP,\n        marginBottom: isTop ? GAP : 0,\n      }}\n      // flush against the trigger, then separates into its own rounded pill;\n      // sits above or below depending on available space\n      className={cn(\n        \"absolute left-0 right-0 z-20 overflow-hidden border border-border bg-background shadow-lg\",\n        isTop ? \"bottom-full\" : \"top-full\",\n        className,\n      )}\n    >\n      <ActiveOptionContext.Provider value={activeId}>\n        <motion.div\n          initial={false}\n          animate={open ? \"show\" : \"hidden\"}\n          className=\"p-1\"\n        >\n          {children}\n        </motion.div>\n      </ActiveOptionContext.Provider>\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: SelectItemProps) {\n  const ctx = useSelectContext(\"SelectItem\");\n  const activeId = useContext(ActiveOptionContext);\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label, disabled);\n  }, [ctx.register, value, label, disabled]);\n  useLayoutEffect(() => () => ctx.unregister(value), [ctx.unregister, value]);\n\n  return (\n    <motion.div variants={ctx.reduce ? undefined : ITEM_VARIANTS}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        // One tab stop: the active option, which focus follows while open.\n        tabIndex={activeId === value ? 0 : -1}\n        data-listbox-id={value}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-listbox.ts","type":"registry:hook","target":"@lib/hooks/use-listbox.ts","content":"\"use client\";\n\nimport {\n  type KeyboardEvent,\n  type RefObject,\n  useEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\n\nexport interface ListboxRow {\n  id: string;\n  label: string;\n  disabled?: boolean;\n}\n\n// How long typed characters keep building one typeahead query.\nconst TYPEAHEAD_MS = 500;\n\n/**\n * Keyboard model for a popup listbox whose options are focusable buttons.\n *\n * The active option is resolved during render through `useRowCursor`, so the\n * roving `tabIndex` and the next key press always agree with what is on\n * screen. Opening starts on the selected option, or the first enabled one.\n * Arrow keys, Home and End move between enabled options, and printable\n * characters jump to the next option whose label starts with what was typed.\n * DOM focus follows the active option while the list is open; each option\n * carries its id in `data-listbox-id` so the list can find it.\n */\nexport function useListbox({\n  open,\n  rows,\n  selected,\n  list,\n}: {\n  open: boolean;\n  rows: readonly ListboxRow[];\n  selected: string | undefined;\n  list: RefObject<HTMLElement | null>;\n}) {\n  const enabled = useMemo(() => rows.filter((row) => !row.disabled), [rows]);\n  const { activeIndex, moveTo, moveActive } = useRowCursor(enabled, \"\");\n  useOnOpen(open, () =>\n    moveTo(enabled.some((row) => row.id === selected) ? (selected ?? null) : null),\n  );\n  const activeId = enabled[activeIndex]?.id;\n  const typed = useRef({ text: \"\", at: 0 });\n  // A list rendered open does not take focus from the page on mount; focus\n  // follows only once it has been opened, or once focus is already inside.\n  const openedOnMount = useRef(open);\n  useEffect(() => {\n    if (!open) openedOnMount.current = false;\n  }, [open]);\n\n  useEffect(() => {\n    if (!open || activeId === undefined) return;\n    if (\n      openedOnMount.current &&\n      !list.current?.contains(document.activeElement)\n    )\n      return;\n    const option = Array.from(\n      list.current?.querySelectorAll<HTMLElement>(\"[data-listbox-id]\") ?? [],\n    ).find((element) => element.dataset.listboxId === activeId);\n    option?.focus({ preventScroll: false });\n  }, [open, activeId, list]);\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    const { key } = event;\n    if (key === \"ArrowDown\" || key === \"ArrowUp\") {\n      event.preventDefault();\n      moveActive(key === \"ArrowDown\" ? 1 : -1);\n    } else if (key === \"Home\" || key === \"End\") {\n      event.preventDefault();\n      const row = key === \"Home\" ? enabled[0] : enabled[enabled.length - 1];\n      if (row) moveTo(row.id);\n    } else if (\n      key.length === 1 &&\n      key !== \" \" &&\n      !event.ctrlKey &&\n      !event.metaKey &&\n      !event.altKey\n    ) {\n      const now = Date.now();\n      const char = key.toLowerCase();\n      const text =\n        now - typed.current.at < TYPEAHEAD_MS ? typed.current.text + char : char;\n      typed.current = { text, at: now };\n      // A repeated single letter cycles through the options that start with it.\n      const from = text.length === 1 ? activeIndex + 1 : activeIndex;\n      for (let step = 0; step < enabled.length; step++) {\n        const row = enabled[(from + step) % enabled.length];\n        if (row.label.toLowerCase().startsWith(text)) {\n          moveTo(row.id);\n          break;\n        }\n      }\n    }\n  };\n\n  return { activeId, onKeyDown };\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/hooks/use-on-open.ts","type":"registry:hook","target":"@lib/hooks/use-on-open.ts","content":"\"use client\";\n\nimport { useState } from \"react\";\n\n/**\n * Runs `start` during the render in which `open` becomes true.\n *\n * Opening a list starts a fresh session — an empty query, the highlight back at\n * the top — and that is a resolution, not a side effect: a passive effect runs\n * after the commit, so the new session would carry the last one's state for a\n * window in which a key press can land.\n *\n * `start` may only set state belonging to the calling component. Anything that\n * reaches outside it — a consumer's callback, a DOM write, moving focus — is a\n * side effect, and React rejects a callback that sets another component's state\n * during this one's render. Put those in an effect keyed to `open`.\n */\nexport function useOnOpen(open: boolean, start: () => void) {\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) start();\n  }\n}\n"},{"path":"lib/hooks/use-row-cursor.ts","type":"registry:hook","target":"@lib/hooks/use-row-cursor.ts","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to: the row's id, stamped with\n * the query it was placed under.\n */\ntype RowCursor = { id: string; query: string };\n\n/** The cursor's row, or -1 once the query has moved on or the row has left. */\nfunction indexOfCursor(\n  rows: readonly { id: string }[],\n  query: string,\n  cursor: RowCursor | null,\n) {\n  if (cursor === null || cursor.query !== query) return -1;\n  return rows.findIndex((row) => row.id === cursor.id);\n}\n\n/**\n * The highlighted row of a list whose rows can change under it.\n *\n * Which row is highlighted is resolved during render, never in a passive\n * effect: a passive effect runs after the commit, so a list that had just\n * changed would carry an `aria-activedescendant` naming a row that has left it,\n * and a key pressed in that window would commit the wrong row or nothing at\n * all.\n *\n * The cursor holds the row's id, not its position. A position alone cannot tell\n * a list that shrank from one that swapped its rows for a different set of the\n * same length, and the second case is the one that silently hands Enter to a\n * row the user never chose. A cursor whose row has left the list returns the\n * highlight to the first row rather than to the nearest surviving one: the row\n * the user aimed at is gone, and the first row is where a new query already\n * puts the highlight.\n *\n * Pass the query the list is filtered by. The cursor is stamped with it and\n * dropped when it changes, so a caller cannot forget to clear it — including a\n * caller whose query arrives as a prop and so never runs its own handler. Rows\n * that come back under a query that has moved on cannot revive it either.\n * `moveTo(null)` is for deliberate resets, such as reopening the list.\n *\n * Both callbacks keep one identity for the life of the component, and read the\n * rows and the query through a ref to do it. A caller will put them in an\n * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`\n * rebuilt on every keystroke would re-run that effect on every keystroke.\n */\nexport function useRowCursor(rows: readonly { id: string }[], query: string) {\n  const [cursor, setCursor] = useState<RowCursor | null>(null);\n  // Written after commit, not during render: a render React discards or has not\n  // finished still runs the component body, and an event handler that read this\n  // in that window would stamp the cursor with a query the committed tree does\n  // not have.\n  const latest = useRef({ rows, query });\n  useLayoutEffect(() => {\n    latest.current = { rows, query };\n  });\n\n  const cursorRow = indexOfCursor(rows, query, cursor);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // already gone, so rows that come back cannot revive a highlight the user has\n  // stopped aiming at.\n  if (cursor !== null && cursorRow < 0) setCursor(null);\n\n  const moveTo = useCallback(\n    (id: string | null) =>\n      setCursor(id === null ? null : { id, query: latest.current.query }),\n    [],\n  );\n\n  const moveActive = useCallback((direction: 1 | -1) => {\n    const { rows: live, query: liveQuery } = latest.current;\n    const last = live.length - 1;\n    if (last < 0) return;\n    // Steps from the row the cursor is really on, inside the update, so that\n    // two keys landing in one batch move two rows rather than one.\n    setCursor((current) => {\n      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);\n      const next = Math.min(Math.max(at + direction, 0), last);\n      return { id: live[next].id, query: liveQuery };\n    });\n  }, []);\n\n  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };\n}\n"}]}