Files
govoplan-core/webui/src/components/SearchableSelect.tsx
T

385 lines
11 KiB
TypeScript

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<readonly SearchableSelectOption[]>;
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<HTMLDivElement | null>(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<readonly SearchableSelectOption[]>(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<HTMLInputElement>) {
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<HTMLDivElement>) {
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 (
<div
ref={rootRef}
className={rootClassName}
data-interface-id={interfaceId}
data-help-topic-id={helpTopicId}
onBlur={closeOnFocusLeave}
>
<div className="searchable-select-control">
<Search size={17} aria-hidden="true" />
<input
id={pickerId}
type="search"
role="combobox"
value={inputValue}
disabled={disabled}
required={required && !value}
placeholder={translateText(
open ? searchPlaceholder : placeholder
)}
aria-label={translateText(ariaLabel)}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && activeIndex >= 0
? `${pickerId}-option-${activeIndex}`
: undefined
}
aria-describedby={statusId}
aria-busy={loading}
onFocus={() => {
if (!open) setInputValue("");
setOpen(true);
}}
onChange={(event) => {
setInputValue(event.target.value);
setOpen(true);
setFailed(false);
if (value) onChange("", null);
}}
onKeyDown={handleKeyDown}
/>
<ChevronDown size={17} aria-hidden="true" />
</div>
{open && !disabled && (
<div
id={listboxId}
className="searchable-select-results"
role="listbox"
aria-label={translateText(ariaLabel)}
>
{visibleResults.map((option, index) => (
<button
key={option.value}
id={`${pickerId}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
className={[
"searchable-select-option",
option.value === value ? "is-selected" : "",
index === activeIndex ? "is-active" : ""
]
.filter(Boolean)
.join(" ")}
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setActiveIndex(index);
}}
onClick={() => choose(option)}
>
<strong>{option.label}</strong>
{option.description && <small>{option.description}</small>}
</button>
))}
{visibleStatus && (
<p className="searchable-select-message">
{translateText(visibleStatus)}
</p>
)}
</div>
)}
<span id={statusId} className="sr-only" aria-live="polite">
{translateText(visibleStatus)}
</span>
</div>
);
}