Files
WebForm-mw/components/fields/FieldRenderer.tsx

336 lines
12 KiB
TypeScript

"use client";
import type { FieldConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
interface FieldRendererProps {
field: FieldConfig;
register: UseFormRegister<FieldValues>;
errors: FieldErrors;
/** 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[];
}
/**
* 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,
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.help && <Help id={helpId!}>{field.help}</Help>}
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
</div>
);
}
// ── Default: text-like (text / email / phone / date) ────────────────────
const inputType =
field.type === "email" ? "email" :
field.type === "phone" ? "tel" :
field.type === "date" ? "date" :
"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>
);
}
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>
);
}