feat(school-detail): highlight active section in sticky nav on scroll
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 43s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 43s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
Uses IntersectionObserver on each section element. Multiple thresholds (0, 0.1, 0.25, 0.5, 0.75, 1.0) track the intersection ratio of every section simultaneously; whichever has the highest visible ratio at any moment becomes the active item. rootMargin offsets for the sticky nav height so a section is only considered active once it's genuinely in view beneath the bar. Active link gets .sectionNavLinkActive — coral background + white text, matching the phase tab active style used elsewhere in the product. Observer is cleaned up on unmount. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,17 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionNavLinkActive {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionNavLinkActive:hover {
|
||||||
|
background: var(--accent-coral-dark, #c45a3f);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* Unified card for all content sections */
|
/* Unified card for all content sections */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export function SchoolDetailView({
|
|||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
const isInComparison = isSelected(schoolInfo.urn);
|
const isInComparison = isSelected(schoolInfo.urn);
|
||||||
|
|
||||||
|
const [activeSection, setActiveSection] = useState<string>('');
|
||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
// Phase detection
|
// Phase detection
|
||||||
@@ -80,6 +82,42 @@ export function SchoolDetailView({
|
|||||||
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
||||||
const isPrimary = !isSecondary;
|
const isPrimary = !isSecondary;
|
||||||
|
|
||||||
|
// Track active section as user scrolls
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = navItems.map(n => n.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
|
// We keep a map of how much each section is visible so we can pick the
|
||||||
|
// most-visible one when multiple overlap the viewport.
|
||||||
|
const ratioMap: Record<string, number> = {};
|
||||||
|
|
||||||
|
const pickActive = () => {
|
||||||
|
const top = Object.entries(ratioMap).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
setActiveSection(top?.[1] > 0 ? top[0] : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
ids.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
ratioMap[id] = 0;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
ratioMap[id] = entry.intersectionRatio;
|
||||||
|
pickActive();
|
||||||
|
},
|
||||||
|
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0], rootMargin: '-56px 0px 0px 0px' },
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
observers.push(obs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observers.forEach(o => o.disconnect());
|
||||||
|
// navItems identity is stable per render — eslint-disable is intentional
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [navItems.map(n => n.id).join(',')]);
|
||||||
|
|
||||||
// National averages (fetched dynamically so they stay current)
|
// National averages (fetched dynamically so they stay current)
|
||||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -314,7 +352,13 @@ export function SchoolDetailView({
|
|||||||
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
||||||
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
||||||
{navItems.map(({ id, label }) => (
|
{navItems.map(({ id, label }) => (
|
||||||
<a key={id} href={`#${id}`} className={styles.sectionNavLink}>{label}</a>
|
<a
|
||||||
|
key={id}
|
||||||
|
href={`#${id}`}
|
||||||
|
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user