Sticky submit bar: show org name + currently viewed stage

Repurpose the previously-empty left side of the floating submit bar.
Top line is the org name; secondary line updates as the user scrolls
so the currently viewed stage is always visible even after the
top-of-form header has scrolled out of sight.

IntersectionObserver with a top-biased rootMargin tracks which
section is in view; topmost intersecting section wins ties.
Submit-state feedback (error / in-flight) still takes priority over
the viewing/draft text when active.
This commit is contained in:
Joel Brock
2026-05-21 12:30:54 -07:00
parent 2ca2d378a4
commit 7e5b1e1197
+114 -30
View File
@@ -54,6 +54,10 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const [submitState, setSubmitState] = useState<SubmitStatus>({ kind: "idle" });
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null);
const [draftRestored, setDraftRestored] = useState(false);
// Section currently in view, updated as the user scrolls. Surfaces in
// the sticky SubmitBar so the org name + viewed stage remain visible
// even when the top-of-form header has scrolled out of sight.
const [viewedSectionId, setViewedSectionId] = useState<string | null>(null);
const {
register,
@@ -158,6 +162,54 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
};
}, [cid, cs, reset, config.stageField]);
// ── Observe which section is currently being viewed ───────────────────
// The sticky submit bar mirrors this so the user always knows which org
// they're on and which stage they're scrolled to, even after the top
// header has left the viewport. We bias intersection toward the upper
// 40% of the viewport — once a section's box enters that band, it
// becomes "viewed"; the topmost intersecting section wins ties.
useEffect(() => {
if (load.kind !== "ready") return;
const els = Array.from(
document.querySelectorAll<HTMLElement>("[data-section-id]"),
);
if (els.length === 0) return;
const intersecting = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const id = entry.target.getAttribute("data-section-id");
if (!id) continue;
if (entry.isIntersecting) intersecting.add(id);
else intersecting.delete(id);
}
// Pick the topmost intersecting section (closest to viewport top).
let bestId: string | null = null;
let bestDist = Infinity;
for (const el of els) {
const id = el.getAttribute("data-section-id");
if (!id || !intersecting.has(id)) continue;
const dist = Math.abs(el.getBoundingClientRect().top);
if (dist < bestDist) {
bestDist = dist;
bestId = id;
}
}
if (bestId) setViewedSectionId(bestId);
},
{ rootMargin: "0px 0px -60% 0px", threshold: [0, 0.1] },
);
for (const el of els) observer.observe(el);
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]);
// ── Auto-save draft on idle ────────────────────────────────────────────
// Debounce — save 1.5s after the user stops editing. Uses watch's
// subscription API so we get notified on every change WITHOUT re-rendering
@@ -326,7 +378,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
? sectionsToRender[i + 1].pathwayState
: undefined;
return (
<li key={section.id} className="relative list-none">
<li
key={section.id}
data-section-id={section.id}
className="relative list-none"
>
<MobileStem show={i > 0} state={pathwayState} />
<RailMarker
rank={section.rank}
@@ -353,6 +409,8 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
state={submitState}
isDirty={isDirty}
draftSavedAt={draftSavedAt}
orgName={load.data.orgName}
viewedSectionLabel={viewedSectionLabel}
/>
</form>
);
@@ -458,32 +516,74 @@ function SubmitBar({
state,
isDirty,
draftSavedAt,
orgName,
viewedSectionLabel,
}: {
state: SubmitStatus;
isDirty: boolean;
draftSavedAt: string | null;
orgName: string;
viewedSectionLabel: string | null;
}) {
// Build the small secondary line under the org name. Priority:
// 1. submit feedback (error or in-flight) — most urgent
// 2. "Viewing: <stage>" when the user has scrolled to a section
// 3. draft / edit status — falls back when there's no section in view
// yet (e.g. immediately after load, before any scroll)
let secondary: React.ReactNode = null;
if (state.kind === "error") {
secondary = (
<p className="truncate text-xs font-medium text-clay-700" role="alert">
{state.message}
</p>
);
} else if (state.kind === "submitting") {
secondary = (
<p className="truncate text-xs text-ink-soft" role="status">
Saving your check-in
</p>
);
} else if (viewedSectionLabel) {
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>
);
} else if (isDirty && draftSavedAt) {
secondary = (
<p className="truncate text-xs text-ink-mute">
Draft saved {formatRelative(draftSavedAt)} (locally on this device)
</p>
);
} else if (isDirty) {
secondary = (
<p className="truncate text-xs text-ink-mute">
Editing your draft will save automatically
</p>
);
} else {
secondary = null;
}
return (
<div
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6"
className="sticky bottom-3 z-20 flex flex-col gap-3 rounded-lg border border-rule bg-paper/95 px-5 py-4 shadow-[0_-4px_24px_-12px_rgba(60,80,40,0.2)] backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-6"
style={{ marginBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex flex-col gap-0.5">
<SubmitFeedback state={state} />
{state.kind === "idle" && (
<p className="text-xs text-ink-mute">
{isDirty
? draftSavedAt
? `Draft saved ${formatRelative(draftSavedAt)} (locally on this device)`
: "Editing — your draft will save automatically"
: ""}
</p>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-medium leading-tight text-ink sm:text-lg">
{orgName}
</p>
{secondary && <div className="mt-0.5">{secondary}</div>}
</div>
<button
type="submit"
disabled={state.kind === "submitting"}
className="inline-flex items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-md bg-leaf-700 px-6 py-2.5 font-medium text-paper transition hover:bg-leaf-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 disabled:cursor-not-allowed disabled:bg-ink-mute"
>
{state.kind === "submitting" ? (
<>
@@ -497,22 +597,6 @@ function SubmitBar({
);
}
function SubmitFeedback({ state }: { state: SubmitStatus }) {
if (state.kind === "submitting")
return (
<p className="text-sm text-ink-soft" role="status">
Saving your check-in
</p>
);
if (state.kind === "error")
return (
<p className="text-sm font-medium text-clay-700" role="alert">
{state.message}
</p>
);
return null;
}
function Spinner() {
return (
<svg