Locked future-stage cards + journey rail
Future stages now render as preview-only "look ahead" cards instead of being hidden. A user at stage 2 can see headers and contents for stages 3, 4, 5, but those sections are visibly locked and uneditable. Locked-card treatment: - Dashed-rule border, paper-2 fill, no shadow — visually quieter than active cards - "Upcoming" pill in the header with a small lock glyph - Muted stage rank mark (dashed badge, low-opacity icon) - Panel content wrapped in fieldset[disabled] so every form control inside is natively non-interactive, with an opacity tweak for affordance - "A look ahead" banner explaining that fields will become editable when the co-op reaches this stage Section visibleWhen is still consulted on submit, so locked-stage values never get written back to CiviCRM even if data is prefilled. Journey rail: - Vertical rail (md+) in a new left gutter; each card carries an aligned marker. Past stages = filled leaf circle with check; current = filled leaf disc with rank number, leaf-100 halo ring, and a subtle rail-pulse box-shadow animation (motion-safe). A "Now" pill sits beneath the current marker. Future stages = dashed hollow ring with lock glyph. - Connector segments between markers are solid leaf when the next stage is past-or-current, dashed muted when future — so the transition from "traveled" to "ahead" reads at the right place in the journey. - Mobile fallback: a small vertical stem in the gap between adjacent cards, styled the same way (solid vs dashed) so the progression cue still reads on narrow viewports.
This commit is contained in:
@@ -37,6 +37,8 @@ type SubmitStatus =
|
||||
| { kind: "success" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
type PathwayState = "past" | "current" | "future";
|
||||
|
||||
const STAGE_RANK: Record<string, number> = {
|
||||
Inquiry: 0,
|
||||
Organizing: 1,
|
||||
@@ -157,18 +159,25 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
};
|
||||
}, [load.kind, cid, watch]);
|
||||
|
||||
// ── Section ordering ───────────────────────────────────────────────────
|
||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
||||
|
||||
// Section pathway state — past / current / future is determined entirely
|
||||
// by stage rank vs the org's current rank. Sections never hide; future
|
||||
// stages render in a locked treatment so users can preview what's ahead.
|
||||
//
|
||||
// Section-level visibleWhen is still consulted on submit to strip
|
||||
// locked-stage values from the payload (see onSubmit below), so a future
|
||||
// stage's data never gets accidentally written.
|
||||
const sectionsToRender = useMemo(
|
||||
() =>
|
||||
config.sections.map((s) => ({
|
||||
section: s,
|
||||
sectionVisible: evaluate(s.visibleWhen, evalState),
|
||||
})),
|
||||
[config.sections, evalState],
|
||||
config.sections.map((s) => {
|
||||
const pathwayState: PathwayState =
|
||||
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
|
||||
return { section: s, pathwayState, locked: pathwayState === "future" };
|
||||
}),
|
||||
[config.sections, currentRank],
|
||||
);
|
||||
|
||||
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
|
||||
|
||||
// Track which sections the user has manually opened/closed so we can
|
||||
// programmatically re-open them when validation surfaces an error within.
|
||||
// Used by the onInvalid handler below.
|
||||
@@ -290,19 +299,30 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
||||
}} />
|
||||
)}
|
||||
|
||||
<ol className="space-y-5">
|
||||
{sectionsToRender.map(({ section, sectionVisible }) => {
|
||||
if (!sectionVisible) return null;
|
||||
<ol className="relative space-y-5 md:pl-20">
|
||||
{sectionsToRender.map((entry, i) => {
|
||||
const { section, pathwayState, locked } = entry;
|
||||
const nextState =
|
||||
i < sectionsToRender.length - 1
|
||||
? sectionsToRender[i + 1].pathwayState
|
||||
: undefined;
|
||||
return (
|
||||
<li key={section.id} className="list-none">
|
||||
<li key={section.id} className="relative list-none">
|
||||
<MobileStem show={i > 0} state={pathwayState} />
|
||||
<RailMarker
|
||||
rank={section.rank}
|
||||
state={pathwayState}
|
||||
nextState={nextState}
|
||||
/>
|
||||
<StageSection
|
||||
section={section}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
formValues={evalState}
|
||||
isCurrent={section.rank === currentRank}
|
||||
defaultOpen={section.rank === currentRank || section.rank === 0}
|
||||
isCurrent={pathwayState === "current"}
|
||||
locked={locked}
|
||||
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||||
options={load.data.options ?? {}}
|
||||
/>
|
||||
</li>
|
||||
@@ -545,6 +565,99 @@ function ErrorState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical journey rail marker, visible on md+ only. Sits in the OL's left
|
||||
* gutter, aligned to the card header. Includes a downward connecting line
|
||||
* toward the next marker; the line's style is determined by whether the
|
||||
* NEXT stage is past/current (solid leaf) or future (dashed muted) — that
|
||||
* way the transition from "traveled" to "ahead" happens right after the
|
||||
* current marker, which reads correctly as "you've come this far."
|
||||
*/
|
||||
function RailMarker({
|
||||
rank,
|
||||
state,
|
||||
nextState,
|
||||
}: {
|
||||
rank: number;
|
||||
state: PathwayState;
|
||||
nextState?: PathwayState;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none hidden md:block absolute -left-14 top-0 bottom-0 w-12"
|
||||
>
|
||||
{nextState && (
|
||||
<span
|
||||
className={
|
||||
"absolute left-1/2 -translate-x-1/2 top-12 -bottom-11 " +
|
||||
(nextState === "future"
|
||||
? "w-0 border-l border-dashed border-rule"
|
||||
: "w-px bg-leaf-500/70")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MarkerCircle rank={rank} state={state} />
|
||||
{state === "current" && (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-[60px] whitespace-nowrap rounded-full bg-leaf-700 px-1.5 py-[1px] text-[9px] font-medium uppercase tracking-[0.12em] text-paper shadow-sm">
|
||||
Now
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
|
||||
if (state === "past") {
|
||||
return (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3">
|
||||
<path d="M3 6.5l2 2 4-5" />
|
||||
</svg>
|
||||
<span className="sr-only">Stage {rank} (completed)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (state === "current") {
|
||||
return (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-7 w-7 items-center justify-center rounded-full bg-leaf-700 text-paper text-[11px] font-semibold shadow-md ring-4 ring-leaf-100 motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite]">
|
||||
{rank}
|
||||
<span className="sr-only">Stage {rank} (current)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3">
|
||||
<rect x="2.5" y="5.5" width="7" height="5" rx="0.75" />
|
||||
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
|
||||
</svg>
|
||||
<span className="sr-only">Stage {rank} (upcoming)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-only inter-card stem. Sits centered in the gap between adjacent
|
||||
* stage cards. Solid leaf when arriving at a past-or-current stage; dashed
|
||||
* muted when arriving at a future (locked) stage.
|
||||
*/
|
||||
function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
|
||||
<span
|
||||
className={
|
||||
"block h-4 " +
|
||||
(state === "future"
|
||||
? "w-0 border-l border-dashed border-rule"
|
||||
: "w-px bg-leaf-500/70")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return iso;
|
||||
|
||||
Reference in New Issue
Block a user