spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { useEffect, useRef, useState } from "react";4import { cn } from "@/components/ui/cn";56export interface SectionLink {7 id: string;8 label: string;9 count?: number | null;10}1112/**13 * In-page anchor navigation for stacked detail sections (sticky under the header, horizontal scroll on mobile).14 * Tracks the active section with an IntersectionObserver (the section whose top is closest below the sticky bar)15 * and keeps the active pill scrolled into view on narrow screens. Props unchanged from the server version.16 */17export function SectionNav({ items, className }: { items: SectionLink[]; className?: string }) {18 const list = items.filter((it) => it.count === undefined || it.count === null || it.count > 0);19 const [active, setActive] = useState<string | null>(null);20 const navRef = useRef<HTMLElement>(null);2122 useEffect(() => {23 if (typeof IntersectionObserver === "undefined") return;24 const els = list.map((it) => document.getElementById(it.id)).filter((e): e is HTMLElement => !!e);25 if (!els.length) return;26 const ratios = new Map<string, number>();27 const pick = () => {28 // the first section (in DOM order) whose top is at or above the sticky offset and still on screen wins;29 // fall back to the most visible one30 const offset = 120;31 let best: string | null = null;32 for (const el of els) {33 const r = el.getBoundingClientRect();34 if (r.top <= offset && r.bottom > offset) {35 best = el.id;36 break;37 }38 }39 if (!best) {40 let max = 0;41 for (const [id, v] of ratios) if (v > max) [best, max] = [id, v];42 }43 if (best) setActive(best);44 };45 const io = new IntersectionObserver(46 (entries) => {47 for (const e of entries) ratios.set(e.target.id, e.isIntersecting ? e.intersectionRatio : 0);48 pick();49 },50 { rootMargin: "-120px 0px -40% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] },51 );52 els.forEach((el) => io.observe(el));53 const onScroll = () => pick();54 window.addEventListener("scroll", onScroll, { passive: true });55 pick();56 return () => {57 io.disconnect();58 window.removeEventListener("scroll", onScroll);59 };60 // eslint-disable-next-line react-hooks/exhaustive-deps61 }, [list.map((l) => l.id).join("|")]);6263 useEffect(() => {64 if (!active || !navRef.current) return;65 const a = navRef.current.querySelector<HTMLElement>(`a[href="#${active}"]`);66 a?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });67 }, [active]);6869 if (list.length < 2) return null;70 return (71 <nav ref={navRef} aria-label="Sections" className={cn("sticky top-12 z-[15] -mx-3 overflow-x-auto border-b border-hair bg-paper/95 px-3 backdrop-blur no-scrollbar sm:top-14 sm:mx-0 sm:px-0", className)}>72 <ul className="flex w-max min-w-full gap-0">73 {list.map((it) => {74 const on = active === it.id;75 return (76 <li key={it.id} className="first:[&>a]:pl-0">77 <a href={`#${it.id}`} aria-current={on ? "location" : undefined} className={cn("inline-flex h-10 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-[12.5px] font-medium transition-colors", on ? "border-ink text-ink" : "border-transparent text-ink-3 hover:border-hair-2 hover:text-ink")}>78 {it.label}79 {typeof it.count === "number" && <span className={cn("figure text-[11px]", on ? "text-ink-3" : "text-ink-4")}>{it.count}</span>}80 </a>81 </li>82 );83 })}84 </ul>85 </nav>86 );87}88