spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Search, X } from 'lucide-react';3import { useEffect, useId, useRef, useState } from 'react';4import { t } from '@/i18n';5import { clientExplore } from '@/lib/client-api-explore';6import { cn } from '@/lib/cn';7import type { SearchHit } from '@/lib/types';89export interface PickedEntity {10 type: 'country' | 'indicator';11 id: string;12 slug: string;13 name: string;14 flag?: string | null;15 hint?: string | null;16}1718/**19 * Typeahead over `/api/v1/search?type=country|indicator`. Keyboard: ↑↓ ↵ esc. Emits a `PickedEntity`20 * and clears itself (`keepValue` to show the picked name instead). 44 px tall on phones.21 */22export function EntityPicker({23 type,24 placeholder,25 onPick,26 exclude = [],27 keepValue = false,28 autoFocus = false,29 className,30 size = 'md',31}: {32 type: 'country' | 'indicator';33 placeholder: string;34 onPick: (e: PickedEntity) => void;35 exclude?: string[];36 keepValue?: boolean;37 autoFocus?: boolean;38 className?: string;39 size?: 'sm' | 'md';40}) {41 const id = useId();42 const [q, setQ] = useState('');43 const [hits, setHits] = useState<SearchHit[]>([]);44 const [open, setOpen] = useState(false);45 const [active, setActive] = useState(0);46 const [loading, setLoading] = useState(false);47 const abortRef = useRef<AbortController | null>(null);48 const wrapRef = useRef<HTMLDivElement>(null);4950 useEffect(() => {51 const term = q.trim();52 abortRef.current?.abort();53 if (term.length < 1) {54 setHits([]);55 setLoading(false);56 return;57 }58 const ctrl = new AbortController();59 abortRef.current = ctrl;60 setLoading(true);61 const timer = setTimeout(async () => {62 try {63 const res = await clientExplore.search(term, type, 10, ctrl.signal);64 if (ctrl.signal.aborted) return;65 setHits(res.hits.filter((h) => h.type === type && !exclude.includes(h.id) && !exclude.includes(h.slug ?? '')));66 setActive(0);67 setOpen(true);68 } catch {69 if (!ctrl.signal.aborted) setHits([]);70 } finally {71 if (!ctrl.signal.aborted) setLoading(false);72 }73 }, 140);74 return () => clearTimeout(timer);75 // eslint-disable-next-line react-hooks/exhaustive-deps76 }, [q, type, exclude.join(',')]);7778 useEffect(() => {79 const onDoc = (e: PointerEvent) => {80 if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);81 };82 document.addEventListener('pointerdown', onDoc);83 return () => document.removeEventListener('pointerdown', onDoc);84 }, []);8586 const pick = (h: SearchHit) => {87 onPick({ type, id: h.id, slug: h.slug ?? h.id, name: h.name, flag: h.country?.flag ?? null, hint: h.hint });88 setQ(keepValue ? h.name : '');89 setHits([]);90 setOpen(false);91 };9293 const onKey = (e: React.KeyboardEvent) => {94 if (e.key === 'ArrowDown') {95 e.preventDefault();96 setOpen(true);97 setActive((a) => Math.min(hits.length - 1, a + 1));98 } else if (e.key === 'ArrowUp') {99 e.preventDefault();100 setActive((a) => Math.max(0, a - 1));101 } else if (e.key === 'Enter') {102 const h = hits[active];103 if (h) {104 e.preventDefault();105 pick(h);106 }107 } else if (e.key === 'Escape') {108 setOpen(false);109 }110 };111112 const listId = `${id}-list`;113 return (114 <div ref={wrapRef} className={cn('relative min-w-0', className)}>115 <div className="relative flex items-center">116 <Search size={15} aria-hidden className="absolute left-2.5 text-ink-3" />117 <input118 type="search"119 value={q}120 onChange={(e) => setQ(e.target.value)}121 onFocus={() => hits.length && setOpen(true)}122 onKeyDown={onKey}123 placeholder={placeholder}124 aria-label={placeholder}125 role="combobox"126 aria-expanded={open}127 aria-controls={listId}128 aria-autocomplete="list"129 aria-activedescendant={open && hits[active] ? `${id}-opt-${hits[active].id}` : undefined}130 autoComplete="off"131 autoFocus={autoFocus}132 enterKeyHint="go"133 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')}134 />135 {q ? (136 <button type="button" onClick={() => { setQ(''); setHits([]); }} className="absolute right-0 grid h-11 w-9 place-items-center text-ink-3 hover:text-ink md:h-9" aria-label={t('search.clear')}>137 <X size={14} aria-hidden />138 </button>139 ) : null}140 </div>141 {open && (hits.length > 0 || loading) ? (142 <ul id={listId} role="listbox" className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-sm border border-rule bg-surface py-1 shadow-pop">143 {loading && hits.length === 0 ? <li className="px-3 py-2 text-xs text-ink-3">{t('search.loading')}</li> : null}144 {hits.map((h, i) => (145 <li key={h.id} id={`${id}-opt-${h.id}`} role="option" aria-selected={i === active}>146 <button147 type="button"148 onMouseEnter={() => setActive(i)}149 onClick={() => pick(h)}150 className={cn('flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm md:min-h-[36px]', i === active ? 'bg-surface-2' : 'hover:bg-surface-2/60')}151 >152 {h.country?.flag ? <span aria-hidden className="w-5 text-center text-base leading-none">{h.country.flag}</span> : null}153 <span className="min-w-0 flex-1">154 <span className="block truncate text-ink">{h.name}</span>155 {h.hint ? <span className="block truncate text-2xs text-ink-3">{h.hint}</span> : null}156 </span>157 </button>158 </li>159 ))}160 </ul>161 ) : null}162 </div>163 );164}165