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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user