{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "type": "registry:component",
  "description": "A multi-select combobox with inline search, toggle-based selection, and full controlled/uncontrolled API",
  "dependencies": [
    "cmdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "popover",
    "command",
    "input",
    "skeleton",
    "utils",
    "https://kaui-shadcn-registry.vercel.app/r/use-controlled-state.json"
  ],
  "files": [
    {
      "type": "registry:component",
      "target": "components/ui/multi-select.tsx",
      "path": "src/registry/base/multi-select/components/multi-select.tsx",
      "content": "import {\n  useCallback,\n  useMemo,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { Check } from \"lucide-react\";\n\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { cn } from \"@/lib/utils\";\n\nimport { useControlledState } from \"@/hooks/use-controlled-state\";\nimport {\n  useFilteredOptions,\n  type SelectionOption,\n} from \"@/hooks/use-filtered-options\";\n\ntype MultiSelectProps<T extends string> = {\n  /** Full list of options to display. */\n  items: SelectionOption<T>[];\n\n  /** Controlled array of currently selected values. */\n  value: T[];\n  /** Called with the full updated selection array after each toggle. */\n  onValueChange: (value: T[]) => void;\n\n  /** Controlled open state of the dropdown. Omit to let the component manage it internally. */\n  open?: boolean;\n  /** Called whenever the open state changes. */\n  onOpenChange?: (open: boolean) => void;\n\n  /** Controlled search query. Omit to let the component manage query state internally. */\n  query?: string;\n  /** Called whenever the search query changes. */\n  onQueryChange?: (query: string) => void;\n\n  /**\n   * Custom renderer for each list item.\n   * Receives the option and a boolean indicating whether it is currently selected.\n   * When omitted a default label + checkmark layout is used.\n   */\n  renderOption?: (option: SelectionOption<T>, selected: boolean) => ReactNode;\n\n  /** Placeholder shown in the search input when empty. @default \"Search...\" */\n  placeholder?: string;\n\n  /** Content shown when no options match the current query. @default \"No results found\" */\n  emptyContent?: ReactNode;\n  /**\n   * Content shown in place of the option list while `isLoading` is `true`.\n   * Defaults to a single skeleton bar.\n   */\n  loadingContent?: ReactNode;\n\n  /**\n   * Custom filter predicate. Receives each option and the current query string.\n   * Replaces the default case-insensitive label match when provided.\n   */\n  filterFn?: (item: SelectionOption<T>, query: string) => boolean;\n  /**\n   * Skip local filtering entirely.\n   * Use when `items` are already filtered server-side for the current query.\n   * @default false\n   */\n  disableLocalFilter?: boolean;\n\n  /** Render the loading skeleton in place of the option list. */\n  isLoading?: boolean;\n\n  /** Clear the search query after each selection toggle. @default true */\n  clearQueryOnSelect?: boolean;\n  /** Close the dropdown immediately after each selection toggle. @default false */\n  closeAfterSelect?: boolean;\n\n  /** Content rendered as a leading addon inside the input (e.g. a search icon or label). */\n  startAddon?: ReactNode;\n  /** Content rendered as a trailing addon inside the input (e.g. a selected-count badge or clear button). */\n  endAddon?: ReactNode;\n} & ComponentProps<typeof PopoverContent>;\n\n/**\n * Multi-select combobox that toggles values in and out of a selection array.\n *\n * **Required:** `value`, `onValueChange`, `items`\n *\n * Supports controlled `open` and `query` state, async loading, custom option rendering,\n * and custom filtering. Embed icons or actions inside the input via `startAddon` / `endAddon`.\n */\nexport function MultiSelect<T extends string>({\n  items,\n  value,\n  onValueChange,\n  open: openProp,\n  onOpenChange,\n  query: queryProp,\n  onQueryChange,\n  renderOption,\n  placeholder = \"Search...\",\n  emptyContent = \"No results found\",\n  loadingContent,\n  filterFn,\n  disableLocalFilter = false,\n  isLoading,\n  clearQueryOnSelect = true,\n  closeAfterSelect = false,\n  startAddon,\n  endAddon,\n  ...props\n}: MultiSelectProps<T>) {\n  const [values, setValues] = useControlledState({\n    value,\n    defaultValue: [],\n    onChange: onValueChange,\n  });\n\n  const [open, setOpen] = useControlledState({\n    value: openProp,\n    defaultValue: false,\n    onChange: onOpenChange,\n  });\n\n  const [query, setQuery] = useControlledState({\n    value: queryProp,\n    defaultValue: \"\",\n    onChange: onQueryChange,\n  });\n\n  const selectedSet = useMemo(() => new Set(values), [values]);\n\n  const filteredItems = useFilteredOptions({\n    items,\n    query,\n    filterFn,\n    disableLocalFilter,\n  });\n\n  const toggleValue = useCallback(\n    (nextValue: string) => {\n      const v = nextValue as T;\n\n      const next = selectedSet.has(v)\n        ? values.filter((item) => item !== v)\n        : [...values, v];\n\n      setValues(next);\n\n      if (clearQueryOnSelect) {\n        setQuery(\"\");\n      }\n\n      if (closeAfterSelect) {\n        setOpen(false);\n      }\n    },\n    [\n      values,\n      selectedSet,\n      setValues,\n      clearQueryOnSelect,\n      setQuery,\n      closeAfterSelect,\n      setOpen,\n    ],\n  );\n\n  return (\n    <div data-slot=\"combobox-content\" className=\"relative w-full\">\n      <Popover open={open} onOpenChange={setOpen}>\n        <Command shouldFilter={false}>\n          <PopoverAnchor asChild>\n            <InputGroup>\n              {startAddon && (\n                <InputGroupAddon align=\"inline-start\">\n                  {startAddon}\n                </InputGroupAddon>\n              )}\n              <CommandPrimitive.Input\n                asChild\n                value={query}\n                onValueChange={setQuery}\n                onMouseDown={() => setOpen(true)}\n                onKeyDown={(e) => {\n                  if (e.key === \"Escape\") {\n                    if (open) {\n                      setOpen(false);\n                    } else {\n                      setQuery(\"\");\n                    }\n                    return;\n                  }\n                  setOpen(true);\n                }}\n              >\n                <InputGroupInput\n                  placeholder={placeholder}\n                  onFocus={() => setOpen(true)}\n                />\n              </CommandPrimitive.Input>\n              {endAddon && (\n                <InputGroupAddon align=\"inline-end\">{endAddon}</InputGroupAddon>\n              )}\n            </InputGroup>\n          </PopoverAnchor>\n\n          {!open && <CommandList aria-hidden=\"true\" className=\"hidden\" />}\n\n          <PopoverContent\n            asChild\n            className=\"w-(--radix-popover-trigger-width) p-0\"\n            onInteractOutside={(event) => {\n              if (\n                event.target instanceof Element &&\n                event.target.hasAttribute(\"cmdk-input\")\n              ) {\n                event.preventDefault();\n              }\n            }}\n            {...props}\n          >\n            <CommandList>\n              {isLoading ? (\n                <CommandPrimitive.Loading>\n                  {loadingContent ?? (\n                    <div className=\"p-2\">\n                      <Skeleton className=\"h-6 w-full\" />\n                    </div>\n                  )}\n                </CommandPrimitive.Loading>\n              ) : filteredItems.length ? (\n                <CommandGroup>\n                  {filteredItems.map((item) => {\n                    const isSelected = selectedSet.has(item.value);\n\n                    return (\n                      <CommandItem\n                        key={item.value}\n                        value={item.value}\n                        disabled={item.disabled}\n                        onSelect={toggleValue}\n                        onMouseDown={(e) => e.preventDefault()}\n                      >\n                        {renderOption ? (\n                          renderOption(item, isSelected)\n                        ) : (\n                          <>\n                            <Check\n                              className={cn(\n                                \"mr-2 h-4 w-4\",\n                                isSelected ? \"opacity-100\" : \"opacity-0\",\n                              )}\n                            />\n                            {item.label}\n                          </>\n                        )}\n                      </CommandItem>\n                    );\n                  })}\n                </CommandGroup>\n              ) : (\n                <CommandEmpty>{emptyContent}</CommandEmpty>\n              )}\n            </CommandList>\n          </PopoverContent>\n        </Command>\n      </Popover>\n    </div>\n  );\n}\n"
    },
    {
      "type": "registry:hook",
      "target": "hooks/use-filtered-options.ts",
      "path": "src/registry/base/use-filtered-options/hooks/use-filtered-options.ts",
      "content": "import { useMemo } from \"react\";\n\nexport type SelectionOption<T extends string> = {\n  /** Unique identifier for this option, used as the cmdk `value`. */\n  value: T;\n  /** Display string shown in the list and the search input. */\n  label: string;\n  /** When `true`, the item renders but cannot be selected. @default false */\n  disabled?: boolean;\n};\n\ntype UseFilteredOptionsProps<T extends string> = {\n  items: SelectionOption<T>[];\n  query: string;\n  disableLocalFilter?: boolean;\n  filterFn?: (item: SelectionOption<T>, query: string) => boolean;\n};\n\n/**\n * Filters a `SelectionOption` list against a search query.\n *\n * Returns the full list when `disableLocalFilter` is `true` or `query` is blank.\n * Applies `filterFn` when provided; otherwise uses a case-insensitive substring\n * match on `label`.\n */\nexport function useFilteredOptions<T extends string>({\n  items,\n  query,\n  disableLocalFilter = false,\n  filterFn,\n}: UseFilteredOptionsProps<T>) {\n  return useMemo(() => {\n    if (disableLocalFilter || !query.trim()) {\n      return items;\n    }\n    if (filterFn) {\n      return items.filter((item) => filterFn(item, query));\n    }\n    const normalizedQuery = query.toLowerCase();\n    return items.filter((item) =>\n      item.label.toLowerCase().includes(normalizedQuery),\n    );\n  }, [items, query, disableLocalFilter, filterFn]);\n}\n"
    }
  ]
}