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"
|
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}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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