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%
4.9 KB · 137 lines tsx
Raw Blame History
1'use client';2import { Table2 } from 'lucide-react';3import { useId, useState, type ReactNode } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import type { Provenance } from '@/lib/types';7import type { ProvenancePayload } from '@/components/data/provenance-context';8import { SourceLine } from './source-line';910export interface TableColumn {11  key: string;12  label: string;13  numeric?: boolean;14}15export interface TableData {16  columns: TableColumn[];17  rows: Array<Record<string, string>>;18}1920/**21 * Wraps any chart: optional heading, the chart, a source attribution line (clickable → provenance),22 * and the accessible data-table toggle. `summary` is the auto-generated text summary announced to AT.23 */24export function ChartFrame({25  title,26  subtitle,27  summary,28  provenance,29  payload,30  table,31  actions,32  legend,33  note,34  children,35  className,36  minHeight,37}: {38  title?: ReactNode;39  subtitle?: ReactNode;40  summary: string;41  provenance?: Provenance | null;42  payload?: ProvenancePayload | null;43  table?: TableData;44  actions?: ReactNode;45  legend?: ReactNode;46  note?: ReactNode;47  children: ReactNode;48  className?: string;49  /** Reserve height to avoid layout shift while the chart mounts. */50  minHeight?: number;51}) {52  const [showTable, setShowTable] = useState(false);53  const id = useId();54  return (55    <figure className={cn('min-w-0', className)} aria-describedby={`${id}-sum`}>56      {title || actions ? (57        <figcaption className="mb-2 flex items-start justify-between gap-3">58          <div className="min-w-0">59            {title ? <div className="text-sm font-semibold text-ink">{title}</div> : null}60            {subtitle ? <div className="text-xs text-ink-2">{subtitle}</div> : null}61          </div>62          {actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}63        </figcaption>64      ) : null}65      {legend}66      <div style={minHeight ? { minHeight } : undefined}>{children}</div>67      <p id={`${id}-sum`} className="sr-only">68        {summary}69      </p>70      <div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">71        <div className="min-w-0 flex-1">72          <SourceLine provenance={provenance} payload={payload} />73          {note ? <p className="text-2xs text-ink-3">{note}</p> : null}74        </div>75        {table ? (76          <button77            type="button"78            onClick={() => setShowTable((s) => !s)}79            aria-expanded={showTable}80            aria-controls={`${id}-table`}81            className="inline-flex min-h-[32px] items-center gap-1 rounded-sm px-1.5 text-2xs text-ink-3 hover:bg-surface-2 hover:text-ink"82          >83            <Table2 size={12} aria-hidden />84            {showTable ? t('common.hideTable') : t('common.viewTable')}85          </button>86        ) : null}87      </div>88      {table && showTable ? (89        <div id={`${id}-table`} className="mt-2 max-h-72 overflow-auto border-y border-rule">90          <table className="w-full text-xs tnum">91            <caption className="sr-only">{t('chart.table.caption')}</caption>92            <thead className="sticky top-0 bg-paper">93              <tr>94                {table.columns.map((c) => (95                  <th key={c.key} scope="col" className={cn('px-2 py-1.5 text-left font-medium text-ink-2', c.numeric && 'text-right')}>96                    {c.label}97                  </th>98                ))}99              </tr>100            </thead>101            <tbody className="divide-y divide-rule">102              {table.rows.map((r, i) => (103                <tr key={i}>104                  {table.columns.map((c) => (105                    <td key={c.key} className={cn('px-2 py-1', c.numeric && 'text-right')}>106                      {r[c.key] ?? t('common.na')}107                    </td>108                  ))}109                </tr>110              ))}111            </tbody>112          </table>113        </div>114      ) : null}115    </figure>116  );117}118119/** Legend for ≥ 2 series (a single series needs none — the title names it). */120export function Legend({ items }: { items: Array<{ label: string; color: string; dashed?: boolean; shape?: 'line' | 'rect' }> }) {121  if (items.length < 2) return null;122  return (123    <ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2" aria-label={t('common.legend')}>124      {items.map((it) => (125        <li key={it.label} className="inline-flex items-center gap-1.5">126          {it.shape === 'rect' ? (127            <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-xs" style={{ background: it.color }} />128          ) : (129            <span aria-hidden className="inline-block h-0.5 w-4 rounded-full" style={{ background: it.color, ...(it.dashed ? { backgroundImage: `repeating-linear-gradient(90deg, ${it.color} 0 3px, transparent 3px 6px)`, backgroundColor: 'transparent' } : {}) }} />130          )}131          <span>{it.label}</span>132        </li>133      ))}134    </ul>135  );136}137