spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Download, Plus, Share2, SlidersHorizontal, X } from 'lucide-react';3import { usePathname, useRouter } from 'next/navigation';4import { useCallback, useEffect, useRef, useState } from 'react';5import { t } from '@/i18n';6import { cn } from '@/lib/cn';7import { COMPARE_TABS, MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state';8import { routes } from '@/lib/site';9import { COMPARE_UI_MODES, type CompareUiMode, type CountryLite } from '@/lib/types-compare';10import { seriesVar } from '@/components/charts/palette';11import { BottomSheet } from '@/components/data/bottom-sheet';12import { CountryPickerSheet } from './country-picker';1314export const COMPARE_MIN_YEAR = 1960;1516/**17 * Sticky controls of /compare/[...slugs]: country chips (remove, reorder, add), view tabs and — for chart18 * tabs — year range, value mode, log toggle, CSV download and share. Every change is written to the URL19 * (`router.replace`, no scroll jump); country changes rewrite the path (`router.push`).20 */21export function CompareControls({ countries, allCountries, state, maxYear, downloadHref }: { countries: CountryLite[]; allCountries: CountryLite[]; state: CompareState; maxYear: number; downloadHref: string | null }) {22 const router = useRouter();23 const pathname = usePathname();24 const [picker, setPicker] = useState(false);25 const [options, setOptions] = useState(false);26 const [copied, setCopied] = useState(false);27 const [dragFrom, setDragFrom] = useState<number | null>(null);28 const tabsRef = useRef<HTMLDivElement>(null);29 const slugs = countries.map((c) => c.slug);30 const exclude = new Set(slugs);31 const chartsTab = state.tab !== 'snapshot';3233 useEffect(() => {34 const el = tabsRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');35 el?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'instant' as ScrollBehavior });36 }, [state.tab]);3738 const setState = useCallback(39 (patch: Partial<CompareState>) => {40 router.replace(`${pathname}${compareQuery({ ...state, ...patch })}`, { scroll: false });41 },42 [router, pathname, state],43 );44 const setCountries = (next: string[]) => {45 router.push(`${routes.compare(...next)}${compareQuery(state)}`, { scroll: false });46 };47 const move = (from: number, to: number) => {48 if (to < 0 || to >= slugs.length || from === to) return;49 const next = [...slugs];50 const [it] = next.splice(from, 1);51 next.splice(to, 0, it!);52 setCountries(next);53 };54 const remove = (slug: string) => {55 if (slugs.length <= MIN_COMPARE_COUNTRIES) return;56 setCountries(slugs.filter((s) => s !== slug));57 };58 const add = (c: CountryLite) => {59 if (slugs.length >= MAX_COMPARE_COUNTRIES) return;60 setCountries([...slugs, c.slug]);61 };62 const share = async () => {63 const url = window.location.href;64 try {65 if (navigator.share) {66 await navigator.share({ title: document.title, url });67 return;68 }69 await navigator.clipboard.writeText(url);70 setCopied(true);71 setTimeout(() => setCopied(false), 1800);72 } catch {73 /* cancelled */74 }75 };7677 const years: number[] = [];78 for (let y = maxYear; y >= COMPARE_MIN_YEAR; y--) years.push(y);79 const canRemove = slugs.length > MIN_COMPARE_COUNTRIES;80 const full = slugs.length >= MAX_COMPARE_COUNTRIES;8182 const rangeAndMode = (83 <>84 <fieldset className="flex items-center gap-1.5">85 <legend className="sr-only">{t('compare.range')}</legend>86 <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">87 <span className="text-2xs uppercase tracking-wide text-ink-3">{t('compare.range.from')}</span>88 <select value={state.from ?? ''} onChange={(e) => setState({ from: e.target.value ? Number(e.target.value) : null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('compare.range.from')}>89 <option value="">{t('compare.range.all')}</option>90 {years.map((y) => (91 <option key={y} value={y} disabled={state.to != null && y > state.to}>92 {y}93 </option>94 ))}95 </select>96 </label>97 <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">98 <span className="text-2xs uppercase tracking-wide text-ink-3">{t('compare.range.to')}</span>99 <select value={state.to ?? ''} onChange={(e) => setState({ to: e.target.value ? Number(e.target.value) : null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('compare.range.to')}>100 <option value="">{t('compare.range.all')}</option>101 {years.map((y) => (102 <option key={y} value={y} disabled={state.from != null && y < state.from}>103 {y}104 </option>105 ))}106 </select>107 </label>108 </fieldset>109 <div role="radiogroup" aria-label={t('compare.valueMode')} className="flex flex-wrap gap-1">110 {COMPARE_UI_MODES.map((m: CompareUiMode) => (111 <button key={m} type="button" role="radio" aria-checked={state.mode === m} onClick={() => setState({ mode: m })} title={t(`compare.mode.hint.${m}` as 'compare.mode.hint.pct')} className={cn('inline-flex h-11 items-center rounded-sm border px-2.5 text-sm md:h-9 md:px-2', state.mode === m ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>112 {t(`compare.mode.${m}` as 'compare.mode.absolute')}113 </button>114 ))}115 </div>116 <label className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border px-2.5 text-sm md:h-9 md:px-2', state.log ? 'border-ink text-ink' : 'border-rule text-ink-2')}>117 <input type="checkbox" checked={state.log} onChange={(e) => setState({ log: e.target.checked })} className="accent-[var(--accent)]" />118 {t('compare.log')}119 </label>120 </>121 );122123 const actions = (124 <>125 {downloadHref ? (126 <a href={downloadHref} download className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:h-9 md:px-2">127 <Download size={14} aria-hidden />128 <span className="hidden sm:inline">{t('compare.download')}</span>129 <span className="sm:hidden">CSV</span>130 </a>131 ) : null}132 <button type="button" onClick={share} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink md:h-9 md:px-2" aria-live="polite">133 {copied ? <Check size={14} aria-hidden className="text-up" /> : <Share2 size={14} aria-hidden />}134 {copied ? t('compare.copied') : t('compare.share')}135 </button>136 </>137 );138139 return (140 <div className="sticky top-[52px] z-20 -mx-4 border-b border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6 md:top-[56px]">141 {/* Row 1: country chips */}142 <div className="flex items-center gap-2 px-4 pt-2 sm:px-6">143 <ul className="scrollbar-none flex min-w-0 flex-1 gap-1.5 overflow-x-auto py-0.5" aria-label={t('compare.legend')}>144 {countries.map((c, i) => (145 <li146 key={c.slug}147 className={cn('group/chip shrink-0', dragFrom === i && 'opacity-50')}148 draggable149 onDragStart={() => setDragFrom(i)}150 onDragOver={(e) => e.preventDefault()}151 onDrop={() => {152 if (dragFrom != null) move(dragFrom, i);153 setDragFrom(null);154 }}155 onDragEnd={() => setDragFrom(null)}156 >157 <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm md:h-9">158 <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />159 <span aria-hidden>{c.flag}</span>160 <span className="max-w-[8rem] truncate text-ink md:max-w-[11rem]">{c.name}</span>161 <span className="hidden items-center md:flex">162 <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0} className="grid h-9 w-6 place-items-center text-ink-3 hover:text-ink disabled:opacity-25" aria-label={t('compare.builder.moveUp', { name: c.name })}>163 <ChevronLeft size={13} aria-hidden />164 </button>165 <button type="button" onClick={() => move(i, i + 1)} disabled={i === countries.length - 1} className="grid h-9 w-6 place-items-center text-ink-3 hover:text-ink disabled:opacity-25" aria-label={t('compare.builder.moveDown', { name: c.name })}>166 <ChevronRight size={13} aria-hidden />167 </button>168 </span>169 <button type="button" onClick={() => remove(c.slug)} disabled={!canRemove} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down disabled:opacity-25 md:h-9 md:w-7" aria-label={t('compare.removeCountry', { name: c.name })}>170 <X size={14} aria-hidden />171 </button>172 </span>173 </li>174 ))}175 <li className="shrink-0">176 <button type="button" onClick={() => setPicker(true)} disabled={full} className={cn('inline-flex h-11 items-center gap-1 rounded-sm border border-dashed px-2.5 text-sm md:h-9', full ? 'border-rule text-ink-3' : 'border-rule-strong text-ink-2 hover:border-accent hover:text-accent')} aria-label={t('compare.addCountry')}>177 <Plus size={14} aria-hidden />178 {t('compare.add')}179 </button>180 </li>181 </ul>182 <button type="button" onClick={() => setOptions(true)} className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 md:hidden" aria-label={t('compare.options')}>183 <SlidersHorizontal size={15} aria-hidden />184 <span className="sr-only sm:not-sr-only">{t('compare.options')}</span>185 </button>186 </div>187188 {/* Row 2: tabs */}189 <div ref={tabsRef} role="tablist" aria-label={t('compare.tabs')} className="scrollbar-none mt-1 flex gap-0.5 overflow-x-auto px-4 sm:px-6">190 {COMPARE_TABS.map((tab: CompareTab) => {191 const active = state.tab === tab;192 return (193 <button194 key={tab}195 type="button"196 role="tab"197 aria-selected={active}198 onClick={() => setState({ tab, indicator: tab === state.tab ? state.indicator : null })}199 className={cn('relative inline-flex h-11 shrink-0 items-center px-3 text-sm md:h-10', active ? 'font-medium text-ink' : 'text-ink-2 hover:text-ink')}200 >201 {t(`compare.tab.${tab}` as 'compare.tab.snapshot')}202 {active ? <span aria-hidden className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-ink" /> : null}203 </button>204 );205 })}206 </div>207208 {/* Row 3 (md+): range · mode · log · download · share */}209 <div className="hidden flex-wrap items-center gap-2 border-t border-rule px-4 py-1.5 sm:px-6 md:flex">210 {chartsTab ? rangeAndMode : null}211 <div className="ml-auto flex items-center gap-2">{actions}</div>212 </div>213214 <CountryPickerSheet open={picker} onClose={() => setPicker(false)} countries={allCountries} exclude={exclude} onPick={add} />215216 <BottomSheet open={options} onClose={() => setOptions(false)} side="center" title={t('compare.options')}>217 <div className="space-y-5">218 <div>219 <div className="eyebrow mb-1.5">{t('compare.legend')}</div>220 <ol className="divide-y divide-rule border-y border-rule">221 {countries.map((c, i) => (222 <li key={c.slug} className="flex min-h-[48px] items-center gap-2">223 <span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />224 <span aria-hidden className="text-lg leading-none">225 {c.flag}226 </span>227 <span className="min-w-0 flex-1 truncate text-sm text-ink">{c.name}</span>228 <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0} className="tap grid place-items-center text-ink-2 disabled:opacity-30" aria-label={t('compare.builder.moveUp', { name: c.name })}>229 <ChevronUp size={16} aria-hidden />230 </button>231 <button type="button" onClick={() => move(i, i + 1)} disabled={i === countries.length - 1} className="tap grid place-items-center text-ink-2 disabled:opacity-30" aria-label={t('compare.builder.moveDown', { name: c.name })}>232 <ChevronDown size={16} aria-hidden />233 </button>234 <button type="button" onClick={() => remove(c.slug)} disabled={!canRemove} className="tap grid place-items-center text-ink-3 hover:text-down disabled:opacity-30" aria-label={t('compare.removeCountry', { name: c.name })}>235 <X size={16} aria-hidden />236 </button>237 </li>238 ))}239 </ol>240 <button type="button" onClick={() => { setOptions(false); setPicker(true); }} disabled={full} className="mt-2 inline-flex h-11 items-center gap-1 rounded-sm border border-dashed border-rule-strong px-3 text-sm text-ink-2 disabled:opacity-40">241 <Plus size={14} aria-hidden />242 {t('compare.addCountry')}243 </button>244 </div>245 {chartsTab ? (246 <div>247 <div className="eyebrow mb-1.5">{t('compare.range')} · {t('compare.valueMode')}</div>248 <div className="flex flex-wrap gap-2">{rangeAndMode}</div>249 </div>250 ) : null}251 <div className="flex flex-wrap gap-2">{actions}</div>252 </div>253 </BottomSheet>254 </div>255 );256}257