import * as React from 'react'; import { Badge } from '@/components/badge'; import { useMessages } from '~/i18n'; import type { AxisValue, Entry, MatrixSpec, PropBag } from '~/registry/types'; import { snippetOf, snippetOfMany } from '../lib/codegen'; import { CopyButton } from './copy'; import { ErrorBoundary } from './error-boundary'; import { SectionHeading } from './section'; function label(value: AxisValue) { return String(value); } /** * An axis value, as a pill. * * `outline` on the kit's neutral palette rather than plain grey text: these * labels are the axis *values* — `solid`, `lg`, `true` — and reading them as * tokens rather than as prose is the whole point of the grid. The Badge is * `asChild`-free and non-interactive, so it costs nothing but the border. */ function AxisLabel({ value }: { value: AxisValue }) { return ( {label(value)} ); } /** * The grid itself, with nothing registry-shaped about it: two lists of labels and * a function that turns a pair of them into an element. * * This is the piece Storybook has no equivalent of: a CBAR component set on the * Figma canvas *is* this grid, so putting the live component in the same shape * makes the two directly comparable side by side. `Matrix` below feeds it from an * `Entry`; the button gallery feeds it lists the user has filtered, which is why * the two halves are separate. */ export function MatrixGrid({ title, description, rows, cols, cell, code, snippet, cellTitle, copyAll = true, }: { title: string; description?: string; rows: readonly AxisValue[]; /* A single `null` column draws the grid as one unlabelled column — the shape a `MatrixSpec` with no `cols` asks for. */ cols: readonly (AxisValue | null)[]; cell: (row: AxisValue, col: AxisValue | null) => React.ReactNode; /** * What the copy buttons serialise, when that is not what the grid shows. * * The one caller is the Figma-layout tab, whose `hover` and `active` rows draw * a forced `className` because those states are CSS here rather than props — * and nobody should paste `className="bg-(--ctl-solid-hover)"`. Everywhere * else this is omitted and the shown element is the copied one, which is the * rule the rest of the showcase runs on. */ code?: (row: AxisValue, col: AxisValue | null) => React.ReactNode; /** * The copy text for a cell, verbatim — no serialisation at all. * * `code` still goes through `snippetOf`, which is right whenever the thing to * paste is JSX. `/toasts` needs the opposite: a toast is produced by a * function call, and the element on screen is `Toast`, which the kit does not * publish — so there is no JSX worth serialising and `snippetOf` would escape * the call's braces into a JSX expression. Supply the call instead. * * Takes precedence over `code`; pass one or the other, never both. */ snippet?: (row: AxisValue, col: AxisValue | null) => string; cellTitle?: (row: AxisValue, col: AxisValue | null) => string; /** * The grid-level "copy every cell" button. On by default; the two button * pages turn it off, because a grid there is a whole treatment × palette * sweep and nobody pastes 35 buttons at once — a cell at a time is the unit * that gets used. */ copyAll?: boolean; }) { const m = useMessages(); if (rows.length === 0 || cols.length === 0) return null; /* Every cell is built once, up front. The grid shows the element and the copy buttons serialise that same element, so a snippet cannot describe a variant other than the one under it. */ const cells = rows.map((row) => cols.map((col) => cell(row, col))); const snippets = code ? rows.map((row) => cols.map((col) => code(row, col))) : cells; /* Verbatim text wins over serialisation when the page supplied it. */ const textOf = (row: AxisValue, col: AxisValue | null, r: number, i: number) => snippet ? snippet(row, col) : snippetOf(snippets[r][i]); return (
{/* `h2`: a grid is a top-level section of the page it sits on, whose `h1` is the component name — see the note in `playground.tsx`. */} snippet ? rows .flatMap((row, r) => cols.map((col, i) => textOf(row, col, r, i))) .join('\n') : snippetOfMany(snippets.flat()) } label={m.matrix.copyAll(rows.length * cols.length)} title={m.matrix.copyAllTitle} /> ) : null } />
{/* Column headers. The empty first cell is the row-label gutter — it draws no leading edge, so the header lines up with the body's first hairline rather than boxing the gutter in. */}
{cols.map((col, i) => (
{col === null ? null : }
))} {rows.map((row, r) => ( {/* No `border-r` any more: the first cell of the row draws that line as its own `border-inline-start`, which is what keeps it continuous with the hairlines between the cells beside it. */}
{cols.map((col, i) => { const where = cellTitle?.(row, col); return (
{/* One cell throwing must cost one cell. Without this a component broken at a single axis value takes the whole grid — and the page you were reading it on — with it. */} {cells[r][i]} {/* Revealed on hover so the grid still reads as a grid, but focusable all the same — a keyboard reaches every cell. Icon-only because a cell has room for a 32px square and not for the word; the label survives as its accessible name. */} textOf(row, col, r, i)} title={where} iconOnly className="absolute top-1 right-1 size-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" />
); })}
))}
); } /** One axis pair of an `Entry`, rendered as a labelled grid — rows down, columns across. */ export function Matrix({ entry, spec }: { entry: Entry; spec: MatrixSpec }) { const axes = entry.axes ?? {}; const rowValues = axes[spec.rows]; const colValues = spec.cols ? axes[spec.cols] : undefined; if (!entry.render || !rowValues) return null; const columns = colValues ?? [null]; const render = entry.render; return ( { const props: PropBag = { ...entry.defaults, ...spec.pin, [spec.rows]: row, ...(spec.cols && col !== null ? { [spec.cols]: col } : {}), }; return render(props); }} cellTitle={(row, col) => `${spec.rows}=${label(row)}${ spec.cols && col !== null ? `, ${spec.cols}=${label(col)}` : '' }` } /> ); }