{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listbox",
  "type": "registry:ui",
  "title": "Listbox",
  "description": "An inline list where the choice slides between rows and picks merge into blocks.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/listbox.tsx",
      "content": "\"use client\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Via = \"pointer\" | \"keyboard\";\n\ntype Ctx = {\n  selected: string[];\n  active: string | null;\n  multiple: boolean;\n  via: Via;\n  reduce: boolean;\n  focused: string | null;\n  pick: (value: string, how: { via: Via; shift?: boolean }) => void;\n  setActive: (value: string) => void;\n};\nconst ListboxContext = createContext<Ctx | null>(null);\nconst useListbox = () => {\n  const ctx = use(ListboxContext);\n  if (!ctx) throw new Error(\"ListboxItem must be inside a Listbox\");\n  return ctx;\n};\n\ntype Common = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Shown in place of the rows when there are none. */\n  empty?: React.ReactNode;\n};\ntype SingleProps = { multiple?: false; value?: string | null; defaultValue?: string | null; onValueChange?: (value: string | null) => void };\ntype MultipleProps = { multiple: true; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void };\nexport type ListboxProps = Common & (SingleProps | MultipleProps);\n\nconst fold = (s: string) => s.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase().trim();\nconst toList = (v: string | string[] | null | undefined) => (v == null ? [] : Array.isArray(v) ? v : [v]);\n\n/**\n * An always-visible list you pick from: single (selection follows focus) or multiple\n * (click toggles, Shift extends). One tab stop; arrows, Home, End and typing move inside it.\n */\nexport function Listbox(props: ListboxProps) {\n  const { multiple = false, value, defaultValue, onValueChange, empty, className, children, onKeyDown, onFocus, ...rest } = props;\n  const reduce = !!useReducedMotion();\n  const root = useRef<HTMLDivElement>(null);\n  const [inner, setInner] = useState<string[]>(() => toList(defaultValue));\n  const controlled = value !== undefined;\n  const selected = controlled ? toList(value) : inner;\n  const [activeState, setActive] = useState<string | null>(null);\n  const [via, setVia] = useState<Via>(\"pointer\");\n  const anchor = useRef<string | null>(null);\n  const typed = useRef({ text: \"\", at: 0 });\n  const layoutId = useId();\n\n  const active = activeState ?? selected[0] ?? null;\n\n  // Open on the choice: a chosen row below the fold is scrolled into view once, inside the list only.\n  useEffect(() => {\n    const list = root.current;\n    const row = list?.querySelector<HTMLElement>('[role=\"option\"][aria-selected=\"true\"]');\n    if (!list || !row) return;\n    const top = row.offsetTop - list.clientHeight / 2 + row.offsetHeight / 2;\n    if (row.offsetTop + row.offsetHeight > list.clientHeight) list.scrollTop = Math.max(0, top);\n  }, []);\n\n  const emit = (next: string[]) => {\n    if (!controlled) setInner(next);\n    if (multiple) (onValueChange as MultipleProps[\"onValueChange\"])?.(next);\n    else (onValueChange as SingleProps[\"onValueChange\"])?.(next[0] ?? null);\n  };\n\n  // The rows in the order they're on screen, read from the DOM so groups and conditional rows just work.\n  const options = () => [...(root.current?.querySelectorAll<HTMLElement>('[role=\"option\"]') ?? [])];\n  const enabled = () => options().filter((o) => o.getAttribute(\"aria-disabled\") !== \"true\");\n  const valueOf = (el: HTMLElement) => el.dataset.value ?? \"\";\n\n  const range = (from: string, to: string) => {\n    const vals = enabled().map(valueOf);\n    const [a, b] = [vals.indexOf(from), vals.indexOf(to)].sort((x, y) => x - y);\n    return a < 0 ? [to] : vals.slice(a, b + 1);\n  };\n\n  const pick = (v: string, { via: how, shift }: { via: Via; shift?: boolean }) => {\n    setVia(how);\n    setActive(v);\n    if (!multiple) {\n      if (selected[0] !== v) emit([v]);\n      return;\n    }\n    if (shift && anchor.current) {\n      const add = range(anchor.current, v);\n      emit([...selected, ...add.filter((x) => !selected.includes(x))]);\n      return;\n    }\n    anchor.current = v;\n    emit(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);\n  };\n\n  const move = (el: HTMLElement | undefined, opts: { select?: boolean; extend?: boolean } = {}) => {\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.scrollIntoView({ block: \"nearest\" });\n    const v = valueOf(el);\n    setVia(\"keyboard\");\n    setActive(v);\n    if (opts.select && selected[0] !== v) emit([v]);\n    if (opts.extend && !selected.includes(v)) emit([...selected, v]);\n  };\n\n  const handleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (e.defaultPrevented) return;\n    const list = enabled();\n    if (!list.length) return;\n    const current = list.findIndex((o) => valueOf(o) === active);\n    const follow = !multiple; // single: selection follows focus\n    const go = (i: number) => {\n      e.preventDefault();\n      move(list[Math.max(0, Math.min(list.length - 1, i))], { select: follow, extend: multiple && e.shiftKey });\n    };\n    const mod = e.metaKey || e.ctrlKey;\n    if (e.key === \"ArrowDown\") return go(current < 0 ? 0 : current + 1);\n    if (e.key === \"ArrowUp\") return go(current < 0 ? 0 : current - 1);\n    if (e.key === \"Home\") return go(0);\n    if (e.key === \"End\") return go(list.length - 1);\n    if ((e.key === \" \" || e.key === \"Enter\") && active) {\n      e.preventDefault();\n      return pick(active, { via: \"keyboard\", shift: e.shiftKey });\n    }\n    if (multiple && mod && e.key.toLowerCase() === \"a\") {\n      e.preventDefault();\n      const all = list.map(valueOf);\n      setVia(\"keyboard\");\n      return emit(all.every((v) => selected.includes(v)) ? [] : all);\n    }\n    // Typeahead: letters build a prefix for half a second; the same letter again cycles its matches.\n    if (e.key.length === 1 && !mod && !e.altKey && e.key !== \" \") {\n      const now = e.timeStamp;\n      const t = typed.current;\n      t.text = now - t.at > 500 ? e.key : t.text + e.key;\n      t.at = now;\n      const q = fold(t.text);\n      const cycling = q.length > 1 && [...q].every((c) => c === q[0]);\n      const needle = cycling ? q[0] : q;\n      const label = (o: HTMLElement) => fold(o.dataset.label ?? o.textContent ?? \"\");\n      const start = cycling || q.length === 1 ? current + 1 : Math.max(current, 0);\n      const ordered = [...list.slice(start), ...list.slice(0, start)];\n      const hit = ordered.find((o) => label(o).startsWith(needle));\n      if (hit) {\n        e.preventDefault();\n        move(hit, { select: follow });\n      }\n    }\n  };\n\n  const ctx: Ctx = { selected, active, focused: activeState, multiple, via, reduce, pick, setActive };\n\n  const hasOptions = !!children && (!Array.isArray(children) || children.length > 0);\n\n  return (\n    <ListboxContext value={ctx}>\n      <LayoutGroup id={layoutId}>\n        <div\n          ref={root}\n          role=\"listbox\"\n          aria-multiselectable={multiple || undefined}\n          aria-orientation=\"vertical\"\n          // Until a row has been focused, the list itself is the tab stop and hands focus to the\n          // first chosen row (or the first row). After that the last focused row keeps it.\n          tabIndex={activeState ? -1 : 0}\n          onKeyDown={handleKey}\n          onFocus={(e) => {\n            onFocus?.(e);\n            if (e.target !== root.current) return;\n            const rows = enabled();\n            move(rows.find((o) => selected.includes(valueOf(o))) ?? rows[0]);\n          }}\n          data-multiple={multiple || undefined}\n          className={cn(\n            \"relative flex flex-col overflow-y-auto overscroll-contain rounded-xl border border-line bg-raised p-1 text-[13px] text-fg shadow-[var(--shadow)] outline-none\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            className,\n          )}\n          {...rest}\n        >\n          {hasOptions ? children : <div className=\"px-3 py-6 text-center text-[12.5px] text-fg-3\">{empty ?? \"Nothing here yet\"}</div>}\n        </div>\n      </LayoutGroup>\n    </ListboxContext>\n  );\n}\n\nexport type ListboxGroupProps = React.ComponentProps<\"div\"> & { label: React.ReactNode };\n\n/** A labeled section. Its heading sticks to the top while its rows scroll under it. */\nexport function ListboxGroup({ label, className, children, ...rest }: ListboxGroupProps) {\n  const id = useId();\n  return (\n    <div role=\"group\" aria-labelledby={id} className={cn(\"flex flex-col not-first:mt-1\", className)} {...rest}>\n      <div\n        id={id}\n        role=\"presentation\"\n        className=\"sticky -top-1 z-[2] -mx-1 bg-raised px-3 pt-2 pb-1 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase select-none\"\n      >\n        {label}\n      </div>\n      {children}\n    </div>\n  );\n}\n\nexport type ListboxItemProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  value: string;\n  children: React.ReactNode;\n  /** Used for typeahead when the children aren't plain text. */\n  label?: string;\n  description?: React.ReactNode;\n  /** Right-aligned mono meta: an offset, a count, a shortcut. */\n  hint?: React.ReactNode;\n  icon?: React.ReactNode;\n  disabled?: boolean;\n};\n\nexport function ListboxItem({ value, label, description, hint, icon, disabled, className, children, onClick, onFocus, ...rest }: ListboxItemProps) {\n  const ctx = useListbox();\n  const isSelected = ctx.selected.includes(value);\n  const isActive = ctx.focused === value;\n  const instant = ctx.reduce || ctx.via === \"keyboard\";\n\n  return (\n    <div\n      role=\"option\"\n      aria-selected={isSelected}\n      aria-disabled={disabled || undefined}\n      data-value={value}\n      data-label={label ?? (typeof children === \"string\" ? children : undefined)}\n      data-selected={isSelected || undefined}\n      data-disabled={disabled || undefined}\n      tabIndex={isActive && !disabled ? 0 : -1}\n      onClick={(e) => {\n        onClick?.(e);\n        if (!disabled && !e.defaultPrevented) ctx.pick(value, { via: \"pointer\", shift: e.shiftKey });\n      }}\n      onFocus={(e) => {\n        onFocus?.(e);\n        if (!disabled) ctx.setActive(value);\n      }}\n      onMouseDown={(e) => e.shiftKey && e.preventDefault() /* no text selection on shift-click */}\n      className={cn(\n        \"group/opt relative isolate flex shrink-0 cursor-default scroll-mt-8 scroll-mb-1 items-center gap-2.5 rounded-lg px-2 outline-none select-none\",\n        description ? \"min-h-11 py-1.5\" : \"h-8 pointer-coarse:h-10\",\n        \"hover:bg-hover data-disabled:text-fg-4 data-disabled:hover:bg-transparent\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        // Multiple: neighboring picks merge into one block, corners only at the ends.\n        ctx.multiple && \"transition-[border-radius] duration-150 ease-out data-selected:bg-line data-selected:hover:bg-line [[data-selected]+&]:data-selected:rounded-t-none [&:has(+[data-selected])]:data-selected:rounded-b-none\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* Single: one pill that slides to whichever row is chosen. Arrow keys move it instantly. */}\n      {!ctx.multiple && isSelected && (\n        <motion.span\n          layoutId=\"selected\"\n          aria-hidden\n          transition={instant ? { duration: 0 } : spring.snappy}\n          className=\"absolute inset-0 -z-10 rounded-lg bg-line\"\n        />\n      )}\n      {ctx.multiple && <Box on={isSelected} reduce={ctx.reduce} />}\n      {icon && <span className=\"relative flex shrink-0 text-fg-3 [&_svg]:size-4\">{icon}</span>}\n      <span className=\"relative flex min-w-0 flex-1 flex-col\">\n        <span className={cn(\"truncate\", !ctx.multiple && isSelected && \"font-medium\")}>{children}</span>\n        {description && <span className=\"truncate text-[12px] leading-4 text-fg-3\">{description}</span>}\n      </span>\n      {hint && <span className=\"relative shrink-0 font-mono text-2xs text-fg-3 tabular\">{hint}</span>}\n      {!ctx.multiple && (\n        <span className=\"relative grid size-4 shrink-0 place-items-center\">\n          {isSelected && <Tick reduce={ctx.reduce || instant} />}\n        </span>\n      )}\n    </div>\n  );\n}\n\nconst noop = () => () => {};\n\n// The tick draws when a row becomes the choice, but not for the choice already there on page load.\nfunction Tick({ reduce }: { reduce: boolean }) {\n  const hydrated = useSyncExternalStore(noop, () => true, () => false);\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={hydrated && !reduce ? { pathLength: 0 } : false} animate={{ pathLength: 1 }} transition={{ duration: 0.22, ease: ease.out, delay: 0.04 }} />\n    </svg>\n  );\n}\n\nfunction Box({ on, reduce }: { on: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative grid size-3.5 shrink-0 place-items-center rounded-[4px] border transition-[background-color,border-color,scale] duration-150 ease-out group-active/opt:scale-[0.88]\",\n        on ? \"border-fg bg-fg text-frame\" : \"border-line-2 bg-frame group-hover/opt:border-fg-4\",\n      )}\n    >\n      <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <motion.path\n          d=\"M2 5.2 4.1 7.2 8 2.8\"\n          initial={false}\n          animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.2, ease: ease.out }, opacity: { duration: 0.05 } }}\n        />\n      </svg>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/listbox"
}
