From 7e5b1e1197098d57c4735d077261cfcf1959af2f Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Thu, 21 May 2026 12:30:54 -0700 Subject: [PATCH] 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. --- components/EngagementForm.tsx | 144 +++++++++++++++++++++++++++------- 1 file changed, 114 insertions(+), 30 deletions(-) diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index 50c4d89..fe5acfd 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -54,6 +54,10 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { const [submitState, setSubmitState] = useState({ kind: "idle" }); const [draftSavedAt, setDraftSavedAt] = useState(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(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("[data-section-id]"), + ); + if (els.length === 0) return; + + const intersecting = new Set(); + 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 ( -
  • +
  • 0} state={pathwayState} /> ); @@ -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: " 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 = ( +

    + ✕ {state.message} +

    + ); + } else if (state.kind === "submitting") { + secondary = ( +

    + Saving your check-in… +

    + ); + } else if (viewedSectionLabel) { + secondary = ( +

    + Viewing:{" "} + {viewedSectionLabel} + {isDirty && draftSavedAt && ( + · saved {formatRelative(draftSavedAt)} + )} +

    + ); + } else if (isDirty && draftSavedAt) { + secondary = ( +

    + Draft saved {formatRelative(draftSavedAt)} (locally on this device) +

    + ); + } else if (isDirty) { + secondary = ( +

    + Editing — your draft will save automatically +

    + ); + } else { + secondary = null; + } + return (
    -
    - - {state.kind === "idle" && ( -

    - {isDirty - ? draftSavedAt - ? `Draft saved ${formatRelative(draftSavedAt)} (locally on this device)` - : "Editing — your draft will save automatically" - : ""} -

    - )} +
    +

    + {orgName} +

    + {secondary &&
    {secondary}
    }