{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ui-multi-select",
  "title": "UI Multi Select",
  "author": "AlgorizeTech",
  "description": "A multi-select combobox with search, clearable chips, hierarchical options and optional portal rendering. Use it when users pick several values from a long list.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/ui/multi-select.tsx",
      "content": "\"use client\";\n\nimport {\n  useState,\n  useRef,\n  useEffect,\n  type KeyboardEvent,\n  type MouseEvent,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { ChevronDown, X, Check, Search, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type MultiSelectOption = {\n  value: string;\n  label: string;\n  children?: MultiSelectOption[];\n};\n\nexport type MultiSelectValue =\n  | MultiSelectOption\n  | MultiSelectOption[]\n  | null\n  | undefined;\n\ntype MultiSelectProps = {\n  options: MultiSelectOption[];\n  value: MultiSelectValue;\n  onChange: (value: MultiSelectValue) => void;\n  placeholder?: string;\n  isClearable?: boolean;\n  isMulti?: boolean;\n  className?: string;\n  portal?: boolean;\n  loading?: boolean;\n  hierarchical?: boolean;\n};\n\ntype DropdownPosition = {\n  top: number;\n  left: number;\n  width: number;\n};\n\nexport default function MultiSelect({\n  options,\n  value,\n  onChange,\n  placeholder = \"Select...\",\n  isClearable = true,\n  isMulti = false,\n  className = \"\",\n  portal = true,\n  loading = false,\n  hierarchical = false,\n}: MultiSelectProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [searchTerm, setSearchTerm] = useState(\"\");\n  const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition>({\n    top: 0,\n    left: 0,\n    width: 0,\n  });\n  const containerRef = useRef<HTMLDivElement>(null);\n  const dropdownRef = useRef<HTMLDivElement>(null);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const [highlightedIndex, setHighlightedIndex] = useState(-1);\n\n  const filterOptionsRecursive = (\n    opts: MultiSelectOption[],\n    term: string,\n  ): MultiSelectOption[] => {\n    if (!term) return opts;\n    return opts\n      .filter((option) => {\n        const labelMatch = option.label\n          ?.toLowerCase()\n          ?.includes(term.toLowerCase());\n        const hasMatchingChildren = option.children\n          ? filterOptionsRecursive(option.children, term).length > 0\n          : false;\n        return labelMatch || hasMatchingChildren;\n      })\n      .map((option) => {\n        if (option.children) {\n          return {\n            ...option,\n            children: filterOptionsRecursive(option.children, term),\n          };\n        }\n        return option;\n      });\n  };\n\n  const filteredOptions = hierarchical\n    ? filterOptionsRecursive(options || [], searchTerm)\n    : (options || []).filter((option) =>\n        option.label?.toLowerCase()?.includes(searchTerm.toLowerCase()),\n      );\n\n  const flattenOptions = (\n    opts: MultiSelectOption[],\n  ): MultiSelectOption[] => {\n    const result: MultiSelectOption[] = [];\n    for (const opt of opts) {\n      result.push(opt);\n      if (opt.children) result.push(...flattenOptions(opt.children));\n    }\n    return result;\n  };\n  const flatFilteredOptions = hierarchical\n    ? flattenOptions(filteredOptions)\n    : filteredOptions;\n  const flatIndexMap = new Map<string, number>();\n  flatFilteredOptions.forEach((opt, i) => flatIndexMap.set(opt.value, i));\n\n  // Resetting on a change is done during render rather than in an effect: an\n  // effect would paint one frame with the stale search term and highlight.\n  const [prevIsOpen, setPrevIsOpen] = useState(isOpen);\n  if (prevIsOpen !== isOpen) {\n    setPrevIsOpen(isOpen);\n    setHighlightedIndex(-1);\n    if (!isOpen) setSearchTerm(\"\");\n  }\n\n  const [prevSearchTerm, setPrevSearchTerm] = useState(searchTerm);\n  if (prevSearchTerm !== searchTerm) {\n    setPrevSearchTerm(searchTerm);\n    setHighlightedIndex(-1);\n  }\n\n  useEffect(() => {\n    if (highlightedIndex >= 0 && listRef.current) {\n      const item = listRef.current.querySelector(\n        `[data-option-index=\"${highlightedIndex}\"]`,\n      );\n      if (item) item.scrollIntoView({ block: \"nearest\" });\n    }\n  }, [highlightedIndex]);\n\n  const calculatePosition = () => {\n    if (!containerRef.current) return;\n    const rect = containerRef.current.getBoundingClientRect();\n    setDropdownPosition({\n      top: rect.bottom + 4,\n      left: rect.left,\n      width: rect.width,\n    });\n  };\n\n  useEffect(() => {\n    const handleClickOutside = (event: globalThis.MouseEvent) => {\n      if (containerRef.current?.contains(event.target as Node)) return;\n      if (dropdownRef.current?.contains(event.target as Node)) return;\n      setIsOpen(false);\n    };\n    const handleScroll = () => {\n      if (isOpen) calculatePosition();\n    };\n    const handleResize = () => {\n      if (isOpen) calculatePosition();\n    };\n    document.addEventListener(\"mousedown\", handleClickOutside, true);\n    window.addEventListener(\"scroll\", handleScroll, true);\n    window.addEventListener(\"resize\", handleResize);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside, true);\n      window.removeEventListener(\"scroll\", handleScroll, true);\n      window.removeEventListener(\"resize\", handleResize);\n    };\n  }, [isOpen]);\n\n  useEffect(() => {\n    if (isOpen && searchInputRef.current) {\n      calculatePosition();\n      searchInputRef.current.focus();\n    }\n  }, [isOpen]);\n\n  const handleSelect = (option: MultiSelectOption) => {\n    if (isMulti) {\n      const currentValue = Array.isArray(value) ? value : [];\n      const isAlreadySelected = currentValue.some(\n        (item) => item.value === option.value,\n      );\n      if (isAlreadySelected) {\n        onChange(currentValue.filter((item) => item.value !== option.value));\n      } else {\n        onChange([...currentValue, option]);\n      }\n    } else {\n      onChange(option);\n      setIsOpen(false);\n    }\n    setSearchTerm(\"\");\n  };\n\n  const isSelected = (option: MultiSelectOption) => {\n    if (isMulti) {\n      return (\n        Array.isArray(value) &&\n        value.some((item) => item.value === option.value)\n      );\n    }\n    return (\n      value !== null &&\n      value !== undefined &&\n      !Array.isArray(value) &&\n      value.value === option.value\n    );\n  };\n\n  const handleClear = (e: MouseEvent) => {\n    e.stopPropagation();\n    onChange(isMulti ? [] : null);\n    setSearchTerm(\"\");\n  };\n\n  const handleRemoveValue = (e: MouseEvent, option: MultiSelectOption) => {\n    e.stopPropagation();\n    const currentValue = Array.isArray(value) ? value : [];\n    onChange(currentValue.filter((item) => item.value !== option.value));\n  };\n\n  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    const optionsCount = flatFilteredOptions.length;\n    switch (e.key) {\n      case \"ArrowDown\":\n        if (optionsCount === 0) break;\n        e.preventDefault();\n        setHighlightedIndex((prev) =>\n          prev < optionsCount - 1 ? prev + 1 : 0,\n        );\n        break;\n      case \"ArrowUp\":\n        if (optionsCount === 0) break;\n        e.preventDefault();\n        setHighlightedIndex((prev) =>\n          prev > 0 ? prev - 1 : optionsCount - 1,\n        );\n        break;\n      case \"Enter\":\n        if (highlightedIndex >= 0 && highlightedIndex < optionsCount) {\n          e.preventDefault();\n          handleSelect(flatFilteredOptions[highlightedIndex]);\n        }\n        break;\n      case \"Escape\":\n        e.preventDefault();\n        setIsOpen(false);\n        break;\n    }\n  };\n\n  const renderOption = (option: MultiSelectOption, level = 0) => {\n    const hasChildren = option.children && option.children.length > 0;\n    const selected = isSelected(option);\n    const indexForOption = flatIndexMap.get(option.value) ?? -1;\n\n    return (\n      <div key={option.value} onClick={(e) => e.stopPropagation()}>\n        <li\n          data-option-index={indexForOption}\n          onClick={(e) => {\n            e.stopPropagation();\n            handleSelect(option);\n          }}\n          onMouseEnter={() => setHighlightedIndex(indexForOption)}\n          className={cn(\n            \"flex cursor-pointer items-center py-2 text-sm transition-colors relative\",\n            selected\n              ? \"bg-primary/5 text-primary\"\n              : highlightedIndex === indexForOption\n                ? \"bg-muted\"\n                : \"hover:bg-muted\",\n          )}\n          style={{ paddingLeft: `${level * 20 + 12}px` }}\n        >\n          {isMulti && (\n            <div\n              className={cn(\n                \"mr-2 flex h-4 w-4 items-center justify-center rounded flex-shrink-0\",\n                selected ? \"bg-primary/50\" : \"border border-border\",\n              )}\n            >\n              {selected && <Check className=\"h-3 w-3\" />}\n            </div>\n          )}\n          <span className=\"flex-1\">{option.label}</span>\n          {!isMulti && selected && (\n            <Check className=\"ml-auto h-4 w-4 text-primary\" />\n          )}\n        </li>\n        {hasChildren && (\n          <div>\n            {option.children!.map((child) => renderOption(child, level + 1))}\n          </div>\n        )}\n      </div>\n    );\n  };\n\n  const renderDropdown = () => (\n    <div\n      ref={dropdownRef}\n      className=\"absolute overflow-hidden rounded-md border bg-card shadow-lg z-50\"\n      style={\n        portal\n          ? {\n              position: \"fixed\",\n              top: `${dropdownPosition.top}px`,\n              left: `${dropdownPosition.left}px`,\n              width: `${dropdownPosition.width}px`,\n              zIndex: 99999,\n              maxHeight: \"200px\",\n            }\n          : {\n              top: \"100%\",\n              left: 0,\n              right: 0,\n              maxHeight: \"200px\",\n            }\n      }\n      onClick={(e) => e.stopPropagation()}\n    >\n      <div className=\"relative border-b border-border\">\n        <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground\" />\n        <input\n          ref={searchInputRef}\n          type=\"text\"\n          value={searchTerm}\n          onChange={(e) => setSearchTerm(e.target.value)}\n          onKeyDown={handleKeyDown}\n          className=\"w-full border-0 py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-0 bg-transparent\"\n          placeholder=\"Search...\"\n          onClick={(e) => e.stopPropagation()}\n        />\n      </div>\n      <ul\n        ref={listRef}\n        className=\"max-h-44 overflow-auto p-1.5 space-y-0.5\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {loading ? (\n          <li className=\"flex items-center justify-center px-3 py-3 text-sm text-muted-foreground\">\n            <Loader2 className=\"h-4 w-4 animate-spin mr-2\" />\n            Loading...\n          </li>\n        ) : filteredOptions.length === 0 ? (\n          <li className=\"px-3 py-2 text-sm text-muted-foreground\">\n            {searchTerm ? \"No options found\" : \"No options available\"}\n          </li>\n        ) : hierarchical ? (\n          filteredOptions.map((option) => renderOption(option, 0))\n        ) : (\n          filteredOptions.map((option, idx) => (\n            <li\n              key={option.value}\n              data-option-index={idx}\n              onClick={(e) => {\n                e.stopPropagation();\n                handleSelect(option);\n              }}\n              onMouseEnter={() => setHighlightedIndex(idx)}\n              className={cn(\n                \"flex cursor-pointer items-center px-3 py-2 text-sm rounded-md transition-all duration-150 font-medium\",\n                isSelected(option)\n                  ? \"bg-primary text-primary-foreground font-semibold\"\n                  : highlightedIndex === idx\n                    ? \"bg-primary/10 text-primary\"\n                    : \"hover:bg-primary/10 hover:text-primary text-foreground\"\n              )}\n            >\n              {isMulti && (\n                <div\n                  className={cn(\n                    \"mr-2 flex h-4 w-4 items-center justify-center rounded\",\n                    isSelected(option)\n                      ? \"bg-primary-foreground/20 text-primary-foreground\"\n                      : \"border border-border\",\n                  )}\n                >\n                  {isSelected(option) && <Check className=\"h-3 w-3\" />}\n                </div>\n              )}\n              <span>{option.label}</span>\n              {!isMulti && isSelected(option) && (\n                <Check className=\"ml-auto h-4 w-4 text-primary-foreground\" />\n              )}\n            </li>\n          ))\n        )}\n      </ul>\n    </div>\n  );\n\n  const hasValue = isMulti\n    ? Array.isArray(value) && value.length > 0\n    : value !== null && value !== undefined && !Array.isArray(value);\n\n  return (\n    <div ref={containerRef} className={cn(\"relative transition-all duration-300\", isOpen && !portal && \"pb-[210px]\", className)}>\n      <div\n        className=\"flex min-h-10 cursor-pointer items-center justify-between rounded-sm border border-border bg-background px-3 md:py-2.5 py-3 text-sm\"\n        tabIndex={0}\n        onClick={() => !loading && setIsOpen(!isOpen)}\n        onKeyDown={(e) => {\n          if (\n            (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") &&\n            !isOpen &&\n            !loading\n          ) {\n            e.preventDefault();\n            setIsOpen(true);\n          }\n        }}\n      >\n        <div\n          className={cn(\n            \"flex flex-1 items-center gap-1\",\n            isMulti ? \"overflow-hidden whitespace-nowrap\" : \"flex-wrap\",\n          )}\n        >\n          {isMulti ? (\n            Array.isArray(value) && value.length > 0 ? (\n              <div className=\"flex gap-1 overflow-hidden\">\n                {value.length <= 3 ? (\n                  value.map((item) => (\n                    <div\n                      key={item.value}\n                      className=\"flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs flex-shrink-0\"\n                    >\n                      {item.label}\n                      <button\n                        onClick={(e) => handleRemoveValue(e, item)}\n                        className=\"ml-1 text-muted-foreground hover:text-foreground cursor-pointer\"\n                      >\n                        <X className=\"h-3 w-3\" />\n                      </button>\n                    </div>\n                  ))\n                ) : (\n                  <>\n                    <div className=\"flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs flex-shrink-0\">\n                      {value[0].label}\n                      <button\n                        onClick={(e) => handleRemoveValue(e, value[0])}\n                        className=\"ml-1 text-muted-foreground hover:text-foreground cursor-pointer\"\n                      >\n                        <X className=\"h-3 w-3\" />\n                      </button>\n                    </div>\n                    <div className=\"flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs flex-shrink-0\">\n                      +{value.length - 1} more\n                    </div>\n                  </>\n                )}\n              </div>\n            ) : (\n              <span className=\"text-muted-foreground\">{placeholder}</span>\n            )\n          ) : value && !Array.isArray(value) ? (\n            <span>{value.label}</span>\n          ) : (\n            <span className=\"text-muted-foreground\">{placeholder}</span>\n          )}\n        </div>\n        <div className=\"flex items-center\">\n          {isClearable && hasValue && !loading && (\n            <button\n              onClick={handleClear}\n              className=\"mr-1 rounded-full p-1 text-muted-foreground hover:bg-muted cursor-pointer hover:text-muted-foreground\"\n            >\n              <X className=\"h-3 w-3\" />\n            </button>\n          )}\n          {loading ? (\n            <Loader2 className=\"h-4 w-4 animate-spin ml-2 text-muted-foreground\" />\n          ) : (\n            <ChevronDown\n              className={`h-4 w-4 transition-transform ml-2 ${isOpen ? \"rotate-180\" : \"\"}`}\n            />\n          )}\n        </div>\n      </div>\n      {isOpen && (\n        <>\n          {portal && typeof window !== \"undefined\" ? (\n            createPortal(renderDropdown(), document.body)\n          ) : (\n            <div className=\"absolute z-50 mt-1 w-full\">{renderDropdown()}</div>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/custom/ui/multi-select.tsx"
    }
  ],
  "docs": "Named export: MultiSelect. Set `hierarchical` with nested `children` options for parent/child groups, and `portal` to escape a clipping overflow container. `isMulti` and `isClearable` are independent, so it also works as a searchable single select.",
  "categories": [
    "forms",
    "utilities"
  ],
  "type": "registry:ui"
}
