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%
7.4 KB · 170 lines tsx
Raw Blame History
1'use client';2import { Search, X } from 'lucide-react';3import { useEffect, useMemo, useRef, useState } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import type { CountryLite } from '@/lib/types-compare';7import { BottomSheet } from '@/components/data/bottom-sheet';89/** Case/diacritic-insensitive prefix-then-substring match on name / ISO3 / slug. */10export function filterCountries(items: CountryLite[], q: string, exclude: ReadonlySet<string> = new Set()): CountryLite[] {11  const ql = q.trim().toLowerCase();12  const pool = items.filter((c) => !exclude.has(c.slug));13  if (!ql) return pool;14  const starts = pool.filter((c) => c.name.toLowerCase().startsWith(ql) || c.id.toLowerCase() === ql || c.slug.startsWith(ql));15  const contains = pool.filter((c) => !starts.includes(c) && (c.name.toLowerCase().includes(ql) || c.slug.includes(ql)));16  return [...starts, ...contains];17}1819/**20 * Country typeahead list (used inline on desktop and inside the bottom sheet on phones). Arrow keys move,21 * Enter picks, Esc clears. Already-selected countries are excluded.22 */23export function CountryTypeahead({ countries, exclude, onPick, autoFocus, placeholder, max = 12, className, disabled }: { countries: CountryLite[]; exclude: ReadonlySet<string>; onPick: (c: CountryLite) => void; autoFocus?: boolean; placeholder?: string; max?: number; className?: string; disabled?: boolean }) {24  const [q, setQ] = useState('');25  const [open, setOpen] = useState(false);26  const [cursor, setCursor] = useState(0);27  const boxRef = useRef<HTMLDivElement>(null);28  const results = useMemo(() => filterCountries(countries, q, exclude).slice(0, max), [countries, q, exclude, max]);2930  useEffect(() => {31    if (!open) return;32    const onDoc = (e: MouseEvent) => {33      if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);34    };35    document.addEventListener('mousedown', onDoc);36    return () => document.removeEventListener('mousedown', onDoc);37  }, [open]);3839  const pick = (c: CountryLite) => {40    onPick(c);41    setQ('');42    setCursor(0);43  };4445  return (46    <div ref={boxRef} className={cn('relative', className)}>47      <div className="relative">48        <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />49        <input50          type="search"51          role="combobox"52          aria-expanded={open && results.length > 0}53          aria-controls="country-typeahead-list"54          aria-autocomplete="list"55          autoFocus={autoFocus}56          disabled={disabled}57          value={q}58          onChange={(e) => {59            setQ(e.target.value);60            setOpen(true);61            setCursor(0);62          }}63          onFocus={() => setOpen(true)}64          onKeyDown={(e) => {65            if (e.key === 'ArrowDown') {66              e.preventDefault();67              setCursor((c) => Math.min(results.length - 1, c + 1));68              setOpen(true);69            } else if (e.key === 'ArrowUp') {70              e.preventDefault();71              setCursor((c) => Math.max(0, c - 1));72            } else if (e.key === 'Enter') {73              const r = results[cursor];74              if (r) {75                e.preventDefault();76                pick(r);77              }78            } else if (e.key === 'Escape') {79              setQ('');80              setOpen(false);81            }82          }}83          placeholder={placeholder ?? t('compare.builder.search')}84          aria-label={placeholder ?? t('compare.builder.search')}85          className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent disabled:opacity-50 md:h-10"86        />87        {q ? (88          <button type="button" onClick={() => setQ('')} className="absolute right-1 top-1/2 grid h-9 w-9 -translate-y-1/2 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>89            <X size={14} aria-hidden />90          </button>91        ) : null}92      </div>93      {open && (q || results.length) ? (94        <ul id="country-typeahead-list" 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">95          {results.length === 0 ? (96            <li className="px-3 py-2 text-sm text-ink-3">{t('compare.builder.noMatch', { q })}</li>97          ) : (98            results.map((c, i) => (99              <li key={c.id} role="option" aria-selected={i === cursor}>100                <button101                  type="button"102                  onMouseDown={(e) => e.preventDefault()}103                  onClick={() => pick(c)}104                  onMouseEnter={() => setCursor(i)}105                  className={cn('flex min-h-[44px] w-full items-center gap-2.5 px-3 text-left text-sm md:min-h-[36px]', i === cursor ? 'bg-surface-2 text-ink' : 'text-ink-2')}106                >107                  <span aria-hidden className="text-lg leading-none">108                    {c.flag}109                  </span>110                  <span className="truncate">{c.name}</span>111                  <span className="tnum ml-auto text-2xs text-ink-3">{c.id}</span>112                </button>113              </li>114            ))115          )}116        </ul>117      ) : null}118    </div>119  );120}121122/** Country picker in a sheet (bottom sheet on phones, centred panel on desktop): search + tappable list. */123export function CountryPickerSheet({ open, onClose, countries, exclude, onPick, title, closeOnPick = true }: { open: boolean; onClose: () => void; countries: CountryLite[]; exclude: ReadonlySet<string>; onPick: (c: CountryLite) => void; title?: string; closeOnPick?: boolean }) {124  const [q, setQ] = useState('');125  const results = useMemo(() => filterCountries(countries, q, exclude), [countries, q, exclude]);126  useEffect(() => {127    if (!open) setQ('');128  }, [open]);129  return (130    <BottomSheet open={open} onClose={onClose} side="center" title={title ?? t('compare.addCountry')}>131      <div className="relative">132        <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />133        <input134          type="search"135          autoFocus136          value={q}137          onChange={(e) => setQ(e.target.value)}138          placeholder={t('compare.builder.search')}139          aria-label={t('compare.builder.search')}140          className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-3 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent"141        />142      </div>143      {results.length === 0 ? (144        <p className="py-6 text-center text-sm text-ink-3">{t('compare.builder.noMatch', { q })}</p>145      ) : (146        <ul className="mt-2 divide-y divide-rule">147          {results.map((c) => (148            <li key={c.id}>149              <button150                type="button"151                onClick={() => {152                  onPick(c);153                  if (closeOnPick) onClose();154                }}155                className="flex min-h-[48px] w-full items-center gap-3 py-1 text-left text-sm text-ink hover:text-accent"156              >157                <span aria-hidden className="text-xl leading-none">158                  {c.flag}159                </span>160                <span className="truncate">{c.name}</span>161                <span className="tnum ml-auto text-2xs text-ink-3">{c.id}</span>162              </button>163            </li>164          ))}165        </ul>166      )}167    </BottomSheet>168  );169}170