Submit bar: animated stage-progress pills mirroring the header

Bar's secondary line is now a horizontal cluster of the same six
pills used in the page header, followed by the viewed stage label.
Active pill expands and gains a slow halo (rail-pulse keyframe) when
in the bar; same component runs without the pulse in the static
header. Width transitions smoothly between ranks as the user scrolls,
so the indicator visibly tracks progress through the form.

StageProgress now takes an optional `pulse` prop and tightens its
transition timing for nicer scroll-driven animation.
This commit is contained in:
Joel Brock
2026-05-21 12:41:14 -07:00
parent 7e5b1e1197
commit de53fde1f7
+32 -15
View File
@@ -204,11 +204,12 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
return () => observer.disconnect();
}, [load.kind, config.sections]);
const viewedSectionLabel = useMemo(() => {
if (!viewedSectionId) return null;
const s = config.sections.find((x) => x.id === viewedSectionId);
return s?.label ?? null;
}, [viewedSectionId, config.sections]);
const viewedSection = useMemo(
() => (viewedSectionId ? config.sections.find((x) => x.id === viewedSectionId) ?? null : null),
[viewedSectionId, config.sections],
);
const viewedSectionLabel = viewedSection?.label ?? null;
const viewedRank = viewedSection?.rank ?? null;
// ── Auto-save draft on idle ────────────────────────────────────────────
// Debounce — save 1.5s after the user stops editing. Uses watch's
@@ -411,6 +412,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
draftSavedAt={draftSavedAt}
orgName={load.data.orgName}
viewedSectionLabel={viewedSectionLabel}
viewedRank={viewedRank}
/>
</form>
);
@@ -463,15 +465,22 @@ function resolveStageLabel(
* Six small dots representing the six stages, with the current rank filled
* and earlier ranks marked as visited. Quietly conveys progression without
* pretending to be a "100% complete" progress bar.
*
* `pulse` adds a slow halo to the active pill — used in the floating
* submit bar (where the active rank moves as the user scrolls) to draw
* the eye to the changing indicator. Left off in the static header.
*/
function StageProgress({ currentRank }: { currentRank: number }) {
function StageProgress({ currentRank, pulse = false }: { currentRank: number; pulse?: boolean }) {
return (
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
{[0, 1, 2, 3, 4, 5].map((r) => (
<span
key={r}
className={
"h-1.5 rounded-full transition-all " +
"h-1.5 rounded-full transition-all duration-500 ease-out " +
(r === currentRank && pulse
? "motion-safe:animate-[rail-pulse_2.6s_ease-in-out_infinite] "
: "") +
(r < currentRank
? "w-2.5 bg-leaf-300"
: r === currentRank
@@ -518,12 +527,14 @@ function SubmitBar({
draftSavedAt,
orgName,
viewedSectionLabel,
viewedRank,
}: {
state: SubmitStatus;
isDirty: boolean;
draftSavedAt: string | null;
orgName: string;
viewedSectionLabel: string | null;
viewedRank: number | null;
}) {
// Build the small secondary line under the org name. Priority:
// 1. submit feedback (error or in-flight) — most urgent
@@ -543,15 +554,21 @@ function SubmitBar({
Saving your check-in
</p>
);
} else if (viewedSectionLabel) {
} else if (viewedSectionLabel && viewedRank != null) {
secondary = (
<p className="truncate text-xs text-ink-mute" aria-live="polite">
<span className="uppercase tracking-[0.12em]">Viewing:</span>{" "}
<span className="text-ink-soft">{viewedSectionLabel}</span>
{isDirty && draftSavedAt && (
<span className="text-ink-mute"> · saved {formatRelative(draftSavedAt)}</span>
)}
</p>
<div
className="flex min-w-0 items-center gap-2.5"
aria-live="polite"
aria-label={`Viewing ${viewedSectionLabel}`}
>
<StageProgress currentRank={viewedRank} pulse />
<p className="min-w-0 truncate text-xs text-ink-mute">
<span className="text-ink-soft">{viewedSectionLabel}</span>
{isDirty && draftSavedAt && (
<span className="text-ink-mute"> · saved {formatRelative(draftSavedAt)}</span>
)}
</p>
</div>
);
} else if (isDirty && draftSavedAt) {
secondary = (