'use client'; import { Search, X } from 'lucide-react'; import { useEffect, useId, useRef, useState } from 'react'; import { t } from '@/i18n'; import { clientExplore } from '@/lib/client-api-explore'; import { cn } from '@/lib/cn'; import type { SearchHit } from '@/lib/types'; export interface PickedEntity { type: 'country' | 'indicator'; id: string; slug: string; name: string; flag?: string | null; hint?: string | null; } /** * Typeahead over `/api/v1/search?type=country|indicator`. Keyboard: ↑↓ ↵ esc. Emits a `PickedEntity` * and clears itself (`keepValue` to show the picked name instead). 44 px tall on phones. */ export function EntityPicker({ type, placeholder, onPick, exclude = [], keepValue = false, autoFocus = false, className, size = 'md', }: { type: 'country' | 'indicator'; placeholder: string; onPick: (e: PickedEntity) => void; exclude?: string[]; keepValue?: boolean; autoFocus?: boolean; className?: string; size?: 'sm' | 'md'; }) { const id = useId(); const [q, setQ] = useState(''); const [hits, setHits] = useState([]); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const [loading, setLoading] = useState(false); const abortRef = useRef(null); const wrapRef = useRef(null); useEffect(() => { const term = q.trim(); abortRef.current?.abort(); if (term.length < 1) { setHits([]); setLoading(false); return; } const ctrl = new AbortController(); abortRef.current = ctrl; setLoading(true); const timer = setTimeout(async () => { try { const res = await clientExplore.search(term, type, 10, ctrl.signal); if (ctrl.signal.aborted) return; setHits(res.hits.filter((h) => h.type === type && !exclude.includes(h.id) && !exclude.includes(h.slug ?? ''))); setActive(0); setOpen(true); } catch { if (!ctrl.signal.aborted) setHits([]); } finally { if (!ctrl.signal.aborted) setLoading(false); } }, 140); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [q, type, exclude.join(',')]); useEffect(() => { const onDoc = (e: PointerEvent) => { if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener('pointerdown', onDoc); return () => document.removeEventListener('pointerdown', onDoc); }, []); const pick = (h: SearchHit) => { onPick({ type, id: h.id, slug: h.slug ?? h.id, name: h.name, flag: h.country?.flag ?? null, hint: h.hint }); setQ(keepValue ? h.name : ''); setHits([]); setOpen(false); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive((a) => Math.min(hits.length - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === 'Enter') { const h = hits[active]; if (h) { e.preventDefault(); pick(h); } } else if (e.key === 'Escape') { setOpen(false); } }; const listId = `${id}-list`; return (
setQ(e.target.value)} onFocus={() => hits.length && setOpen(true)} onKeyDown={onKey} placeholder={placeholder} aria-label={placeholder} role="combobox" aria-expanded={open} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={open && hits[active] ? `${id}-opt-${hits[active].id}` : undefined} autoComplete="off" autoFocus={autoFocus} enterKeyHint="go" className={cn('w-full rounded-sm border border-rule bg-surface pl-8 pr-8 text-sm outline-none placeholder:text-ink-3 focus:border-accent', size === 'sm' ? 'h-11 md:h-9' : 'h-11 md:h-10')} /> {q ? ( ) : null}
{open && (hits.length > 0 || loading) ? (
    {loading && hits.length === 0 ?
  • {t('search.loading')}
  • : null} {hits.map((h, i) => (
  • ))}
) : null}
); }