Files
WebForm-mw/components/StageSection.tsx

173 lines
5.7 KiB
TypeScript

"use client";
import { useId, useMemo, useState } from "react";
import type { StageSectionConfig, SelectOption } from "@/types/form";
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
import { FieldRenderer } from "./fields/FieldRenderer";
import { MatrixGroup } from "./MatrixGroup";
import { evaluate } from "@/lib/conditional";
interface StageSectionProps {
section: StageSectionConfig;
register: UseFormRegister<FieldValues>;
errors: FieldErrors;
/** Live form values, used to evaluate per-field visibility rules. */
formValues: Record<string, unknown>;
/** Whether this is the section that matches the current org stage (for the "current" badge). */
isCurrent: boolean;
/** Whether the section starts open. */
defaultOpen: boolean;
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
options: Record<number, SelectOption[]>;
}
/**
* One accordion card per stage section. Renders all fields whose individual
* visibility rules pass. The section's own visibility is decided by the
* parent EngagementForm; if rendered, it's visible.
*/
export function StageSection({
section,
register,
errors,
formValues,
isCurrent,
defaultOpen,
options,
}: StageSectionProps) {
const [open, setOpen] = useState(defaultOpen);
const headingId = useId();
const panelId = useId();
// Names of fields that appear inside any matrix group; excluded from the
// standalone per-field grid so they don't render twice.
const matrixFieldNames = useMemo(() => {
const names = new Set<string>();
for (const m of section.matrixGroups ?? []) {
for (const row of m.rows) for (const f of row.fields) names.add(f);
}
return names;
}, [section.matrixGroups]);
const visibleFields = section.fields.filter(
(f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name),
);
return (
<section
aria-labelledby={headingId}
className={
"overflow-hidden rounded-xl border bg-white shadow-sm transition " +
(isCurrent
? "border-leaf-300 ring-1 ring-leaf-200"
: "border-stone-200")
}
>
<h2 id={headingId} className="m-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={
"group flex w-full items-center justify-between gap-4 px-5 py-4 text-left " +
"focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
(isCurrent ? "bg-leaf-50" : "bg-stone-50 hover:bg-stone-100")
}
>
<span className="flex items-center gap-3">
<RankBadge rank={section.rank} isCurrent={isCurrent} />
<span className="flex flex-col">
<span className="text-base font-semibold text-stone-900">{section.label}</span>
{isCurrent && (
<span className="text-xs font-medium uppercase tracking-wide text-leaf-700">
Current stage
</span>
)}
</span>
</span>
<Chevron open={open} />
</button>
</h2>
<div
id={panelId}
role="region"
aria-labelledby={headingId}
hidden={!open}
className="border-t border-stone-100"
>
<div className="px-5 py-5 sm:px-6 sm:py-6">
{section.intro && (
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
)}
{(section.matrixGroups ?? []).length > 0 && (
<div className="mb-6 space-y-5">
{section.matrixGroups!.map((g) => (
<MatrixGroup key={g.id} group={g} register={register} />
))}
</div>
)}
{visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
) : visibleFields.length === 0 ? null : (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
{visibleFields.map((f) => {
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
const wide =
f.type === "textarea" ||
f.type === "boolean" ||
f.type === "multiselect";
return (
<div key={f.name} className={wide ? "md:col-span-2" : ""}>
<FieldRenderer
field={f}
register={register}
errors={errors}
readonlyValue={formValues[f.name]}
resolvedOptions={resolvedOptions}
/>
</div>
);
})}
</div>
)}
</div>
</div>
</section>
);
}
function RankBadge({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
return (
<span
aria-hidden
className={
"inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold " +
(isCurrent
? "bg-leaf-600 text-white"
: "bg-stone-200 text-stone-700")
}
>
{rank}
</span>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="currentColor"
className={"h-5 w-5 text-stone-500 transition-transform " + (open ? "rotate-180" : "")}
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
);
}