{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "type": "registry:ui",
  "title": "Multi Select",
  "description": "Searchable multi-select with chips, select-all, a max limit, and async option loading — built on Popover + cmdk, with a plain string[] value.",
  "dependencies": [
    "@radix-ui/react-popover",
    "@saasflare/ui",
    "cmdk",
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/multi-select.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n/**\n * @fileoverview Saasflare MultiSelect — searchable, chip-rendering multi-select.\n * @module packages/ui/components/ui/multi-select\n * @package ui\n * @layer core\n *\n * Data-driven (`options` array), self-contained multi-select built on the SAME\n * stack as the single-select Combobox (Radix Popover + cmdk) with zero new\n * runtime dependencies. It owns its own selection / query / open state\n * (controlled OR uncontrolled), so consumers get select-all, max, clearable,\n * and chips out of the box instead of hand-wiring Combobox + Badge + useState\n * (the gap the Combobox JSDoc explicitly calls out as \"not built in\").\n *\n * Async option loading is supported and DOCUMENTED rather than a new prop\n * surface: pass `loading` + an updated `options` array and debounce your fetch\n * off `onSearchChange`. The presence of `onSearchChange` flips cmdk to\n * `shouldFilter={false}` so the server is the filter; omit it for built-in\n * client-side fuzzy search.\n *\n * Select-all scope: operates on the CURRENTLY-FILTERED, non-disabled options\n * (and respects `max`), toggling between \"select all\" and \"Clear\".\n *\n * @example\n * import { MultiSelect, type MultiSelectOption } from \"@saasflare/ui\"\n *\n * const options: MultiSelectOption[] = [\n *   { value: \"react\", label: \"React\" },\n *   { value: \"vue\", label: \"Vue\" },\n *   { value: \"svelte\", label: \"Svelte\" },\n * ]\n *\n * const [value, setValue] = React.useState<string[]>([])\n * <MultiSelect options={options} value={value} onValueChange={setValue} />\n */\n\nimport * as React from \"react\"\nimport { AnimatePresence, m } from \"motion/react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { cn } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\nimport { spring, useSaasflareMotion } from \"@saasflare/ui\"\nimport { CaretDownIcon, CheckIcon, CircleNotchIcon, MagnifyingGlassIcon, XIcon } from \"@saasflare/ui\"\nimport { Badge } from \"@saasflare/ui\"\n\n// React-style dev warnings: the consumer's bundler replaces process.env.NODE_ENV.\ndeclare const process: { readonly env: { readonly NODE_ENV?: string } }\n\n/**\n * Motion-wrapped chip span for the AnimatePresence enter/exit path. MUST be\n * defined at module top level — defining it inside the component creates a fresh\n * component identity per render and breaks React reconciliation (button.tsx\n * MotionSlot rule).\n */\nconst MotionChip = m.create(\"span\")\n\n/** A selectable option in {@link MultiSelect}. */\nexport interface MultiSelectOption {\n  /** Stable unique key + the value stored in `value[]`. */\n  value: string\n  /** Visible label (also the chip text + the search match target). */\n  label: string\n  /** Optional group heading; options sharing a `group` render under one cmdk group. */\n  group?: string\n  /** Disable selecting/deselecting this option. */\n  disabled?: boolean\n  /** Optional leading node (icon/avatar) rendered in the list row. */\n  icon?: React.ReactNode\n}\n\n/** Motion/HTML event keys that collide with React's — stripped from the root div. */\ntype MultiSelectDomConflicts =\n  | \"onDrag\"\n  | \"onDragStart\"\n  | \"onDragEnd\"\n  | \"onAnimationStart\"\n  | \"onAnimationEnd\"\n  | \"onChange\"\n  | \"value\"\n  | \"defaultValue\"\n\n/** Props for {@link MultiSelect}. Extends the 4-axis Saasflare contract. */\nexport interface MultiSelectProps\n  extends Omit<React.ComponentProps<\"div\">, MultiSelectDomConflicts | keyof SaasflareComponentProps>,\n    SaasflareComponentProps {\n  /** Options to choose from. Update this array (+ `loading`) for async loading. */\n  options: MultiSelectOption[]\n  /** Controlled selected values (array of `option.value`). */\n  value?: string[]\n  /** Uncontrolled initial selection. @default [] */\n  defaultValue?: string[]\n  /** Fires on every selection change with the next value array. */\n  onValueChange?: (value: string[]) => void\n  /** Trigger placeholder when nothing is selected. @default \"Select…\" */\n  placeholder?: string\n  /** Search input placeholder. @default \"Search…\" */\n  searchPlaceholder?: string\n  /** Node shown when the query matches no option. @default \"No results.\" */\n  emptyMessage?: React.ReactNode\n  /** Max number of selectable values. Over-limit options become non-interactive (data-disabled) and a \"Max N selected\" hint shows. */\n  max?: number\n  /** Show a clear-all (×) affordance on the trigger when selection is non-empty. @default true */\n  clearable?: boolean\n  /** Show a \"Select all / Clear\" header row (operates on the CURRENTLY FILTERED, non-disabled options). @default false */\n  selectAll?: boolean\n  /** Label for the select-all row. @default \"Select all\" */\n  selectAllLabel?: string\n  /** Close the popover after each pick. @default false (multi-pick stays open) */\n  closeOnSelect?: boolean\n  /** Max chip ROWS shown collapsed on the trigger before overflowing to \"+N more\" (HeroUI isMultiline analog). @default 1 */\n  maxRows?: number\n  /** Async: render a spinner row + aria-busy; pair with `onSearchChange` for server filtering. @default false */\n  loading?: boolean\n  /** Controlled search query (optional). */\n  searchValue?: string\n  /** Fires on query change. PRESENCE of this prop flips cmdk to `shouldFilter={false}` (server-side filtering); omit it for built-in client fuzzy search. */\n  onSearchChange?: (query: string) => void\n  /** Disable the whole control. */\n  disabled?: boolean\n  /** Forwarded to the cmdk list for ARIA. */\n  \"aria-label\"?: string\n  /** className on the trigger button (root data-axes div wraps it). */\n  className?: string\n  /** className on the popover content. */\n  contentClassName?: string\n}\n\n/**\n * Approximate the number of chips that fit in `maxRows` rows before collapsing\n * to a \"+N more\" badge. Pragmatic v1 heuristic: ~3 chips per row (no pixel\n * measuring). Documented so behaviour is predictable rather than fragile.\n */\nconst CHIPS_PER_ROW = 3\n\n/**\n * Searchable, chip-rendering multi-select on Radix Popover + cmdk.\n *\n * Resolves the four orthogonal axes (`surface` / `radius` / `animated` /\n * `iconWeight`) via {@link useSaasflareProps} and emits `data-surface` /\n * `data-radius` / `data-animated` on the root; the axes are forwarded to the\n * PORTALLED popover content so `surface=\"glass\"` theming carries into the\n * dropdown.\n *\n * Selection is controlled via `value` or uncontrolled via `defaultValue`\n * (identical guard to tag-input). `onValueChange` always fires with the next\n * array. Select-all is CURRENT-FILTER scope and respects `max`.\n *\n * @component\n * @layer core\n *\n * @param {MultiSelectOption[]} options - Options to choose from.\n * @param {string[]} value - Controlled selected values.\n * @param {string[]} defaultValue - Uncontrolled initial selection.\n * @param {(value: string[]) => void} onValueChange - Fires on every selection change.\n * @param {number} max - Max selectable values; over-limit options become non-interactive.\n * @param {boolean} selectAll - Show a select-all / clear header row.\n * @param {boolean} loading - Async spinner row + aria-busy.\n * @param {(query: string) => void} onSearchChange - Lifts the query AND flips cmdk to server-side filtering.\n * @param {string} surface - Surface style override (inherits from provider when omitted).\n * @param {string} radius - Radius preset override (inherits from provider when omitted).\n * @param {string} iconWeight - Phosphor icon weight override (inherits from provider when omitted).\n * @param {boolean} animated - Gate motion effects (inherits from provider when omitted).\n *\n * @example\n * // Controlled multi-select with chips + search\n * const [value, setValue] = React.useState<string[]>([\"react\"])\n * <MultiSelect\n *   options={[\n *     { value: \"react\", label: \"React\" },\n *     { value: \"vue\", label: \"Vue\" },\n *   ]}\n *   value={value}\n *   onValueChange={setValue}\n * />\n *\n * @example\n * // Select-all header + max limit + collapse to \"+N more\"\n * <MultiSelect options={options} selectAll max={3} maxRows={1} />\n *\n * @example\n * // Async server filtering: presence of onSearchChange disables client filter\n * <MultiSelect\n *   options={remoteOptions}\n *   loading={isFetching}\n *   onSearchChange={(q) => debouncedFetch(q)}\n * />\n */\nexport function MultiSelect({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  placeholder = \"Select…\",\n  searchPlaceholder = \"Search…\",\n  emptyMessage = \"No results.\",\n  max,\n  clearable = true,\n  selectAll = false,\n  selectAllLabel = \"Select all\",\n  closeOnSelect = false,\n  maxRows = 1,\n  loading = false,\n  searchValue,\n  onSearchChange,\n  disabled = false,\n  surface,\n  radius,\n  animated,\n  iconWeight,\n  className,\n  contentClassName,\n  \"aria-label\": ariaLabel,\n  ...props\n}: MultiSelectProps): React.ReactElement {\n  const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n  const motion = useSaasflareMotion(sf.animated, spring)\n\n  /* ── Controlled / uncontrolled selection split (tag-input pattern) ── */\n  const isControlled = value !== undefined\n  const [internal, setInternal] = React.useState<string[]>(defaultValue ?? [])\n  const selected = isControlled ? (value as string[]) : internal\n\n  /* ── Query is internal but lift-able via searchValue / onSearchChange ── */\n  const isQueryControlled = searchValue !== undefined\n  const [internalQuery, setInternalQuery] = React.useState(\"\")\n  const query = isQueryControlled ? (searchValue as string) : internalQuery\n\n  /* ── open state is always internal ── */\n  const [open, setOpen] = React.useState(false)\n\n  // Presence of onSearchChange means the parent owns filtering (server-side).\n  const serverFiltered = onSearchChange !== undefined\n\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const listId = React.useId()\n  const liveId = React.useId()\n\n  /* ── Fast lookup: value -> option (for chip labels + stale-key fallback) ── */\n  const optionByValue = React.useMemo(() => {\n    const map = new Map<string, MultiSelectOption>()\n    for (const opt of options) map.set(opt.value, opt)\n    return map\n  }, [options])\n\n  const atMax = typeof max === \"number\" && selected.length >= max\n\n  const commit = React.useCallback(\n    (next: string[]) => {\n      if (!isControlled) setInternal(next)\n      onValueChange?.(next)\n    },\n    [isControlled, onValueChange],\n  )\n\n  const setQuery = React.useCallback(\n    (next: string) => {\n      if (!isQueryControlled) setInternalQuery(next)\n      onSearchChange?.(next)\n    },\n    [isQueryControlled, onSearchChange],\n  )\n\n  /* ── Toggle: remove if present, else add unless max reached (no-op) ── */\n  const toggle = React.useCallback(\n    (val: string) => {\n      const opt = optionByValue.get(val)\n      if (opt?.disabled) return\n      if (selected.includes(val)) {\n        commit(selected.filter((v) => v !== val))\n      } else {\n        if (typeof max === \"number\" && selected.length >= max) return\n        commit([...selected, val])\n      }\n      if (closeOnSelect) setOpen(false)\n    },\n    [optionByValue, selected, commit, max, closeOnSelect],\n  )\n\n  const removeValue = React.useCallback(\n    (val: string) => {\n      commit(selected.filter((v) => v !== val))\n    },\n    [commit, selected],\n  )\n\n  const clearAll = React.useCallback(() => {\n    commit([])\n  }, [commit])\n\n  /* ── Currently visible (filtered) options, for select-all scope. When the\n   * parent owns filtering, `options` is already the filtered set; otherwise\n   * apply the same case-insensitive label substring cmdk uses for the heading\n   * scope. cmdk still drives the actual list rendering + keyboard nav. ── */\n  const filteredOptions = React.useMemo(() => {\n    if (serverFiltered || query.trim() === \"\") return options\n    const q = query.trim().toLowerCase()\n    return options.filter((o) => o.label.toLowerCase().includes(q))\n  }, [serverFiltered, query, options])\n\n  // Non-disabled subset of the filtered options drives select-all behaviour.\n  const selectableFiltered = React.useMemo(\n    () => filteredOptions.filter((o) => !o.disabled),\n    [filteredOptions],\n  )\n\n  const allFilteredSelected =\n    selectableFiltered.length > 0 &&\n    selectableFiltered.every((o) => selected.includes(o.value))\n\n  const handleSelectAll = React.useCallback(() => {\n    if (allFilteredSelected) {\n      // Clear only the filtered, selectable values (leave others intact).\n      const filteredVals = new Set(selectableFiltered.map((o) => o.value))\n      commit(selected.filter((v) => !filteredVals.has(v)))\n      return\n    }\n    // Add filtered selectable values up to `max`, preserving existing order.\n    const next = [...selected]\n    const present = new Set(selected)\n    for (const o of selectableFiltered) {\n      if (typeof max === \"number\" && next.length >= max) break\n      if (!present.has(o.value)) {\n        next.push(o.value)\n        present.add(o.value)\n      }\n    }\n    commit(next)\n  }, [allFilteredSelected, selectableFiltered, selected, commit, max])\n\n  /* ── Dev guidance: max should be ≥ 1 to be meaningful ── */\n  if (process.env.NODE_ENV !== \"production\") {\n    if (typeof max === \"number\" && max < 1) {\n      console.warn(\n        \"[Saasflare][MultiSelect] `max` should be a positive integer; values below 1 disable all selection.\",\n      )\n    }\n  }\n\n  /* ── Group options by `group` for cmdk groups, preserving first-seen order. ── */\n  const groups = React.useMemo(() => {\n    const order: (string | undefined)[] = []\n    const byGroup = new Map<string | undefined, MultiSelectOption[]>()\n    for (const opt of options) {\n      if (!byGroup.has(opt.group)) {\n        byGroup.set(opt.group, [])\n        order.push(opt.group)\n      }\n      byGroup.get(opt.group)!.push(opt)\n    }\n    return order.map((g) => ({ group: g, items: byGroup.get(g)! }))\n  }, [options])\n\n  /* ── Trigger Backspace removes the last chip only when there is a selection.\n   * (The search input has its own empty-query guard below.) ── */\n  const onTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (disabled) return\n    if (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault()\n      setOpen(true)\n    }\n    if (e.key === \"Backspace\" && selected.length > 0) {\n      e.preventDefault()\n      removeValue(selected[selected.length - 1])\n    }\n  }\n\n  /* ── Backspace in the EMPTY search input removes the last chip (tag-input\n   * parity). Must NOT fire mid-query. ── */\n  const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Backspace\" && query === \"\" && selected.length > 0) {\n      e.preventDefault()\n      removeValue(selected[selected.length - 1])\n    }\n  }\n\n  /* ── Collapse chips to \"+N more\" past the maxRows-derived threshold. ── */\n  const visibleCount = Math.max(1, maxRows) * CHIPS_PER_ROW\n  const overflowCount = selected.length > visibleCount ? selected.length - visibleCount : 0\n  const visibleSelected = overflowCount > 0 ? selected.slice(0, visibleCount) : selected\n\n  const hasSelection = selected.length > 0\n  const showClear = clearable && hasSelection && !disabled\n\n  return (\n    <div\n      data-slot=\"multi-select\"\n      data-surface={sf.surface}\n      data-radius={sf.radius}\n      data-animated={String(sf.animated)}\n      data-disabled={String(disabled)}\n      className={cn(\"w-full\", className)}\n      {...props}\n    >\n      <PopoverPrimitive.Root open={open} onOpenChange={disabled ? undefined : setOpen}>\n        <PopoverPrimitive.Trigger asChild>\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            role=\"combobox\"\n            aria-haspopup=\"listbox\"\n            aria-expanded={open}\n            aria-controls={listId}\n            aria-label={ariaLabel}\n            aria-disabled={disabled || undefined}\n            disabled={disabled}\n            data-slot=\"multi-select-trigger\"\n            onKeyDown={onTriggerKeyDown}\n            className={cn(\n              \"flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none\",\n              \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n              \"data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50\",\n            )}\n            data-disabled={String(disabled)}\n          >\n            {!hasSelection ? (\n              <span className=\"px-1 text-muted-foreground\">{placeholder}</span>\n            ) : (\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {visibleSelected.map((val) => {\n                  const opt = optionByValue.get(val)\n                  // Stale-value fallback: show the raw key, don't drop the chip.\n                  const label = opt?.label ?? val\n                  return (\n                    <MotionChip\n                      key={val}\n                      initial={motion.disabled ? false : { opacity: 0, scale: 0.85 }}\n                      animate={motion.disabled ? false : { opacity: 1, scale: 1 }}\n                      exit={motion.disabled ? undefined : { opacity: 0, scale: 0.85 }}\n                      transition={motion.transition}\n                      className=\"inline-flex\"\n                    >\n                      <Badge\n                        variant=\"soft\"\n                        intent=\"neutral\"\n                        animated={false}\n                        data-slot=\"multi-select-chip\"\n                        className=\"gap-1 pr-1\"\n                      >\n                        <span className=\"max-w-[12rem] truncate\">{label}</span>\n                        {/* Mouse-only affordance: a focusable control may not nest\n                          * inside the trigger <button>. Keyboard parity comes from\n                          * Backspace on the trigger / empty search input. */}\n                        <span\n                          aria-hidden=\"true\"\n                          data-slot=\"multi-select-chip-remove\"\n                          onClick={(e) => {\n                            e.stopPropagation()\n                            if (!disabled) removeValue(val)\n                          }}\n                          className=\"inline-flex size-3.5 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground\"\n                        >\n                          <XIcon weight={sf.iconWeight} className=\"size-2.5\" />\n                        </span>\n                      </Badge>\n                    </MotionChip>\n                  )\n                })}\n                {overflowCount > 0 ? (\n                  <span key=\"__overflow\" className=\"inline-flex\">\n                    <Badge variant=\"soft\" intent=\"neutral\" animated={false} data-slot=\"multi-select-overflow\">\n                      {`+${overflowCount} more`}\n                    </Badge>\n                  </span>\n                ) : null}\n              </AnimatePresence>\n            )}\n\n            <span className=\"ml-auto flex shrink-0 items-center gap-1 pl-1\">\n              {showClear ? (\n                <span\n                  aria-hidden=\"true\"\n                  data-slot=\"multi-select-clear\"\n                  onClick={(e) => {\n                    e.stopPropagation()\n                    clearAll()\n                  }}\n                  className=\"inline-flex size-4 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground\"\n                >\n                  <XIcon weight={sf.iconWeight} className=\"size-3.5\" />\n                </span>\n              ) : null}\n              <CaretDownIcon\n                weight={sf.iconWeight}\n                aria-hidden=\"true\"\n                className=\"size-4 shrink-0 opacity-50\"\n              />\n            </span>\n          </button>\n        </PopoverPrimitive.Trigger>\n\n        <PopoverPrimitive.Portal>\n          <PopoverPrimitive.Content\n            data-slot=\"multi-select-content\"\n            data-surface={sf.surface}\n            data-radius={sf.radius}\n            data-animated={String(sf.animated)}\n            align=\"start\"\n            sideOffset={6}\n            onOpenAutoFocus={(e) => {\n              // Keep focus inside the popover (cmdk auto-focuses its input).\n              e.preventDefault()\n            }}\n            className={cn(\n              \"z-50 w-[var(--radix-popover-trigger-width)] min-w-[12rem] origin-[var(--radix-popover-content-transform-origin)] overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n              contentClassName,\n            )}\n          >\n            <CommandPrimitive\n              data-slot=\"multi-select-command\"\n              shouldFilter={!serverFiltered}\n              loop\n              className=\"flex w-full flex-col\"\n            >\n              <div\n                data-slot=\"multi-select-input-wrapper\"\n                className=\"flex h-9 items-center gap-2 border-b px-3\"\n              >\n                <MagnifyingGlassIcon\n                  weight={sf.iconWeight}\n                  className=\"size-4 shrink-0 opacity-50\"\n                />\n                <CommandPrimitive.Input\n                  data-slot=\"multi-select-input\"\n                  value={query}\n                  onValueChange={setQuery}\n                  onKeyDown={onSearchKeyDown}\n                  placeholder={searchPlaceholder}\n                  className=\"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n                />\n              </div>\n\n              {atMax ? (\n                <div\n                  data-slot=\"multi-select-max-hint\"\n                  className=\"border-b px-3 py-1.5 text-xs text-muted-foreground\"\n                >\n                  {`Max ${max} selected`}\n                </div>\n              ) : null}\n\n              <CommandPrimitive.List\n                id={listId}\n                role=\"listbox\"\n                aria-multiselectable=\"true\"\n                aria-busy={loading || undefined}\n                aria-label={ariaLabel}\n                data-slot=\"multi-select-list\"\n                className=\"max-h-[min(24rem,var(--radix-popover-content-available-height))] scroll-py-1 overflow-x-hidden overflow-y-auto p-1\"\n              >\n                {loading ? (\n                  <div\n                    data-slot=\"multi-select-loading\"\n                    className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\"\n                  >\n                    <CircleNotchIcon\n                      weight=\"regular\"\n                      aria-hidden=\"true\"\n                      className=\"size-4 animate-spin\"\n                    />\n                    <span>Loading…</span>\n                  </div>\n                ) : (\n                  <>\n                    <CommandPrimitive.Empty\n                      data-slot=\"multi-select-empty\"\n                      className=\"py-6 text-center text-sm text-muted-foreground\"\n                    >\n                      {emptyMessage}\n                    </CommandPrimitive.Empty>\n\n                    {selectAll && selectableFiltered.length > 0 ? (\n                      <CommandPrimitive.Item\n                        data-slot=\"multi-select-select-all\"\n                        value=\"__select_all__\"\n                        onSelect={handleSelectAll}\n                        className=\"relative flex w-full cursor-default items-center gap-2 rounded-sm border-b py-1.5 pr-8 pl-2 text-sm font-medium outline-hidden select-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground\"\n                      >\n                        {allFilteredSelected ? \"Clear\" : selectAllLabel}\n                        {allFilteredSelected ? (\n                          <CheckIcon weight={sf.iconWeight} className=\"absolute right-2 size-4\" />\n                        ) : null}\n                      </CommandPrimitive.Item>\n                    ) : null}\n\n                    {groups.map(({ group, items }) => {\n                      const rows = items.map((opt) => {\n                        const isSelected = selected.includes(opt.value)\n                        // Over-limit unselected options are non-interactive.\n                        const overLimit = !isSelected && atMax\n                        const itemDisabled = opt.disabled === true || overLimit\n                        return (\n                          <CommandPrimitive.Item\n                            key={opt.value}\n                            data-slot=\"multi-select-item\"\n                            value={opt.value}\n                            keywords={[opt.label]}\n                            disabled={itemDisabled}\n                            data-checked={isSelected ? \"true\" : undefined}\n                            onSelect={() => toggle(opt.value)}\n                            className=\"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n                          >\n                            {opt.icon ? <span className=\"shrink-0\">{opt.icon}</span> : null}\n                            <span className=\"truncate\">{opt.label}</span>\n                            {isSelected ? (\n                              <CheckIcon\n                                weight={sf.iconWeight}\n                                data-slot=\"multi-select-item-indicator\"\n                                className=\"absolute right-2 size-4\"\n                              />\n                            ) : null}\n                          </CommandPrimitive.Item>\n                        )\n                      })\n\n                      if (group === undefined) return <React.Fragment key=\"__ungrouped__\">{rows}</React.Fragment>\n\n                      return (\n                        <CommandPrimitive.Group\n                          key={group}\n                          heading={group}\n                          data-slot=\"multi-select-group\"\n                          className=\"overflow-hidden text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\"\n                        >\n                          {rows}\n                        </CommandPrimitive.Group>\n                      )\n                    })}\n                  </>\n                )}\n              </CommandPrimitive.List>\n            </CommandPrimitive>\n          </PopoverPrimitive.Content>\n        </PopoverPrimitive.Portal>\n      </PopoverPrimitive.Root>\n\n      {/* Visually-hidden live region announcing the selection count. */}\n      <span\n        id={liveId}\n        aria-live=\"polite\"\n        className=\"sr-only\"\n      >\n        {`${selected.length} selected`}\n      </span>\n    </div>\n  )\n}\n",
      "target": "components/ui/multi-select.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/multi-select.json"
  }
}
