"use client"; import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { ChevronDown, Search, X } from "lucide-react"; export type VendorOption = { id: string; name: string; vendorId: string | null }; /** * Filter vendors by a free-text query, matching case-insensitively against the * vendor name and the formal code (`vendorId`). An empty/whitespace query * returns the full list unchanged. */ export function filterVendors(vendors: T[], query: string): T[] { const q = query.trim().toLowerCase(); if (!q) return vendors; return vendors.filter( (v) => v.name.toLowerCase().includes(q) || (v.vendorId ? v.vendorId.toLowerCase().includes(q) : false) ); } /** Label shown for a vendor: "{name} (CODE)" when verified, "{name} (unverified)" otherwise. */ export function vendorLabel(v: VendorOption): string { return `${v.name} ${v.vendorId ? `(${v.vendorId})` : "(unverified)"}`; } interface Props { name: string; vendors: VendorOption[]; /** Initial selected vendor id (uncontrolled — the component owns its state). */ initialValue?: string; placeholder?: string; /** Optional callback when the selection changes. */ onChange?: (value: string) => void; } export function VendorSelect({ name, vendors, initialValue = "", placeholder = "No vendor selected", onChange, }: Props) { const [value, setValue] = useState(initialValue); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const containerRef = useRef(null); const searchRef = useRef(null); const [portalStyle, setPortalStyle] = useState({}); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); const updatePortalPos = useCallback(() => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); setPortalStyle({ position: "fixed", top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999, }); }, []); useLayoutEffect(() => { if (!open) return; updatePortalPos(); }, [open, updatePortalPos]); useEffect(() => { if (!open) return; window.addEventListener("scroll", updatePortalPos, true); window.addEventListener("resize", updatePortalPos); return () => { window.removeEventListener("scroll", updatePortalPos, true); window.removeEventListener("resize", updatePortalPos); }; }, [open, updatePortalPos]); // Close on outside click / Escape useEffect(() => { if (!open) return; function handleKey(e: KeyboardEvent) { if (e.key === "Escape") { setOpen(false); setQuery(""); } } function handleClick(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setQuery(""); } } document.addEventListener("keydown", handleKey); document.addEventListener("mousedown", handleClick); return () => { document.removeEventListener("keydown", handleKey); document.removeEventListener("mousedown", handleClick); }; }, [open]); useEffect(() => { if (open) searchRef.current?.focus(); }, [open]); const selected = vendors.find((v) => v.id === value); const selectedLabel = selected ? vendorLabel(selected) : ""; const filtered = filterVendors(vendors, query); const select = useCallback((id: string) => { setValue(id); onChange?.(id); setOpen(false); setQuery(""); }, [onChange]); const handleClear = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setValue(""); onChange?.(""); }, [onChange]); const dropdownPanel = (
{/* Search input */}
setQuery(e.target.value)} placeholder="Search by name or code…" className="flex-1 text-sm outline-none placeholder:text-neutral-400" /> {query && ( )}
{/* Options list */}
{/* "No vendor selected" empty option — always available */} {filtered.length === 0 ? (

No vendors match “{query}”

) : ( filtered.map((v) => ( )) )}
); return (
{open && mounted && createPortal(dropdownPanel, document.body)}
); }