{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ui-multi-select","type":"registry:component","title":"Multi Select","description":"Tag-based multi-select primitive built on command patterns.","version":"1.0.0","status":"ga","files":[{"path":"src/components/ui/multi-select.tsx","type":"registry:component","content":"'use client'\n\nimport * as React from 'react'\n\nimport { useEffect } from 'react'\n\nimport { Command as CommandPrimitive, useCommandState } from 'cmdk'\nimport { XIcon } from 'lucide-react'\n\nimport { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'\nimport { cn } from '@/lib/utils'\n\nexport interface Option {\n  value: string\n  label: string\n  disable?: boolean\n\n  /** fixed option that can't be removed. */\n  fixed?: boolean\n\n  /** Group the options by providing key. */\n  [key: string]: string | boolean | undefined\n}\ninterface GroupOption {\n  [key: string]: Option[]\n}\n\ninterface MultipleSelectorProps {\n  value?: Option[]\n  defaultOptions?: Option[]\n\n  /** manually controlled options */\n  options?: Option[]\n  placeholder?: string\n\n  /** Loading component. */\n  loadingIndicator?: React.ReactNode\n\n  /** Empty component. */\n  emptyIndicator?: React.ReactNode\n\n  /** Debounce time for async search. Only work with `onSearch`. */\n  delay?: number\n\n  /**\n   * Only work with `onSearch` prop. Trigger search when `onFocus`.\n   * For example, when user click on the input, it will trigger the search to get initial options.\n   **/\n  triggerSearchOnFocus?: boolean\n\n  /** async search */\n  onSearch?: (value: string) => Promise<Option[]>\n\n  /**\n   * sync search. This search will not showing loadingIndicator.\n   * The rest props are the same as async search.\n   * i.e.: creatable, groupBy, delay.\n   **/\n  onSearchSync?: (value: string) => Option[]\n  onChange?: (options: Option[]) => void\n\n  /** Limit the maximum number of selected options. */\n  maxSelected?: number\n\n  /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */\n  onMaxSelected?: (maxLimit: number) => void\n\n  /** Hide the placeholder when there are options selected. */\n  hidePlaceholderWhenSelected?: boolean\n  disabled?: boolean\n\n  /** Group the options base on provided key. */\n  groupBy?: string\n  className?: string\n  badgeClassName?: string\n\n  /**\n   * First item selected is a default behavior by cmdk. That is why the default is true.\n   * This is a workaround solution by add a dummy item.\n   *\n   * @reference: https://github.com/pacocoursey/cmdk/issues/171\n   */\n  selectFirstItem?: boolean\n\n  /** Allow user to create option when there is no option matched. */\n  creatable?: boolean\n\n  /** Props of `Command` */\n  commandProps?: React.ComponentPropsWithoutRef<typeof Command>\n\n  /** Props of `CommandInput` */\n  inputProps?: Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>, 'value' | 'placeholder' | 'disabled'>\n\n  /** hide the clear all button. */\n  hideClearAllButton?: boolean\n}\n\nexport interface MultipleSelectorRef {\n  selectedValue: Option[]\n  input: HTMLInputElement\n  focus: () => void\n  reset: () => void\n}\n\nexport function useDebounce<T>(value: T, delay?: number): T {\n  const [debouncedValue, setDebouncedValue] = React.useState<T>(value)\n\n  useEffect(() => {\n    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)\n\n    return () => {\n      clearTimeout(timer)\n    }\n  }, [value, delay])\n\n  return debouncedValue\n}\n\nfunction transToGroupOption(options: Option[], groupBy?: string) {\n  if (options.length === 0) {\n    return {}\n  }\n\n  if (!groupBy) {\n    return {\n      '': options\n    }\n  }\n\n  const groupOption: GroupOption = {}\n\n  options.forEach(option => {\n    const key = (option[groupBy] as string) || ''\n\n    if (!groupOption[key]) {\n      groupOption[key] = []\n    }\n\n    groupOption[key].push(option)\n  })\n\n  return groupOption\n}\n\nfunction removePickedOption(groupOption: GroupOption, picked: Option[]) {\n  const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption\n\n  for (const [key, value] of Object.entries(cloneOption)) {\n    cloneOption[key] = value.filter(val => !picked.find(p => p.value === val.value))\n  }\n\n  return cloneOption\n}\n\nfunction isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {\n  for (const [, value] of Object.entries(groupOption)) {\n    if (value.some(option => targetOption.find(p => p.value === option.value))) {\n      return true\n    }\n  }\n\n  return false\n}\n\nconst CommandEmpty = ({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) => {\n  const render = useCommandState(state => state.filtered.count === 0)\n\n  if (!render) return null\n\n  return <div className={cn('px-2 py-4 text-center text-sm', className)} cmdk-empty='' role='presentation' {...props} />\n}\n\nCommandEmpty.displayName = 'CommandEmpty'\n\nconst MultipleSelector = ({\n  value,\n  onChange,\n  placeholder,\n  defaultOptions: arrayDefaultOptions = [],\n  options: arrayOptions,\n  delay,\n  onSearch,\n  onSearchSync,\n  loadingIndicator,\n  emptyIndicator,\n  maxSelected = Number.MAX_SAFE_INTEGER,\n  onMaxSelected,\n  hidePlaceholderWhenSelected,\n  disabled,\n  groupBy,\n  className,\n  badgeClassName,\n  selectFirstItem = true,\n  creatable = false,\n  triggerSearchOnFocus = false,\n  commandProps,\n  inputProps,\n  hideClearAllButton = false\n}: MultipleSelectorProps) => {\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const [open, setOpen] = React.useState(false)\n  const [onScrollbar, setOnScrollbar] = React.useState(false)\n  const [isLoading, setIsLoading] = React.useState(false)\n  const dropdownRef = React.useRef<HTMLDivElement>(null) // Added this\n\n  const [selected, setSelected] = React.useState<Option[]>(value || [])\n\n  const [options, setOptions] = React.useState<GroupOption>(transToGroupOption(arrayDefaultOptions, groupBy))\n\n  const [inputValue, setInputValue] = React.useState('')\n  const debouncedSearchTerm = useDebounce(inputValue, delay || 500)\n\n  const handleClickOutside = (event: MouseEvent | TouchEvent) => {\n    if (\n      dropdownRef.current &&\n      !dropdownRef.current.contains(event.target as Node) &&\n      inputRef.current &&\n      !inputRef.current.contains(event.target as Node)\n    ) {\n      setOpen(false)\n      inputRef.current.blur()\n    }\n  }\n\n  const handleUnselect = React.useCallback(\n    (option: Option) => {\n      const newOptions = selected.filter(s => s.value !== option.value)\n\n      setSelected(newOptions)\n      onChange?.(newOptions)\n    },\n    [onChange, selected]\n  )\n\n  const handleKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      const input = inputRef.current\n\n      if (input) {\n        if (e.key === 'Delete' || e.key === 'Backspace') {\n          if (input.value === '' && selected.length > 0) {\n            const lastSelectOption = selected[selected.length - 1]\n\n            // If last item is fixed, we should not remove it.\n            if (!lastSelectOption.fixed) {\n              handleUnselect(selected[selected.length - 1])\n            }\n          }\n        }\n\n        // This is not a default behavior of the <input /> field\n        if (e.key === 'Escape') {\n          input.blur()\n        }\n      }\n    },\n    [handleUnselect, selected]\n  )\n\n  useEffect(() => {\n    if (open) {\n      document.addEventListener('mousedown', handleClickOutside)\n      document.addEventListener('touchend', handleClickOutside)\n    } else {\n      document.removeEventListener('mousedown', handleClickOutside)\n      document.removeEventListener('touchend', handleClickOutside)\n    }\n\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside)\n      document.removeEventListener('touchend', handleClickOutside)\n    }\n  }, [open])\n\n  useEffect(() => {\n    if (value) {\n      setSelected(value)\n    }\n  }, [value])\n\n  useEffect(() => {\n    /** If `onSearch` is provided, do not trigger options updated. */\n    if (!arrayOptions || onSearch) {\n      return\n    }\n\n    const newOption = transToGroupOption(arrayOptions || [], groupBy)\n\n    if (JSON.stringify(newOption) !== JSON.stringify(options)) {\n      setOptions(newOption)\n    }\n  }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options])\n\n  useEffect(() => {\n    /** sync search */\n\n    const doSearchSync = () => {\n      const res = onSearchSync?.(debouncedSearchTerm)\n\n      setOptions(transToGroupOption(res || [], groupBy))\n    }\n\n    const exec = async () => {\n      if (!onSearchSync || !open) return\n\n      if (triggerSearchOnFocus) {\n        doSearchSync()\n      }\n\n      if (debouncedSearchTerm) {\n        doSearchSync()\n      }\n    }\n\n    void exec()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])\n\n  useEffect(() => {\n    /** async search */\n\n    const doSearch = async () => {\n      setIsLoading(true)\n      const res = await onSearch?.(debouncedSearchTerm)\n\n      setOptions(transToGroupOption(res || [], groupBy))\n      setIsLoading(false)\n    }\n\n    const exec = async () => {\n      if (!onSearch || !open) return\n\n      if (triggerSearchOnFocus) {\n        await doSearch()\n      }\n\n      if (debouncedSearchTerm) {\n        await doSearch()\n      }\n    }\n\n    void exec()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])\n\n  const CreatableItem = () => {\n    if (!creatable) return undefined\n\n    if (\n      isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||\n      selected.find(s => s.value === inputValue)\n    ) {\n      return undefined\n    }\n\n    const Item = (\n      <CommandItem\n        value={inputValue}\n        className='cursor-pointer'\n        onMouseDown={e => {\n          e.preventDefault()\n          e.stopPropagation()\n        }}\n        onSelect={(value: string) => {\n          if (selected.length >= maxSelected) {\n            onMaxSelected?.(selected.length)\n\n            return\n          }\n\n          setInputValue('')\n          const newOptions = [...selected, { value, label: value }]\n\n          setSelected(newOptions)\n          onChange?.(newOptions)\n        }}\n      >\n        {`Create \"${inputValue}\"`}\n      </CommandItem>\n    )\n\n    // For normal creatable\n    if (!onSearch && inputValue.length > 0) {\n      return Item\n    }\n\n    // For async search creatable. avoid showing creatable item before loading at first.\n    if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {\n      return Item\n    }\n\n    return undefined\n  }\n\n  const EmptyItem = React.useCallback(() => {\n    if (!emptyIndicator) return undefined\n\n    // For async search that showing emptyIndicator\n    if (onSearch && !creatable && Object.keys(options).length === 0) {\n      return (\n        <CommandItem value='-' disabled>\n          {emptyIndicator}\n        </CommandItem>\n      )\n    }\n\n    return <CommandEmpty>{emptyIndicator}</CommandEmpty>\n  }, [creatable, emptyIndicator, onSearch, options])\n\n  const selectables = React.useMemo<GroupOption>(() => removePickedOption(options, selected), [options, selected])\n\n  /** Avoid Creatable Selector freezing or lagging when paste a long string. */\n  const commandFilter = React.useCallback(() => {\n    if (commandProps?.filter) {\n      return commandProps.filter\n    }\n\n    if (creatable) {\n      return (value: string, search: string) => {\n        return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1\n      }\n    }\n\n    // Using default filter in `cmdk`. We don&lsquo;t have to provide it.\n    return undefined\n  }, [creatable, commandProps?.filter])\n\n  return (\n    <Command\n      ref={dropdownRef}\n      {...commandProps}\n      onKeyDown={e => {\n        handleKeyDown(e)\n        commandProps?.onKeyDown?.(e)\n      }}\n      className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}\n      shouldFilter={commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.\n      filter={commandFilter()}\n    >\n      <div\n        className={cn(\n          'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive relative min-h-[38px] rounded-md border text-sm transition-[color,box-shadow] outline-none focus-within:ring-[3px] has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50',\n          {\n            'p-1': selected.length !== 0,\n            'cursor-text': !disabled && selected.length !== 0\n          },\n          !hideClearAllButton && 'pr-9',\n          className\n        )}\n        onClick={() => {\n          if (disabled) return\n          inputRef?.current?.focus()\n        }}\n      >\n        <div className='flex flex-wrap gap-1'>\n          {selected.map(option => {\n            return (\n              <div\n                key={option.value}\n                className={cn(\n                  'animate-fadeIn bg-background text-secondary-foreground hover:bg-background relative inline-flex h-7 cursor-default items-center rounded-md border pr-7 pl-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 data-fixed:pr-2',\n                  badgeClassName\n                )}\n                data-fixed={option.fixed}\n                data-disabled={disabled || undefined}\n              >\n                {option.label}\n                <button\n                  className='text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -right-px flex size-7 items-center justify-center rounded-r-md border border-transparent p-0 outline-hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px]'\n                  onKeyDown={e => {\n                    if (e.key === 'Enter') {\n                      handleUnselect(option)\n                    }\n                  }}\n                  onMouseDown={e => {\n                    e.preventDefault()\n                    e.stopPropagation()\n                  }}\n                  onClick={() => handleUnselect(option)}\n                  aria-label='Remove'\n                >\n                  <XIcon size={14} aria-hidden='true' />\n                </button>\n              </div>\n            )\n          })}\n          {/* Avoid having the \"Search\" Icon */}\n          <CommandPrimitive.Input\n            {...inputProps}\n            ref={inputRef}\n            value={inputValue}\n            disabled={disabled}\n            onValueChange={value => {\n              setInputValue(value)\n              inputProps?.onValueChange?.(value)\n            }}\n            onBlur={event => {\n              if (!onScrollbar) {\n                setOpen(false)\n              }\n\n              inputProps?.onBlur?.(event)\n            }}\n            onFocus={event => {\n              setOpen(true)\n\n              if (triggerSearchOnFocus) {\n                onSearch?.(debouncedSearchTerm)\n              }\n\n              inputProps?.onFocus?.(event)\n            }}\n            placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}\n            className={cn(\n              'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-hidden disabled:cursor-not-allowed',\n              {\n                'w-full': hidePlaceholderWhenSelected,\n                'px-3 py-2': selected.length === 0,\n                'ml-1': selected.length !== 0\n              },\n              inputProps?.className\n            )}\n          />\n          <button\n            type='button'\n            onClick={() => {\n              setSelected(selected.filter(s => s.fixed))\n              onChange?.(selected.filter(s => s.fixed))\n            }}\n            className={cn(\n              'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute top-0 right-0 flex size-9 items-center justify-center rounded-md border border-transparent transition-[color,box-shadow] outline-none focus-visible:ring-[3px]',\n              (hideClearAllButton ||\n                disabled ||\n                selected.length < 1 ||\n                selected.filter(s => s.fixed).length === selected.length) &&\n                'hidden'\n            )}\n            aria-label='Clear all'\n          >\n            <XIcon size={16} aria-hidden='true' />\n          </button>\n        </div>\n      </div>\n      <div className='relative'>\n        <div\n          className={cn(\n            'border-input absolute top-2 z-10 w-full overflow-hidden rounded-md border',\n            'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n            !open && 'hidden'\n          )}\n          data-state={open ? 'open' : 'closed'}\n        >\n          {open && (\n            <CommandList\n              className='bg-popover text-popover-foreground shadow-lg outline-hidden'\n              onMouseLeave={() => {\n                setOnScrollbar(false)\n              }}\n              onMouseEnter={() => {\n                setOnScrollbar(true)\n              }}\n              onMouseUp={() => {\n                inputRef?.current?.focus()\n              }}\n            >\n              {isLoading ? (\n                <>{loadingIndicator}</>\n              ) : (\n                <>\n                  {EmptyItem()}\n                  {CreatableItem()}\n                  {!selectFirstItem && <CommandItem value='-' className='hidden' />}\n                  {Object.entries(selectables).map(([key, dropdowns]) => (\n                    <CommandGroup key={key} heading={key} className='h-full overflow-auto'>\n                      <>\n                        {dropdowns.map(option => {\n                          return (\n                            <CommandItem\n                              key={option.value}\n                              value={option.value}\n                              disabled={option.disable}\n                              onMouseDown={e => {\n                                e.preventDefault()\n                                e.stopPropagation()\n                              }}\n                              onSelect={() => {\n                                if (selected.length >= maxSelected) {\n                                  onMaxSelected?.(selected.length)\n\n                                  return\n                                }\n\n                                setInputValue('')\n                                const newOptions = [...selected, option]\n\n                                setSelected(newOptions)\n                                onChange?.(newOptions)\n                              }}\n                              className={cn(\n                                'cursor-pointer',\n                                option.disable && 'pointer-events-none cursor-not-allowed opacity-50'\n                              )}\n                            >\n                              {option.label}\n                            </CommandItem>\n                          )\n                        })}\n                      </>\n                    </CommandGroup>\n                  ))}\n                </>\n              )}\n            </CommandList>\n          )}\n        </div>\n      </div>\n    </Command>\n  )\n}\n\nMultipleSelector.displayName = 'MultipleSelector'\nexport default MultipleSelector\n"}]}