From 7aa13bb9045a20a877fdd79d164b2b19f0f8f053 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sat, 16 May 2026 09:18:12 +0330 Subject: [PATCH] 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. --- .../components/tender-details-header.tsx | 4 +- src/components/FormElements/select.tsx | 476 +++++++++++++++++- .../FormElements/use-select-server-search.ts | 102 ++++ .../Tables/tenders/TenderListFilters.tsx | 2 + src/hooks/queries/index.ts | 1 + src/hooks/queries/useSelectSearchQuery.ts | 40 ++ 6 files changed, 606 insertions(+), 19 deletions(-) create mode 100644 src/components/FormElements/use-select-server-search.ts create mode 100644 src/hooks/queries/useSelectSearchQuery.ts diff --git a/src/app/tenders/[details]/components/tender-details-header.tsx b/src/app/tenders/[details]/components/tender-details-header.tsx index 23f08ba..a798596 100644 --- a/src/app/tenders/[details]/components/tender-details-header.tsx +++ b/src/app/tenders/[details]/components/tender-details-header.tsx @@ -70,6 +70,8 @@ export function TenderDetailsHeader({ name="lang" label="Display language" items={Languages} + searchable + searchPlaceholder="Search language or ISO code…" disabled={isTranslating} prefixIcon={ @@ -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} diff --git a/src/components/FormElements/select.tsx b/src/components/FormElements/select.tsx index d1d1ce6..f9f5443 100644 --- a/src/components/FormElements/select.tsx +++ b/src/components/FormElements/select.tsx @@ -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 = { name: Path; @@ -27,6 +74,21 @@ type PropsType = { 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; } & ( | { placeholder?: string; defaultValue?: string } | { placeholder: string; defaultValue?: string } @@ -41,7 +103,6 @@ export function Select({ prefixIcon, className, name, - register, errors, required, onChange, @@ -52,12 +113,47 @@ export function Select({ value: controlledValue, dataCy, disabled = false, + searchable = false, + searchPlaceholder = "Search by label or value…", + searchMode = "client", + searchDebounceMs = 300, + searchQueryKey, + searchFn, + register: _register, ...props }: PropsType) { + 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(null); + const triggerRef = useRef(null); + const searchInputRef = useRef(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({ } }, [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; + onChange?.(synthetic); + }; + const handleClear = () => { if (controlledValue === undefined) { setValue(""); } setIsOptionSelected(false); onClear?.(); - if (onChange) { - const event = { - target: { value: "", name }, - } as unknown as ChangeEvent; - 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( +
{ + 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), + }} + > +
+ + 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" + /> +
+
    + {isSearchLoading ? ( +
  • + + Searching… +
  • + ) : isSearchError ? ( +
  • + Could not load options. Try again. +
  • + ) : listItems.length === 0 ? ( +
  • + No matches. +
  • + ) : ( + listItems.map((item) => { + const v = String(item.value); + const isActive = v === String(value); + return ( +
  • + +
  • + ); + }) + )} +
+
, + document.body, + ); + + if (searchable) { + return ( +
+ + +
+ + + {prefixIcon && ( +
+ {prefixIcon} +
+ )} + + + + {clearable && value && ( + + )} + + +
+ + {dropdown} + + {error && ( +

+ {error.message as string} +

+ )} +
+ ); + } + return (