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%
6.4 KB · 138 lines tsx
Raw Blame History
1'use client';2import { ArrowDown, ArrowUp } from 'lucide-react';3import Link from 'next/link';4import { useMemo, useState } from 'react';5import { t } from '@/i18n';6import { cn } from '@/lib/cn';7import { formatValue, isNum } from '@/lib/format';8import { routes } from '@/lib/site';9import type { IndicatorCard } from '@/lib/types';10import type { RegionMember } from '@/lib/types-explore';1112/**13 * Sortable members table (flag, name + up to 5 headline values). Table on sm+, list rows on phones (each row14 * shows the country and the first three values). Sorting is client-side; default = population desc.15 */16export function MembersTable({ members, indicators }: { members: RegionMember[]; indicators: IndicatorCard[] }) {17  const cols = indicators.slice(0, 5);18  const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: cols[0]?.slug ?? 'name', dir: 'desc' });1920  const rows = useMemo(() => {21    const copy = [...members];22    const num = (m: RegionMember, k: string) => {23      const v = m.values[k]?.value;24      return isNum(v) ? v : null;25    };26    copy.sort((a, b) => {27      if (sort.key === 'name') return (sort.dir === 'asc' ? 1 : -1) * (a.name ?? '').localeCompare(b.name ?? '');28      const va = num(a, sort.key);29      const vb = num(b, sort.key);30      if (va == null && vb == null) return (a.name ?? '').localeCompare(b.name ?? '');31      if (va == null) return 1;32      if (vb == null) return -1;33      return sort.dir === 'asc' ? va - vb : vb - va;34    });35    return copy;36  }, [members, sort]);3738  const toggle = (key: string) => setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'name' ? 'asc' : 'desc' }));39  const Th = ({ k, label, numeric }: { k: string; label: string; numeric?: boolean }) => {40    const active = sort.key === k;41    return (42      <th scope="col" aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'} className={cn('py-1.5 pr-3 font-medium', numeric && 'text-right')}>43        <button type="button" onClick={() => toggle(k)} className={cn('inline-flex min-h-[32px] items-center gap-1 rounded-xs hover:text-ink', active ? 'text-ink' : 'text-ink-3', numeric && 'justify-end')}>44          {label}45          {active ? sort.dir === 'asc' ? <ArrowUp size={12} aria-hidden /> : <ArrowDown size={12} aria-hidden /> : null}46        </button>47      </th>48    );49  };5051  return (52    <div className="min-w-0">53      {/* Mobile sort control */}54      <div className="mb-2 flex items-center gap-2 sm:hidden">55        <label className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">56          <span className="text-xs">{t('common.sortBy')}</span>57          <select value={sort.key} onChange={(e) => setSort({ key: e.target.value, dir: e.target.value === 'name' ? 'asc' : 'desc' })} className="bg-transparent pr-1 text-ink outline-none">58            <option value="name">{t('common.name')}</option>59            {cols.map((c) => (60              <option key={c.slug} value={c.slug}>61                {c.short_name ?? c.name}62              </option>63            ))}64          </select>65        </label>66        <button type="button" onClick={() => setSort((s) => ({ ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' }))} className="tap inline-flex items-center justify-center rounded-sm border border-rule text-ink-2" aria-label={sort.dir === 'asc' ? t('table.sortAsc') : t('table.sortDesc')}>67          {sort.dir === 'asc' ? <ArrowUp size={15} aria-hidden /> : <ArrowDown size={15} aria-hidden />}68        </button>69      </div>7071      <table className="hidden w-full border-collapse text-sm sm:table">72        <thead>73          <tr className="border-b border-rule text-left text-xs">74            <Th k="name" label={t('region.members.country')} />75            {cols.map((c) => (76              <Th key={c.slug} k={c.slug} label={c.short_name ?? c.name ?? c.slug} numeric />77            ))}78          </tr>79        </thead>80        <tbody className="divide-y divide-rule">81          {rows.map((m) => (82            <tr key={m.id} className="hover:bg-surface-2/60">83              <td className="py-2 pr-3">84                <Link href={routes.country(m.slug ?? m.id)} className="link-quiet inline-flex min-h-[32px] items-center gap-2 text-ink">85                  <span aria-hidden className="text-base leading-none">{m.flag}</span>86                  <span className="truncate">{m.name}</span>87                </Link>88              </td>89              {cols.map((c) => {90                const v = m.values[c.slug];91                return (92                  <td key={c.slug} className="tnum py-2 pr-3 text-right text-ink">93                    {v && isNum(v.value) ? (94                      <>95                        {formatValue(v.value, c)}96                        {v.year ? <span className="ml-1 text-2xs text-ink-3">{v.year}</span> : null}97                      </>98                    ) : (99                      <span className="text-ink-3">{t('region.members.noValue')}</span>100                    )}101                  </td>102                );103              })}104            </tr>105          ))}106        </tbody>107      </table>108109      <ul className="divide-y divide-rule sm:hidden">110        {rows.map((m) => (111          <li key={m.id} className="py-2.5">112            <Link href={routes.country(m.slug ?? m.id)} className="link-quiet flex min-h-[44px] items-center gap-2 text-sm font-medium text-ink">113              <span aria-hidden className="text-lg leading-none">{m.flag}</span>114              <span className="truncate">{m.name}</span>115            </Link>116            {/* Values never truncate: the year sits on its own line under the value. */}117            <dl className="mt-1 grid grid-cols-3 gap-x-3">118              {cols.slice(0, 3).map((c) => {119                const v = m.values[c.slug];120                const has = !!v && isNum(v.value);121                return (122                  <div key={c.slug} className="min-w-0">123                    <dt className="truncate text-2xs text-ink-3">{c.short_name ?? c.name}</dt>124                    <dd className="tnum text-sm leading-tight text-ink">125                      <span className="block break-words">{has ? formatValue(v.value, c) : t('region.members.noValue')}</span>126                      {has && v.year ? <span className="block text-2xs text-ink-3">{v.year}</span> : null}127                    </dd>128                  </div>129                );130              })}131            </dl>132          </li>133        ))}134      </ul>135    </div>136  );137}138