{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox-multi",
  "title": "Multi combobox",
  "description": "Styled combobox: the Multi set. 5 decorative combobox variations (ChipsCombobox, CountCombobox, ChecksCombobox, InlineMultiCombobox, ClearCombobox) 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/combobox-multi.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronsUpDown, Eraser, Search, X } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* Multi combobox family: 5 decorative SELF-CONTAINED multi-selects. NO radix, NO\n   portal, NO cmdk. The TagsCombobox in essentials puts the selections INSIDE THE\n   TRIGGER as chips; this family deliberately takes another route: the trigger\n   always stays a plain search <input>, and the selection summary lives BELOW THE\n   INPUT on a separate line (a chip series, a counter badge, a summary line) or in\n   the panel footer. Each export is a complete combobox: the input filters, the\n   arrow keys move the highlight, Enter toggles a selection, Escape closes. The\n   selection persists while the panel is open. Color comes ONLY from tokens, via\n   alpha color-mix. */\n\nexport type StyledSize = \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nconst triggerHeight: Record<StyledSize, string> = {\n  sm: \"h-8\",\n  md: \"h-9\",\n  lg: \"h-10\",\n  xl: \"h-12\",\n}\n\nconst panelWidth: Record<StyledSize, string> = {\n  sm: \"w-56\",\n  md: \"w-64\",\n  lg: \"w-72\",\n  xl: \"w-80\",\n}\n\nexport interface ComboboxOption {\n  value: string\n  label: React.ReactNode\n}\n\ninterface ComboboxProps {\n  className?: string\n  size?: StyledSize\n  placeholder?: string\n  options?: ComboboxOption[]\n  value?: string\n  defaultValue?: string\n  onValueChange?: (v: string) => void\n}\n\nconst defaultOptions: ComboboxOption[] = [\n  { value: \"next\", label: \"Next.js\" },\n  { value: \"react\", label: \"React\" },\n  { value: \"vue\", label: \"Vue\" },\n  { value: \"svelte\", label: \"Svelte\" },\n  { value: \"solid\", label: \"Solid\" },\n  { value: \"astro\", label: \"Astro\" },\n]\n\nfunction nodeText(node: React.ReactNode): string {\n  if (node === null || node === undefined || typeof node === \"boolean\") return \"\"\n  if (typeof node === \"string\" || typeof node === \"number\") return String(node)\n  if (Array.isArray(node)) return node.map(nodeText).join(\"\")\n  if (React.isValidElement(node)) {\n    return nodeText((node.props as { children?: React.ReactNode }).children)\n  }\n  return \"\"\n}\n\nfunction filterOptions(options: ComboboxOption[], query: string): ComboboxOption[] {\n  const q = query.trim().toLowerCase()\n  if (q.length === 0) return options\n  return options.filter((o) => nodeText(o.label).toLowerCase().includes(q))\n}\n\nconst rootBase = \"relative\"\n\nconst inputBase =\n  \"w-full rounded-lg border border-field-border bg-transparent pl-9 pr-9 text-sm text-foreground shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40\"\n\nconst panelBase =\n  \"absolute left-0 right-0 top-full z-50 mt-2 min-w-56 origin-top overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-lg outline-none\"\n\nconst listBase = \"max-h-56 overflow-y-auto p-1.5\"\n\nconst optionBase =\n  \"flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none\"\n\nconst emptyBase = \"py-6 text-center text-sm text-muted-foreground\"\n\nconst footerBase =\n  \"flex items-center justify-between gap-2 border-t border-border px-3 py-2 text-xs text-muted-foreground\"\n\nconst panelMotion = {\n  initial: { opacity: 0, scale: 0.98, y: -6 },\n  animate: { opacity: 1, scale: 1, y: 0 },\n  exit: { opacity: 0, scale: 0.98, y: -6 },\n}\n\n/* Multi-selection state: the array of selected values plus filter, highlight and keyboard. The panel stays OPEN after a selection. */\nfunction useMultiCombobox(props: ComboboxProps) {\n  const { options = defaultOptions, defaultValue, onValueChange } = props\n  const [selected, setSelected] = React.useState<string[]>(defaultValue ? [defaultValue] : [])\n  const [open, setOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n\n  const wrapperRef = React.useRef<HTMLDivElement>(null)\n  const uid = React.useId()\n  const listId = `${uid}-list`\n  const optionId = React.useCallback((i: number) => `${uid}-option-${i}`, [uid])\n\n  const filtered = React.useMemo(() => filterOptions(options, query), [options, query])\n\n  React.useEffect(() => {\n    setActive((prev) => (prev >= filtered.length ? 0 : prev))\n  }, [filtered.length])\n\n  React.useEffect(() => {\n    if (!open) return\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false)\n    }\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(\"keydown\", onKey)\n    window.addEventListener(\"pointerdown\", onPointer)\n    return () => {\n      window.removeEventListener(\"keydown\", onKey)\n      window.removeEventListener(\"pointerdown\", onPointer)\n    }\n  }, [open])\n\n  const toggle = React.useCallback(\n    (v: string) => {\n      setSelected((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]))\n      onValueChange?.(v)\n    },\n    [onValueChange]\n  )\n\n  const clear = React.useCallback(() => {\n    setSelected([])\n  }, [])\n\n  const onInputKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault()\n        setOpen(true)\n        setActive((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length))\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault()\n        setOpen(true)\n        setActive((prev) =>\n          filtered.length === 0 ? 0 : (prev - 1 + filtered.length) % filtered.length\n        )\n      } else if (e.key === \"Enter\") {\n        e.preventDefault()\n        const item = filtered[active]\n        if (item) toggle(item.value)\n      } else if (e.key === \"Escape\") {\n        e.preventDefault()\n        setOpen(false)\n      }\n    },\n    [filtered, active, toggle]\n  )\n\n  const selectedOptions = React.useMemo(\n    () =>\n      selected\n        .map((v) => options.find((o) => o.value === v))\n        .filter((o): o is ComboboxOption => o !== undefined),\n    [selected, options]\n  )\n\n  return {\n    options,\n    selected,\n    selectedOptions,\n    open,\n    setOpen,\n    query,\n    setQuery,\n    active,\n    setActive,\n    filtered,\n    toggle,\n    clear,\n    onInputKeyDown,\n    wrapperRef,\n    listId,\n    optionId,\n  }\n}\n\ntype MultiState = ReturnType<typeof useMultiCombobox>\n\n/* Presentation shell: a plain search input plus a panel. The panel body arrives through children; the summary falls BELOW the input (the point that separates it from Tags). */\nfunction MultiField({\n  state,\n  size,\n  className,\n  placeholder,\n  trailing,\n  summary,\n  children,\n}: {\n  state: MultiState\n  size: StyledSize\n  className?: string\n  placeholder: string\n  trailing?: React.ReactNode\n  summary?: React.ReactNode\n  children: React.ReactNode\n}) {\n  const reduce = useReducedMotion() ?? false\n  const { open, setOpen, setQuery, query, active, filtered, onInputKeyDown, wrapperRef, listId, optionId } =\n    state\n\n  const activeId = open && filtered[active] ? optionId(active) : undefined\n\n  return (\n    <div\n      ref={wrapperRef}\n      data-slot=\"styled-combobox\"\n      className={cn(rootBase, panelWidth[size], className)}\n    >\n      <div className=\"relative\">\n        <Search className=\"pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" />\n        <input\n          role=\"combobox\"\n          aria-expanded={open}\n          aria-controls={listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeId}\n          value={query}\n          placeholder={placeholder}\n          onFocus={() => setOpen(true)}\n          onChange={(e) => {\n            setOpen(true)\n            setQuery(e.target.value)\n          }}\n          onKeyDown={onInputKeyDown}\n          className={cn(inputBase, triggerHeight[size])}\n        />\n        <span className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground [&_svg]:size-4 [&_i]:text-base [&_i]:leading-none\">\n          {trailing ?? <ChevronsUpDown className=\"opacity-70\" />}\n        </span>\n      </div>\n\n      {summary}\n\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            id={listId}\n            role=\"listbox\"\n            aria-multiselectable=\"true\"\n            className={panelBase}\n            initial={reduce ? { opacity: 0 } : panelMotion.initial}\n            animate={reduce ? { opacity: 1 } : panelMotion.animate}\n            exit={reduce ? { opacity: 0 } : panelMotion.exit}\n            transition={reduce ? { duration: 0 } : { duration: 0.16, ease: \"easeOut\" }}\n          >\n            {children}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/* The shared multi option rows; the leading element changes per variant. */\nfunction MultiRows({\n  state,\n  leading,\n}: {\n  state: MultiState\n  leading?: (isSelected: boolean) => React.ReactNode\n}) {\n  const { filtered, active, setActive, selected, toggle, optionId } = state\n  if (filtered.length === 0) return <p className={emptyBase}>No results found.</p>\n  return (\n    <>\n      {filtered.map((option, i) => {\n        const isActive = i === active\n        const isSelected = selected.includes(option.value)\n        return (\n          <div\n            key={option.value}\n            id={optionId(i)}\n            role=\"option\"\n            tabIndex={-1}\n            aria-selected={isSelected}\n            onMouseEnter={() => setActive(i)}\n            onClick={() => toggle(option.value)}\n            className={cn(optionBase, isActive && \"bg-accent text-accent-foreground\")}\n          >\n            {leading ? leading(isSelected) : null}\n            <span className=\"flex-1 truncate\">{option.label}</span>\n            {!leading && isSelected ? <Check className=\"text-foreground\" /> : null}\n          </div>\n        )\n      })}\n    </>\n  )\n}\n\n/* ChipsCombobox: the selected items line up as a row of round chips BELOW THE INPUT; each chip is removed with its own X. The trigger stays plain. */\nexport function ChipsCombobox(props: ComboboxProps) {\n  const { className, size = \"md\", placeholder = \"Search framework...\" } = props\n  const state = useMultiCombobox(props)\n  const { selectedOptions, toggle } = state\n\n  return (\n    <MultiField\n      state={state}\n      size={size}\n      className={className}\n      placeholder={placeholder}\n      summary={\n        selectedOptions.length > 0 ? (\n          <div className=\"mt-2 flex flex-wrap gap-1.5\">\n            {selectedOptions.map((option) => (\n              <span\n                key={option.value}\n                className=\"inline-flex items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--color-primary)_14%,transparent)] px-2.5 py-0.5 text-xs font-medium text-foreground\"\n              >\n                {option.label}\n                <button\n                  type=\"button\"\n                  aria-label={`Remove ${nodeText(option.label)}`}\n                  onClick={() => toggle(option.value)}\n                  className=\"inline-flex rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3 [&_i]:text-xs [&_i]:leading-none\"\n                >\n                  <X />\n                </button>\n              </span>\n            ))}\n          </div>\n        ) : null\n      }\n    >\n      <div className={listBase}>\n        <MultiRows state={state} />\n      </div>\n    </MultiField>\n  )\n}\n\n/* CountCombobox: chip yok. Input sagindaki sayac rozeti kac secim oldugunu soyler. */\nexport function CountCombobox(props: ComboboxProps) {\n  const { className, size = \"md\", placeholder = \"Search framework...\" } = props\n  const state = useMultiCombobox(props)\n  const { selected } = state\n\n  return (\n    <MultiField\n      state={state}\n      size={size}\n      className={className}\n      placeholder={placeholder}\n      trailing={\n        selected.length > 0 ? (\n          <span className=\"inline-flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold leading-5 text-primary-foreground\">\n            {selected.length}\n          </span>\n        ) : undefined\n      }\n    >\n      <div className={listBase}>\n        <MultiRows state={state} />\n      </div>\n      <div className={footerBase}>\n        <span>\n          {selected.length} of {state.options.length} selected\n        </span>\n      </div>\n    </MultiField>\n  )\n}\n\n/* ChecksCombobox: every row carries a checkbox; a readable, form-like list. */\nexport function ChecksCombobox(props: ComboboxProps) {\n  const { className, size = \"md\", placeholder = \"Search framework...\" } = props\n  const state = useMultiCombobox(props)\n\n  return (\n    <MultiField state={state} size={size} className={className} placeholder={placeholder}>\n      <div className={listBase}>\n        <MultiRows\n          state={state}\n          leading={(isSelected) => (\n            <span\n              className={cn(\n                \"flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-border transition-colors\",\n                isSelected && \"border-transparent bg-primary text-primary-foreground\"\n              )}\n            >\n              {isSelected ? <Check className=\"size-3\" /> : null}\n            </span>\n          )}\n        />\n      </div>\n    </MultiField>\n  )\n}\n\n/* InlineMultiCombobox: the selection summary is one comma-separated line under the input; the plainest and narrowest multi-select. */\nexport function InlineMultiCombobox(props: ComboboxProps) {\n  const { className, size = \"md\", placeholder = \"Search framework...\" } = props\n  const state = useMultiCombobox(props)\n  const { selectedOptions } = state\n\n  const summaryText = selectedOptions.map((o) => nodeText(o.label)).join(\", \")\n\n  return (\n    <MultiField\n      state={state}\n      size={size}\n      className={className}\n      placeholder={placeholder}\n      summary={\n        <p className=\"mt-1.5 truncate px-1 text-xs text-muted-foreground\">\n          {summaryText.length > 0 ? (\n            <>\n              <span className=\"font-medium text-foreground\">Selected: </span>\n              {summaryText}\n            </>\n          ) : (\n            \"Nothing selected yet.\"\n          )}\n        </p>\n      }\n    >\n      <div className={listBase}>\n        <MultiRows state={state} />\n      </div>\n    </MultiField>\n  )\n}\n\n/* ClearCombobox: panel altliginda secim sayaci + \"Clear all\"; input saginda da\n   hizli temizleme dugmesi. */\nexport function ClearCombobox(props: ComboboxProps) {\n  const { className, size = \"md\", placeholder = \"Search framework...\" } = props\n  const state = useMultiCombobox(props)\n  const { selected, clear } = state\n\n  return (\n    <MultiField\n      state={state}\n      size={size}\n      className={className}\n      placeholder={placeholder}\n      trailing={\n        selected.length > 0 ? (\n          <span className=\"pointer-events-auto inline-flex\">\n            <button\n              type=\"button\"\n              aria-label=\"Clear selection\"\n              onClick={clear}\n              className=\"inline-flex size-5 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3.5 [&_i]:text-sm [&_i]:leading-none\"\n            >\n              <X />\n            </button>\n          </span>\n        ) : undefined\n      }\n    >\n      <div className={listBase}>\n        <MultiRows state={state} />\n      </div>\n      <div className={footerBase}>\n        <span>{selected.length} selected</span>\n        <button\n          type=\"button\"\n          onClick={clear}\n          disabled={selected.length === 0}\n          className=\"inline-flex h-7 items-center gap-1.5 rounded-md px-2 font-medium text-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&_i]:text-sm [&_i]:leading-none\"\n        >\n          <Eraser />\n          Clear all\n        </button>\n      </div>\n    </MultiField>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ui/combobox-multi.tsx"
    }
  ],
  "categories": [
    "styled",
    "combobox"
  ],
  "type": "registry:component"
}