{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "title": "Multi Select",
  "description": "Pick several values from a fixed list, with search, groups, chips and a selection limit.",
  "dependencies": [
    "radix-ui",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "badge",
    "popover",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/multi-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  filterOptions,\n  groupOptions,\n  toggleAll,\n  toggleValue,\n  type MultiSelectOption,\n} from \"@/registry/default/lib/multi-select\"\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport { Badge } from \"@/registry/default/ui/badge\"\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n} from \"@/registry/default/ui/popover\"\n\nfunction SelectionMark({ checked }: { checked: boolean }) {\n  return (\n    <span\n      data-checked={checked ? \"\" : undefined}\n      className=\"flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground\"\n    >\n      {checked && <CheckIcon className=\"size-3\" />}\n    </span>\n  )\n}\n\nfunction MultiSelect({\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  maxSelected,\n  maxVisibleChips,\n  disabled,\n  placeholder,\n  className,\n  onKeyDown,\n  onBlur,\n  ...props\n}: Omit<\n  React.ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"type\"\n> & {\n  options: MultiSelectOption[]\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (value: string[]) => void\n  maxSelected?: number\n  maxVisibleChips?: number\n}) {\n  const listId = React.useId()\n  const fieldRef = React.useRef<HTMLDivElement>(null)\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const { value: selected, setValue: setSelected } = useControllableState({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  })\n  const [query, setQuery] = React.useState(\"\")\n  const [isOpen, setIsOpen] = React.useState(false)\n  const [highlightedIndex, setHighlightedIndex] = React.useState(0)\n  const [activeIndex, setActiveIndex] = React.useState<number | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const groups = groupOptions(filterOptions(options, query))\n  const visibleOptions = groups.flatMap((group) => group.options)\n  const hasSelectAll = maxSelected === undefined && visibleOptions.length > 1\n  const offset = hasSelectAll ? 1 : 0\n  const itemCount = visibleOptions.length + offset\n  const isFull = maxSelected !== undefined && selected.length >= maxSelected\n  const allVisibleSelected = visibleOptions.every((option) =>\n    selected.includes(option.value)\n  )\n  const chipCount = maxVisibleChips ?? selected.length\n  const hiddenCount = Math.max(0, selected.length - chipCount)\n\n  React.useEffect(() => {\n    if (!isOpen) return\n    document\n      .getElementById(`${listId}-${highlightedIndex}`)\n      ?.scrollIntoView({ block: \"nearest\" })\n  }, [isOpen, highlightedIndex, listId])\n\n  function labelOf(optionValue: string) {\n    return (\n      options.find((option) => option.value === optionValue)?.label ??\n      optionValue\n    )\n  }\n\n  function open() {\n    if (disabled || isOpen) return\n    setHighlightedIndex(0)\n    setIsOpen(true)\n  }\n\n  function activate(index: number) {\n    setActiveIndex(null)\n    if (hasSelectAll && index === 0) {\n      setSelected(toggleAll(selected, visibleOptions))\n      setAnnouncement(\n        allVisibleSelected ? \"Cleared matching options\" : \"Selected all\"\n      )\n      return\n    }\n    const option = visibleOptions[index - offset]\n    if (!option) return\n    const next = toggleValue(selected, option.value, maxSelected)\n    if (next === selected) {\n      setAnnouncement(`Limit of ${maxSelected} reached`)\n      return\n    }\n    setSelected(next)\n    setQuery(\"\")\n    setHighlightedIndex(0)\n    setAnnouncement(\n      next.length > selected.length\n        ? `Added ${option.label}`\n        : `Removed ${option.label}`\n    )\n  }\n\n  function selectChip(index: number | null) {\n    setActiveIndex(index)\n    if (index === null) return\n    setAnnouncement(\n      `${labelOf(selected[index])} selected, ${index + 1} of ${selected.length}. Press Backspace to remove`\n    )\n  }\n\n  function removeAt(index: number) {\n    const removed = selected[index]\n    setSelected(selected.filter((_, itemIndex) => itemIndex !== index))\n    setActiveIndex(null)\n    setAnnouncement(`Removed ${labelOf(removed)}`)\n    inputRef.current?.focus()\n  }\n\n  function clear() {\n    setSelected([])\n    setActiveIndex(null)\n    setAnnouncement(\"Cleared selection\")\n    inputRef.current?.focus()\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n    onKeyDown?.(event)\n    if (event.defaultPrevented) return\n\n    const { selectionStart, selectionEnd } = event.currentTarget\n    const isCaretAtStart = selectionStart === 0 && selectionEnd === 0\n    const lastIndex = selected.length - 1\n\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault()\n        setActiveIndex(null)\n        if (!isOpen) return open()\n        if (itemCount === 0) return\n        const delta = event.key === \"ArrowDown\" ? 1 : -1\n        setHighlightedIndex((highlightedIndex + delta + itemCount) % itemCount)\n        return\n      }\n      case \"Enter\":\n        if (!isOpen || itemCount === 0) return\n        event.preventDefault()\n        activate(highlightedIndex)\n        return\n      case \"Escape\":\n        setActiveIndex(null)\n        if (!isOpen) return\n        event.preventDefault()\n        setIsOpen(false)\n        return\n      case \"Backspace\":\n        if (!isCaretAtStart || selected.length === 0) return\n        event.preventDefault()\n        if (activeIndex === null) selectChip(lastIndex)\n        else removeAt(activeIndex)\n        return\n      case \"Delete\":\n        if (activeIndex === null) return\n        event.preventDefault()\n        removeAt(activeIndex)\n        return\n      case \"ArrowLeft\":\n        if (!isCaretAtStart || selected.length === 0) return\n        event.preventDefault()\n        selectChip(\n          activeIndex === null ? lastIndex : Math.max(0, activeIndex - 1)\n        )\n        return\n      case \"ArrowRight\":\n        if (activeIndex === null) return\n        event.preventDefault()\n        selectChip(activeIndex >= lastIndex ? null : activeIndex + 1)\n        return\n      default:\n        setActiveIndex(null)\n    }\n  }\n\n  function renderOption(option: MultiSelectOption) {\n    const index = visibleOptions.indexOf(option) + offset\n    const isSelected = selected.includes(option.value)\n    return (\n      <div\n        key={option.value}\n        id={`${listId}-${index}`}\n        role=\"option\"\n        aria-selected={isSelected}\n        aria-disabled={isFull && !isSelected ? true : undefined}\n        data-highlighted={index === highlightedIndex ? \"\" : undefined}\n        onPointerMove={() => setHighlightedIndex(index)}\n        onClick={() => activate(index)}\n        className=\"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm aria-disabled:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n      >\n        <SelectionMark checked={isSelected} />\n        {option.label}\n      </div>\n    )\n  }\n\n  return (\n    <Popover\n      open={isOpen}\n      onOpenChange={(nextOpen) => {\n        if (!nextOpen) setIsOpen(false)\n      }}\n    >\n      <PopoverAnchor asChild>\n        <div\n          ref={fieldRef}\n          data-slot=\"multi-select\"\n          data-disabled={disabled ? \"\" : undefined}\n          onClick={() => {\n            inputRef.current?.focus()\n            open()\n          }}\n          className={cn(\n            \"flex min-h-9 w-full min-w-0 cursor-text flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-2 py-1.5 text-base shadow-xs transition-[color,box-shadow] md:text-sm dark:bg-input/30\",\n            \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n            \"has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20\",\n            \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n            className\n          )}\n        >\n          {selected.slice(0, chipCount).map((item, index) => (\n            <Badge\n              key={item}\n              data-slot=\"multi-select-chip\"\n              data-active={activeIndex === index ? \"\" : undefined}\n              variant=\"secondary\"\n              className=\"gap-1 pr-1 data-[active]:ring-2 data-[active]:ring-ring\"\n            >\n              {labelOf(item)}\n              <button\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={`Remove ${labelOf(item)}`}\n                disabled={disabled}\n                onClick={(event) => {\n                  event.stopPropagation()\n                  removeAt(index)\n                }}\n                className=\"rounded-sm text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                <XIcon className=\"size-3\" />\n              </button>\n            </Badge>\n          ))}\n          {hiddenCount > 0 && (\n            <Badge\n              data-slot=\"multi-select-overflow\"\n              data-active={\n                activeIndex !== null && activeIndex >= chipCount\n                  ? \"\"\n                  : undefined\n              }\n              variant=\"outline\"\n              className=\"data-[active]:ring-2 data-[active]:ring-ring\"\n            >\n              +{hiddenCount}\n            </Badge>\n          )}\n          <input\n            ref={inputRef}\n            type=\"text\"\n            role=\"combobox\"\n            data-slot=\"multi-select-input\"\n            aria-autocomplete=\"list\"\n            aria-expanded={isOpen}\n            aria-controls={isOpen ? listId : undefined}\n            aria-activedescendant={\n              isOpen && itemCount > 0\n                ? `${listId}-${highlightedIndex}`\n                : undefined\n            }\n            value={query}\n            disabled={disabled}\n            placeholder={selected.length === 0 ? placeholder : undefined}\n            onChange={(event) => {\n              setQuery(event.target.value)\n              setHighlightedIndex(0)\n              setActiveIndex(null)\n              setIsOpen(true)\n            }}\n            onKeyDown={handleKeyDown}\n            onBlur={(event) => {\n              onBlur?.(event)\n              setActiveIndex(null)\n              setIsOpen(false)\n            }}\n            className=\"h-6 min-w-20 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n            {...props}\n          />\n          {selected.length > 0 && (\n            <button\n              type=\"button\"\n              aria-label=\"Clear selection\"\n              disabled={disabled}\n              onClick={(event) => {\n                event.stopPropagation()\n                clear()\n              }}\n              className=\"ml-auto rounded-sm p-0.5 text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <XIcon className=\"size-4\" />\n            </button>\n          )}\n          <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            {announcement}\n          </span>\n        </div>\n      </PopoverAnchor>\n      <PopoverContent\n        align=\"start\"\n        onOpenAutoFocus={(event) => event.preventDefault()}\n        onCloseAutoFocus={(event) => event.preventDefault()}\n        onPointerDown={(event) => event.preventDefault()}\n        onInteractOutside={(event) => {\n          if (fieldRef.current?.contains(event.target as Node)) {\n            event.preventDefault()\n          }\n        }}\n        className=\"w-(--radix-popover-trigger-width) p-1\"\n      >\n        {itemCount === 0 ? (\n          <p className=\"px-2 py-1.5 text-sm text-muted-foreground\">\n            No results\n          </p>\n        ) : (\n          <div\n            id={listId}\n            role=\"listbox\"\n            aria-multiselectable=\"true\"\n            data-slot=\"multi-select-list\"\n            className=\"max-h-64 overflow-y-auto\"\n          >\n            {hasSelectAll && (\n              <div\n                id={`${listId}-0`}\n                role=\"option\"\n                aria-selected={allVisibleSelected}\n                data-highlighted={highlightedIndex === 0 ? \"\" : undefined}\n                onPointerMove={() => setHighlightedIndex(0)}\n                onClick={() => activate(0)}\n                className=\"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-medium data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n              >\n                <SelectionMark checked={allVisibleSelected} />\n                Select all\n              </div>\n            )}\n            {groups.map((group, groupIndex) =>\n              group.label ? (\n                <div\n                  key={group.label}\n                  role=\"group\"\n                  aria-labelledby={`${listId}-group-${groupIndex}`}\n                >\n                  <div\n                    id={`${listId}-group-${groupIndex}`}\n                    role=\"presentation\"\n                    className=\"px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground\"\n                  >\n                    {group.label}\n                  </div>\n                  {group.options.map(renderOption)}\n                </div>\n              ) : (\n                <React.Fragment key=\"\">\n                  {group.options.map(renderOption)}\n                </React.Fragment>\n              )\n            )}\n          </div>\n        )}\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport { MultiSelect, type MultiSelectOption }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/multi-select.ts",
      "content": "export type MultiSelectOption = {\n  value: string\n  label: string\n  group?: string\n}\n\nexport type MultiSelectGroup = {\n  label?: string\n  options: MultiSelectOption[]\n}\n\nexport function filterOptions(options: MultiSelectOption[], query: string) {\n  const needle = query.trim().toLowerCase()\n  if (!needle) return options\n  return options.filter(\n    (option) =>\n      option.label.toLowerCase().includes(needle) ||\n      option.value.toLowerCase().includes(needle)\n  )\n}\n\nexport function groupOptions(options: MultiSelectOption[]): MultiSelectGroup[] {\n  const groups = new Map<string | undefined, MultiSelectOption[]>()\n  for (const option of options) {\n    groups.set(option.group, [...(groups.get(option.group) ?? []), option])\n  }\n  return Array.from(groups, ([label, groupOptions]) => ({\n    label,\n    options: groupOptions,\n  }))\n}\n\nexport function toggleValue(\n  selected: string[],\n  value: string,\n  maxSelected?: number\n) {\n  if (selected.includes(value)) return selected.filter((item) => item !== value)\n  if (maxSelected !== undefined && selected.length >= maxSelected) {\n    return selected\n  }\n  return [...selected, value]\n}\n\nexport function toggleAll(selected: string[], options: MultiSelectOption[]) {\n  const values = options.map((option) => option.value)\n  if (values.every((value) => selected.includes(value))) {\n    return selected.filter((value) => !values.includes(value))\n  }\n  return [...selected, ...values.filter((value) => !selected.includes(value))]\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}