import { useEffect, useId, useMemo, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"; import { ChevronDown, Search } from "lucide-react"; import { usePlatformLanguage } from "../i18n/LanguageContext"; import type { PlatformInterfaceIdentityProps } from "../types"; export type SearchableSelectOption = { value: string; label: string; description?: string | null; searchText?: string | null; disabled?: boolean; }; export type SearchableSelectLoadOptions = ( query: string, options: { limit: number; signal: AbortSignal } ) => Promise; export type SearchableSelectCreateCustomOption = ( value: string ) => SearchableSelectOption | null; export type SearchableSelectProps = PlatformInterfaceIdentityProps & { id?: string; value: string; onChange: ( value: string, option: SearchableSelectOption | null ) => void; options?: readonly SearchableSelectOption[]; loadOptions?: SearchableSelectLoadOptions; createCustomOption?: SearchableSelectCreateCustomOption; selectedOption?: SearchableSelectOption | null; "aria-label"?: string; placeholder?: string; searchPlaceholder?: string; emptyText?: string; loadingText?: string; errorText?: string; disabled?: boolean; required?: boolean; minQueryLength?: number; searchLimit?: number; debounceMs?: number; className?: string; }; const EMPTY_OPTIONS: readonly SearchableSelectOption[] = []; export function filterSearchableSelectOptions( options: readonly SearchableSelectOption[], query: string, limit = 50 ): SearchableSelectOption[] { const needle = query.trim().toLocaleLowerCase(); const filtered = needle ? options.filter((option) => [ option.label, option.description ?? "", option.searchText ?? "", option.value ] .join(" ") .toLocaleLowerCase() .includes(needle) ) : [...options]; return filtered.slice(0, Math.max(1, Math.floor(limit))); } export default function SearchableSelect({ id, value, onChange, options = EMPTY_OPTIONS, loadOptions, createCustomOption, selectedOption, "aria-label": ariaLabel = "Select an option", placeholder = "Select an option", searchPlaceholder = "Search...", emptyText = "No matching options.", loadingText = "Loading...", errorText = "Options could not be loaded.", disabled = false, required = false, minQueryLength = 0, searchLimit = 50, debounceMs = 200, className = "", interfaceId, helpTopicId }: SearchableSelectProps) { const { translateText } = usePlatformLanguage(); const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-"); const pickerId = id || `searchable-select-${generatedId}`; const listboxId = `${pickerId}-results`; const statusId = `${pickerId}-status`; const rootRef = useRef(null); const resolvedSelected = useMemo( () => selectedOption ?? options.find((option) => option.value === value) ?? null, [options, selectedOption, value] ); const selectedLabel = resolvedSelected?.label ?? value; const [inputValue, setInputValue] = useState(selectedLabel); const [results, setResults] = useState(EMPTY_OPTIONS); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const normalizedMinLength = Math.max(0, Math.floor(minQueryLength)); const normalizedLimit = Math.max(1, Math.min(200, Math.floor(searchLimit))); const visibleResults = useMemo(() => { const customValue = inputValue.trim(); const customOption = customValue ? createCustomOption?.(customValue) : null; if ( !customOption || results.some((option) => option.value === customOption.value) ) { return results; } return [...results, customOption]; }, [createCustomOption, inputValue, results]); useEffect(() => { if (!open) setInputValue(selectedLabel); }, [open, selectedLabel]); useEffect(() => { if (!open || disabled) { setLoading(false); setFailed(false); setResults(EMPTY_OPTIONS); return; } const query = inputValue.trim(); if (query.length < normalizedMinLength) { setLoading(false); setFailed(false); setResults(EMPTY_OPTIONS); return; } if (!loadOptions) { setResults(filterSearchableSelectOptions(options, query, normalizedLimit)); setLoading(false); setFailed(false); return; } const controller = new AbortController(); const timer = window.setTimeout(() => { setLoading(true); setFailed(false); void loadOptions(query, { limit: normalizedLimit, signal: controller.signal }) .then((nextOptions) => { if (!controller.signal.aborted) setResults(nextOptions); }) .catch((error: unknown) => { if ( controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError") ) { return; } setResults(EMPTY_OPTIONS); setFailed(true); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); }, Math.max(0, Math.floor(debounceMs))); return () => { controller.abort(); window.clearTimeout(timer); }; }, [ debounceMs, disabled, inputValue, loadOptions, normalizedLimit, normalizedMinLength, open, options ]); useEffect(() => { const selectedIndex = visibleResults.findIndex( (option) => option.value === value && !option.disabled ); const firstEnabledIndex = visibleResults.findIndex( (option) => !option.disabled ); setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex); }, [value, visibleResults]); function choose(option: SearchableSelectOption) { if (disabled || option.disabled) return; onChange(option.value, option); setInputValue(option.label); setOpen(false); setActiveIndex(-1); } function moveActive(delta: 1 | -1) { const enabledIndexes = visibleResults .map((option, index) => ({ option, index })) .filter(({ option }) => !option.disabled) .map(({ index }) => index); if (!enabledIndexes.length) { setActiveIndex(-1); return; } const currentPosition = enabledIndexes.indexOf(activeIndex); const nextPosition = currentPosition < 0 ? delta > 0 ? 0 : enabledIndexes.length - 1 : (currentPosition + delta + enabledIndexes.length) % enabledIndexes.length; setActiveIndex(enabledIndexes[nextPosition]); } function handleKeyDown(event: KeyboardEvent) { 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" && open && activeIndex >= 0) { const option = visibleResults[activeIndex]; if (option && !option.disabled) { event.preventDefault(); choose(option); } return; } if (event.key === "Escape") { event.preventDefault(); setOpen(false); setInputValue(selectedLabel); setActiveIndex(-1); } } function closeOnFocusLeave(event: FocusEvent) { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) { return; } setOpen(false); setInputValue(selectedLabel); setActiveIndex(-1); } const minimumLengthMissing = inputValue.trim().length < normalizedMinLength; const visibleStatus = loading ? loadingText : failed ? errorText : minimumLengthMissing ? `Type at least ${normalizedMinLength} characters.` : !visibleResults.length ? emptyText : ""; const rootClassName = ["searchable-select", className] .filter(Boolean) .join(" "); return (
{open && !disabled && (
{visibleResults.map((option, index) => ( ))} {visibleStatus && (

{translateText(visibleStatus)}

)}
)} {translateText(visibleStatus)}
); }