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();
|
return () => observer.disconnect();
|
||||||
}, [load.kind, config.sections]);
|
}, [load.kind, config.sections]);
|
||||||
|
|
||||||
const viewedSectionLabel = useMemo(() => {
|
const viewedSection = useMemo(
|
||||||
if (!viewedSectionId) return null;
|
() => (viewedSectionId ? config.sections.find((x) => x.id === viewedSectionId) ?? null : null),
|
||||||
const s = config.sections.find((x) => x.id === viewedSectionId);
|
[viewedSectionId, config.sections],
|
||||||
return s?.label ?? null;
|
);
|
||||||
}, [viewedSectionId, config.sections]);
|
const viewedSectionLabel = viewedSection?.label ?? null;
|
||||||
|
const viewedRank = viewedSection?.rank ?? null;
|
||||||
|
|
||||||
// ── Auto-save draft on idle ────────────────────────────────────────────
|
// ── Auto-save draft on idle ────────────────────────────────────────────
|
||||||
// Debounce — save 1.5s after the user stops editing. Uses watch's
|
// 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}
|
draftSavedAt={draftSavedAt}
|
||||||
orgName={load.data.orgName}
|
orgName={load.data.orgName}
|
||||||
viewedSectionLabel={viewedSectionLabel}
|
viewedSectionLabel={viewedSectionLabel}
|
||||||
|
viewedRank={viewedRank}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -463,15 +465,22 @@ function resolveStageLabel(
|
|||||||
* Six small dots representing the six stages, with the current rank filled
|
* Six small dots representing the six stages, with the current rank filled
|
||||||
* and earlier ranks marked as visited. Quietly conveys progression without
|
* and earlier ranks marked as visited. Quietly conveys progression without
|
||||||
* pretending to be a "100% complete" progress bar.
|
* 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 (
|
return (
|
||||||
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
||||||
{[0, 1, 2, 3, 4, 5].map((r) => (
|
{[0, 1, 2, 3, 4, 5].map((r) => (
|
||||||
<span
|
<span
|
||||||
key={r}
|
key={r}
|
||||||
className={
|
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
|
(r < currentRank
|
||||||
? "w-2.5 bg-leaf-300"
|
? "w-2.5 bg-leaf-300"
|
||||||
: r === currentRank
|
: r === currentRank
|
||||||
@@ -518,12 +527,14 @@ function SubmitBar({
|
|||||||
draftSavedAt,
|
draftSavedAt,
|
||||||
orgName,
|
orgName,
|
||||||
viewedSectionLabel,
|
viewedSectionLabel,
|
||||||
|
viewedRank,
|
||||||
}: {
|
}: {
|
||||||
state: SubmitStatus;
|
state: SubmitStatus;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
draftSavedAt: string | null;
|
draftSavedAt: string | null;
|
||||||
orgName: string;
|
orgName: string;
|
||||||
viewedSectionLabel: string | null;
|
viewedSectionLabel: string | null;
|
||||||
|
viewedRank: number | null;
|
||||||
}) {
|
}) {
|
||||||
// Build the small secondary line under the org name. Priority:
|
// Build the small secondary line under the org name. Priority:
|
||||||
// 1. submit feedback (error or in-flight) — most urgent
|
// 1. submit feedback (error or in-flight) — most urgent
|
||||||
@@ -543,15 +554,21 @@ function SubmitBar({
|
|||||||
Saving your check-in…
|
Saving your check-in…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
} else if (viewedSectionLabel) {
|
} else if (viewedSectionLabel && viewedRank != null) {
|
||||||
secondary = (
|
secondary = (
|
||||||
<p className="truncate text-xs text-ink-mute" aria-live="polite">
|
<div
|
||||||
<span className="uppercase tracking-[0.12em]">Viewing:</span>{" "}
|
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>
|
<span className="text-ink-soft">{viewedSectionLabel}</span>
|
||||||
{isDirty && draftSavedAt && (
|
{isDirty && draftSavedAt && (
|
||||||
<span className="text-ink-mute"> · saved {formatRelative(draftSavedAt)}</span>
|
<span className="text-ink-mute"> · saved {formatRelative(draftSavedAt)}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
} else if (isDirty && draftSavedAt) {
|
} else if (isDirty && draftSavedAt) {
|
||||||
secondary = (
|
secondary = (
|
||||||
|
|||||||
Reference in New Issue
Block a user