| "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> |
| ); |
| } |
|
|