spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ChevronDown, Search, X } from 'lucide-react';3import { useEffect, useId, useMemo, useRef, useState } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { topicById } from '@/lib/topics';78import { toIndicatorOption, type IndicatorOption } from '@/lib/indicator-options';9export { toIndicatorOption, type IndicatorOption };1011/**12 * Indicator picker for the analytical views: a button showing the current indicator, opening a searchable13 * list grouped by topic (featured first). Client-side filtering over the full list (≈ 260 rows, passed from14 * the server). Keyboard: type to filter, ↑↓ ↵ esc. Closes on outside pointerdown.15 */16export function IndicatorSelect({17 options,18 value,19 onChange,20 label,21 className,22 size = 'md',23 align = 'left',24}: {25 options: IndicatorOption[];26 value: string;27 onChange: (slug: string) => void;28 label?: string;29 className?: string;30 size?: 'sm' | 'md';31 align?: 'left' | 'right';32}) {33 const id = useId();34 const [open, setOpen] = useState(false);35 const [q, setQ] = useState('');36 const [active, setActive] = useState(0);37 const wrap = useRef<HTMLDivElement>(null);38 const input = useRef<HTMLInputElement>(null);39 const current = options.find((o) => o.slug === value) ?? null;4041 useEffect(() => {42 if (!open) return;43 setTimeout(() => input.current?.focus(), 20);44 const onDoc = (e: PointerEvent) => {45 if (!wrap.current?.contains(e.target as Node)) setOpen(false);46 };47 document.addEventListener('pointerdown', onDoc);48 return () => document.removeEventListener('pointerdown', onDoc);49 }, [open]);5051 const groups = useMemo(() => {52 const ql = q.trim().toLowerCase();53 const pool = ql ? options.filter((o) => `${o.name} ${o.short_name ?? ''} ${o.slug} ${o.unit ?? ''}`.toLowerCase().includes(ql)) : options;54 const featured = ql ? [] : pool.filter((o) => o.featured);55 const byTopic = new Map<string, IndicatorOption[]>();56 for (const o of pool) {57 const k = o.topic ?? 'other';58 if (!byTopic.has(k)) byTopic.set(k, []);59 byTopic.get(k)!.push(o);60 }61 const out: Array<{ key: string; label: string; items: IndicatorOption[] }> = [];62 if (featured.length) out.push({ key: 'featured', label: t('control.featured'), items: featured });63 for (const [k, items] of Array.from(byTopic.entries()).sort((a, b) => (topicById(a[0])?.order ?? 99) - (topicById(b[0])?.order ?? 99))) out.push({ key: k, label: topicById(k)?.name ?? k, items });64 return out;65 }, [options, q]);66 const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);6768 useEffect(() => setActive(0), [q]);6970 const pick = (o: IndicatorOption) => {71 onChange(o.slug);72 setOpen(false);73 setQ('');74 };75 const onKey = (e: React.KeyboardEvent) => {76 if (e.key === 'ArrowDown') {77 e.preventDefault();78 setActive((a) => Math.min(flat.length - 1, a + 1));79 } else if (e.key === 'ArrowUp') {80 e.preventDefault();81 setActive((a) => Math.max(0, a - 1));82 } else if (e.key === 'Enter') {83 e.preventDefault();84 const o = flat[active];85 if (o) pick(o);86 } else if (e.key === 'Escape') {87 setOpen(false);88 }89 };90 useEffect(() => {91 if (!open) return;92 const el = wrap.current?.querySelector<HTMLElement>(`[data-idx="${active}"]`);93 el?.scrollIntoView({ block: 'nearest' });94 }, [active, open]);9596 return (97 <div ref={wrap} className={cn('relative min-w-0', className)}>98 <button99 type="button"100 onClick={() => setOpen((o) => !o)}101 aria-haspopup="listbox"102 aria-expanded={open}103 aria-controls={`${id}-list`}104 className={cn('flex w-full min-w-0 items-center gap-2 rounded-sm border border-rule bg-surface text-left text-ink hover:border-rule-strong', size === 'sm' ? 'h-11 px-2.5 text-sm md:h-9' : 'h-12 px-3 text-base md:h-10 md:text-sm')}105 >106 <span className="min-w-0 flex-1">107 {label ? <span className="block text-2xs uppercase tracking-wide text-ink-3">{label}</span> : null}108 <span className="block truncate font-medium">{current?.short_name ?? current?.name ?? value}</span>109 </span>110 <ChevronDown size={15} aria-hidden className={cn('shrink-0 text-ink-3 transition-transform', open && 'rotate-180')} />111 </button>112 {open ? (113 <div className={cn('absolute top-full z-40 mt-1 w-[min(92vw,28rem)] rounded-sm border border-rule bg-surface shadow-pop', align === 'right' ? 'right-0' : 'left-0')}>114 <div className="relative border-b border-rule p-2">115 <Search size={14} aria-hidden className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-ink-3" />116 <input ref={input} type="search" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} placeholder={t('control.searchIndicators')} aria-label={t('control.searchIndicators')} role="combobox" aria-expanded={open} aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={flat[active] ? `${id}-opt-${flat[active].slug}` : undefined} autoComplete="off" className="h-10 w-full rounded-sm border border-rule bg-paper pl-8 pr-8 text-sm outline-none placeholder:text-ink-3 focus:border-accent" />117 {q ? (118 <button type="button" onClick={() => setQ('')} className="absolute right-3 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>119 <X size={13} aria-hidden />120 </button>121 ) : null}122 </div>123 <ul id={`${id}-list`} role="listbox" className="max-h-[min(60vh,22rem)] overflow-y-auto py-1">124 {flat.length === 0 ? <li className="px-3 py-3 text-sm text-ink-3">{t('control.noMatch')}</li> : null}125 {groups.map((g) => (126 <li key={g.key} role="presentation">127 <div className="eyebrow px-3 pb-1 pt-2">{g.label}</div>128 <ul role="group" aria-label={g.label}>129 {g.items.map((o) => {130 const i = flat.indexOf(o);131 const sel = o.slug === value;132 return (133 <li key={`${g.key}-${o.slug}`} id={`${id}-opt-${o.slug}`} role="option" aria-selected={sel} data-idx={i}>134 <button type="button" onMouseEnter={() => setActive(i)} onClick={() => pick(o)} className={cn('flex min-h-[44px] w-full items-center gap-2 px-3 py-1.5 text-left text-sm md:min-h-[36px]', i === active ? 'bg-surface-2' : 'hover:bg-surface-2/60', sel && 'font-medium text-accent')}>135 <span className="min-w-0 flex-1">136 <span className="block truncate">{o.name}</span>137 <span className="tnum block truncate text-2xs text-ink-3">138 {o.unit ?? ''}139 {o.first_year && o.last_year ? ` · ${o.first_year}–${o.last_year}` : ''}140 {o.n_countries != null ? ` · ${o.n_countries} countries` : ''}141 </span>142 </span>143 </button>144 </li>145 );146 })}147 </ul>148 </li>149 ))}150 </ul>151 </div>152 ) : null}153 </div>154 );155}156157/** Small segmented control (radio group look) used by the analytical views for view/mode switches. */158export function Segmented<T extends string>({ value, onChange, options, label, className, size = 'md' }: { value: T; onChange: (v: T) => void; options: Array<{ value: T; label: string; icon?: React.ReactNode }>; label: string; className?: string; size?: 'sm' | 'md' }) {159 return (160 <div role="radiogroup" aria-label={label} className={cn('inline-flex max-w-full items-center gap-0.5 rounded-sm border border-rule bg-surface p-0.5', className)}>161 {options.map((o) => (162 <button key={o.value} type="button" role="radio" aria-checked={value === o.value} onClick={() => onChange(o.value)} className={cn('inline-flex shrink-0 items-center gap-1 rounded-xs px-2.5 text-sm', size === 'sm' ? 'h-9 md:h-8 md:px-2 md:text-xs' : 'h-10 md:h-8', value === o.value ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>163 {o.icon}164 <span className="truncate">{o.label}</span>165 </button>166 ))}167 </div>168 );169}170