{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "multi-combobox",
  "title": "Multi Combobox",
  "author": "Matthew Blode",
  "description": "A combobox that allows selecting multiple items with tag-style badges.",
  "dependencies": ["@base-ui/react", "downshift", "lodash", "blode-icons-react"],
  "registryDependencies": ["badge"],
  "files": [
    {
      "path": "ui/multi-combobox.tsx",
      "content": "\"use client\";\n\nimport { ChevronDownIcon, CrossSmallIcon } from \"blode-icons-react\";\nimport { useCombobox, useMultipleSelection } from \"downshift\";\nimport type { UseMultipleSelectionStateChange } from \"downshift\";\nimport snakeCase from \"lodash/snakeCase\";\nimport uniqBy from \"lodash/uniqBy\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Popover, PopoverAnchor, PopoverContent } from \"@/components/ui/popover\";\n\nimport { Badge } from \"./badge\";\nimport type { BadgeProps } from \"./badge\";\n\nexport interface MultiComboboxOption {\n  description?: string;\n  id?: string | number;\n  label?: string;\n  variant?: BadgeProps[\"variant\"];\n}\n\nexport type OnMultiChangeParams = (\n  changes: UseMultipleSelectionStateChange<MultiComboboxOption>,\n) => void;\n\nexport interface MultiComboboxProps {\n  create?: boolean;\n  disabled?: boolean;\n  id?: string;\n  inputClassName?: string;\n  maxDropdownHeight?: number;\n  onChange?: OnMultiChangeParams;\n  onInputChange?: (value: string) => void;\n  options: MultiComboboxOption[];\n  placeholder?: string;\n  ref?: React.Ref<MultiComboboxRef>;\n  startOpen?: boolean;\n  values?: MultiComboboxOption[];\n}\n\nexport interface MultiComboboxRef {\n  clearInput: () => void;\n}\n\nconst getFilteredOptions = (\n  options: MultiComboboxOption[],\n  selectedItems: MultiComboboxOption[],\n  inputValue: string,\n) => {\n  const lowerCasedInputValue = inputValue.toLowerCase();\n\n  return options.filter(\n    (option) =>\n      !selectedItems.some(({ id }) => id === option.id) &&\n      (option.label || \"\").toLowerCase().includes(lowerCasedInputValue),\n  );\n};\n\nconst MultiCombobox = ({\n  options,\n  values,\n  onChange,\n  onInputChange,\n  startOpen,\n  placeholder = \"Filter\",\n  create = false,\n  id,\n  inputClassName,\n  disabled = false,\n  maxDropdownHeight = 250,\n  ref,\n}: MultiComboboxProps) => {\n  const [inputValue, setInputValue] = React.useState(\"\");\n  const [selectedItems, setSelectedItems] = React.useState(values ?? []);\n  const [prevValues, setPrevValues] = React.useState(values);\n\n  if (values !== prevValues) {\n    setPrevValues(values);\n    setSelectedItems(values ?? []);\n  }\n\n  const items = React.useMemo(\n    () => getFilteredOptions(options, selectedItems, inputValue),\n    [options, selectedItems, inputValue],\n  );\n\n  const { getSelectedItemProps, getDropdownProps, removeSelectedItem, addSelectedItem } =\n    useMultipleSelection({\n      onStateChange(changes) {\n        const uniqueItems = uniqBy(changes.selectedItems ?? [], ({ id: itemId }) => itemId);\n\n        switch (changes.type) {\n          case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:\n          case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:\n          case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:\n          case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:\n          case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem: {\n            setSelectedItems(uniqueItems);\n            break;\n          }\n          default: {\n            break;\n          }\n        }\n\n        onChange?.({\n          ...changes,\n          selectedItems: uniqueItems,\n        });\n      },\n      selectedItems,\n    });\n\n  const {\n    isOpen,\n    getToggleButtonProps,\n    getMenuProps,\n    getInputProps,\n    highlightedIndex,\n    getItemProps,\n    setInputValue: comboboxSetInputValue,\n    openMenu,\n  } = useCombobox({\n    defaultHighlightedIndex: 0,\n    initialIsOpen: startOpen,\n    itemToString(item) {\n      return item?.label || \"\";\n    },\n    items,\n    labelId: id,\n    onStateChange(changes) {\n      switch (changes.type) {\n        case useCombobox.stateChangeTypes.InputKeyDownEnter:\n        case useCombobox.stateChangeTypes.ItemClick: {\n          if (changes.selectedItem) {\n            addSelectedItem(changes.selectedItem);\n          }\n          setInputValue(\"\");\n          comboboxSetInputValue(\"\");\n          break;\n        }\n        case useCombobox.stateChangeTypes.InputChange: {\n          const nextValue = changes.inputValue ?? \"\";\n          setInputValue(nextValue);\n          onInputChange?.(nextValue);\n          break;\n        }\n        default: {\n          break;\n        }\n      }\n    },\n    selectedItem: null,\n  });\n\n  const clearInput = React.useCallback(() => {\n    setInputValue(\"\");\n    comboboxSetInputValue(\"\");\n  }, [comboboxSetInputValue]);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      clearInput,\n    }),\n    [clearInput],\n  );\n\n  const handleCreate = () => {\n    const normalizedInput = inputValue.trim();\n    if (!normalizedInput) {\n      return;\n    }\n\n    addSelectedItem({\n      id: snakeCase(normalizedInput),\n      label: normalizedInput,\n    });\n\n    clearInput();\n  };\n\n  const shouldCreate = create && inputValue.trim().length > 0;\n\n  return (\n    <Popover defaultOpen={startOpen} open={isOpen}>\n      <PopoverAnchor asChild>\n        <div\n          className={cn(\n            \"flex min-h-[var(--field-height)] grow appearance-none rounded-[var(--field-radius)] border border-input bg-card bg-clip-border text-base shadow-input focus-within:border-ring focus-within:outline-hidden hover:border-input-hover\",\n            inputClassName,\n          )}\n        >\n          <button\n            aria-label=\"toggle menu\"\n            className=\"relative flex grow cursor-pointer bg-none px-3\"\n            disabled={disabled}\n            type=\"button\"\n            {...getToggleButtonProps()}\n          >\n            <span className=\"flex min-h-[var(--field-height)] grow flex-wrap items-center gap-2 bg-transparent py-1\">\n              {selectedItems.map((selectedItem, index) => (\n                <Badge\n                  key={`${selectedItem.id}-${index}`}\n                  {...getSelectedItemProps({\n                    index,\n                    selectedItem,\n                  })}\n                  variant={selectedItem.variant}\n                >\n                  {selectedItem.label}\n                  <button\n                    aria-label={`Remove ${selectedItem.label ?? \"item\"}`}\n                    className=\"cursor-pointer pl-1\"\n                    onClick={(event) => {\n                      event.stopPropagation();\n                      removeSelectedItem(selectedItem);\n                    }}\n                    type=\"button\"\n                  >\n                    <CrossSmallIcon className=\"size-3.5\" />\n                  </button>\n                </Badge>\n              ))}\n              <input\n                className=\"grow border-none bg-transparent outline-none placeholder:text-placeholder-foreground\"\n                data-testid=\"multi-combobox-input\"\n                placeholder={selectedItems.length === 0 ? placeholder : \"\"}\n                {...getInputProps(\n                  getDropdownProps({\n                    disabled,\n                    id,\n                    onClick: (event) => {\n                      event.stopPropagation();\n                      if (!disabled) {\n                        openMenu();\n                      }\n                    },\n                    onFocus: () => {\n                      if (!disabled) {\n                        openMenu();\n                      }\n                    },\n                    preventKeyAction: isOpen,\n                  }),\n                )}\n              />\n            </span>\n            <div className=\"flex h-full items-center\">\n              <ChevronDownIcon className=\"size-4 opacity-50\" color=\"currentColor\" />\n            </div>\n          </button>\n        </div>\n      </PopoverAnchor>\n\n      <PopoverContent\n        align=\"start\"\n        asChild\n        className=\"popover-content fade-in-80 relative z-110 w-(--anchor-width) max-w-(--available-width) translate-y-1 animate-in overflow-hidden rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-soft\"\n        onOpenAutoFocus={(event) => event.preventDefault()}\n        sideOffset={0}\n      >\n        <div\n          className=\"w-full overflow-y-auto p-1\"\n          style={{ maxHeight: maxDropdownHeight }}\n          {...getMenuProps({}, { suppressRefError: true })}\n        >\n          {shouldCreate ? (\n            <button\n              className=\"w-full cursor-pointer px-4 py-2 text-left\"\n              onClick={handleCreate}\n              type=\"button\"\n            >\n              Create {inputValue}\n            </button>\n          ) : null}\n\n          {isOpen &&\n            items.map((item, index) => (\n              <div\n                className={cn(\"cursor-pointer rounded-lg px-4 py-2\", {\n                  \"bg-accent text-accent-foreground\": highlightedIndex === index,\n                })}\n                key={`${item.id}-${index}`}\n                {...getItemProps({ index, item })}\n              >\n                {item.label}\n              </div>\n            ))}\n\n          {items.length === 0 ? (\n            <div className=\"cursor-not-allowed px-4 py-3 text-center\">\n              <div className=\"text-muted-foreground\">No results</div>\n            </div>\n          ) : null}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport { MultiCombobox };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
