{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "title": "Multi Select",
  "author": "AniBT <https://anibt.net>",
  "description": "AniBT subscription picker: chosen values as removable tags in a 32px field, a searchable checklist with a 全部 row and candy tick boxes that stays open while you tick.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@anibt/theme",
    "@anibt/button",
    "@anibt/command",
    "@anibt/popover"
  ],
  "files": [
    {
      "path": "registry/anibt/ui/multi-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ChevronsUpDownIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/registry/anibt/ui/button\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/registry/anibt/ui/command\"\nimport { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from \"@/registry/anibt/ui/popover\"\n\n/**\n * MultiSelect — AniBT 「贴纸手账」 many-choice combobox for an open-ended list\n * (the subscription picker: 字幕组, 标签).\n *\n * The chosen values sit inside a 32px field as removable tags; the rest of\n * the field is the button that opens a searchable checklist. Nothing chosen\n * means \"all\", and the field says so in the muted ink (`placeholder`, e.g.\n * 「全部字幕组」). The list leads with that 「全部」 row, then one row per\n * option with A's 18px candy tick box. It stays open while you tick, so three\n * groups are three clicks, not three openings; the foot shows the count and a\n * 完成 key.\n *\n * In a checklist pink means chosen, so the keyboard cursor here is the\n * neutral sunk press, not the menu's pink wash. A click on a row ticks it\n * without taking focus from the search, so the next keystroke still filters.\n *\n * Built on `@anibt/popover` + `@anibt/command` (Radix + cmdk). The popover is\n * modal, so it scrolls and takes clicks inside a Dialog or a Drawer.\n */\n\ntype MultiSelectOption = {\n  value: string\n  label: string\n  /** Extra words the search matches (romaji, an alias). */\n  keywords?: string\n  /** Drawn before the label in the list: an avatar, an icon. */\n  icon?: React.ReactNode\n  disabled?: boolean\n}\n\ntype MultiSelectProps = {\n  /** The chosen values; empty means \"all\". */\n  value: readonly string[]\n  onValueChange: (value: string[]) => void\n  options: readonly MultiSelectOption[]\n  /** What an empty choice means: 「全部字幕组」. Also the list's first row. */\n  placeholder?: string\n  searchPlaceholder?: string\n  emptyText?: string\n  /** Shown instead of the list when there are no options at all. */\n  noOptionsText?: string\n  /** The 「全部」 row at the top of the list. */\n  showAll?: boolean\n  /** The icon at the start of the field. */\n  icon?: React.ReactNode\n  doneLabel?: React.ReactNode\n  /** The count in the foot: `已选 3 项`. */\n  countLabel?: (count: number) => string\n  /** The accessible name of a tag's remove button. */\n  removeLabel?: (label: string) => string\n  disabled?: boolean\n  id?: string\n  className?: string\n  contentClassName?: string\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\nconst ROW =\n  \"min-h-9 gap-2.5 data-[selected=true]:bg-muted data-[selected=true]:text-foreground\"\n\nconst FOCUS_RING = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid\"\n\n/** The 18px candy tick box a row leads with: drawn, not a control. */\nfunction RowCheck({ checked }: { checked: boolean }) {\n  return (\n    <span aria-hidden=\"true\" className={cn(\"anibt-check\", checked && \"anibt-check-on\")}>\n      {checked ? <CheckIcon className=\"size-3 animate-anibt-pop text-anibt-candy-ink\" strokeWidth={3} /> : null}\n    </span>\n  )\n}\n\n/** Ticks a row without moving focus out of the search. */\nfunction keepSearchFocus(event: React.MouseEvent<HTMLDivElement>) {\n  event.preventDefault()\n}\n\nfunction isCoarsePointer() {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(pointer: coarse)\").matches\n}\n\nfunction MultiSelect({\n  value,\n  onValueChange,\n  options,\n  placeholder = \"全部\",\n  searchPlaceholder = \"搜索…\",\n  emptyText = \"没有匹配的结果\",\n  noOptionsText = \"暂无选项\",\n  showAll = true,\n  icon,\n  doneLabel = \"完成\",\n  countLabel = (count) => `已选 ${count} 项`,\n  removeLabel = (label) => `移除 ${label}`,\n  disabled = false,\n  id,\n  className,\n  contentClassName,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n}: MultiSelectProps) {\n  const [open, setOpen] = React.useState(false)\n  const summaryId = React.useId()\n  const isAll = value.length === 0\n  const labelOf = (item: string) => options.find((option) => option.value === item)?.label ?? item\n\n  const toggle = (item: string) => {\n    const next = value.includes(item) ? value.filter((entry) => entry !== item) : [...value, item]\n    onValueChange(next)\n  }\n\n  const summary = isAll ? placeholder : countLabel(value.length)\n\n  return (\n    <Popover modal open={open} onOpenChange={setOpen}>\n      <PopoverAnchor {...{ asChild: true }}>\n        <div\n          role=\"group\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          data-slot=\"multi-select\"\n          data-state={open ? \"open\" : \"closed\"}\n          data-disabled={disabled || undefined}\n          className={cn(\n            \"anibt-field h-auto min-h-8 flex-wrap gap-1 py-1 pr-1 pl-2.5\",\n            open && \"anibt-field-on\",\n            disabled && \"pointer-events-none bg-muted\",\n            className\n          )}\n        >\n          {icon ? <span className=\"flex shrink-0 items-center text-muted-foreground [&>svg]:size-4\">{icon}</span> : null}\n          {value.map((item) => {\n            const name = labelOf(item)\n            return (\n              <span key={item} className=\"anibt-tag max-w-full gap-0.5 pr-0.5\" title={name}>\n                <span className=\"anibt-ell\">{name}</span>\n                <button\n                  type=\"button\"\n                  aria-label={removeLabel(name)}\n                  disabled={disabled}\n                  onClick={() => toggle(item)}\n                  className={cn(\n                    \"relative grid size-4 shrink-0 place-items-center rounded-[4px] text-muted-foreground transition-colors after:absolute after:-inset-1 hover:bg-accent hover:text-accent-foreground\",\n                    FOCUS_RING\n                  )}\n                >\n                  <XIcon className=\"size-3\" strokeWidth={2.5} aria-hidden=\"true\" />\n                </button>\n              </span>\n            )\n          })}\n          <PopoverTrigger {...{ asChild: true }}>\n            <button\n              type=\"button\"\n              id={id}\n              disabled={disabled}\n              aria-haspopup=\"listbox\"\n              // Named by the field's label plus what is chosen: 「字幕组 已选 2 项」.\n              aria-label={ariaLabelledBy ? undefined : ariaLabel ? `${ariaLabel} · ${summary}` : summary}\n              aria-labelledby={ariaLabelledBy ? `${ariaLabelledBy} ${summaryId}` : undefined}\n              className=\"flex h-6 min-w-12 flex-1 items-center justify-between gap-2 rounded-[6px] pl-0.5 text-left text-[13px] text-muted-foreground outline-none\"\n            >\n              <span className=\"anibt-ell\">{isAll ? placeholder : null}</span>\n              <span id={summaryId} className=\"sr-only\">\n                {summary}\n              </span>\n              <ChevronsUpDownIcon className=\"size-4 shrink-0\" aria-hidden=\"true\" />\n            </button>\n          </PopoverTrigger>\n        </div>\n      </PopoverAnchor>\n      <PopoverContent\n        align=\"start\"\n        onOpenAutoFocus={(event) => {\n          if (isCoarsePointer()) event.preventDefault()\n        }}\n        data-vaul-no-drag=\"\"\n        className={cn(\n          \"w-(--radix-popover-trigger-width) max-w-[calc(100vw-2rem)] min-w-64 gap-0 overflow-hidden p-0\",\n          contentClassName\n        )}\n      >\n        <Command loop data-slot=\"multi-select-content\">\n          <CommandInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} />\n          <CommandList className=\"max-h-[min(45dvh,18rem)] px-1\">\n            <CommandEmpty>{emptyText}</CommandEmpty>\n            {showAll ? (\n              <CommandGroup className=\"p-0\">\n                <CommandItem\n                  value={`* ${placeholder}`}\n                  aria-checked={isAll}\n                  onMouseDown={keepSearchFocus}\n                  onSelect={() => onValueChange([])}\n                  className={ROW}\n                >\n                  <RowCheck checked={isAll} />\n                  <span className=\"anibt-ell min-w-0 flex-1 font-semibold\">{placeholder}</span>\n                </CommandItem>\n              </CommandGroup>\n            ) : null}\n            {showAll ? <CommandSeparator /> : null}\n            {options.length === 0 ? (\n              <p className=\"px-2.5 py-2 text-[12px] text-muted-foreground\">{noOptionsText}</p>\n            ) : (\n              <CommandGroup className=\"p-0\">\n                {options.map((option) => {\n                  const checked = value.includes(option.value)\n                  return (\n                    <CommandItem\n                      key={option.value}\n                      value={[option.label, option.keywords, option.value].filter(Boolean).join(\" \")}\n                      disabled={option.disabled}\n                      aria-checked={checked}\n                      onMouseDown={keepSearchFocus}\n                      onSelect={() => toggle(option.value)}\n                      className={ROW}\n                    >\n                      <RowCheck checked={checked} />\n                      {option.icon ? <span className=\"flex shrink-0 items-center\">{option.icon}</span> : null}\n                      <span className=\"anibt-ell min-w-0 flex-1\" title={option.label}>\n                        {option.label}\n                      </span>\n                    </CommandItem>\n                  )\n                })}\n              </CommandGroup>\n            )}\n          </CommandList>\n          <div className=\"anibt-stitch-over flex h-11 shrink-0 items-center justify-between gap-2 px-3\">\n            <span className=\"anibt-num text-[12px] text-muted-foreground\">{summary}</span>\n            <Button type=\"button\" size=\"sm\" variant=\"secondary\" onClick={() => setOpen(false)}>\n              {doneLabel}\n            </Button>\n          </div>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport { MultiSelect }\nexport type { MultiSelectOption, MultiSelectProps }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms",
    "overlay"
  ],
  "type": "registry:ui"
}