{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "title": "Multi Select",
  "description": "Select that holds several values, shown as tags in a field that wraps.",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "cmdk@^1.1.1",
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "@pestoui/command",
    "@pestoui/popover",
    "@pestoui/style",
    "@pestoui/tooltip",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/ui/multi-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { defaultFilter } from \"cmdk\"\nimport { ChevronDownIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/components/ui/command\"\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/* The field wraps to as many rows as the tags need, so the size axis sets a MIN\n   height. On a single row it lands exactly on the Input / SelectTrigger box, and\n   it carries the same border + focus treatment — the ring is drawn here because\n   the real focus target is the trigger button nested inside. */\nconst multiSelectVariants = cva(\n  \"group/multi-select flex w-full flex-wrap items-center gap-1.5 border border-input bg-card text-left transition-colors has-[[data-slot=multi-select-trigger]:focus-visible]:border-ring has-[[data-slot=multi-select-trigger]:focus-visible]:ring-3 has-[[data-slot=multi-select-trigger]:focus-visible]:ring-ring/50 has-disabled:cursor-not-allowed has-disabled:bg-input/50 has-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n  {\n    variants: {\n      size: {\n        default: \"min-h-11 rounded-lg px-2 py-1.5 text-body\",\n        sm: \"min-h-9 rounded-[min(var(--radius-md),12px)] px-1.5 py-1 text-sm\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n    },\n  }\n)\n\n/* Entrance pop for a tag that was just picked — 0.9 → 1 with the opacity, on the\n   bouncy spring. A tag is small, arrives one at a time, and is the confirmation\n   that the click in the list landed, which is the low-frequency moment overshoot\n   is reserved for.\n\n   It is applied to exactly one value rather than to every tag, because a mount\n   animation cannot tell \"the user just added this\" from \"the field opened\n   already holding four values\" — and a wall of tags popping on first paint is\n   the failure mode. That is also why Select all never sets it. `anim-*` rather\n   than `duration-*`: the entrance runs long while the field's own colour\n   transition stays short. */\nconst tagAppear =\n  \"animate-in fade-in zoom-in-90 anim-spring-bouncy ease-spring-bouncy\"\n\n/* The field's two icon buttons — a tag's dismiss and the field-level clear —\n   are the same box, so the shape is written once and each adds its own colour\n   and transition. Squircle, not a circle: explicit radii that stay under half\n   the box at both sizes, the same step-down Tag's dismiss uses. */\nconst fieldIconButton =\n  \"inline-flex size-5 shrink-0 items-center justify-center rounded-[7px] text-muted-foreground outline-none hover:bg-foreground/10 hover:text-foreground focus-visible:bg-foreground/10 focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-90 disabled:pointer-events-none group-data-[size=sm]/multi-select:size-4.5 group-data-[size=sm]/multi-select:rounded-[6px] dark:hover:bg-foreground/15 dark:focus-visible:bg-foreground/15 [&_svg]:pointer-events-none [&_svg]:size-3.5 group-data-[size=sm]/multi-select:[&_svg]:size-3\"\n\n/* The tick CommandItem draws for a checked row. Checked is the brand accent;\n   --info is informational only and must never stand in for a selection state.\n   --selected, never --primary: this is a small glyph on an unfilled row, so it\n   carries the state on its own contrast, and the CTA lime is far too light to\n   read there. The important flag is what beats CommandItem's own\n   `data-selected:*:[svg]:text-foreground` while the row is highlighted. */\nconst checkedTick = \"[&>svg:last-child]:text-selected!\"\n\n/* Leading media on a tag: a bare <img> is an avatar, a bare <svg> is a glyph,\n   anything else (a colour dot, a stacked mark) passes through at its own size.\n   Mirrors Tag's leading slot so the two read as the same component family. */\nconst tagLeadingMedia =\n  \"inline-flex shrink-0 items-center justify-center [&>img]:size-5 [&>img]:rounded-full [&>img]:object-cover [&>svg]:size-3.5 group-data-[size=sm]/multi-select:[&>img]:size-4.5 group-data-[size=sm]/multi-select:[&>svg]:size-3\"\n\n/* Same slot on an option row. No svg rule here on purpose — the popover is\n   portalled out of the field, so the size group does not reach it, and\n   CommandItem already sizes a bare glyph to match its own rows. */\nconst optionLeadingMedia =\n  \"inline-flex shrink-0 items-center justify-center [&>img]:size-5 [&>img]:rounded-full [&>img]:object-cover\"\n\n/* Sentinel row values. They live outside the filter store (forceMount items are\n   never registered), so they only have to avoid colliding with a real option. */\nconst ACTIONS_GROUP_VALUE = \"multi-select:actions\"\nconst SELECT_ALL_VALUE = \"multi-select:select-all\"\nconst CLEAR_VALUE = \"multi-select:clear\"\n\ntype MultiSelectOption = {\n  value: string\n  label: string\n  /* Second line under the label. Wired through aria-describedby, so it is never\n     folded into the row's accessible name — \"Engineering, 12 users\" as a name\n     is worse than useless in a long list. */\n  description?: string\n  /* Leading avatar, icon or dot — sized for you when it is a bare svg/img.\n     Rendered on the option row and reused verbatim on the tag. */\n  leading?: React.ReactNode\n  /* Extra search terms beyond the label and the value. */\n  keywords?: string[]\n  disabled?: boolean\n}\n\ntype MultiSelectGroup = {\n  label: string\n  options: MultiSelectOption[]\n}\n\n/* Flat or grouped, and the two may be mixed — a member is a group when it\n   carries its own `options`. Loose options keep their order and share one\n   unlabelled group, so `options` never has to be rewritten to add a heading. */\ntype MultiSelectItems = (MultiSelectOption | MultiSelectGroup)[]\n\ntype MultiSelectProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> &\n  VariantProps<typeof multiSelectVariants> & {\n    options: MultiSelectItems\n    value?: string[]\n    defaultValue?: string[]\n    onValueChange?: (value: string[]) => void\n    placeholder?: string\n    searchPlaceholder?: string\n    emptyMessage?: React.ReactNode\n    /* Tags past this count collapse into a single \"+N\" badge. */\n    maxDisplay?: number\n    /* Hard cap on the selection. Unselected rows go disabled once it is hit;\n       the rows already chosen stay live so they can still be dropped. */\n    maxSelected?: number\n    /* Shown, and announced, while the cap is blocking further picks. */\n    maxSelectedMessage?: string\n    /* Render the search box inside the popover. */\n    showSearch?: boolean\n    /* Leading \"Select all\" / \"Clear\" rows above the list. */\n    showSelectAll?: boolean\n    selectAllLabel?: string\n    clearLabel?: string\n    /* Trailing button on the field that empties it in one action. */\n    clearable?: boolean\n    clearAllLabel?: string\n    disabled?: boolean\n  }\n\nfunction isMultiSelectGroup(\n  item: MultiSelectOption | MultiSelectGroup\n): item is MultiSelectGroup {\n  return Array.isArray((item as MultiSelectGroup).options)\n}\n\nfunction MultiSelect({\n  className,\n  size = \"default\",\n  options,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  placeholder = \"Select options\",\n  searchPlaceholder = \"Search options…\",\n  emptyMessage = \"No options found.\",\n  maxDisplay,\n  maxSelected,\n  maxSelectedMessage,\n  showSearch = true,\n  showSelectAll = false,\n  selectAllLabel = \"Select all\",\n  clearLabel = \"Clear\",\n  clearable = false,\n  clearAllLabel = \"Clear selection\",\n  disabled = false,\n  id,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  ...props\n}: MultiSelectProps) {\n  const [open, setOpen] = React.useState(false)\n  const [search, setSearch] = React.useState(\"\")\n  const [internalValue, setInternalValue] = React.useState<string[]>(\n    () => defaultValue ?? []\n  )\n  /* The one value whose tag should play the entrance pop — set only when the\n     user picks it, so the tags a controlled field mounts with stay still. It is\n     never cleared: the class is inert once its animation has run, and the tag\n     replays it only by mounting again, which is exactly when it should. */\n  const [arrived, setArrived] = React.useState<string>()\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const commandRef = React.useRef<HTMLDivElement>(null)\n  const descriptionBaseId = React.useId()\n\n  const isControlled = valueProp !== undefined\n  const selected = isControlled ? valueProp : internalValue\n\n  /* Loose options and groups normalise to one shape so the render below has a\n     single path. An empty heading renders no heading at all, which is how a\n     flat list keeps looking exactly like a flat list. */\n  const groups = React.useMemo<MultiSelectGroup[]>(() => {\n    const next: MultiSelectGroup[] = []\n    let loose: MultiSelectOption[] | null = null\n    for (const item of options) {\n      if (isMultiSelectGroup(item)) {\n        loose = null\n        next.push(item)\n      } else {\n        if (!loose) {\n          loose = []\n          next.push({ label: \"\", options: loose })\n        }\n        loose.push(item)\n      }\n    }\n    return next\n  }, [options])\n\n  const allOptions = React.useMemo(\n    () => groups.flatMap((group) => group.options),\n    [groups]\n  )\n  const optionByValue = React.useMemo(\n    () => new Map(allOptions.map((option) => [option.value, option])),\n    [allOptions]\n  )\n  /* Built once rather than inline per row: cmdk re-registers an item whenever\n     its keywords array changes identity, and a fresh array every render would\n     mean re-registering the whole list every render. */\n  const keywordsByValue = React.useMemo(() => {\n    const next = new Map<string, string[]>()\n    for (const option of allOptions) {\n      next.set(option.value, [option.label, ...(option.keywords ?? [])])\n    }\n    return next\n  }, [allOptions])\n\n  const atCap = maxSelected !== undefined && selected.length >= maxSelected\n\n  function commit(next: string[]) {\n    if (!isControlled) setInternalValue(next)\n    onValueChange?.(next)\n  }\n\n  function toggle(optionValue: string) {\n    const adding = !selected.includes(optionValue)\n    /* The row is already disabled at the cap; this is the keyboard backstop. */\n    if (adding && atCap) return\n    if (adding) setArrived(optionValue)\n    commit(\n      adding\n        ? [...selected, optionValue]\n        : selected.filter((item) => item !== optionValue)\n    )\n  }\n\n  function remove(optionValue: string) {\n    commit(selected.filter((item) => item !== optionValue))\n    /* The tag being removed is unmounting — hand focus back to the field so\n       keyboard users are not dropped onto <body>. */\n    triggerRef.current?.focus()\n  }\n\n  /* \"Select all\" means all *matching* options while a query is live, which is\n     what the word reads as with a filter applied. Scoring the query here with\n     cmdk's own filter is what keeps the two in step — the list and the row can\n     never disagree about what \"matching\" means. */\n  const selectableMatches = React.useMemo(() => {\n    if (!showSelectAll) return []\n    const pool = search\n      ? allOptions.filter(\n          (option) =>\n            defaultFilter(\n              option.value,\n              search,\n              keywordsByValue.get(option.value)\n            ) > 0\n        )\n      : allOptions\n    return pool.filter((option) => !option.disabled)\n  }, [showSelectAll, search, allOptions, keywordsByValue])\n\n  const allMatchingSelected =\n    selectableMatches.length > 0 &&\n    selectableMatches.every((option) => selected.includes(option.value))\n\n  function selectAll() {\n    const next = [...selected]\n    for (const option of selectableMatches) {\n      if (!next.includes(option.value)) next.push(option.value)\n    }\n    /* Already-selected values sit at the front, so clamping to the cap only\n       ever drops picks the user has not made yet. */\n    commit(maxSelected === undefined ? next : next.slice(0, maxSelected))\n  }\n\n  function clearAll() {\n    commit([])\n    triggerRef.current?.focus()\n  }\n\n  /* Values with no matching option still render, labelled by their own value. */\n  const selectedOptions = selected.map(\n    (item) =>\n      optionByValue.get(item) ?? {\n        value: item,\n        label: item,\n      }\n  )\n  const visibleOptions =\n    maxDisplay === undefined\n      ? selectedOptions\n      : selectedOptions.slice(0, Math.max(maxDisplay, 0))\n  const hiddenOptions = selectedOptions.slice(visibleOptions.length)\n  const overflowCount = hiddenOptions.length\n  const hiddenLabels = hiddenOptions.map((option) => option.label).join(\", \")\n\n  /* role=\"combobox\" is name-from-author, NOT name-from-content — text inside the\n     button is never read as its name. So the field always ships an explicit one,\n     with the count folded in so the selection is announced without walking the\n     tag list. An external aria-labelledby wins outright. */\n  const triggerLabel =\n    selectedOptions.length > 0\n      ? `${ariaLabel ?? placeholder}, ${selectedOptions.length} selected`\n      : (ariaLabel ?? placeholder)\n\n  const capMessage =\n    maxSelected === undefined\n      ? undefined\n      : (maxSelectedMessage ?? `You can select up to ${maxSelected}.`)\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(disabled ? false : next)\n        /* A query is a way of getting to a pick, not part of the selection —\n           it does not survive the popover closing. */\n        if (!next) setSearch(\"\")\n      }}\n    >\n      <PopoverAnchor asChild>\n        <div\n          data-slot=\"multi-select\"\n          data-size={size}\n          aria-invalid={ariaInvalid}\n          className={cn(multiSelectVariants({ size }), className)}\n          {...props}\n        >\n          {selectedOptions.length > 0 && (\n            /* display:contents — the tags wrap in the same flex flow as the\n               trigger instead of forming a second, separately wrapping box. */\n            <ul\n              role=\"list\"\n              aria-label=\"Selected options\"\n              className=\"contents\"\n            >\n              {visibleOptions.map((option) => (\n                <li\n                  key={option.value}\n                  data-slot=\"multi-select-tag\"\n                  className={cn(\n                    \"inline-flex h-7 max-w-full items-center gap-1 rounded-full bg-secondary pr-1 pl-2.5 text-caption font-medium text-secondary-foreground has-data-[slot=multi-select-tag-leading]:pl-1.5 group-data-[size=sm]/multi-select:h-6 group-data-[size=sm]/multi-select:pl-2 group-data-[size=sm]/multi-select:has-data-[slot=multi-select-tag-leading]:pl-1\",\n                    option.value === arrived && tagAppear\n                  )}\n                >\n                  {option.leading ? (\n                    <span\n                      data-slot=\"multi-select-tag-leading\"\n                      aria-hidden=\"true\"\n                      className={tagLeadingMedia}\n                    >\n                      {option.leading}\n                    </span>\n                  ) : null}\n                  <span className=\"min-w-0 truncate\">{option.label}</span>\n                  <button\n                    type=\"button\"\n                    data-slot=\"multi-select-tag-remove\"\n                    disabled={disabled}\n                    onClick={() => remove(option.value)}\n                    className={cn(\n                      fieldIconButton,\n                      \"transition-[color,background-color,scale] duration-spring-press ease-spring-press\"\n                    )}\n                  >\n                    <XIcon aria-hidden=\"true\" />\n                    <span className=\"sr-only\">Remove {option.label}</span>\n                  </button>\n                </li>\n              ))}\n              {overflowCount > 0 && (\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <li\n                      data-slot=\"multi-select-overflow\"\n                      className=\"inline-flex h-7 shrink-0 items-center rounded-full bg-secondary px-2.5 text-caption font-medium tabular-nums text-muted-foreground group-data-[size=sm]/multi-select:h-6 group-data-[size=sm]/multi-select:px-2\"\n                    >\n                      <span aria-hidden=\"true\">+{overflowCount}</span>\n                      {/* The hidden labels are spelled out here as well as in\n                          the tooltip — the pill is not a focus stop, so a\n                          hover-only affordance would leave them unreachable. */}\n                      <span className=\"sr-only\">\n                        {overflowCount} more selected: {hiddenLabels}\n                      </span>\n                    </li>\n                  </TooltipTrigger>\n                  <TooltipContent>{hiddenLabels}</TooltipContent>\n                </Tooltip>\n              )}\n            </ul>\n          )}\n\n          <PopoverTrigger asChild>\n            <button\n              ref={triggerRef}\n              type=\"button\"\n              role=\"combobox\"\n              id={id}\n              data-slot=\"multi-select-trigger\"\n              aria-expanded={open}\n              aria-invalid={ariaInvalid}\n              aria-label={ariaLabelledby ? undefined : triggerLabel}\n              aria-labelledby={ariaLabelledby}\n              disabled={disabled}\n              onKeyDown={(event) => {\n                /* Backspace peels off the last tag, the way a tag input does. */\n                if (event.key === \"Backspace\" && selected.length > 0) {\n                  event.preventDefault()\n                  commit(selected.slice(0, -1))\n                }\n              }}\n              className=\"group/multi-select-trigger flex h-7 min-w-16 flex-1 items-center gap-2 bg-transparent px-2 text-left outline-none group-data-[size=sm]/multi-select:h-6 disabled:cursor-not-allowed\"\n            >\n              {selectedOptions.length === 0 ? (\n                <span className=\"min-w-0 truncate text-muted-foreground\">\n                  {placeholder}\n                </span>\n              ) : null}\n              <ChevronDownIcon\n                aria-hidden=\"true\"\n                /* One chevron that turns: a half turn lands exactly on the up\n                   chevron, so the resting shape is unchanged in both states and\n                   only the change between them is animated. */\n                className=\"ml-auto size-4 shrink-0 text-muted-foreground transition-transform duration-spring-press ease-spring-press group-aria-expanded/multi-select-trigger:rotate-180\"\n              />\n            </button>\n          </PopoverTrigger>\n\n          {/* Trails the trigger rather than sitting inside it: the trigger is\n              the role=\"combobox\", and a button nested in a combobox is a\n              nested-interactive violation. That puts it after the chevron,\n              which is the price of keeping the chevron clickable. */}\n          {clearable && selectedOptions.length > 0 ? (\n            <button\n              type=\"button\"\n              data-slot=\"multi-select-clear\"\n              disabled={disabled}\n              onClick={clearAll}\n              className={cn(\n                fieldIconButton,\n                \"transition-all duration-fast ease-out-expo active:duration-spring-press active:ease-spring-press\"\n              )}\n            >\n              <XIcon aria-hidden=\"true\" />\n              <span className=\"sr-only\">{clearAllLabel}</span>\n            </button>\n          ) : null}\n        </div>\n      </PopoverAnchor>\n\n      <PopoverContent\n        align=\"start\"\n        sideOffset={6}\n        className=\"w-(--radix-popover-trigger-width) min-w-52 p-0\"\n        onOpenAutoFocus={(event) => {\n          /* With no search box there is no tabbable child, so focus lands on\n             the popover itself — one level above Command, where cmdk's keydown\n             handler never sees the arrow keys. Put it on Command instead. */\n          if (!showSearch) {\n            event.preventDefault()\n            commandRef.current?.focus()\n          }\n        }}\n        onInteractOutside={(event) => {\n          /* Dismissing a tag, or clearing the field, is a field interaction and\n             not an outside click — keep the list open so several can be dropped\n             in a row. */\n          const target = event.target as HTMLElement | null\n          if (\n            target?.closest(\n              \"[data-slot='multi-select-tag-remove'],[data-slot='multi-select-clear']\"\n            )\n          ) {\n            event.preventDefault()\n          }\n        }}\n      >\n        <Command\n          ref={commandRef}\n          tabIndex={showSearch ? undefined : -1}\n          className=\"rounded-[18px]! outline-none\"\n        >\n          {showSearch ? (\n            <CommandInput\n              placeholder={searchPlaceholder}\n              value={search}\n              onValueChange={setSearch}\n            />\n          ) : null}\n          <CommandList>\n            {showSelectAll && allOptions.length > 0 ? (\n              <>\n                {/* Rows, not a footer: a footer sits outside the listbox, so\n                    the roving focus arrow keys drive never reaches it.\n                    forceMount keeps them present while a query filters the\n                    list — they are the answer to the query, not a match for\n                    it. */}\n                <CommandGroup forceMount value={ACTIONS_GROUP_VALUE}>\n                  <CommandItem\n                    forceMount\n                    value={SELECT_ALL_VALUE}\n                    onSelect={selectAll}\n                    disabled={\n                      selectableMatches.length === 0 ||\n                      allMatchingSelected ||\n                      atCap\n                    }\n                    data-checked={allMatchingSelected}\n                    className={checkedTick}\n                  >\n                    <span className=\"min-w-0 truncate\">{selectAllLabel}</span>\n                  </CommandItem>\n                  {selected.length > 0 ? (\n                    <CommandItem\n                      forceMount\n                      value={CLEAR_VALUE}\n                      onSelect={() => commit([])}\n                    >\n                      <span className=\"min-w-0 truncate\">{clearLabel}</span>\n                    </CommandItem>\n                  ) : null}\n                </CommandGroup>\n                <CommandSeparator alwaysRender className=\"my-1\" />\n              </>\n            ) : null}\n\n            <CommandEmpty>{emptyMessage}</CommandEmpty>\n\n            {groups.map((group, groupIndex) => (\n              <CommandGroup\n                key={`${groupIndex}-${group.label}`}\n                /* Explicit and unique: cmdk re-sorts groups during a search by\n                   looking each one up through its data-value, and that value\n                   otherwise falls back to the heading — or, for an unlabelled\n                   group, to its own text. Two groups sharing a heading would\n                   then chase the same element. */\n                value={`${groupIndex}-${group.label}`}\n                heading={group.label || undefined}\n              >\n                {group.options.map((option, optionIndex) => {\n                  const isSelected = selected.includes(option.value)\n                  const descriptionId = option.description\n                    ? `${descriptionBaseId}-${groupIndex}-${optionIndex}`\n                    : undefined\n                  return (\n                    <CommandItem\n                      key={option.value}\n                      /* cmdk filters and de-duplicates on `value`, so the id\n                         carries it — two groups sharing a label is normal, two\n                         groups sharing an id is not. The label rides along as a\n                         keyword, which is what the search actually matches. */\n                      value={option.value}\n                      keywords={keywordsByValue.get(option.value)}\n                      onSelect={() => toggle(option.value)}\n                      /* The cap disables what has not been picked yet and\n                         leaves what has, so a full field can still be emptied\n                         from the list. */\n                      disabled={option.disabled || (!isSelected && atCap)}\n                      aria-describedby={descriptionId}\n                      /* data-checked reveals CommandItem's own check mark;\n                         aria-checked is what actually announces the state. */\n                      data-checked={isSelected}\n                      aria-checked={isSelected}\n                      className={cn(checkedTick, option.description && \"py-2.5\")}\n                    >\n                      {option.leading ? (\n                        <span\n                          data-slot=\"multi-select-option-leading\"\n                          aria-hidden=\"true\"\n                          className={optionLeadingMedia}\n                        >\n                          {option.leading}\n                        </span>\n                      ) : null}\n                      {option.description ? (\n                        <span className=\"flex min-w-0 flex-1 flex-col\">\n                          <span className=\"truncate\">{option.label}</span>\n                          {/* Described-by, never part of the name: folding it\n                              into the label would have every row announce as\n                              \"Engineering 12 users\". */}\n                          <span\n                            id={descriptionId}\n                            className=\"truncate text-sm text-muted-foreground\"\n                          >\n                            {option.description}\n                          </span>\n                        </span>\n                      ) : (\n                        <span className=\"min-w-0 truncate\">{option.label}</span>\n                      )}\n                    </CommandItem>\n                  )\n                })}\n              </CommandGroup>\n            ))}\n          </CommandList>\n\n          {capMessage !== undefined ? (\n            /* Rendered whenever a cap exists and left empty until it bites, so\n               the live region is already in the accessibility tree when the\n               text arrives — a region that mounts with its message is the one\n               screen readers stay silent about. */\n            <div\n              role=\"status\"\n              aria-live=\"polite\"\n              data-slot=\"multi-select-cap\"\n              className=\"px-2.5 py-1.5 text-sm text-muted-foreground empty:hidden\"\n            >\n              {atCap ? capMessage : null}\n            </div>\n          ) : null}\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport { MultiSelect, multiSelectVariants }\nexport type {\n  MultiSelectGroup,\n  MultiSelectItems,\n  MultiSelectOption,\n  MultiSelectProps,\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}