"use client"; import * as React from "react"; import { Check, ChevronsUpDown, LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; export interface AutocompleteOption { value: string; label?: string; badge?: string; isDefault?: boolean; } interface AutocompleteProps { value: string; onChange: (value: string) => void; options: (string | AutocompleteOption)[]; placeholder?: string; searchPlaceholder?: string; emptyMessage?: string; className?: string; disabled?: boolean; icon?: LucideIcon; allowCreate?: boolean; createLabel?: (value: string) => string; "data-testid"?: string; itemTestIdPrefix?: string; } function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption { if (typeof opt === "string") { return { value: opt, label: opt }; } return { ...opt, label: opt.label ?? opt.value }; } export function Autocomplete({ value, onChange, options, placeholder = "Select an option...", searchPlaceholder = "Search...", emptyMessage = "No results found.", className, disabled = false, icon: Icon, allowCreate = false, createLabel = (v) => `Create "${v}"`, "data-testid": testId, itemTestIdPrefix = "option", }: AutocompleteProps) { const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(""); const [triggerWidth, setTriggerWidth] = React.useState(0); const triggerRef = React.useRef(null); const normalizedOptions = React.useMemo( () => options.map(normalizeOption), [options] ); // Update trigger width when component mounts or value changes React.useEffect(() => { if (triggerRef.current) { const updateWidth = () => { setTriggerWidth(triggerRef.current?.offsetWidth || 0); }; updateWidth(); const resizeObserver = new ResizeObserver(updateWidth); resizeObserver.observe(triggerRef.current); return () => { resizeObserver.disconnect(); }; } }, [value]); // Filter options based on input const filteredOptions = React.useMemo(() => { if (!inputValue) return normalizedOptions; const lower = inputValue.toLowerCase(); return normalizedOptions.filter( (opt) => opt.value.toLowerCase().includes(lower) || opt.label?.toLowerCase().includes(lower) ); }, [normalizedOptions, inputValue]); // Check if user typed a new value that doesn't exist const isNewValue = allowCreate && inputValue.trim() && !normalizedOptions.some( (opt) => opt.value.toLowerCase() === inputValue.toLowerCase() ); // Get display value const displayValue = React.useMemo(() => { if (!value) return null; const found = normalizedOptions.find((opt) => opt.value === value); return found?.label ?? value; }, [value, normalizedOptions]); return ( {isNewValue ? (
Press enter to create{" "} {inputValue}
) : ( emptyMessage )}
{/* Show "Create new" option if typing a new value */} {isNewValue && ( { onChange(inputValue); setInputValue(""); setOpen(false); }} className="text-[var(--status-success)]" data-testid={`${itemTestIdPrefix}-create-new`} > {Icon && } {createLabel(inputValue)} (new) )} {filteredOptions.map((option) => ( { onChange(currentValue === value ? "" : currentValue); setInputValue(""); setOpen(false); }} data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`} > {Icon && } {option.label} {option.badge && ( ({option.badge}) )} ))}
); }