153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
"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 $ suffix 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 with a paper background and a hairline
|
|
* ink rule on its right edge so the boundary reads cleanly during scroll.
|
|
*
|
|
* 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-lg border border-rule bg-paper p-4 shadow-sm sm:p-5"
|
|
>
|
|
<header className="mb-3">
|
|
<h3
|
|
id={`${group.id}-heading`}
|
|
className="font-display text-lg font-medium leading-tight tracking-tight text-ink"
|
|
>
|
|
{group.label}
|
|
</h3>
|
|
{group.intro && (
|
|
<p className="mt-1 text-sm text-ink-soft 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-paper-2 border-b border-rule px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-ink-mute shadow-[1px_0_0_0_var(--color-rule)]"
|
|
>
|
|
Metric
|
|
</th>
|
|
{group.cols.map((c) => (
|
|
<th
|
|
key={c.key}
|
|
scope="col"
|
|
className="border-b border-rule bg-paper-2 px-2 py-2 text-center text-xs font-medium uppercase tracking-wide text-ink-mute min-w-[5.5rem] tabular-nums"
|
|
>
|
|
{c.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{group.rows.map((row, rowIdx) => {
|
|
const zebra = rowIdx % 2 === 1;
|
|
return (
|
|
<tr key={rowIdx}>
|
|
<th
|
|
scope="row"
|
|
className={
|
|
"sticky left-0 z-10 border-b border-rule-soft px-3 py-1.5 text-left text-sm font-medium text-ink whitespace-nowrap shadow-[1px_0_0_0_var(--color-rule)] " +
|
|
(zebra ? "bg-paper-2/50" : "bg-paper")
|
|
}
|
|
>
|
|
{decoratedRowLabel(row.label, row.type)}
|
|
</th>
|
|
{row.fields.map((fieldName, colIdx) => (
|
|
<td
|
|
key={`${rowIdx}-${colIdx}`}
|
|
className={
|
|
"border-b border-rule-soft px-1 py-1 " +
|
|
(zebra ? "bg-paper-2/50" : "bg-paper")
|
|
}
|
|
>
|
|
<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-rule-soft bg-paper px-2 py-1 text-right text-ink " +
|
|
"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"}
|
|
/>
|
|
);
|
|
}
|