{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"select","type":"registry:ui","title":"Select","description":"Custom listbox select in the native top layer: grouped options with icons, a gliding highlight, drawn check marks, typeahead, full keyboard support and a real form value.","dependencies":["motion","lucide-react"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/select.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { MotionConfig, motion } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n// The listbox is a native `popover=\"manual\"` element: top layer, never clipped by\n// overflow:hidden parents. Enter/exit via :popover-open + @starting-style; options\n// cascade in with a short stagger and the check mark draws its stroke.\nconst css = `\n.ui-select-pop{opacity:0;scale:.96;translate:0 var(--ui-sel-dy,-6px);transition:opacity .12s ease-in,scale .12s ease-in,translate .12s ease-in,display .12s allow-discrete,overlay .12s allow-discrete}\n.ui-select-pop:popover-open{opacity:1;scale:1;translate:0 0;transition-duration:.22s;transition-timing-function:cubic-bezier(.2,.9,.3,1.1)}\n@starting-style{.ui-select-pop:popover-open{opacity:0;scale:.96;translate:0 var(--ui-sel-dy,-6px)}}\n@keyframes ui-select-opt{from{opacity:0;translate:0 -3px}}\n.ui-select-pop:popover-open [role=option]{animation:ui-select-opt .25s ease-out both;animation-delay:calc(min(var(--i),10)*18ms)}\n.ui-select-check path{stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset .25s cubic-bezier(.6,0,.4,1) .05s}\n[aria-selected=true] .ui-select-check path{stroke-dashoffset:0}\n@keyframes ui-select-val{from{opacity:0;translate:0 4px}}\n.ui-select-val{animation:ui-select-val .22s ease-out}\n@media (prefers-reduced-motion:reduce){.ui-select-pop{transition-property:opacity,display,overlay;scale:1;translate:0 0}.ui-select-pop [role=option],.ui-select-val{animation:none!important}.ui-select-check path{transition:none}}\n`;\n\nexport type SelectOption = { value: string; label: string; icon?: ReactNode; description?: string; disabled?: boolean };\nexport type SelectGroup = { label: string; options: SelectOption[] };\n\nexport interface SelectProps {\n  /** Options, optionally grouped under a heading. */\n  options: (SelectOption | SelectGroup)[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  placeholder?: string;\n  /** Form field name; the value is submitted like a native select. */\n  name?: string;\n  required?: boolean;\n  disabled?: boolean;\n  /** Open on first render (uncontrolled). */\n  defaultOpen?: boolean;\n  /** Id for the trigger, so a `<label htmlFor>` can name it. */\n  id?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n}\n\nconst isGroup = (o: SelectOption | SelectGroup): o is SelectGroup => \"options\" in o;\n\nexport function Select({\n  options,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  placeholder = \"Select an option\",\n  name,\n  required,\n  disabled,\n  defaultOpen = false,\n  id,\n  className,\n  ...aria\n}: SelectProps) {\n  const [inner, setInner] = useState(defaultValue);\n  const value = valueProp ?? inner;\n  const [open, setOpen] = useState(defaultOpen);\n  const flat = options.flatMap((o) => (isGroup(o) ? o.options : [o]));\n  const selectedIndex = flat.findIndex((o) => o.value === value);\n  const [active, setActive] = useState(selectedIndex);\n  const selected = flat[selectedIndex];\n\n  const uid = useId();\n  const triggerId = id ?? `${uid}-trigger`;\n  const listId = `${uid}-list`;\n  const optId = (i: number) => `${uid}-opt-${i}`;\n  const trigger = useRef<HTMLButtonElement>(null);\n  const list = useRef<HTMLDivElement>(null);\n  const input = useRef<HTMLInputElement>(null);\n  const typed = useRef({ text: \"\", timer: undefined as ReturnType<typeof setTimeout> | undefined });\n\n  const commit = (i: number) => {\n    const o = flat[i];\n    if (!o || o.disabled) return;\n    if (valueProp === undefined) setInner(o.value);\n    if (o.value !== value) onValueChange?.(o.value);\n  };\n\n  const show = (at: number) => {\n    setActive(at);\n    setOpen(true);\n  };\n\n  const enabled = (i: number) => flat[i] && !flat[i].disabled;\n  // Next enabled index from `from` in direction `dir`, staying put at the ends.\n  const step = (from: number, dir: 1 | -1) => {\n    for (let i = from + dir; i >= 0 && i < flat.length; i += dir) if (enabled(i)) return i;\n    return from;\n  };\n  const first = () => step(-1, 1);\n  const last = () => step(flat.length, -1);\n\n  // Typeahead: accumulate keys for 500ms; repeating one letter cycles through matches.\n  const typeahead = (key: string) => {\n    const t = typed.current;\n    clearTimeout(t.timer);\n    t.text = t.text === key ? key : t.text + key;\n    t.timer = setTimeout(() => (t.text = \"\"), 500);\n    const q = t.text.toLowerCase();\n    const start = open ? active : selectedIndex;\n    const order = [...flat.keys()].map((k) => (start + 1 + k) % flat.length);\n    const hit = order.find((i) => enabled(i) && flat[i].label.toLowerCase().startsWith(q));\n    if (hit === undefined) return;\n    if (open) setActive(hit);\n    else commit(hit);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {\n    const k = e.key;\n    if (k.length === 1 && k !== \" \" && !e.ctrlKey && !e.metaKey && !e.altKey) {\n      typeahead(k);\n      return;\n    }\n    if (k === \" \" && typed.current.text) {\n      e.preventDefault();\n      typeahead(k);\n      return;\n    }\n    if (!open) {\n      const at = selectedIndex >= 0 ? selectedIndex : first();\n      const map: Record<string, number> = { ArrowDown: at, ArrowUp: at, Enter: at, \" \": at, Home: first(), End: last() };\n      if (map[k] === undefined) return;\n      e.preventDefault();\n      show(map[k]);\n      return;\n    }\n    if (k === \"Escape\") {\n      e.preventDefault();\n      setOpen(false);\n    } else if (k === \"Enter\" || k === \" \" || (k === \"ArrowUp\" && e.altKey)) {\n      e.preventDefault();\n      commit(active);\n      setOpen(false);\n    } else if (k === \"Tab\") {\n      commit(active);\n      setOpen(false);\n    } else {\n      const move: Record<string, number> = {\n        ArrowDown: step(active, 1),\n        ArrowUp: step(active, -1),\n        Home: first(),\n        End: last(),\n        PageDown: Math.min(flat.length - 1, active + 10),\n        PageUp: Math.max(0, active - 10),\n      };\n      if (move[k] === undefined) return;\n      e.preventDefault();\n      setActive(enabled(move[k]) ? move[k] : step(move[k], k === \"PageUp\" ? 1 : -1));\n    }\n  };\n\n  // Place the popover under the trigger (or above when there is no room), matching its width.\n  const place = useCallback(() => {\n    const t = trigger.current?.getBoundingClientRect();\n    const p = list.current;\n    if (!t || !p) return;\n    const h = p.offsetHeight;\n    const below = window.innerHeight - t.bottom - 8;\n    const up = below < h && t.top > below;\n    p.style.minWidth = `${t.width}px`;\n    p.style.left = `${Math.min(t.left, window.innerWidth - p.offsetWidth - 8)}px`;\n    p.style.top = `${up ? t.top - 6 - h : t.bottom + 6}px`;\n    p.style.setProperty(\"--ui-sel-dy\", up ? \"6px\" : \"-6px\");\n    p.style.transformOrigin = up ? \"bottom\" : \"top\";\n  }, []);\n\n  useLayoutEffect(() => {\n    const p = list.current;\n    if (!p?.showPopover) return;\n    if (!open) {\n      if (p.matches(\":popover-open\")) p.hidePopover();\n      return;\n    }\n    if (!p.matches(\":popover-open\")) p.showPopover();\n    place();\n    const outside = (e: Event) => {\n      const n = e.target as Node;\n      if (!trigger.current?.contains(n) && !p.contains(n)) setOpen(false);\n    };\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    document.addEventListener(\"pointerdown\", outside);\n    return () => {\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n      document.removeEventListener(\"pointerdown\", outside);\n    };\n  }, [open, place]);\n\n  useEffect(() => {\n    if (open) list.current?.querySelector(`[id=\"${optId(active)}\"]`)?.scrollIntoView({ block: \"nearest\" });\n  });\n\n  // Form integration: reset restores the default; an invalid required field focuses the trigger.\n  useEffect(() => {\n    const form = input.current?.form;\n    if (!form || valueProp !== undefined) return;\n    const reset = () => setInner(defaultValue);\n    form.addEventListener(\"reset\", reset);\n    return () => form.removeEventListener(\"reset\", reset);\n  }, [defaultValue, valueProp]);\n  useEffect(() => () => clearTimeout(typed.current.timer), []);\n\n  let i = -1;\n  const renderOption = (o: SelectOption) => {\n    const idx = ++i;\n    const isActive = open && idx === active;\n    return (\n      <div\n        key={o.value}\n        id={optId(idx)}\n        role=\"option\"\n        aria-selected={o.value === value}\n        aria-disabled={o.disabled || undefined}\n        data-active={isActive || undefined}\n        style={{ \"--i\": idx } as CSSProperties}\n        onPointerMove={() => !o.disabled && idx !== active && setActive(idx)}\n        onClick={() => {\n          if (o.disabled) return;\n          commit(idx);\n          setOpen(false);\n        }}\n        className={cn(\n          \"relative flex cursor-default items-center gap-2.5 rounded-md py-2 pr-8 pl-2.5 text-sm text-popover-foreground outline-none select-none\",\n          o.disabled && \"opacity-45\",\n        )}\n      >\n        {isActive && (\n          <motion.span\n            layoutId={`${uid}-hl`}\n            transition={{ type: \"spring\", stiffness: 600, damping: 40 }}\n            className=\"absolute inset-0 rounded-md bg-muted\"\n          />\n        )}\n        {o.icon && <span className=\"relative grid shrink-0 place-items-center text-muted-foreground [&_svg]:size-4\">{o.icon}</span>}\n        <span className=\"relative grid min-w-0\">\n          <span className=\"truncate\">{o.label}</span>\n          {o.description && <span className=\"truncate text-xs text-muted-foreground\">{o.description}</span>}\n        </span>\n        <svg\n          aria-hidden\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={2.75}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className=\"ui-select-check absolute right-2.5 size-4 text-primary\"\n        >\n          <path pathLength={1} d=\"M5 12.5l4.5 4.5L19 7.5\" />\n        </svg>\n      </div>\n    );\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className={cn(\"relative w-full max-w-xs\", className)}>\n        <style href=\"ui-select\" precedence=\"default\">\n          {css}\n        </style>\n        <button\n          ref={trigger}\n          id={triggerId}\n          type=\"button\"\n          role=\"combobox\"\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={listId}\n          aria-activedescendant={open && active >= 0 ? optId(active) : undefined}\n          aria-required={required || undefined}\n          {...aria}\n          disabled={disabled}\n          onClick={() => (open ? setOpen(false) : show(selectedIndex >= 0 ? selectedIndex : first()))}\n          onKeyDown={onKeyDown}\n          onBlur={() => setOpen(false)}\n          className={cn(\n            \"flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 text-left text-sm shadow-xs outline-none\",\n            \"transition-[border-color,box-shadow] duration-200 hover:border-ring/50 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50\",\n            open && \"border-ring/60 ring-[3px] ring-ring/20\",\n          )}\n        >\n          <span key={value} className={cn(\"ui-select-val flex min-w-0 items-center gap-2\", !selected && \"text-muted-foreground\")}>\n            {selected?.icon && <span className=\"grid shrink-0 place-items-center text-muted-foreground [&_svg]:size-4\">{selected.icon}</span>}\n            <span className=\"truncate\">{selected?.label ?? placeholder}</span>\n          </span>\n          <ChevronDown\n            aria-hidden\n            className={cn(\n              \"size-4 shrink-0 text-muted-foreground transition-transform duration-300 ease-[cubic-bezier(.3,1.4,.5,1)] motion-reduce:transition-none\",\n              open && \"rotate-180\",\n            )}\n          />\n        </button>\n        <div\n          ref={list}\n          id={listId}\n          role=\"listbox\"\n          popover=\"manual\"\n          aria-labelledby={aria[\"aria-labelledby\"] ?? triggerId}\n          tabIndex={-1}\n          // Keep focus on the combobox while clicking options.\n          onMouseDown={(e) => e.preventDefault()}\n          className=\"ui-select-pop fixed inset-auto m-0 max-h-72 overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl shadow-black/20\"\n        >\n          {options.map((o, g) =>\n            isGroup(o) ? (\n              <div key={`g${g}`} role=\"group\" aria-labelledby={`${uid}-g${g}`} className=\"not-first:mt-1 not-first:border-t not-first:border-border not-first:pt-1\">\n                <div id={`${uid}-g${g}`} className=\"px-2.5 pt-1.5 pb-1 text-xs font-medium text-muted-foreground\">\n                  {o.label}\n                </div>\n                {o.options.map(renderOption)}\n              </div>\n            ) : (\n              renderOption(o)\n            ),\n          )}\n        </div>\n        {name !== undefined && (\n          <input\n            ref={input}\n            aria-hidden\n            tabIndex={-1}\n            name={name}\n            value={value}\n            required={required}\n            disabled={disabled}\n            onChange={() => {}}\n            onInvalid={() => trigger.current?.focus()}\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-px opacity-0\"\n          />\n        )}\n      </div>\n    </MotionConfig>\n  );\n}\n"}]}