Build standalone CiviCRM check-in middleware
This commit is contained in:
266
components/fields/FieldRenderer.tsx
Normal file
266
components/fields/FieldRenderer.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import type { FieldConfig } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function FieldRenderer({ field, register, errors, readonlyValue }: 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 baseInputClass =
|
||||
"w-full rounded-md border border-stone-300 bg-white px-3 py-2 text-stone-900 " +
|
||||
"placeholder:text-stone-400 shadow-sm transition " +
|
||||
"focus:border-leaf-600 focus:outline-none focus:ring-2 focus:ring-leaf-500/40 " +
|
||||
"disabled:bg-stone-50 disabled:text-stone-500 " +
|
||||
"aria-invalid:border-red-500 aria-invalid:ring-red-500/30";
|
||||
|
||||
// ── Readonly display field ──────────────────────────────────────────────
|
||||
if (field.type === "readonly") {
|
||||
const display = readonlyValue == null || readonlyValue === "" ? "—" : 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-stone-200 bg-stone-50 px-3 py-2 text-stone-700"
|
||||
>
|
||||
{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)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-stone-400 text-leaf-700 focus:ring-2 focus:ring-leaf-500/40"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={id} className="font-medium text-stone-800 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: field.required })}
|
||||
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: field.required })}
|
||||
className={baseInputClass + " bg-white"}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select…
|
||||
</option>
|
||||
{(field.options ?? []).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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File ────────────────────────────────────────────────────────────────
|
||||
if (field.type === "file") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label id={id} field={field} />
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
{...register(field.name, { required: field.required })}
|
||||
className="block w-full text-sm text-stone-700 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"
|
||||
/>
|
||||
{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-stone-500"
|
||||
>
|
||||
{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: field.required,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
className={
|
||||
baseInputClass +
|
||||
(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-stone-500"
|
||||
>
|
||||
{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: field.required })}
|
||||
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-stone-800">
|
||||
{field.label}
|
||||
{field.required && <RequiredMark />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return (
|
||||
<span aria-label="required" className="ml-1 text-red-600">
|
||||
*
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Help({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} className="text-xs text-stone-600">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorText({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} role="alert" className="text-xs font-medium text-red-700">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user