import { useEffect, useId, useMemo, useRef, useState, type InputHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { X } from "@phosphor-icons/react"; import { cn } from "../../lib/cn"; import { FloatingPortal } from "./floating-portal"; import { Tag } from "../status"; import { emitInputChange, fieldNote, type FieldMeta } from "./shared"; export type ComboboxOption = { description?: string; label: string; value: string; }; export type ComboboxProps = InputHTMLAttributes & FieldMeta & { defaultSelectedValues?: string[]; maxSelected?: number; minSelected?: number; multiple?: boolean; onSelectedValuesChange?: (values: string[]) => void; options: ComboboxOption[]; selectedValues?: string[]; }; function comboboxShellClassName({ hasError, disabled, multiple, }: { disabled?: boolean; hasError?: boolean; multiple?: boolean; }) { return cn( multiple ? "flex min-h-[var(--uhuru-control-height)] w-full flex-wrap items-start gap-1.5 rounded-[var(--uhuru-radius-control)] border border-[var(--uhuru-border-default)] bg-[var(--uhuru-surface-default)] px-[var(--uhuru-control-padding-x)] py-1.5 text-sm text-[var(--uhuru-text-primary)] outline-none transition focus-within:border-[var(--uhuru-border-accent)] focus-within:ring-1 focus-within:ring-[var(--uhuru-border-accent)]" : "flex h-[var(--uhuru-control-height)] w-full items-center gap-2 rounded-[var(--uhuru-radius-control)] border border-[var(--uhuru-border-default)] bg-[var(--uhuru-surface-default)] px-[var(--uhuru-control-padding-x)] text-sm text-[var(--uhuru-text-primary)] outline-none transition focus-within:border-[var(--uhuru-border-accent)] focus-within:ring-1 focus-within:ring-[var(--uhuru-border-accent)]", hasError ? "border-[var(--uhuru-error-border)] focus-within:ring-[var(--uhuru-error-border)]" : "", disabled ? "cursor-not-allowed opacity-70" : "", ); } export function Combobox({ defaultValue, defaultSelectedValues, disabled, error, hint, id, label, name, maxSelected, minSelected = 0, multiple, onSelectedValuesChange, onChange, options, placeholder = "Search or choose", selectedValues, value, }: ComboboxProps) { const generatedId = useId(); const fieldId = id ?? generatedId; const hintId = hint ? `${fieldId}-hint` : undefined; const errorId = error ? `${fieldId}-error` : undefined; const panelRef = useRef(null); const inputRef = useRef(null); const suppressNextFocusOpenRef = useRef(false); const isMulti = multiple || Array.isArray(selectedValues) || Array.isArray(defaultSelectedValues) || Array.isArray(value) || Array.isArray(defaultValue); const controlledValue = !isMulti && typeof value === "string" ? value : undefined; const controlledSelectedValues = isMulti ? selectedValues ?? (Array.isArray(value) ? value.map(String) : undefined) : undefined; const [internalValue, setInternalValue] = useState( typeof defaultValue === "string" ? defaultValue : "", ); const [internalSelectedValues, setInternalSelectedValues] = useState( () => defaultSelectedValues ?? (Array.isArray(defaultValue) ? defaultValue.map(String) : []) ?? [], ); const selectedValue = controlledValue ?? internalValue; const selectedValuesList = isMulti ? controlledSelectedValues ?? internalSelectedValues : selectedValue ? [selectedValue] : []; const selectedValueSet = useMemo(() => new Set(selectedValuesList), [selectedValuesList]); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const filteredOptions = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); if (!normalizedSearch) { return options; } return options.filter((option) => { const haystack = `${option.label} ${option.description ?? ""}`.toLowerCase(); return haystack.includes(normalizedSearch); }); }, [options, search]); const [activeIndex, setActiveIndex] = useState(0); const isTyping = search.trim().length > 0; const activeOption = filteredOptions[activeIndex]; const selectedOption = options.find((option) => option.value === selectedValue); const displayValue = isMulti ? (open ? search : "") : open ? (search || selectedOption?.label || "") : (selectedOption?.label || ""); const showClearButton = !isMulti && Boolean(selectedValue || search); const selectedCount = selectedValuesList.length; const minSelectable = Math.max(0, minSelected); const canRemoveSelection = selectedCount > minSelectable; const canAddMore = maxSelected === undefined || selectedCount < maxSelected; function emitSingleChange(nextValue: string) { if (controlledValue === undefined) { setInternalValue(nextValue); } emitInputChange(onChange, nextValue, name); } function emitMultiChange(nextValues: string[]) { if (controlledSelectedValues === undefined) { setInternalSelectedValues(nextValues); } onSelectedValuesChange?.(nextValues); } useEffect(() => { function handlePointerDown(event: PointerEvent) { if (!panelRef.current?.contains(event.target as Node)) { setOpen(false); setSearch(""); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { if (open && isTyping) { setSearch(""); return; } if (open) { setOpen(false); return; } if (selectedOption) { clearSelection(); } } } window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, [isTyping, open, selectedOption]); useEffect(() => { setActiveIndex(0); }, [open, search]); function clearSelection() { if (isMulti) { emitMultiChange([]); setSearch(""); setOpen(true); inputRef.current?.focus(); return; } emitSingleChange(""); setSearch(""); setOpen(true); inputRef.current?.focus(); } function removeValue(valueToRemove: string) { if (!isMulti) { emitSingleChange(""); setSearch(""); setOpen(true); inputRef.current?.focus(); return; } if (!canRemoveSelection && selectedValueSet.has(valueToRemove)) { return; } const nextValues = selectedValuesList.filter((current) => current !== valueToRemove); if (nextValues.length < minSelectable) { return; } emitMultiChange(nextValues); requestAnimationFrame(() => { inputRef.current?.focus(); }); } function selectOption(option: ComboboxOption) { if (isMulti) { const isSelected = selectedValueSet.has(option.value); if (isSelected) { if (!canRemoveSelection) { return; } removeValue(option.value); } else { if (!canAddMore) { return; } emitMultiChange([...selectedValuesList, option.value]); } setSearch(""); setOpen(true); requestAnimationFrame(() => { inputRef.current?.focus(); }); return; } suppressNextFocusOpenRef.current = true; emitSingleChange(option.value); setSearch(""); setOpen(false); requestAnimationFrame(() => { inputRef.current?.focus(); }); } function moveActive(step: 1 | -1) { if (filteredOptions.length === 0) { return; } setActiveIndex((current) => (current + step + filteredOptions.length) % filteredOptions.length); } function handleKeyDown(event: ReactKeyboardEvent) { if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); moveActive(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); setOpen(true); moveActive(-1); return; } if (event.key === "Enter") { event.preventDefault(); if (open) { const option = activeOption; if (option) { selectOption(option); } } else { setOpen(true); } return; } if (event.key === "Backspace" || event.key === "Delete") { if (isMulti) { if (!isTyping && selectedValuesList.length > 0 && canRemoveSelection) { event.preventDefault(); removeValue(selectedValuesList[selectedValuesList.length - 1] ?? ""); } return; } if (!isTyping && selectedOption) { event.preventDefault(); clearSelection(); } return; } if (event.key === "Escape") { if (isTyping) { event.preventDefault(); setSearch(""); } } } const selectedChips = isMulti ? selectedValuesList.map( (selectedValueItem) => options.find((option) => option.value === selectedValueItem) ?? { label: selectedValueItem, value: selectedValueItem, }, ) : []; return ( ); }