385 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|