feat(select): implement searchable select component with server search capabilities

- Enhanced the Select component to support a searchable dropdown, allowing users to filter options by label or value.
- Introduced a new useSelectServerSearch hook for server-side searching, enabling dynamic fetching of options based on user input.
- Updated TenderDetailsHeader and TenderListFilters to utilize the searchable feature, improving user experience in selecting languages and countries.
- Added utility functions for filtering select items and managing search states, ensuring efficient data handling and responsiveness.
This commit is contained in:
AmirReza Jamali
2026-05-16 09:18:12 +03:30
parent 25f8bde229
commit 7aa13bb904
6 changed files with 606 additions and 19 deletions
@@ -70,6 +70,8 @@ export function TenderDetailsHeader({
name="lang" name="lang"
label="Display language" label="Display language"
items={Languages} items={Languages}
searchable
searchPlaceholder="Search language or ISO code…"
disabled={isTranslating} disabled={isTranslating}
prefixIcon={ prefixIcon={
<GlobeIcon className="h-[1.05rem] w-[1.05rem] text-primary dark:text-primary/90" /> <GlobeIcon className="h-[1.05rem] w-[1.05rem] text-primary dark:text-primary/90" />
@@ -77,7 +79,7 @@ export function TenderDetailsHeader({
className={cn( className={cn(
"w-full shrink-0 space-y-0", "w-full shrink-0 space-y-0",
"[&_label]:sr-only", "[&_label]:sr-only",
"[&_select]:h-11 [&_select]:min-h-[2.75rem] [&_select]:rounded-full [&_select]:border-stroke/60 [&_select]:bg-white/95 [&_select]:py-0 [&_select]:text-sm [&_select]:shadow-sm dark:[&_select]:border-white/10 dark:[&_select]:bg-dark-2/92", "[&_button]:h-11 [&_button]:min-h-[2.75rem] [&_button]:rounded-full [&_button]:border-stroke/60 [&_button]:bg-white/95 [&_button]:py-0 [&_button]:text-sm [&_button]:shadow-sm dark:[&_button]:border-white/10 dark:[&_button]:bg-dark-2/92",
)} )}
value={selectedLanguage} value={selectedLanguage}
onChange={onLanguageChange as LanguageSelectOnChange} onChange={onLanguageChange as LanguageSelectOnChange}
+458 -18
View File
@@ -3,7 +3,17 @@
import { ChevronUpIcon } from "@/assets/icons"; import { ChevronUpIcon } from "@/assets/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ILabelValue } from "@/types/shared"; import { ILabelValue } from "@/types/shared";
import { useEffect, useId, useState, type ChangeEvent } from "react"; import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from "react";
import { createPortal } from "react-dom";
import { import {
type FieldErrors, type FieldErrors,
type FieldValues, type FieldValues,
@@ -11,6 +21,43 @@ import {
type UseFormRegister, type UseFormRegister,
type UseFormRegisterReturn, type UseFormRegisterReturn,
} from "react-hook-form"; } from "react-hook-form";
import {
type SelectSearchMode,
useSelectServerSearch,
} from "./use-select-server-search";
export type { SelectSearchMode } from "./use-select-server-search";
/** Match options by label or value; exact / prefix matches rank higher. */
export function filterSelectItems(
items: readonly ILabelValue[],
query: string,
): ILabelValue[] {
const q = query.trim().toLowerCase();
if (!q) return [...items];
const scored: { item: ILabelValue; score: number }[] = [];
for (const item of items) {
const v = String(item.value).toLowerCase();
const l = item.label.toLowerCase();
let score = 0;
if (v === q) score = 1000;
else if (v.startsWith(q)) score = 500;
else if (l.startsWith(q)) score = 400;
else if (l.includes(q)) score = 200;
else if (v.includes(q)) score = 100;
if (score > 0) scored.push({ item, score });
}
scored.sort(
(a, b) =>
b.score - a.score ||
a.item.label.localeCompare(b.item.label, undefined, {
sensitivity: "base",
}),
);
return scored.map((s) => s.item);
}
type PropsType<T extends FieldValues> = { type PropsType<T extends FieldValues> = {
name: Path<T>; name: Path<T>;
@@ -27,6 +74,21 @@ type PropsType<T extends FieldValues> = {
value?: string; value?: string;
dataCy?: string; dataCy?: string;
disabled?: boolean; disabled?: boolean;
/** When true, replaces the native picker with a searchable list (label + value). */
searchable?: boolean;
/** Placeholder for the search field inside the dropdown (searchable only). */
searchPlaceholder?: string;
/**
* `"client"` — filter `items` in the browser (default).
* `"server"` — debounced React Query request via `searchFn` (requires `searchable`).
*/
searchMode?: SelectSearchMode;
/** Debounce delay (ms) before a server search runs. Default: `300`. */
searchDebounceMs?: number;
/** React Query key prefix; debounced search term is appended. Required when `searchMode` is `"server"`. */
searchQueryKey?: readonly unknown[];
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
searchFn?: (query: string) => Promise<ILabelValue[]>;
} & ( } & (
| { placeholder?: string; defaultValue?: string } | { placeholder?: string; defaultValue?: string }
| { placeholder: string; defaultValue?: string } | { placeholder: string; defaultValue?: string }
@@ -41,7 +103,6 @@ export function Select<T extends FieldValues>({
prefixIcon, prefixIcon,
className, className,
name, name,
register,
errors, errors,
required, required,
onChange, onChange,
@@ -52,12 +113,47 @@ export function Select<T extends FieldValues>({
value: controlledValue, value: controlledValue,
dataCy, dataCy,
disabled = false, disabled = false,
searchable = false,
searchPlaceholder = "Search by label or value…",
searchMode = "client",
searchDebounceMs = 300,
searchQueryKey,
searchFn,
register: _register,
...props ...props
}: PropsType<T>) { }: PropsType<T>) {
void _register;
if (
process.env.NODE_ENV !== "production" &&
searchable &&
searchMode === "server" &&
!searchFn
) {
console.warn(
`[Select "${String(name)}"] searchMode="server" requires searchFn.`,
);
}
const effectiveSearchMode: SelectSearchMode =
searchable && searchMode === "server" && searchFn ? "server" : "client";
const id = useId(); const id = useId();
const listboxId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue); const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
const [value, setValue] = useState(defaultValue || ""); const [value, setValue] = useState(defaultValue || "");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [mounted, setMounted] = useState(false);
const [panelRect, setPanelRect] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const error = errors && errors[name]; const error = errors && errors[name];
useEffect(() => { useEffect(() => {
@@ -67,20 +163,372 @@ export function Select<T extends FieldValues>({
} }
}, [controlledValue]); }, [controlledValue]);
const {
isServerMode,
displayItems: serverDisplayItems,
isSearchLoading,
isSearchError,
} = useSelectServerSearch({
searchMode: effectiveSearchMode,
searchable,
open,
query,
debounceMs: searchDebounceMs,
searchQueryKey,
searchFn,
items,
selectedValue: value,
});
const listItems = useMemo(() => {
if (!searchable) return items;
if (isServerMode && serverDisplayItems) return serverDisplayItems;
return filterSelectItems(items, query);
}, [searchable, isServerMode, serverDisplayItems, items, query]);
const selectedLabel = useMemo(() => {
const hit = listItems.find((i) => String(i.value) === String(value));
if (hit) return hit.label;
const fromItems = items.find((i) => String(i.value) === String(value));
return fromItems?.label ?? (value ? String(value) : "");
}, [listItems, items, value]);
const syncPanelPosition = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const gap = 8;
setPanelRect({
top: r.bottom + gap,
left: r.left,
width: r.width,
});
}, []);
useEffect(() => {
setMounted(true);
}, []);
useLayoutEffect(() => {
if (!searchable || !open) return;
syncPanelPosition();
}, [open, searchable, syncPanelPosition]);
useEffect(() => {
if (!searchable || !open) return;
const onScrollOrResize = () => syncPanelPosition();
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};
}, [open, searchable, syncPanelPosition]);
useEffect(() => {
if (!searchable || !open) return;
const onPointerDown = (e: MouseEvent | TouchEvent) => {
const t = e.target as Node;
if (rootRef.current?.contains(t)) return;
const panel = document.getElementById(listboxId);
if (panel?.contains(t)) return;
setOpen(false);
setQuery("");
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("touchstart", onPointerDown, { passive: true });
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("touchstart", onPointerDown);
};
}, [open, searchable, listboxId]);
useEffect(() => {
if (!searchable || !open) return;
const idFrame = window.requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
return () => window.cancelAnimationFrame(idFrame);
}, [open, searchable]);
const emitChange = (next: string) => {
if (controlledValue === undefined) {
setValue(next);
}
setIsOptionSelected(!!next);
const synthetic = {
target: { value: next, name },
currentTarget: { value: next, name },
} as unknown as ChangeEvent<HTMLSelectElement>;
onChange?.(synthetic);
};
const handleClear = () => { const handleClear = () => {
if (controlledValue === undefined) { if (controlledValue === undefined) {
setValue(""); setValue("");
} }
setIsOptionSelected(false); setIsOptionSelected(false);
onClear?.(); onClear?.();
if (onChange) { emitChange("");
const event = {
target: { value: "", name },
} as unknown as ChangeEvent<HTMLSelectElement>;
onChange(event);
}
}; };
const pickItem = (itemValue: string) => {
emitChange(itemValue);
setOpen(false);
setQuery("");
triggerRef.current?.focus();
};
const selectSharedClasses = cn(
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
isOptionSelected && "text-dark dark:text-white",
disabled &&
"cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
prefixIcon && "pl-11.5",
clearable && value && "pr-20",
);
const dropdown =
searchable &&
mounted &&
open &&
panelRect &&
typeof document !== "undefined" &&
createPortal(
<div
id={listboxId}
role="listbox"
aria-label={label}
className="z-[200] max-h-[min(22rem,calc(100vh-6rem))] overflow-hidden rounded-xl border border-stroke/70 bg-white shadow-[0_18px_50px_-12px_rgba(15,23,42,0.25)] dark:border-white/10 dark:bg-dark-2 dark:shadow-[0_24px_60px_-12px_rgba(0,0,0,0.65)]"
style={{
position: "fixed",
top: panelRect.top,
left: (() => {
const w = Math.max(panelRect.width, 200);
if (typeof window === "undefined") return panelRect.left;
return Math.min(
Math.max(8, panelRect.left),
window.innerWidth - w - 8,
);
})(),
width: Math.max(panelRect.width, 200),
}}
>
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
<label htmlFor={`${listboxId}-search`} className="sr-only">
{searchPlaceholder}
</label>
<input
id={`${listboxId}-search`}
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setOpen(false);
setQuery("");
triggerRef.current?.focus();
}
}}
placeholder={searchPlaceholder}
data-cy={dataCy ? `${dataCy}-search` : undefined}
autoComplete="off"
spellCheck={false}
className="w-full rounded-lg border border-stroke/60 bg-white/95 px-3 py-2 text-sm text-dark outline-none ring-primary/20 placeholder:text-dark-5 focus:border-primary/60 focus:ring-2 dark:border-white/10 dark:bg-dark-3/90 dark:text-white dark:placeholder:text-dark-6"
/>
</div>
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5">
{isSearchLoading ? (
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
Searching
</li>
) : isSearchError ? (
<li className="px-3 py-6 text-center text-sm text-error">
Could not load options. Try again.
</li>
) : listItems.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
No matches.
</li>
) : (
listItems.map((item) => {
const v = String(item.value);
const isActive = v === String(value);
return (
<li key={v}>
<button
type="button"
role="option"
aria-selected={isActive}
data-cy={dataCy ? `${dataCy}-option` : undefined}
data-value={v}
onClick={() => pickItem(v)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
isActive
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
)}
>
<span className="min-w-0 truncate">{item.label}</span>
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
{v}
</span>
</button>
</li>
);
})
)}
</ul>
</div>,
document.body,
);
if (searchable) {
return (
<div ref={rootRef} className={cn("space-y-3", className)}>
<label
htmlFor={id}
className="block text-body-sm font-semibold capitalize text-dark dark:text-white"
>
{label}
{required && <span className="ml-1 select-none text-error">*</span>}
</label>
<div className="relative">
<select
{...props}
ref={ref}
name={name}
value={value}
disabled={disabled}
tabIndex={-1}
aria-hidden
className="sr-only"
onChange={(e) => {
if (controlledValue === undefined) {
setValue(e.target.value);
}
setIsOptionSelected(!!e.target.value);
onChange?.(e);
}}
onBlur={onBlur}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{listItems.map((item) => (
<option key={String(item.value)} value={String(item.value)}>
{item.label}
</option>
))}
</select>
{prefixIcon && (
<div className="pointer-events-none absolute left-4 top-1/2 z-[1] -translate-y-1/2">
{prefixIcon}
</div>
)}
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
data-cy={dataCy}
onClick={() => {
if (disabled) return;
setOpen((o) => {
if (o) setQuery("");
return !o;
});
}}
onKeyDown={(e) => {
if (e.key === "Escape" && open) {
e.preventDefault();
setOpen(false);
setQuery("");
}
}}
className={cn(
"flex w-full min-h-[52px] items-center rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 text-start text-body-sm shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)]",
isOptionSelected && "text-dark dark:text-white",
!isOptionSelected && "text-dark-5 dark:text-dark-6",
disabled &&
"cursor-not-allowed opacity-60 focus:shadow-none dark:focus:shadow-none",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16)] dark:border-red/80",
prefixIcon && "pl-11.5",
clearable && value && "pr-20",
"pr-11",
)}
>
<span className="min-w-0 flex-1 truncate">
{value ? (
<>
<span className="text-dark dark:text-white">
{selectedLabel}
</span>
<span className="ml-2 font-mono text-xs text-dark-5 dark:text-dark-6">
{value}
</span>
</>
) : (
placeholder || "Select…"
)}
</span>
</button>
{clearable && value && (
<button
type="button"
onClick={handleClear}
data-cy={dataCy ? `${dataCy}-clear` : undefined}
className="absolute right-10 top-1/2 z-[1] -translate-y-1/2 text-dark-5 transition hover:scale-110 hover:text-dark dark:text-dark-6 dark:hover:text-white"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4L4 12M4 4L12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
<ChevronUpIcon className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 rotate-180 text-dark-5 dark:text-dark-6" />
</div>
{dropdown}
{error && (
<p className="text-red mt-1.5 text-sm font-medium">
{error.message as string}
</p>
)}
</div>
);
}
return ( return (
<div className={cn("space-y-3", className)}> <div className={cn("space-y-3", className)}>
<label <label
@@ -99,6 +547,7 @@ export function Select<T extends FieldValues>({
)} )}
<select <select
{...props}
id={id} id={id}
name={name} name={name}
ref={ref} ref={ref}
@@ -113,16 +562,7 @@ export function Select<T extends FieldValues>({
}} }}
onBlur={onBlur} onBlur={onBlur}
data-cy={dataCy} data-cy={dataCy}
className={cn( className={selectSharedClasses}
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
isOptionSelected && "text-dark dark:text-white",
disabled &&
"cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
prefixIcon && "pl-11.5",
clearable && value && "pr-20",
)}
> >
{placeholder && ( {placeholder && (
<option value="" disabled> <option value="" disabled>
@@ -0,0 +1,102 @@
"use client";
import useDebounce from "@/hooks/useDebounce";
import type { ILabelValue } from "@/types/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export type SelectSearchMode = "client" | "server";
export type UseSelectServerSearchArgs = {
/** When `"server"`, options are loaded via `searchFn` + React Query. */
searchMode: SelectSearchMode;
searchable: boolean;
/** Whether the dropdown panel is open (queries run only while open). */
open: boolean;
/** Live search input (debounced before the request). */
query: string;
debounceMs?: number;
/** Base React Query key; debounced search term is appended. */
searchQueryKey?: readonly unknown[];
searchFn?: (query: string) => Promise<ILabelValue[]>;
/** Fallback / initial options (e.g. first page or selected value). */
items: readonly ILabelValue[];
/** Current selected value — kept visible if missing from server results. */
selectedValue?: string;
};
export function useSelectServerSearch({
searchMode,
searchable,
open,
query,
debounceMs = 300,
searchQueryKey,
searchFn,
items,
selectedValue,
}: UseSelectServerSearchArgs) {
const isServerMode = searchable && searchMode === "server" && !!searchFn;
const debouncedQuery = useDebounce(query, debounceMs);
const queryKey = useMemo(
() => [...(searchQueryKey ?? ["select-search"]), debouncedQuery],
[searchQueryKey, debouncedQuery],
);
const {
data: serverItems,
isFetching,
isPending,
isError,
error,
} = useQuery({
queryKey,
queryFn: () => searchFn!(debouncedQuery),
enabled: isServerMode && open,
placeholderData: keepPreviousData,
staleTime: 30_000,
});
const displayItems = useMemo(() => {
if (!isServerMode) return null;
const list = serverItems ?? (debouncedQuery.trim() ? [] : [...items]);
if (!selectedValue) return list;
const hasSelected = list.some(
(i) => String(i.value) === String(selectedValue),
);
if (hasSelected) return list;
const fromItems = items.find(
(i) => String(i.value) === String(selectedValue),
);
if (fromItems) return [fromItems, ...list];
return [{ value: selectedValue, label: String(selectedValue) }, ...list];
}, [
isServerMode,
serverItems,
debouncedQuery,
items,
selectedValue,
]);
const isSearchDebouncing =
isServerMode && open && query.trim() !== debouncedQuery.trim();
return {
isServerMode,
displayItems,
isSearchLoading:
isServerMode &&
open &&
(isSearchDebouncing || isFetching || isPending),
isSearchDebouncing,
isSearchError: isServerMode && open && isError,
searchError: error,
};
}
@@ -149,6 +149,8 @@ const TenderListFilters = ({
label="Country" label="Country"
items={Countries} items={Countries}
placeholder="Any country" placeholder="Any country"
searchable
searchPlaceholder="Search country or code…"
clearable clearable
prefixIcon={<GlobeIcon />} prefixIcon={<GlobeIcon />}
value={watch("country")} value={watch("country")}
+1
View File
@@ -14,3 +14,4 @@ export * from "./useProfileQueries";
export * from "./useTenders"; export * from "./useTenders";
export * from "./useUsersQueries"; export * from "./useUsersQueries";
export * from "./useAdminListQuery"; export * from "./useAdminListQuery";
export * from "./useSelectSearchQuery";
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { filterSelectItems } from "@/components/FormElements/select";
import type { ILabelValue } from "@/types/shared";
import { useCallback } from "react";
/**
* Example factory for `Select` server search — wire your API inside `fetchItems`.
*
* @example
* const searchCountries = useSelectSearchQuery({
* queryKey: ["countries", "select-search"],
* fetchItems: async (q) => {
* const res = await api.get("/countries", { params: { q } });
* return res.data.map((c) => ({ label: c.name, value: c.code }));
* },
* fallbackItems: Countries,
* });
*
* <Select searchable searchMode="server" searchQueryKey={["countries"]} searchFn={searchCountries} items={Countries} ... />
*/
export function useSelectSearchQuery({
fetchItems,
fallbackItems = [],
}: {
fetchItems: (query: string) => Promise<ILabelValue[]>;
/** Used when the API is unavailable or for local dev until the endpoint exists. */
fallbackItems?: readonly ILabelValue[];
}) {
return useCallback(
async (query: string) => {
try {
return await fetchItems(query);
} catch {
return filterSelectItems(fallbackItems, query);
}
},
[fetchItems, fallbackItems],
);
}