| "use client"; | |
| import * as React from "react"; | |
| import { Check, ChevronsUpDown } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { Input } from "@/components/ui/input"; | |
| interface ComboboxProps { | |
| options: { id: string; name: string }[]; | |
| value: string; | |
| onValueChange: (value: string) => void; | |
| placeholder?: string; | |
| searchPlaceholder?: string; | |
| emptyMessage?: string; | |
| loading?: boolean; | |
| searchValue?: string; | |
| onSearchValueChange?: (value: string) => void; | |
| disableLocalFilter?: boolean; | |
| } | |
| export function Combobox({ | |
| options, | |
| value, | |
| onValueChange, | |
| placeholder = "Select option...", | |
| searchPlaceholder = "Search...", | |
| emptyMessage = "No results found.", | |
| loading = false, | |
| searchValue, | |
| onSearchValueChange, | |
| disableLocalFilter = false, | |
| }: ComboboxProps) { | |
| const [open, setOpen] = React.useState(false); | |
| const [internalSearch, setInternalSearch] = React.useState(""); | |
| const search = searchValue ?? internalSearch; | |
| const filteredOptions = React.useMemo(() => { | |
| if (disableLocalFilter) return options; | |
| if (!search) return options; | |
| const lower = search.toLowerCase(); | |
| return options.filter( | |
| (option) => | |
| option.name.toLowerCase().includes(lower) || | |
| option.id.toLowerCase().includes(lower) | |
| ); | |
| }, [disableLocalFilter, options, search]); | |
| const selectedOption = options.find((opt) => opt.id === value); | |
| const selectedLabel = selectedOption?.name ?? (value ? value : placeholder); | |
| return ( | |
| <Popover open={open} onOpenChange={setOpen}> | |
| <PopoverTrigger asChild> | |
| <Button | |
| variant="outline" | |
| role="combobox" | |
| aria-expanded={open} | |
| className="w-full justify-between font-normal" | |
| > | |
| <span className="truncate"> | |
| {selectedLabel} | |
| </span> | |
| <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start"> | |
| <div className="p-2"> | |
| <Input | |
| placeholder={searchPlaceholder} | |
| value={search} | |
| onChange={(e) => { | |
| const next = e.target.value; | |
| if (onSearchValueChange) { | |
| onSearchValueChange(next); | |
| } else { | |
| setInternalSearch(next); | |
| } | |
| }} | |
| className="h-9" | |
| /> | |
| </div> | |
| <div className="max-h-[300px] overflow-y-auto"> | |
| {loading ? ( | |
| <div className="py-6 text-center text-sm text-muted-foreground"> | |
| Loading... | |
| </div> | |
| ) : filteredOptions.length === 0 ? ( | |
| <div className="py-6 text-center text-sm text-muted-foreground"> | |
| {emptyMessage} | |
| </div> | |
| ) : ( | |
| <div className="p-1"> | |
| {filteredOptions.map((option) => ( | |
| <button | |
| key={option.id} | |
| onClick={() => { | |
| onValueChange(option.id === value ? "" : option.id); | |
| setOpen(false); | |
| if (onSearchValueChange) { | |
| onSearchValueChange(""); | |
| } else { | |
| setInternalSearch(""); | |
| } | |
| }} | |
| className={cn( | |
| "relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground", | |
| value === option.id && "bg-accent" | |
| )} | |
| > | |
| <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | |
| {value === option.id && <Check className="h-4 w-4" />} | |
| </span> | |
| <span className="truncate">{option.name}</span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </PopoverContent> | |
| </Popover> | |
| ); | |
| } | |