SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
1.8 KB · 37 lines tsx
Raw Blame History
1'use client';2import { useEffect, useState } from 'react';3import { t } from '@/i18n';4import { cn } from '@/lib/cn';56/** Sticky table of contents (desktop) with the current section highlighted via IntersectionObserver. */7export function Toc({ items }: { items: Array<{ id: string; label: string }> }) {8  const [active, setActive] = useState<string | null>(items[0]?.id ?? null);9  useEffect(() => {10    if (typeof IntersectionObserver === 'undefined') return;11    const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);12    const io = new IntersectionObserver(13      (entries) => {14        const vis = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);15        if (vis[0]) setActive(vis[0].target.id);16      },17      { rootMargin: '-96px 0px -60% 0px', threshold: 0 },18    );19    els.forEach((e) => io.observe(e));20    return () => io.disconnect();21  }, [items]);22  return (23    <nav aria-label={t('method.toc')} className="lg:sticky lg:top-20">24      <div className="eyebrow mb-1.5 hidden lg:block">{t('method.toc')}</div>25      <ol className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 lg:mx-0 lg:flex-col lg:gap-0 lg:overflow-visible lg:px-0">26        {items.map((i) => (27          <li key={i.id} className="shrink-0">28            <a href={`#${i.id}`} aria-current={active === i.id ? 'location' : undefined} className={cn('inline-flex h-11 items-center whitespace-nowrap rounded-sm px-3 text-sm lg:h-8 lg:px-2 lg:text-xs', active === i.id ? 'bg-ink text-paper lg:bg-transparent lg:font-semibold lg:text-ink' : 'text-ink-2 hover:bg-surface-2 hover:text-ink lg:hover:bg-transparent')}>29              {i.label}30            </a>31          </li>32        ))}33      </ol>34    </nav>35  );36}37