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