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, 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