{
  "$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": [
    "@base-ui/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "badge",
    "popover",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/base/ui/multi-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\"\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\"\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    <PopoverPrimitive.Root\n      open={isOpen}\n      onOpenChange={(nextOpen, { event }) => {\n        if (nextOpen) return\n        if (fieldRef.current?.contains(event.target as Node)) return\n        setIsOpen(false)\n      }}\n    >\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 ? \"\" : 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      <PopoverPrimitive.Portal>\n        <PopoverPrimitive.Positioner\n          anchor={fieldRef}\n          align=\"start\"\n          sideOffset={4}\n          className=\"isolate z-50\"\n        >\n          <PopoverPrimitive.Popup\n            initialFocus={false}\n            finalFocus={false}\n            onPointerDown={(event) => event.preventDefault()}\n            className=\"z-50 w-(--anchor-width) origin-(--transform-origin) rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95\"\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          </PopoverPrimitive.Popup>\n        </PopoverPrimitive.Positioner>\n      </PopoverPrimitive.Portal>\n    </PopoverPrimitive.Root>\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"
}