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%
8.2 KB · 170 lines tsx
Raw Blame History
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