- H3: Live currency preview below currency inputs shows the value formatted with thousands separators (en-US, USD) using Intl.NumberFormat. Skipped inside matrix cells to keep the Y1 monthly table compact. - M1: Date inputs now apply min/max bounds. Default window is 1900-01-01 to 2100-12-31; per-field override via FieldConfig.min/max as ISO strings. - H6: On successful submit, replace the form with a SuccessDestination card (large checkmark, org name, "Submit another" + "safe to close" affordance). Prevents accidental duplicate submits from back-button / autofill replay. - M6: Sticky submit bar respects iOS safe-area-inset-bottom. FieldRenderer now takes a control prop so the currency preview can subscribe to its single field via useWatch without re-rendering the whole form.
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useWatch } from "react-hook-form";
|
|
import type { FieldConfig, SelectOption } from "@/types/form";
|
|
import type {
|
|
UseFormRegister,
|
|
FieldValues,
|
|
FieldErrors,
|
|
Control,
|
|
} from "react-hook-form";
|
|
|
|
interface FieldRendererProps {
|
|
field: FieldConfig;
|
|
register: UseFormRegister<FieldValues>;
|
|
errors: FieldErrors;
|
|
/** Form control — required for currency live-preview formatting. */
|
|
control: Control<FieldValues>;
|
|
/** For readonly display fields, the value to render. */
|
|
readonlyValue?: unknown;
|
|
/**
|
|
* Options resolved at runtime (from /api/data). When `field.optionGroupId`
|
|
* is set, look up options here first; fall back to the field's hard-coded
|
|
* `options` array if absent.
|
|
*/
|
|
resolvedOptions?: SelectOption[];
|
|
}
|
|
|
|
const DATE_MIN_DEFAULT = "1900-01-01";
|
|
const DATE_MAX_DEFAULT = "2100-12-31";
|
|
|
|
/**
|
|
* Renders a single field appropriate to its `type`. All inputs share a common
|
|
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
|
|
* to help/error text, aria-invalid set when in error, and visible focus.
|
|
*
|
|
* Currency, percent, and number all use input type="number" with appropriate
|
|
* `step` and inputMode for mobile keyboards. We keep formatting light — the
|
|
* server is the source of truth for normalization.
|
|
*
|
|
* Required-field validation: when `field.required` is true, RHF's register
|
|
* receives a string error message so the inline ErrorText has something to
|
|
* display. Without that, validation would block submit silently.
|
|
*/
|
|
export function FieldRenderer({
|
|
field,
|
|
register,
|
|
errors,
|
|
control,
|
|
readonlyValue,
|
|
resolvedOptions,
|
|
}: FieldRendererProps) {
|
|
const id = `field-${field.name}`;
|
|
const helpId = field.help ? `${id}-help` : undefined;
|
|
const errorId = errors[field.name] ? `${id}-error` : undefined;
|
|
const describedBy = [helpId, errorId].filter(Boolean).join(" ") || undefined;
|
|
const errorMsg = errors[field.name]?.message as string | undefined;
|
|
const effectiveOptions = resolvedOptions ?? field.options ?? [];
|
|
const requiredOpt = field.required ? `${field.label} is required.` : false;
|
|
|
|
const baseInputClass =
|
|
"w-full rounded-md border border-rule bg-paper px-3 py-2 text-ink " +
|
|
"placeholder:text-ink-mute/70 shadow-sm transition " +
|
|
"focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/30 " +
|
|
"disabled:bg-paper-2 disabled:text-ink-mute " +
|
|
"aria-invalid:border-clay-500 aria-invalid:ring-clay-500/25";
|
|
|
|
// ── Readonly display field ──────────────────────────────────────────────
|
|
if (field.type === "readonly") {
|
|
const opt = effectiveOptions.find((o) => o.value === readonlyValue);
|
|
const display =
|
|
readonlyValue == null || readonlyValue === ""
|
|
? "—"
|
|
: opt?.label ?? String(readonlyValue);
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
<div
|
|
id={id}
|
|
role="textbox"
|
|
aria-readonly="true"
|
|
className="rounded-md border border-rule bg-paper-2/40 px-3 py-2 text-ink-soft"
|
|
>
|
|
{display}
|
|
</div>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Boolean (checkbox) ──────────────────────────────────────────────────
|
|
if (field.type === "boolean") {
|
|
return (
|
|
<div className="flex items-start gap-3 py-1">
|
|
<input
|
|
id={id}
|
|
type="checkbox"
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
{...register(field.name, { required: requiredOpt })}
|
|
className="mt-0.5 h-4 w-4 rounded border-rule text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
|
|
/>
|
|
<div className="flex-1">
|
|
<label htmlFor={id} className="font-medium text-ink cursor-pointer">
|
|
{field.label}
|
|
{field.required && <RequiredMark />}
|
|
</label>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Textarea ────────────────────────────────────────────────────────────
|
|
if (field.type === "textarea") {
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
<textarea
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
aria-required={field.required || undefined}
|
|
placeholder={field.placeholder}
|
|
maxLength={field.maxLength}
|
|
rows={4}
|
|
{...register(field.name, { required: requiredOpt })}
|
|
className={baseInputClass + " min-h-[7rem] leading-6"}
|
|
/>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Select ──────────────────────────────────────────────────────────────
|
|
if (field.type === "select") {
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
<select
|
|
id={id}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
aria-required={field.required || undefined}
|
|
{...register(field.name, { required: requiredOpt })}
|
|
className={baseInputClass}
|
|
defaultValue=""
|
|
>
|
|
<option value="" disabled>
|
|
Select…
|
|
</option>
|
|
{effectiveOptions.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Multiselect (rendered as a checkbox group) ─────────────────────────
|
|
if (field.type === "multiselect") {
|
|
return (
|
|
<fieldset
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
className="space-y-2"
|
|
>
|
|
<legend className="block text-sm font-medium text-ink">
|
|
{field.label}
|
|
{field.required && <RequiredMark />}
|
|
</legend>
|
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
|
{effectiveOptions.map((o, idx) => {
|
|
const optId = `${id}-${idx}`;
|
|
return (
|
|
<label
|
|
key={o.value}
|
|
htmlFor={optId}
|
|
className="flex items-start gap-2 rounded border border-rule bg-paper px-3 py-2 hover:bg-paper-2/60 cursor-pointer transition-colors"
|
|
>
|
|
<input
|
|
id={optId}
|
|
type="checkbox"
|
|
value={o.value}
|
|
{...register(field.name)}
|
|
className="mt-0.5 h-4 w-4 rounded border-rule text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
|
|
/>
|
|
<span className="text-sm text-ink-soft">{o.label}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
// ── File ────────────────────────────────────────────────────────────────
|
|
if (field.type === "file") {
|
|
const priorName = typeof readonlyValue === "string" ? readonlyValue : "";
|
|
const hasPrior = priorName.length > 0;
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
{hasPrior && (
|
|
<p className="text-xs text-ink-soft" aria-live="polite">
|
|
Currently on file: <span className="font-medium text-ink">{priorName}</span>
|
|
</p>
|
|
)}
|
|
<input
|
|
id={id}
|
|
type="file"
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
aria-required={field.required || undefined}
|
|
{...register(field.name, { required: requiredOpt })}
|
|
className="block w-full text-sm text-ink-soft file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer transition"
|
|
/>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Numeric family: number / currency / percent ─────────────────────────
|
|
if (field.type === "number" || field.type === "currency" || field.type === "percent") {
|
|
const prefix = field.type === "currency" ? "$" : null;
|
|
const suffix = field.type === "percent" ? "%" : null;
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
<div className="relative">
|
|
{prefix && (
|
|
<span
|
|
aria-hidden
|
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-mute"
|
|
>
|
|
{prefix}
|
|
</span>
|
|
)}
|
|
<input
|
|
id={id}
|
|
type="number"
|
|
inputMode="decimal"
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
aria-required={field.required || undefined}
|
|
placeholder={field.placeholder}
|
|
step={field.step ?? (field.type === "number" ? 1 : 0.01)}
|
|
min={field.min}
|
|
max={field.max}
|
|
{...register(field.name, {
|
|
required: requiredOpt,
|
|
valueAsNumber: true,
|
|
})}
|
|
className={
|
|
baseInputClass +
|
|
" tabular-nums" +
|
|
(prefix ? " pl-7" : "") +
|
|
(suffix ? " pr-8" : "")
|
|
}
|
|
/>
|
|
{suffix && (
|
|
<span
|
|
aria-hidden
|
|
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-ink-mute"
|
|
>
|
|
{suffix}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{field.type === "currency" && (
|
|
<CurrencyPreview control={control} name={field.name} />
|
|
)}
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Date ────────────────────────────────────────────────────────────────
|
|
if (field.type === "date") {
|
|
const dateMin = typeof field.min === "string" ? field.min : DATE_MIN_DEFAULT;
|
|
const dateMax = typeof field.max === "string" ? field.max : DATE_MAX_DEFAULT;
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
<input
|
|
id={id}
|
|
type="date"
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
aria-required={field.required || undefined}
|
|
min={dateMin}
|
|
max={dateMax}
|
|
{...register(field.name, { required: requiredOpt })}
|
|
className={baseInputClass}
|
|
/>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Default: text-like (text / email / phone) ───────────────────────────
|
|
const inputType =
|
|
field.type === "email" ? "email" :
|
|
field.type === "phone" ? "tel" :
|
|
"text";
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label id={id} field={field} />
|
|
<input
|
|
id={id}
|
|
type={inputType}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={errorMsg ? true : undefined}
|
|
aria-required={field.required || undefined}
|
|
placeholder={field.placeholder}
|
|
maxLength={field.maxLength}
|
|
autoComplete={
|
|
field.type === "email" ? "email" :
|
|
field.type === "phone" ? "tel" :
|
|
undefined
|
|
}
|
|
{...register(field.name, { required: requiredOpt })}
|
|
className={baseInputClass}
|
|
/>
|
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currencyFmt = new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
maximumFractionDigits: 2,
|
|
});
|
|
|
|
function CurrencyPreview({
|
|
control,
|
|
name,
|
|
}: {
|
|
control: Control<FieldValues>;
|
|
name: string;
|
|
}) {
|
|
const raw = useWatch({ control, name });
|
|
if (raw === undefined || raw === null || raw === "") return null;
|
|
const n = typeof raw === "number" ? raw : Number(raw);
|
|
if (!Number.isFinite(n)) return null;
|
|
return (
|
|
<p className="text-xs tabular-nums text-ink-mute" aria-live="polite">
|
|
{currencyFmt.format(n)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
|
return (
|
|
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
|
{field.label}
|
|
{field.required && <RequiredMark />}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function RequiredMark() {
|
|
return (
|
|
<span aria-label="required" className="ml-1 text-clay-700">
|
|
*
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Help({ id, children }: { id: string; children: React.ReactNode }) {
|
|
return (
|
|
<p id={id} className="text-xs text-ink-mute leading-relaxed">
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function ErrorText({ id, children }: { id: string; children: React.ReactNode }) {
|
|
return (
|
|
<p id={id} role="alert" className="text-xs font-medium text-clay-700">
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|