SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
3.5 KB · 88 lines tsx
Raw Blame History
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