Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 116 lines 3.4 kB view raw
1'use client'; 2 3import { useEffect, useState, useRef, useCallback } from 'react'; 4import { cn } from '@/lib/utils'; 5 6export interface SectionNavItem { 7 id: string; 8 label: string; 9} 10 11interface SectionNavProps { 12 sections: SectionNavItem[]; 13} 14 15function useScrollSpy(sections: SectionNavItem[]) { 16 const [activeId, setActiveId] = useState<string | null>(null); 17 const observerRef = useRef<IntersectionObserver | null>(null); 18 19 useEffect(() => { 20 if (sections.length < 3) return; 21 22 const handleIntersect = (entries: IntersectionObserverEntry[]) => { 23 const visible = entries 24 .filter((e) => e.isIntersecting) 25 .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); 26 27 const first = visible[0]; 28 if (first) { 29 setActiveId(first.target.id); 30 } 31 }; 32 33 observerRef.current = new IntersectionObserver(handleIntersect, { 34 rootMargin: '-80px 0px -60% 0px', 35 threshold: 0, 36 }); 37 38 for (const section of sections) { 39 const el = document.getElementById(section.id); 40 if (el) observerRef.current.observe(el); 41 } 42 43 return () => { 44 observerRef.current?.disconnect(); 45 }; 46 }, [sections]); 47 48 return activeId; 49} 50 51export function SectionNav({ sections }: SectionNavProps) { 52 const activeId = useScrollSpy(sections); 53 54 const handleClick = useCallback((id: string) => { 55 const el = document.getElementById(id); 56 if (el) { 57 el.scrollIntoView({ behavior: 'smooth', block: 'start' }); 58 } 59 }, []); 60 61 if (sections.length < 3) return null; 62 63 return ( 64 <> 65 {/* Desktop: left sidebar — positioned by parent flex container */} 66 <nav 67 className="sticky top-20 hidden max-h-[calc(100vh-6rem)] w-44 shrink-0 self-start lg:block" 68 aria-label="Profile sections" 69 > 70 <ul className="space-y-1"> 71 {sections.map((section) => ( 72 <li key={section.id}> 73 <button 74 type="button" 75 onClick={() => handleClick(section.id)} 76 className={cn( 77 'block w-full rounded-md px-3 py-1.5 text-left text-sm transition-colors', 78 activeId === section.id 79 ? 'bg-primary/10 font-medium text-primary' 80 : 'text-muted-foreground hover:bg-accent hover:text-foreground', 81 )} 82 > 83 {section.label} 84 </button> 85 </li> 86 ))} 87 </ul> 88 </nav> 89 90 {/* Mobile: horizontal pill bar */} 91 <nav 92 className="sticky top-14 z-30 -mx-4 overflow-x-auto border-b border-border bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80 lg:hidden" 93 aria-label="Profile sections" 94 > 95 <ul className="flex gap-2"> 96 {sections.map((section) => ( 97 <li key={section.id}> 98 <button 99 type="button" 100 onClick={() => handleClick(section.id)} 101 className={cn( 102 'whitespace-nowrap rounded-full px-3 py-1 text-sm transition-colors', 103 activeId === section.id 104 ? 'bg-primary text-primary-foreground' 105 : 'bg-muted text-muted-foreground hover:bg-accent hover:text-foreground', 106 )} 107 > 108 {section.label} 109 </button> 110 </li> 111 ))} 112 </ul> 113 </nav> 114 </> 115 ); 116}