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:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user