{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "title": "Multi Select",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Search and select several options in a Radix popover with native checkboxes and a selection cap.",
  "dependencies": [
    "radix-ui",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/multi-select.tsx",
      "content": "// Ballmac UI: Multi Select. https://ui.ballmac.com/components/multi-select\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronDown, Search } from \"lucide-react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype MultiSelectOption = { value: string; label: string; disabled?: boolean }\ntype MultiSelectProps = Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  /** Options available for selection. */\n  options: MultiSelectOption[]\n  /** Controlled selected values. */\n  value?: string[]\n  /** Initial values when uncontrolled. */\n  defaultValue?: string[]\n  /** Called with all selected values after a change. */\n  onValueChange?: (value: string[]) => void\n  /** Accessible name of the selector. */\n  label?: string\n  /** Placeholder when no option is selected. */\n  placeholder?: string\n  /** Maximum number of selections. */\n  maxSelected?: number\n  /** Disable the selector. */\n  disabled?: boolean\n  /** Name for repeated hidden form inputs. */\n  name?: string\n}\nfunction MultiSelect({\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  label,\n  placeholder,\n  maxSelected = Number.POSITIVE_INFINITY,\n  disabled = false,\n  name,\n  className,\n  ...props\n}: MultiSelectProps) {\n  const msg = useMessages()\n  label ??= msg(\"multi-select.label\", \"Select options\")\n  placeholder ??= msg(\"multi-select.placeholder\", \"Choose options\")\n  const [internal, setInternal] = React.useState(defaultValue)\n  const [query, setQuery] = React.useState(\"\")\n  const [open, setOpen] = React.useState(false)\n  const selected = value ?? internal\n  const id = React.useId()\n  const filtered = options.filter((option) =>\n    option.label\n      .toLocaleLowerCase(\"en-US\")\n      .includes(query.toLocaleLowerCase(\"en-US\")),\n  )\n  function commit(next: string[]) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n  function toggle(item: string) {\n    if (selected.includes(item))\n      commit(selected.filter((value) => value !== item))\n    else if (selected.length < maxSelected) commit([...selected, item])\n  }\n  return (\n    <div\n      data-slot=\"multi-select\"\n      className={cn(\"w-full min-w-0\", className)}\n      {...props}\n    >\n      <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>\n        <PopoverPrimitive.Trigger\n          data-slot=\"multi-select-trigger\"\n          disabled={disabled}\n          aria-label={label}\n          className=\"flex min-h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-start text-sm shadow-xs outline-none transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50\"\n        >\n          {selected.length ? (\n            <span className=\"flex min-w-0 flex-1 items-center gap-1 overflow-hidden\">\n              {selected.slice(0, 2).map((item) => (\n                <span\n                  key={item}\n                  className=\"max-w-24 shrink-0 truncate rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground\"\n                >\n                  {options.find((option) => option.value === item)?.label ?? item}\n                </span>\n              ))}\n              {selected.length > 2 && (\n                <span className=\"shrink-0 text-xs text-muted-foreground\">\n                  +{selected.length - 2}\n                </span>\n              )}\n            </span>\n          ) : (\n            <span className=\"min-w-0 truncate text-muted-foreground\">\n              {placeholder}\n            </span>\n          )}\n          <ChevronDown\n            aria-hidden=\"true\"\n            className=\"size-4 shrink-0 text-muted-foreground\"\n          />\n        </PopoverPrimitive.Trigger>\n        <PopoverPrimitive.Portal>\n          <PopoverPrimitive.Content\n            data-slot=\"multi-select-content\"\n            align=\"start\"\n            sideOffset={5}\n            className=\"z-50 w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 motion-reduce:animate-none\"\n          >\n            <div className=\"flex items-center gap-2 rounded-md border border-input px-2 focus-within:ring-[3px] focus-within:ring-ring/50\">\n              <Search\n                aria-hidden=\"true\"\n                className=\"size-4 text-muted-foreground\"\n              />\n              <input\n                data-slot=\"multi-select-search\"\n                aria-label={msg(\"multi-select.searchOptions\", \"Search options\")}\n                value={query}\n                onChange={(event) => setQuery(event.target.value)}\n                className=\"h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n                placeholder={msg(\"multi-select.searchOptions2\", \"Search options…\")}\n              />\n            </div>\n            <div\n              role=\"group\"\n              aria-label={label}\n              tabIndex={0}\n              className=\"mt-1 max-h-56 overflow-y-auto outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n            >\n              {filtered.length ? (\n                filtered.map((option) => {\n                  const checked = selected.includes(option.value)\n                  return (\n                    <label\n                      key={option.value}\n                      className={cn(\n                        \"flex min-h-9 cursor-pointer items-center gap-2 rounded-md px-2 text-sm hover:bg-accent\",\n                        (option.disabled ||\n                          (!checked && selected.length >= maxSelected)) &&\n                          \"cursor-not-allowed opacity-50\",\n                      )}\n                    >\n                      <input\n                        type=\"checkbox\"\n                        checked={checked}\n                        disabled={\n                          option.disabled ||\n                          (!checked && selected.length >= maxSelected)\n                        }\n                        onChange={() => toggle(option.value)}\n                        className=\"sr-only peer\"\n                      />\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"flex size-4 items-center justify-center rounded border border-input peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50\"\n                      >\n                        <Check className={cn(\"size-3\", !checked && \"hidden\")} />\n                      </span>\n                      <span className=\"min-w-0 flex-1 truncate\">\n                        {option.label}\n                      </span>\n                    </label>\n                  )\n                })\n              ) : (\n                <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n                  {msg(\"multi-select.noMatchingOptions\", \"No matching options\")}\n                </p>\n              )}\n            </div>\n            <p id={id} className=\"mt-1 px-2 text-xs text-muted-foreground\">\n              {Number.isFinite(maxSelected) ? msg(\"multi-select.selectedOf\", \"{count} selected of {max}\", { count: selected.length, max: maxSelected }) : msg(\"multi-select.selected\", \"{count} selected\", { count: selected.length })}\n            </p>\n          </PopoverPrimitive.Content>\n        </PopoverPrimitive.Portal>\n      </PopoverPrimitive.Root>\n      {name &&\n        selected.map((item) => (\n          <input key={item} type=\"hidden\" name={name} value={item} />\n        ))}\n    </div>\n  )\n}\nexport { MultiSelect, type MultiSelectProps, type MultiSelectOption }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/multi-select.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "multi-select",
      "filter",
      "checkbox"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Search and select several options in a Radix popover with native checkboxes and a selection cap.",
      "whenToUse": [
        "Choose multiple teammates or categories",
        "Filter a list by several states"
      ],
      "whenNotToUse": [
        "Use select when only one option is allowed"
      ],
      "composesWith": [
        "select"
      ],
      "a11y": [
        {
          "keys": "Enter / Space",
          "action": "Opens the option popover"
        },
        {
          "keys": "Tab / Space",
          "action": "Moves to and toggles native checkboxes"
        },
        {
          "keys": "Escape",
          "action": "Closes the popover"
        }
      ],
      "customization": [
        "options, maxSelected",
        "value / defaultValue and onValueChange"
      ]
    },
    "examples": [
      "multi-select-demo",
      "multi-select-states"
    ],
    "url": "https://ui.ballmac.com/components/multi-select"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}