spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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