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