From a470b6fe9d082995cc20d15a19ffd07a2b4abe2e Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 15:56:16 -0600 Subject: [PATCH] feat(water-log/settings): sticky in-page section nav for Smartsheet card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Smartsheet card has six stacked sections (Enable, Connection, Sync frequency, Column mapping, Backfill, Recent activity) — at 1100px viewport that's a long scroll. Add a sticky 'On this page' pill nav at the top of the card that highlights the active section as the user scrolls, with anchor jump on click. - Each gets a stable id (smartsheet-enable, -connection, -frequency, -mapping, -backfill, -activity) and scroll-mt-20 so anchor jumps clear the sticky nav. - IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section is in the top 20% of the viewport and updates activeSection state. - Pill click uses native scrollIntoView, which honors the global scroll-behavior: smooth on (auto-flipped to instant under prefers-reduced-motion by globals.css — no JS matchMedia needed). - URL hash updates via history.replaceState so the destination is shareable without triggering a second jump. - Apple HIG polish: rounded-full pills, focus-visible ring, active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92 with backdrop-blur for the sticky chrome. - All changes additive — no existing Card/ToggleRow behavior modified. Verified: npx tsc --noEmit clean, npm run build clean, npm run lint on this file clean. --- .../water-log/SmartsheetIntegrationCard.tsx | 154 +++++++++++++++++- 1 file changed, 151 insertions(+), 3 deletions(-) diff --git a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx index 68299cb..a8dd5ee 100644 --- a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx +++ b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx @@ -26,7 +26,7 @@ * be lifted later without rewrites. */ -import { useEffect, useReducer, useCallback } from "react"; +import { useEffect, useReducer, useState, useCallback } from "react"; import { getSmartsheetConfig, saveSmartsheetConfig, @@ -58,6 +58,18 @@ const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [ "logged_at", ]; +// IDs for the in-page section nav. Hoisted to module scope so the +// IntersectionObserver effect can list it once in its deps array. +const SECTION_IDS = [ + "smartsheet-enable", + "smartsheet-connection", + "smartsheet-frequency", + "smartsheet-mapping", + "smartsheet-backfill", + "smartsheet-activity", +] as const; +type SectionId = (typeof SECTION_IDS)[number]; + const FREQUENCY_LABELS: Record = { realtime: "Real-time (after every entry)", every_15_minutes: "Every 15 minutes", @@ -295,6 +307,69 @@ export default function SmartsheetIntegrationCard({ return () => window.removeEventListener("keydown", onKey); }, [state.setupModalOpen]); + // ── In-page section nav: which section is currently visible ──────────── + // The "active zone" is the top 20% of the viewport (under the sticky nav). + // When a section enters that zone, it becomes active. We keep the previous + // active section during the gap between sections so the highlight doesn't + // blink off when scrolling between two adjacent cards. + const [activeSection, setActiveSection] = useState( + "smartsheet-enable", + ); + + useEffect(() => { + const elements = SECTION_IDS.map((id) => document.getElementById(id)).filter( + (el): el is HTMLElement => el !== null, + ); + if (elements.length === 0) return; + + const visible = new Set(); + let lastActive: SectionId | null = null; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + visible.add(entry.target.id); + } else { + visible.delete(entry.target.id); + } + } + // Among visible sections, prefer the earliest (topmost) in DOM order. + const ordered = SECTION_IDS.filter((id) => visible.has(id)); + const nextActive = (ordered[0] ?? lastActive) as SectionId | null; + if (nextActive && nextActive !== lastActive) { + lastActive = nextActive; + setActiveSection(nextActive); + } + }, + { + // Top 20% of the viewport = the visible "reading zone" under the nav. + rootMargin: "0px 0px -80% 0px", + threshold: 0, + }, + ); + + for (const el of elements) observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Smooth scroll on pill click. `scroll-behavior: smooth` is set globally on + // and the prefers-reduced-motion media query in globals.css flips + // it to `auto`, so we don't need a JS-side matchMedia check here. + const handleSectionClick = useCallback( + (e: React.MouseEvent, id: string) => { + const el = document.getElementById(id); + if (!el) return; + e.preventDefault(); + el.scrollIntoView({ block: "start" }); + // Reflect the destination in the URL without triggering a jump. + if (typeof window !== "undefined" && window.history) { + window.history.replaceState(null, "", `#${id}`); + } + }, + [], + ); + async function handleSave(e: React.FormEvent) { e.preventDefault(); dispatch({ type: "SAVE_START" }); @@ -400,6 +475,7 @@ export default function SmartsheetIntegrationCard({ return (
+ {state.message && (
@@ -456,6 +533,7 @@ export default function SmartsheetIntegrationCard({ @@ -626,6 +705,7 @@ export default function SmartsheetIntegrationCard({ @@ -717,9 +797,15 @@ export default function SmartsheetIntegrationCard({
- dispatch({ type: "BACKFILL_RANGE", value: v })} /> + dispatch({ type: "BACKFILL_RANGE", value: v })} + /> @@ -808,19 +894,78 @@ export default function SmartsheetIntegrationCard({ // ── Subcomponents (mirror the parent settings page style) ────────────────── +function SectionNav({ + activeId, + onNavigate, +}: { + activeId: string; + onNavigate: (e: React.MouseEvent, id: string) => void; +}) { + const sections: Array<{ id: string; label: string }> = [ + { id: "smartsheet-enable", label: "Enable" }, + { id: "smartsheet-connection", label: "Connection" }, + { id: "smartsheet-frequency", label: "Frequency" }, + { id: "smartsheet-mapping", label: "Mapping" }, + { id: "smartsheet-backfill", label: "Backfill" }, + { id: "smartsheet-activity", label: "Activity" }, + ]; + + return ( + + ); +} + function Card({ + id, title, subtitle, action, children, }: { + id?: string; title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; }) { return ( -
+

@@ -943,10 +1088,12 @@ type BackfillState = { }; function BackfillCard({ + id, state, onRun, onRangeChange, }: { + id?: string; state: BackfillState; onRun: () => void; onRangeChange: (value: "all" | "30d" | "7d") => void; @@ -958,6 +1105,7 @@ function BackfillCard({ return (