SPB Git forge

spb/job-ka

Public
227commits 1branches 0releases
37.7 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
2.7 KB · 61 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Job·Ka — Agrégateur d'offres d'emploi (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/SectionNav.tsx : navigation sticky par sections — pastilles5//   défilables horizontalement, section active suivie au scroll6//   (IntersectionObserver), défilement doux au clic sans modifier l'URL7//   (pas de #hash : la page s'ouvre toujours en haut).8// -----------------------------------------------------------------------------9import { useEffect, useRef, useState } from "react";1011export interface NavItem { id: string; label: string; }1213export default function SectionNav({ items }: { items: NavItem[] }) {14  const [active, setActive] = useState(items[0]?.id);15  const track = useRef<HTMLDivElement>(null);1617  useEffect(() => {18    const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);19    if (els.length === 0) return;20    const visible = new Map<string, number>();21    const io = new IntersectionObserver((entries) => {22      for (const e of entries) visible.set((e.target as HTMLElement).id, e.isIntersecting ? e.intersectionRatio : 0);23      // section active = la première (ordre DOM) visible sous le header24      const first = items.find((i) => (visible.get(i.id) ?? 0) > 0);25      if (first) setActive(first.id);26    }, { rootMargin: "-120px 0px -55% 0px", threshold: [0, 0.1, 0.5] });27    els.forEach((e) => io.observe(e));28    return () => io.disconnect();29  }, [items]);3031  // garder la pastille active visible dans la barre — défilement HORIZONTAL de32  // la piste seulement (jamais scrollIntoView : il ferait défiler la page33  // verticalement, y compris à l'ouverture)34  useEffect(() => {35    const t = track.current, a = t?.querySelector<HTMLElement>(".on");36    if (!t || !a) return;37    const left = a.offsetLeft, right = left + a.offsetWidth;38    if (left < t.scrollLeft) t.scrollTo({ left: Math.max(0, left - 12), behavior: "smooth" });39    else if (right > t.scrollLeft + t.clientWidth) t.scrollTo({ left: right - t.clientWidth + 12, behavior: "smooth" });40  }, [active]);4142  const go = (id: string) => (e: React.MouseEvent) => {43    e.preventDefault();44    document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });45    setActive(id);46  };4748  return (49    <nav className="jk-nav" aria-label="Sections de la fiche">50      <div className="jk-nav-track" ref={track}>51        {items.map((i) => (52          <a key={i.id} href={`#${i.id}`} className={active === i.id ? "on" : ""}53             aria-current={active === i.id ? "true" : undefined} onClick={go(i.id)}>54            {i.label}55          </a>56        ))}57      </div>58    </nav>59  );60}61