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:
+113
-29
@@ -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"
|
||||
: ""}
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user