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:
@@ -182,6 +182,21 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
if (load.kind === "loading") return <LoadingState />;
|
if (load.kind === "loading") return <LoadingState />;
|
||||||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||||||
|
|
||||||
|
// After a successful submit, replace the form entirely with a destination
|
||||||
|
// screen. This prevents accidental duplicate submits (back-button, double-
|
||||||
|
// click, browser autofill replay) and gives the user a clear endpoint.
|
||||||
|
if (submitState.kind === "success") {
|
||||||
|
return (
|
||||||
|
<SuccessDestination
|
||||||
|
orgName={load.data.orgName}
|
||||||
|
onAnother={() => {
|
||||||
|
setSubmitState({ kind: "idle" });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: Record<string, unknown>) => {
|
const onSubmit = async (values: Record<string, unknown>) => {
|
||||||
setSubmitState({ kind: "submitting" });
|
setSubmitState({ kind: "submitting" });
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +298,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
<StageSection
|
<StageSection
|
||||||
section={section}
|
section={section}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
formValues={evalState}
|
formValues={evalState}
|
||||||
isCurrent={section.rank === currentRank}
|
isCurrent={section.rank === currentRank}
|
||||||
@@ -392,7 +408,10 @@ function SubmitBar({
|
|||||||
draftSavedAt: string | null;
|
draftSavedAt: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
<div
|
||||||
|
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
|
||||||
|
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SubmitFeedback state={state} />
|
<SubmitFeedback state={state} />
|
||||||
{state.kind === "idle" && (
|
{state.kind === "idle" && (
|
||||||
@@ -429,12 +448,6 @@ function SubmitFeedback({ state }: { state: SubmitStatus }) {
|
|||||||
Saving your check-in…
|
Saving your check-in…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
if (state.kind === "success")
|
|
||||||
return (
|
|
||||||
<p className="text-sm font-medium text-leaf-800" role="status">
|
|
||||||
✓ Check-in saved. Thank you.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
if (state.kind === "error")
|
if (state.kind === "error")
|
||||||
return (
|
return (
|
||||||
<p className="text-sm font-medium text-clay-700" role="alert">
|
<p className="text-sm font-medium text-clay-700" role="alert">
|
||||||
@@ -471,6 +484,49 @@ function LoadingState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SuccessDestination({
|
||||||
|
orgName,
|
||||||
|
onAnother,
|
||||||
|
}: {
|
||||||
|
orgName: string;
|
||||||
|
onAnother: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="rounded-lg border-2 border-leaf-600 bg-leaf-50/40 px-6 py-10 text-center shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(60,80,40,0.22)] sm:px-10 sm:py-12"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 12l5 5 9-11" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-5 font-display text-2xl font-medium leading-tight tracking-tight text-ink sm:text-3xl">
|
||||||
|
Check-in saved
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-3 max-w-prose text-[15px] leading-relaxed text-ink-soft">
|
||||||
|
Thank you. We've recorded this check-in for{" "}
|
||||||
|
<span className="font-medium text-ink">{orgName}</span>. Your engagement coordinator will see
|
||||||
|
it on their next review.
|
||||||
|
</p>
|
||||||
|
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAnother}
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-rule bg-paper px-5 py-2 text-sm font-medium text-ink-soft transition hover:bg-paper-2/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40"
|
||||||
|
>
|
||||||
|
Submit another check-in
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-ink-mute">It's safe to close this window.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ErrorState({ message }: { message: string }) {
|
function ErrorState({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useId, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
||||||
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
|
import type {
|
||||||
|
UseFormRegister,
|
||||||
|
FieldValues,
|
||||||
|
FieldErrors,
|
||||||
|
Control,
|
||||||
|
} from "react-hook-form";
|
||||||
import { FieldRenderer } from "./fields/FieldRenderer";
|
import { FieldRenderer } from "./fields/FieldRenderer";
|
||||||
import { MatrixGroup } from "./MatrixGroup";
|
import { MatrixGroup } from "./MatrixGroup";
|
||||||
import { StageIcon } from "./StageIcon";
|
import { StageIcon } from "./StageIcon";
|
||||||
@@ -11,6 +16,7 @@ import { evaluate } from "@/lib/conditional";
|
|||||||
interface StageSectionProps {
|
interface StageSectionProps {
|
||||||
section: StageSectionConfig;
|
section: StageSectionConfig;
|
||||||
register: UseFormRegister<FieldValues>;
|
register: UseFormRegister<FieldValues>;
|
||||||
|
control: Control<FieldValues>;
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
/** Live form values, used to evaluate per-field visibility rules. */
|
/** Live form values, used to evaluate per-field visibility rules. */
|
||||||
formValues: Record<string, unknown>;
|
formValues: Record<string, unknown>;
|
||||||
@@ -36,6 +42,7 @@ interface StageSectionProps {
|
|||||||
export function StageSection({
|
export function StageSection({
|
||||||
section,
|
section,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
errors,
|
errors,
|
||||||
formValues,
|
formValues,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
@@ -153,6 +160,7 @@ export function StageSection({
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
field={f}
|
field={f}
|
||||||
register={register}
|
register={register}
|
||||||
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
readonlyValue={formValues[f.name]}
|
readonlyValue={formValues[f.name]}
|
||||||
resolvedOptions={resolvedOptions}
|
resolvedOptions={resolvedOptions}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useWatch } from "react-hook-form";
|
||||||
import type { FieldConfig, SelectOption } from "@/types/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 {
|
interface FieldRendererProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
register: UseFormRegister<FieldValues>;
|
register: UseFormRegister<FieldValues>;
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
|
/** Form control — required for currency live-preview formatting. */
|
||||||
|
control: Control<FieldValues>;
|
||||||
/** For readonly display fields, the value to render. */
|
/** For readonly display fields, the value to render. */
|
||||||
readonlyValue?: unknown;
|
readonlyValue?: unknown;
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +25,9 @@ interface FieldRendererProps {
|
|||||||
resolvedOptions?: SelectOption[];
|
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
|
* Renders a single field appropriate to its `type`. All inputs share a common
|
||||||
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
|
* accessibility scaffold: a real <label htmlFor>, aria-describedby pointing
|
||||||
@@ -34,6 +45,7 @@ export function FieldRenderer({
|
|||||||
field,
|
field,
|
||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
|
control,
|
||||||
readonlyValue,
|
readonlyValue,
|
||||||
resolvedOptions,
|
resolvedOptions,
|
||||||
}: FieldRendererProps) {
|
}: FieldRendererProps) {
|
||||||
@@ -263,17 +275,43 @@ export function FieldRenderer({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{field.type === "currency" && (
|
||||||
|
<CurrencyPreview control={control} name={field.name} />
|
||||||
|
)}
|
||||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||||
</div>
|
</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 =
|
const inputType =
|
||||||
field.type === "email" ? "email" :
|
field.type === "email" ? "email" :
|
||||||
field.type === "phone" ? "tel" :
|
field.type === "phone" ? "tel" :
|
||||||
field.type === "date" ? "date" :
|
|
||||||
"text";
|
"text";
|
||||||
|
|
||||||
return (
|
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 }) {
|
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
||||||
return (
|
return (
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
||||||
|
|||||||
@@ -69,9 +69,13 @@ export interface FieldConfig {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** For select / multiselect. */
|
/** For select / multiselect. */
|
||||||
options?: SelectOption[];
|
options?: SelectOption[];
|
||||||
/** For number / currency / percent — min/max/step constraints. */
|
/**
|
||||||
min?: number;
|
* Min/max bound. For numeric fields, a number; for date fields, an ISO
|
||||||
max?: number;
|
* date string (YYYY-MM-DD). Date fields default to a wide sanity window
|
||||||
|
* (1900-01-01 to 2100-12-31) when not set.
|
||||||
|
*/
|
||||||
|
min?: number | string;
|
||||||
|
max?: number | string;
|
||||||
step?: number;
|
step?: number;
|
||||||
/** For text / textarea — max length. */
|
/** For text / textarea — max length. */
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user