{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"multi-select","type":"registry:ui","title":"Multi Select","description":"Pick several options from a searchable list; the picks show as removable chips.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/command.json","https://dashral.com/r/field.json","https://dashral.com/r/popover.json"],"files":[{"path":"registry/dashral/multi-select.tsx","type":"registry:file","target":"components/dashral/multi-select.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ChevronsUpDownIcon, XIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\nimport { CommandRoot, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from \"./command\"\nimport { PopoverRoot, PopoverAnchor, PopoverContent, PopoverTrigger } from \"./popover\"\n\nexport type MultiSelectOption = { value: string; label: string; disabled?: boolean }\n\ntype MultiSelectProps = FieldOwnProps & {\n  options: MultiSelectOption[]\n  /** Controlled value. Omit it (and use defaultValue) for an uncontrolled select. */\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (value: string[]) => void\n  placeholder?: string\n  searchPlaceholder?: string\n  emptyText?: string\n  /** Chips shown before the rest collapse into \"+3\". */\n  maxShown?: number\n  /** At most this many can be picked; the rest of the list is disabled once it's reached. */\n  maxSelected?: number\n  id?: string\n  disabled?: boolean\n  required?: boolean\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  /** The box's classes. */\n  className?: string\n  \"aria-label\"?: string\n  classNames?: FieldClassNames & { box?: string; content?: string; item?: string }\n}\n\n/**\n * Pick several options from a searchable list. Picked ones show as chips you can remove one by one; Backspace on the\n * field removes the last. Built from Popover and Command, like Combobox.\n */\nfunction MultiSelect(props: MultiSelectProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <MultiSelectControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction MultiSelectControl({\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  placeholder: placeholderProp,\n  searchPlaceholder: searchPlaceholderProp,\n  emptyText: emptyTextProp,\n  maxShown = 3,\n  maxSelected,\n  className,\n  classNames,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: Omit<MultiSelectProps, keyof FieldOwnProps | \"required\">) {\n  const { t } = useLocale()\n  const emptyText = emptyTextProp ?? t(\"noResults\")\n  const searchPlaceholder = searchPlaceholderProp ?? t(\"search\")\n  const placeholder = placeholderProp ?? t(\"selectOptions\")\n  const { id, disabled, ...aria } = useFieldControl<FieldControlProps>({ id: rest.id, disabled: rest.disabled, \"aria-invalid\": rest[\"aria-invalid\"] })\n  const [open, setOpen] = React.useState(false)\n  const [internal, setInternal] = React.useState(defaultValue)\n  const current = value ?? internal\n  const selected = current.map((v) => options.find((o) => o.value === v)).filter((o) => o !== undefined)\n  const full = maxSelected !== undefined && current.length >= maxSelected\n\n  function set(next: string[]) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n  const toggle = (v: string) => set(current.includes(v) ? current.filter((x) => x !== v) : [...current, v])\n\n  return (\n    <PopoverRoot open={open} onOpenChange={setOpen}>\n      <PopoverAnchor asChild>\n        <div\n          data-slot=\"multi-select\"\n          data-disabled={disabled || undefined}\n          className={cn(\n            \"flex min-h-9 w-full items-center gap-1 rounded-md border border-input bg-transparent py-1 pe-1 ps-1.5 text-sm shadow-xs transition-[color,box-shadow]\",\n            \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15 data-disabled:pointer-events-none data-disabled:opacity-50\",\n            \"has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/15\",\n            className,\n            classNames?.box\n          )}\n        >\n          {selected.length > 0 && (\n            <ul aria-label={t(\"selected\")} className=\"flex flex-wrap items-center gap-1\">\n              {selected.slice(0, maxShown).map((option) => (\n                <li key={option.value} className=\"inline-flex h-6 items-center gap-0.5 rounded bg-secondary pe-0.5 ps-2 text-xs font-medium text-secondary-foreground\">\n                  {option.label}\n                  <button\n                    type=\"button\"\n                    aria-label={t(\"remove\", { name: String(option.label) })}\n                    className=\"flex size-5 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/15\"\n                    onClick={() => toggle(option.value)}\n                  >\n                    <XIcon className=\"size-3\" aria-hidden />\n                  </button>\n                </li>\n              ))}\n              {selected.length > maxShown && (\n                <li className=\"px-1 text-xs text-muted-foreground tabular-nums\">+{selected.length - maxShown}</li>\n              )}\n            </ul>\n          )}\n          <PopoverTrigger asChild>\n            <button\n              type=\"button\"\n              id={id}\n              role=\"combobox\"\n              aria-expanded={open}\n              aria-label={ariaLabel}\n              aria-describedby={aria[\"aria-describedby\"]}\n              aria-invalid={aria[\"aria-invalid\"]}\n              disabled={disabled}\n              className=\"flex h-7 min-w-16 flex-1 items-center justify-between gap-2 rounded-sm px-1.5 text-start outline-none\"\n              onKeyDown={(event) => {\n                if (event.key === \"Backspace\" && current.length > 0) {\n                  event.preventDefault()\n                  set(current.slice(0, -1))\n                }\n              }}\n            >\n              <span className={cn(\"truncate\", selected.length > 0 ? \"sr-only\" : \"text-muted-foreground\")}>\n                {selected.length > 0 ? `${selected.length} selected` : placeholder}\n              </span>\n              <ChevronsUpDownIcon className=\"ms-auto size-4 shrink-0 opacity-50\" aria-hidden />\n            </button>\n          </PopoverTrigger>\n        </div>\n      </PopoverAnchor>\n      <PopoverContent className={cn(\"w-(--radix-popover-trigger-width) min-w-56 p-0\", classNames?.content)} align=\"start\">\n        <CommandRoot>\n          <CommandInput placeholder={searchPlaceholder} />\n          <CommandList>\n            <CommandEmpty>{emptyText}</CommandEmpty>\n            <CommandGroup>\n              {options.map((option) => {\n                const checked = current.includes(option.value)\n                return (\n                  <CommandItem\n                    key={option.value}\n                    value={option.label}\n                    disabled={option.disabled || (!checked && full)}\n                    onSelect={() => toggle(option.value)}\n                    className={classNames?.item}\n                  >\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"flex size-4 items-center justify-center rounded-[4px] border border-input\",\n                        checked && \"border-primary bg-primary text-primary-foreground\"\n                      )}\n                    >\n                      {checked && <CheckIcon className=\"size-3 text-current\" />}\n                    </span>\n                    {option.label}\n                    {/* cmdk uses aria-selected for the highlighted item, so the picked state is spoken this way. */}\n                    {checked && <span className=\"sr-only\">, selected</span>}\n                  </CommandItem>\n                )\n              })}\n            </CommandGroup>\n            {current.length > 0 && (\n              <>\n                <CommandSeparator />\n                <CommandGroup>\n                  <CommandItem value=\"__clear__\" onSelect={() => set([])} className=\"justify-center text-muted-foreground\">\n                    Clear selection\n                  </CommandItem>\n                </CommandGroup>\n              </>\n            )}\n          </CommandList>\n        </CommandRoot>\n      </PopoverContent>\n    </PopoverRoot>\n  )\n}\n\nexport { MultiSelect }\n"}]}