{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select-search",
  "title": "Search select",
  "description": "Styled select: the Search set. 5 decorative select variations (FilterSelect, TypeSelect, LiveSelect, ClearSelect, HighlightSelect) on the ai2 token system, powered by framer-motion, reduced-motion aware and sized sm to xl. Part of the free styled layer.",
  "dependencies": [
    "motion@^12.42.2",
    "lucide-react@^1.23.0"
  ],
  "registryDependencies": [
    "@ai2/tokens"
  ],
  "files": [
    {
      "path": "registry/ai2/styled/select-search.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronDown, Search, X } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* Search select family: 5 self-contained single-selects with REAL working\n   filtering. When the panel opens a search field appears at the top; the typed text\n   filters the option list with a case-insensitive substring match. The query and\n   selection state live in the ROOT component (the panel unmounts on close, but the\n   state is not lost). NO radix, NO native <select>, NO portal. Keyboard:\n   ArrowUp/Down moves the highlight, Enter selects, Escape closes and returns focus\n   to the trigger. Color comes ONLY from tokens. */\n\nexport type StyledSize = \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nconst triggerHeight: Record<StyledSize, string> = {\n  sm: \"h-8 text-sm\",\n  md: \"h-9 text-sm\",\n  lg: \"h-10 text-base\",\n  xl: \"h-12 text-base\",\n}\n\nconst panelWidth: Record<StyledSize, string> = {\n  sm: \"w-48\",\n  md: \"w-56\",\n  lg: \"w-64\",\n  xl: \"w-72\",\n}\n\nexport interface StyledSelectOption {\n  value: string\n  label: string\n}\n\nexport interface StyledSelectProps {\n  className?: string\n  size?: StyledSize\n  placeholder?: string\n  options?: StyledSelectOption[]\n  value?: string\n  defaultValue?: string\n  onValueChange?: (v: string) => void\n}\n\nconst defaultOptions: StyledSelectOption[] = [\n  { value: \"next\", label: \"Next.js\" },\n  { value: \"remix\", label: \"Remix\" },\n  { value: \"astro\", label: \"Astro\" },\n  { value: \"nuxt\", label: \"Nuxt\" },\n  { value: \"svelte\", label: \"SvelteKit\" },\n  { value: \"solid\", label: \"SolidStart\" },\n]\n\nconst triggerBase =\n  \"inline-flex w-full shrink-0 select-none items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-field-border bg-background px-3 font-medium text-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none\"\n\nconst panelBase =\n  \"absolute left-0 top-full z-50 mt-1 min-w-48 origin-top overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg outline-none\"\n\nconst optionBase =\n  \"flex w-full cursor-default select-none items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none\"\n\nconst searchInputBase =\n  \"w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground\"\n\nconst panelMotion = {\n  initial: { opacity: 0, scale: 0.96, y: -6 },\n  animate: { opacity: 1, scale: 1, y: 0 },\n  exit: { opacity: 0, scale: 0.96, y: -6 },\n  transition: { type: \"spring\" as const, stiffness: 340, damping: 26 },\n}\n\n/* Case-insensitive substring filtresi. */\nfunction filterOptions(list: StyledSelectOption[], query: string) {\n  const q = query.trim().toLowerCase()\n  if (!q) return list\n  return list.filter((o) => o.label.toLowerCase().includes(q))\n}\n\n/* Eslesen alt dizgiyi <mark> ile isaretler. */\nfunction markMatch(label: string, query: string): React.ReactNode {\n  const q = query.trim()\n  if (!q) return label\n  const at = label.toLowerCase().indexOf(q.toLowerCase())\n  if (at < 0) return label\n  return (\n    <>\n      {label.slice(0, at)}\n      <mark className=\"rounded-sm bg-[color-mix(in_oklab,var(--color-primary)_24%,transparent)] px-0.5 text-inherit\">\n        {label.slice(at, at + q.length)}\n      </mark>\n      {label.slice(at + q.length)}\n    </>\n  )\n}\n\ninterface SearchFlags {\n  /** Shows a magnifier icon in the search field. */\n  icon?: boolean\n  /** Shows a live match counter at the bottom of the panel. */\n  counter?: boolean\n  /** Shows a clear button in the search field. */\n  clearable?: boolean\n  /** Eslesen alt dizgiyi vurgular. */\n  highlight?: boolean\n  /** Arama alani placeholder metni. */\n  searchPlaceholder?: string\n}\n\nfunction BaseSearchSelect({\n  className,\n  size = \"md\",\n  placeholder = \"Select...\",\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  icon,\n  counter,\n  clearable,\n  highlight,\n  searchPlaceholder = \"Search...\",\n}: StyledSelectProps & SearchFlags) {\n  const list = options ?? defaultOptions\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const listId = `${uid}-listbox`\n  const optionId = (i: number) => `${uid}-option-${i}`\n\n  const [open, setOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n  const [internal, setInternal] = React.useState<string | undefined>(defaultValue)\n  const wrapperRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const inputRef = React.useRef<HTMLInputElement>(null)\n\n  const isControlled = value !== undefined\n  const current = isControlled ? value : internal\n  const filtered = filterOptions(list, query)\n  const selectedLabel = list.find((o) => o.value === current)?.label\n\n  const close = React.useCallback(() => {\n    setOpen(false)\n    triggerRef.current?.focus()\n  }, [])\n\n  const select = React.useCallback(\n    (next: string) => {\n      if (!isControlled) setInternal(next)\n      onValueChange?.(next)\n      setOpen(false)\n      setQuery(\"\")\n      triggerRef.current?.focus()\n    },\n    [isControlled, onValueChange],\n  )\n\n  React.useEffect(() => {\n    if (!open) return\n    const onPointer = (e: PointerEvent) => {\n      const node = wrapperRef.current\n      if (node && e.target instanceof Node && !node.contains(e.target)) setOpen(false)\n    }\n    window.addEventListener(\"pointerdown\", onPointer)\n    return () => window.removeEventListener(\"pointerdown\", onPointer)\n  }, [open])\n\n  React.useEffect(() => {\n    if (!open) return\n    const id = window.requestAnimationFrame(() => inputRef.current?.focus())\n    return () => window.cancelAnimationFrame(id)\n  }, [open])\n\n  const onListKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Escape\") {\n      e.preventDefault()\n      close()\n      return\n    }\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault()\n      if (filtered.length === 0) return\n      const dir = e.key === \"ArrowDown\" ? 1 : -1\n      setActive((i) => (i + dir + filtered.length) % filtered.length)\n      return\n    }\n    if (e.key === \"Enter\") {\n      e.preventDefault()\n      const opt = filtered[active]\n      if (opt) select(opt.value)\n    }\n  }\n\n  const onTriggerKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Escape\") {\n      if (!open) return\n      e.preventDefault()\n      close()\n      return\n    }\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\" || e.key === \"Enter\" || e.key === \" \") {\n      if (open) return\n      e.preventDefault()\n      setQuery(\"\")\n      setActive(0)\n      setOpen(true)\n    }\n  }\n\n  const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }\n  const anim = reduce ? fade : panelMotion\n\n  return (\n    <div\n      ref={wrapperRef}\n      data-slot=\"styled-select\"\n      className={cn(\"relative inline-flex w-56 max-w-full\", panelWidth[size], className)}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-slot=\"styled-select-trigger\"\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        aria-controls={listId}\n        className={cn(triggerBase, triggerHeight[size])}\n        onClick={() => {\n          if (!open) {\n            setQuery(\"\")\n            setActive(0)\n          }\n          setOpen(!open)\n        }}\n        onKeyDown={onTriggerKeyDown}\n      >\n        <span className={cn(\"truncate\", selectedLabel === undefined && \"text-muted-foreground\")}>\n          {selectedLabel ?? placeholder}\n        </span>\n        <ChevronDown\n          className={cn(\"shrink-0 opacity-70 transition-transform duration-200\", open && \"rotate-180\")}\n        />\n      </button>\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            data-slot=\"styled-select-content\"\n            className={cn(panelBase, panelWidth[size])}\n            initial={anim.initial}\n            animate={anim.animate}\n            exit={anim.exit}\n            transition={reduce ? { duration: 0.14 } : panelMotion.transition}\n          >\n            <div className=\"flex items-center gap-2 rounded-md border border-field-border bg-background px-2.5 py-1.5 focus-within:ring-[3px] focus-within:ring-ring/50\">\n              {icon ? <Search className=\"size-4 shrink-0 text-muted-foreground\" /> : null}\n              <input\n                ref={inputRef}\n                type=\"text\"\n                aria-label=\"Filter options\"\n                aria-controls={listId}\n                aria-activedescendant={filtered.length > 0 ? optionId(active) : undefined}\n                autoComplete=\"off\"\n                className={searchInputBase}\n                placeholder={searchPlaceholder}\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value)\n                  setActive(0)\n                }}\n                onKeyDown={onListKeyDown}\n              />\n              {clearable && query.length > 0 ? (\n                <button\n                  type=\"button\"\n                  aria-label=\"Clear filter\"\n                  className=\"inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&_i]:text-xs [&_i]:leading-none\"\n                  onClick={() => {\n                    setQuery(\"\")\n                    setActive(0)\n                    inputRef.current?.focus()\n                  }}\n                >\n                  <X />\n                </button>\n              ) : null}\n            </div>\n\n            <div\n              id={listId}\n              role=\"listbox\"\n              className=\"mt-1.5 flex max-h-56 flex-col overflow-auto\"\n              onKeyDown={onListKeyDown}\n            >\n              {filtered.length === 0 ? (\n                <div className=\"px-3 py-6 text-center text-sm text-muted-foreground\">\n                  No results found.\n                </div>\n              ) : (\n                filtered.map((option, i) => {\n                  const isSelected = option.value === current\n                  return (\n                    <button\n                      key={option.value}\n                      id={optionId(i)}\n                      type=\"button\"\n                      role=\"option\"\n                      tabIndex={-1}\n                      aria-selected={isSelected}\n                      className={cn(optionBase, i === active && \"bg-accent text-accent-foreground\")}\n                      onPointerEnter={() => setActive(i)}\n                      onClick={() => select(option.value)}\n                    >\n                      <span className=\"flex size-4 shrink-0 items-center justify-center text-primary\">\n                        {isSelected ? <Check /> : null}\n                      </span>\n                      <span className=\"truncate\">\n                        {highlight ? markMatch(option.label, query) : option.label}\n                      </span>\n                    </button>\n                  )\n                })\n              )}\n            </div>\n\n            {counter ? (\n              <div\n                aria-live=\"polite\"\n                className=\"mt-1.5 border-t border-border px-3 pb-0.5 pt-2 text-xs text-muted-foreground\"\n              >\n                {filtered.length} of {list.length} match\n              </div>\n            ) : null}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/* Filter: sade bir filtre alani panelin ustunde. */\nexport function FilterSelect(props: StyledSelectProps) {\n  return <BaseSearchSelect {...props} searchPlaceholder=\"Filter...\" />\n}\n\n/* Type: lupe ikonlu, yazmaya davet eden arama alani. */\nexport function TypeSelect(props: StyledSelectProps) {\n  return <BaseSearchSelect {...props} icon searchPlaceholder=\"Type to filter...\" />\n}\n\n/* Live: eslesme sayaci panelin altinda canli guncellenir. */\nexport function LiveSelect(props: StyledSelectProps) {\n  return <BaseSearchSelect {...props} icon counter searchPlaceholder=\"Search...\" />\n}\n\n/* Clear: the search field carries a clear button. */\nexport function ClearSelect(props: StyledSelectProps) {\n  return <BaseSearchSelect {...props} icon clearable searchPlaceholder=\"Search...\" />\n}\n\n/* Highlight: eslesen alt dizgi option satirinda vurgulanir. */\nexport function HighlightSelect(props: StyledSelectProps) {\n  return <BaseSearchSelect {...props} icon clearable highlight searchPlaceholder=\"Search...\" />\n}\n",
      "type": "registry:component",
      "target": "components/ui/select-search.tsx"
    }
  ],
  "categories": [
    "styled",
    "select"
  ],
  "type": "registry:component"
}