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:
@@ -70,6 +70,8 @@ export function TenderDetailsHeader({
|
||||
name="lang"
|
||||
label="Display language"
|
||||
items={Languages}
|
||||
searchable
|
||||
searchPlaceholder="Search language or ISO code…"
|
||||
disabled={isTranslating}
|
||||
prefixIcon={
|
||||
<GlobeIcon className="h-[1.05rem] w-[1.05rem] text-primary dark:text-primary/90" />
|
||||
@@ -77,7 +79,7 @@ export function TenderDetailsHeader({
|
||||
className={cn(
|
||||
"w-full shrink-0 space-y-0",
|
||||
"[&_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}
|
||||
onChange={onLanguageChange as LanguageSelectOnChange}
|
||||
|
||||
@@ -3,7 +3,17 @@
|
||||
import { ChevronUpIcon } from "@/assets/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 {
|
||||
type FieldErrors,
|
||||
type FieldValues,
|
||||
@@ -11,6 +21,43 @@ import {
|
||||
type UseFormRegister,
|
||||
type UseFormRegisterReturn,
|
||||
} 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> = {
|
||||
name: Path<T>;
|
||||
@@ -27,6 +74,21 @@ type PropsType<T extends FieldValues> = {
|
||||
value?: string;
|
||||
dataCy?: string;
|
||||
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 }
|
||||
@@ -41,7 +103,6 @@ export function Select<T extends FieldValues>({
|
||||
prefixIcon,
|
||||
className,
|
||||
name,
|
||||
register,
|
||||
errors,
|
||||
required,
|
||||
onChange,
|
||||
@@ -52,12 +113,47 @@ export function Select<T extends FieldValues>({
|
||||
value: controlledValue,
|
||||
dataCy,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search by label or value…",
|
||||
searchMode = "client",
|
||||
searchDebounceMs = 300,
|
||||
searchQueryKey,
|
||||
searchFn,
|
||||
register: _register,
|
||||
...props
|
||||
}: 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 listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isOptionSelected, setIsOptionSelected] = 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];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,20 +163,372 @@ export function Select<T extends FieldValues>({
|
||||
}
|
||||
}, [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 = () => {
|
||||
if (controlledValue === undefined) {
|
||||
setValue("");
|
||||
}
|
||||
setIsOptionSelected(false);
|
||||
onClear?.();
|
||||
if (onChange) {
|
||||
const event = {
|
||||
target: { value: "", name },
|
||||
} as unknown as ChangeEvent<HTMLSelectElement>;
|
||||
onChange(event);
|
||||
}
|
||||
emitChange("");
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<label
|
||||
@@ -99,6 +547,7 @@ export function Select<T extends FieldValues>({
|
||||
)}
|
||||
|
||||
<select
|
||||
{...props}
|
||||
id={id}
|
||||
name={name}
|
||||
ref={ref}
|
||||
@@ -113,16 +562,7 @@ export function Select<T extends FieldValues>({
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
data-cy={dataCy}
|
||||
className={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",
|
||||
)}
|
||||
className={selectSharedClasses}
|
||||
>
|
||||
{placeholder && (
|
||||
<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"
|
||||
items={Countries}
|
||||
placeholder="Any country"
|
||||
searchable
|
||||
searchPlaceholder="Search country or code…"
|
||||
clearable
|
||||
prefixIcon={<GlobeIcon />}
|
||||
value={watch("country")}
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./useProfileQueries";
|
||||
export * from "./useTenders";
|
||||
export * from "./useUsersQueries";
|
||||
export * from "./useAdminListQuery";
|
||||
export * from "./useSelectSearchQuery";
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user