"use client" import * as React from "react" import { CheckIcon, ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { cn, stopInteractivePropagation } from "@/lib/utils" export type ComboboxOption = { value: TValue label: React.ReactNode description?: React.ReactNode disabled?: boolean disabledReason?: React.ReactNode keywords?: string[] data?: TData } export type ComboboxGroup = { label?: React.ReactNode options: ComboboxOption[] } export type ComboboxLabels = { placeholder?: string searchPlaceholder?: string empty?: string clear?: string } export type ComboboxProps = Omit, "onChange"> & { value?: TValue defaultValue?: TValue onValueChange?: (value: TValue | undefined, option?: ComboboxOption) => void options?: ComboboxOption[] groups?: ComboboxGroup[] disabled?: boolean clearable?: boolean searchable?: boolean open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void labels?: ComboboxLabels invalid?: boolean renderOption?: (option: ComboboxOption, state: { selected: boolean }) => React.ReactNode renderValue?: (option: ComboboxOption) => React.ReactNode filterOption?: (option: ComboboxOption, search: string) => boolean triggerClassName?: string contentClassName?: string searchClassName?: string optionClassName?: string } function useControllableState({ value, defaultValue, onChange, }: { value?: TValue defaultValue: TValue onChange?: (value: TValue) => void }) { const [internalValue, setInternalValue] = React.useState(defaultValue) const isControlled = value !== undefined const currentValue = isControlled ? value : internalValue const setValue = React.useCallback( (nextValue: TValue) => { if (!isControlled) { setInternalValue(nextValue) } onChange?.(nextValue) }, [isControlled, onChange] ) return [currentValue, setValue] as const } function normalizeComboboxGroups({ options, groups, }: Pick, "options" | "groups">) { if (groups?.length) return groups if (options?.length) return [{ options }] return [] } function getComboboxOptionText(option: ComboboxOption) { return [ option.value, typeof option.label === "string" || typeof option.label === "number" ? String(option.label) : "", typeof option.description === "string" || typeof option.description === "number" ? String(option.description) : "", ...(option.keywords ?? []), ] .join(" ") .toLowerCase() } function defaultFilterOption(option: ComboboxOption, search: string) { if (!search.trim()) return true return getComboboxOptionText(option).includes(search.trim().toLowerCase()) } function findOption( groups: ComboboxGroup[], value?: TValue ) { if (!value) return undefined for (const group of groups) { const option = group.options.find((item) => item.value === value) if (option) return option } return undefined } function Combobox({ className, value, defaultValue, onValueChange, options, groups, disabled = false, clearable = true, searchable = true, open, defaultOpen = false, onOpenChange, labels, invalid, renderOption, renderValue, filterOption = defaultFilterOption, triggerClassName, contentClassName, searchClassName, optionClassName, ...props }: ComboboxProps) { const normalizedGroups = React.useMemo(() => normalizeComboboxGroups({ options, groups }), [groups, options]) const [currentValue, setCurrentValue] = useControllableState({ value, defaultValue, onChange: (nextValue) => onValueChange?.(nextValue, findOption(normalizedGroups, nextValue)), }) const [isOpen, setIsOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange, }) const [search, setSearch] = React.useState("") const selectedOption = findOption(normalizedGroups, currentValue) const filteredGroups = React.useMemo( () => normalizedGroups .map((group) => ({ ...group, options: group.options.filter((option) => filterOption(option, search)), })) .filter((group) => group.options.length > 0), [filterOption, normalizedGroups, search] ) const hasMatches = filteredGroups.some((group) => group.options.length > 0) const selectOption = (option: ComboboxOption) => { if (option.disabled) return setCurrentValue(option.value) setIsOpen(false) setSearch("") } const clearSelection = () => { setCurrentValue(undefined) setSearch("") } const handleClear = (event: React.MouseEvent) => { stopInteractivePropagation(event) clearSelection() } return (
} > {selectedOption ? renderValue?.(selectedOption) ?? selectedOption.label : ( {labels?.placeholder ?? "Select option"} )} {clearable && currentValue && !disabled ? ( { if (event.key !== "Enter" && event.key !== " ") return event.preventDefault() stopInteractivePropagation(event) clearSelection() }} > ) : null} {searchable ? (
setSearch(event.currentTarget.value)} placeholder={labels?.searchPlaceholder ?? "Search..."} className={cn("pl-9", searchClassName)} autoFocus />
) : null}
{!hasMatches ? (
{labels?.empty ?? "No options found"}
) : null} {filteredGroups.map((group, groupIndex) => (
{group.label ? (
{group.label}
) : null} {group.options.map((option) => { const selected = option.value === currentValue return ( ) })}
))}
) } export { Combobox }