Add matrix-group display for monthly/quarterly time-series fields in Stage 5

This commit is contained in:
Joel Brock
2026-05-09 21:29:21 -07:00
parent 082238d884
commit da3e48a874
4 changed files with 268 additions and 4 deletions

139
components/MatrixGroup.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client";
import type { MatrixGroupConfig, FieldType } from "@/types/form";
import type { UseFormRegister, FieldValues } from "react-hook-form";
interface MatrixGroupProps {
group: MatrixGroupConfig;
register: UseFormRegister<FieldValues>;
}
/**
* Renders a matrix of repeating fields as an HTML table.
*
* - Row labels live in the leftmost column (sticky on horizontal scroll).
* - Column headers carry the period labels (M1..M12, Q1..Q4, …).
* - Each row's `type` controls the cell input rendering: currency cells get
* a $ prefix on the row label rather than per-cell to save space; percent
* cells append %.
* - On narrow viewports the table scrolls horizontally; the metric column
* stays pinned via position:sticky.
*
* Cells are still individual react-hook-form fields, registered by
* `field.fields[colIndex]`. The matrix only changes layout — submission and
* validation behave exactly like the standalone-input version.
*/
export function MatrixGroup({ group, register }: MatrixGroupProps) {
return (
<section
aria-labelledby={`${group.id}-heading`}
className="rounded-xl border border-stone-200 bg-white p-4 shadow-sm sm:p-5"
>
<header className="mb-3">
<h3 id={`${group.id}-heading`} className="text-base font-semibold text-stone-900">
{group.label}
</h3>
{group.intro && (
<p className="mt-1 text-sm text-stone-600 leading-relaxed">{group.intro}</p>
)}
</header>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="min-w-full border-separate border-spacing-0 text-sm">
<thead>
<tr>
<th
scope="col"
className="sticky left-0 z-10 bg-stone-50 border-b border-stone-200 px-3 py-2 text-left font-medium text-stone-700"
>
Metric
</th>
{group.cols.map((c) => (
<th
key={c.key}
scope="col"
className="border-b border-stone-200 bg-stone-50 px-2 py-2 text-center font-medium text-stone-700 min-w-[5.5rem]"
>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{group.rows.map((row, rowIdx) => (
<tr key={rowIdx} className={rowIdx % 2 === 0 ? "bg-white" : "bg-stone-50/40"}>
<th
scope="row"
className="sticky left-0 z-10 border-b border-stone-100 bg-inherit px-3 py-1.5 text-left font-medium text-stone-800 whitespace-nowrap"
>
{decoratedRowLabel(row.label, row.type)}
</th>
{row.fields.map((fieldName, colIdx) => (
<td
key={`${rowIdx}-${colIdx}`}
className="border-b border-stone-100 px-1 py-1"
>
<CellInput
fieldName={fieldName}
type={row.type}
label={`${row.label} ${group.cols[colIdx]?.label ?? ""}`.trim()}
register={register}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
function decoratedRowLabel(label: string, type: FieldType): string {
if (type === "currency") return `${label} ($)`;
if (type === "percent") return `${label} (%)`;
return label;
}
interface CellInputProps {
fieldName: string;
type: FieldType;
label: string; // for aria-label only
register: UseFormRegister<FieldValues>;
}
/**
* A compact, table-cell-sized input. No visible label (the row + column
* already provide context); a screen-reader label is set via aria-label.
* Supports currency / percent / number / text — uniform sizing across cells.
*/
function CellInput({ fieldName, type, label, register }: CellInputProps) {
const isNumeric = type === "currency" || type === "percent" || type === "number";
const baseClass =
"w-full rounded border border-stone-200 bg-white px-2 py-1 text-right text-stone-900 " +
"tabular-nums shadow-sm transition " +
"focus:border-leaf-500 focus:outline-none focus:ring-2 focus:ring-leaf-500/30";
if (isNumeric) {
return (
<input
type="number"
inputMode="decimal"
aria-label={label}
step={type === "number" ? 1 : 0.01}
{...register(fieldName, { valueAsNumber: true })}
className={baseClass}
/>
);
}
return (
<input
type="text"
aria-label={label}
{...register(fieldName)}
className={baseClass + " text-left"}
/>
);
}

View File

@@ -1,9 +1,10 @@
"use client";
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";
import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer";
import { MatrixGroup } from "./MatrixGroup";
import { evaluate } from "@/lib/conditional";
interface StageSectionProps {
@@ -38,7 +39,19 @@ export function StageSection({
const headingId = useId();
const panelId = useId();
const visibleFields = section.fields.filter((f) => evaluate(f.visibleWhen, formValues));
// Names of fields that appear inside any matrix group; excluded from the
// standalone per-field grid so they don't render twice.
const matrixFieldNames = useMemo(() => {
const names = new Set<string>();
for (const m of section.matrixGroups ?? []) {
for (const row of m.rows) for (const f of row.fields) names.add(f);
}
return names;
}, [section.matrixGroups]);
const visibleFields = section.fields.filter(
(f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name),
);
return (
<section
@@ -88,9 +101,16 @@ export function StageSection({
{section.intro && (
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
)}
{visibleFields.length === 0 ? (
{(section.matrixGroups ?? []).length > 0 && (
<div className="mb-6 space-y-5">
{section.matrixGroups!.map((g) => (
<MatrixGroup key={g.id} group={g} register={register} />
))}
</div>
)}
{visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
) : (
) : visibleFields.length === 0 ? null : (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{visibleFields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;