SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
5.8 KB · 165 lines tsx
Raw Blame History
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