{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multiple-selector-dependencies",
  "title": "Multiple Selector Dependencies",
  "description": "component for the Multiple Selector Dependencies",
  "dependencies": [
    "cmdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "command"
  ],
  "files": [
    {
      "path": "app/registry/spectrumui/multiple-selector-dependencies.tsx",
      "content": "\"use client\";\n\nimport { Command as CommandPrimitive, useCommandState } from \"cmdk\";\nimport { X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { forwardRef, useEffect } from \"react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Command,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Option {\n  value: string;\n  label: string;\n  disable?: boolean;\n  /** fixed option that can't be removed. */\n  fixed?: boolean;\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  /** manually controlled options */\n  options?: Option[];\n  placeholder?: string;\n  /** Loading component. */\n  loadingIndicator?: React.ReactNode;\n  /** Empty component. */\n  emptyIndicator?: React.ReactNode;\n  /** Debounce time for async search. Only work with `onSearch`. */\n  delay?: number;\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  /** async search */\n  onSearch?: (value: string) => Promise<Option[]>;\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  /** Limit the maximum number of selected options. */\n  maxSelected?: number;\n  /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */\n  onMaxSelected?: (maxLimit: number) => void;\n  /** Hide the placeholder when there are options selected. */\n  hidePlaceholderWhenSelected?: boolean;\n  disabled?: boolean;\n  /** Group the options base on provided key. */\n  groupBy?: string;\n  className?: string;\n  badgeClassName?: string;\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  /** Allow user to create option when there is no option matched. */\n  creatable?: boolean;\n  /** Props of `Command` */\n  commandProps?: React.ComponentPropsWithoutRef<typeof Command>;\n  /** Props of `CommandInput` */\n  inputProps?: Omit<\n    React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,\n    \"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  if (!groupBy) {\n    return {\n      \"\": options,\n    };\n  }\n\n  const groupOption: GroupOption = {};\n  options.forEach((option) => {\n    const key = (option[groupBy] as string) || \"\";\n    if (!groupOption[key]) {\n      groupOption[key] = [];\n    }\n    groupOption[key].push(option);\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(\n      (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 (\n      value.some((option) => targetOption.find((p) => p.value === option.value))\n    ) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.\n * So we create one and copy the `Empty` implementation from `cmdk`.\n *\n * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607\n **/\nconst CommandEmpty = forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<typeof CommandPrimitive.Empty>\n>(({ className, ...props }, forwardedRef) => {\n  const render = useCommandState((state) => state.filtered.count === 0);\n\n  if (!render) return null;\n\n  return (\n    <div\n      ref={forwardedRef}\n      className={cn(\"py-6 text-center text-sm\", className)}\n      cmdk-empty=\"\"\n      role=\"presentation\"\n      {...props}\n    />\n  );\n});\n\nCommandEmpty.displayName = \"CommandEmpty\";\n\nconst MultipleSelector = React.forwardRef<\n  MultipleSelectorRef,\n  MultipleSelectorProps\n>(\n  (\n    {\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    ref: React.Ref<MultipleSelectorRef>,\n  ) => {\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    const [options, setOptions] = React.useState<GroupOption>(\n      transToGroupOption(arrayDefaultOptions, groupBy),\n    );\n    const [inputValue, setInputValue] = React.useState(\"\");\n    const debouncedSearchTerm = useDebounce(inputValue, delay || 500);\n\n    React.useImperativeHandle(\n      ref,\n      () => ({\n        selectedValue: [...selected],\n        input: inputRef.current as HTMLInputElement,\n        focus: () => inputRef?.current?.focus(),\n        reset: () => setSelected([]),\n      }),\n      [selected],\n    );\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        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        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              // If last item is fixed, we should not remove it.\n              if (!lastSelectOption.fixed) {\n                handleUnselect(selected[selected.length - 1]);\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      const newOption = transToGroupOption(arrayOptions || [], groupBy);\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        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        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      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              return;\n            }\n            setInputValue(\"\");\n            const newOptions = [...selected, { value, label: value }];\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>(\n      () => removePickedOption(options, selected),\n      [options, selected],\n    );\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      // Using default filter in `cmdk`. We don'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(\n          \"h-auto overflow-visible bg-transparent\",\n          commandProps?.className,\n        )}\n        shouldFilter={\n          commandProps?.shouldFilter !== undefined\n            ? commandProps.shouldFilter\n            : !onSearch\n        } // When onSearch is provided, we don't want to filter the options. You can still override it.\n        filter={commandFilter()}\n      >\n        <div\n          className={cn(\n            \"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2\",\n            {\n              \"px-3 py-2\": selected.length !== 0,\n              \"cursor-text\": !disabled && selected.length !== 0,\n            },\n            className,\n          )}\n          onClick={() => {\n            if (disabled) return;\n            inputRef?.current?.focus();\n          }}\n        >\n          <div className=\"relative flex flex-wrap gap-1\">\n            {selected.map((option) => {\n              return (\n                <Badge\n                  key={option.value}\n                  className={cn(\n                    \"data-disabled:bg-muted-foreground data-disabled:text-muted data-disabled:hover:bg-muted-foreground\",\n                    \"data-fixed:bg-muted-foreground data-fixed:text-muted data-fixed:hover:bg-muted-foreground\",\n                    badgeClassName,\n                  )}\n                  data-fixed={option.fixed}\n                  data-disabled={disabled || undefined}\n                >\n                  {option.label}\n                  <button\n                    className={cn(\n                      \"ml-1 rounded-full outline-hidden ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n                      (disabled || option.fixed) && \"hidden\",\n                    )}\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                  >\n                    <X className=\"h-3 w-3 text-muted-foreground hover:text-foreground\" />\n                  </button>\n                </Badge>\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                inputProps?.onBlur?.(event);\n              }}\n              onFocus={(event) => {\n                setOpen(true);\n                triggerSearchOnFocus && onSearch?.(debouncedSearchTerm);\n                inputProps?.onFocus?.(event);\n              }}\n              placeholder={\n                hidePlaceholderWhenSelected && selected.length !== 0\n                  ? \"\"\n                  : placeholder\n              }\n              className={cn(\n                \"flex-1 bg-transparent outline-hidden placeholder:text-muted-foreground\",\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                \"absolute right-0 h-6 w-6 p-0\",\n                (hideClearAllButton ||\n                  disabled ||\n                  selected.length < 1 ||\n                  selected.filter((s) => s.fixed).length === selected.length) &&\n                  \"hidden\",\n              )}\n            >\n              <X />\n            </button>\n          </div>\n        </div>\n        <div className=\"relative\">\n          {open && (\n            <CommandList\n              className=\"absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden animate-in\"\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 && (\n                    <CommandItem value=\"-\" className=\"hidden\" />\n                  )}\n                  {Object.entries(selectables).map(([key, dropdowns]) => (\n                    <CommandGroup\n                      key={key}\n                      heading={key}\n                      className=\"h-full overflow-auto\"\n                    >\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                                  return;\n                                }\n                                setInputValue(\"\");\n                                const newOptions = [...selected, option];\n                                setSelected(newOptions);\n                                onChange?.(newOptions);\n                              }}\n                              className={cn(\n                                \"cursor-pointer\",\n                                option.disable &&\n                                  \"cursor-default text-muted-foreground\",\n                              )}\n                            >\n                              {option.label}\n                            </CommandItem>\n                          );\n                        })}\n                      </>\n                    </CommandGroup>\n                  ))}\n                </>\n              )}\n            </CommandList>\n          )}\n        </div>\n      </Command>\n    );\n  },\n);\n\nMultipleSelector.displayName = \"MultipleSelector\";\nexport default MultipleSelector;\n",
      "type": "registry:component",
      "target": "components/spectrumui/multiple-selector-dependencies.tsx"
    }
  ],
  "type": "registry:component"
}
