Audit short-term: currency preview, date bounds, success destination, safe-area

- 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.
This commit is contained in:
Joel Brock
2026-05-11 09:48:03 -07:00
parent 0d84b9654b
commit a804650f65
4 changed files with 144 additions and 14 deletions

View File

@@ -1,12 +1,20 @@
"use client";
import { useWatch } from "react-hook-form";
import type { FieldConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-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;
/**
@@ -17,6 +25,9 @@ interface FieldRendererProps {
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
@@ -34,6 +45,7 @@ export function FieldRenderer({
field,
register,
errors,
control,
readonlyValue,
resolvedOptions,
}: FieldRendererProps) {
@@ -263,17 +275,43 @@ export function FieldRenderer({
</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>
);
}
// ── Default: text-like (text / email / phone / date) ────────────────────
// ── 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" :
field.type === "date" ? "date" :
"text";
return (
@@ -301,6 +339,30 @@ export function FieldRenderer({
);
}
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">