- Introduces a local tracking variable in `Navbar` scroll effect to prevent redundant `setState` calls during scroll. - Ensures the state is correctly initialized on mount if the page is already scrolled. - Uses `requestAnimationFrame` for efficient scroll handling. This reduces the number of React reconciliation cycles during scrolling, improving main thread performance. Co-authored-by: ragusa-it <196988693+ragusa-it@users.noreply.github.com>
146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { Link, useLocation } from 'react-router-dom';
|
|
import { motion } from 'motion/react';
|
|
import { useTranslation } from '../../i18n';
|
|
import { debounce } from '../../utils/debounce';
|
|
import styles from './Navbar.module.css';
|
|
|
|
export function Navbar() {
|
|
const { t, language, setLanguage } = useTranslation();
|
|
const location = useLocation();
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
const [indicatorX, setIndicatorX] = useState(0);
|
|
const navLinksRef = useRef<HTMLDivElement>(null);
|
|
const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
|
|
|
const navLinks = [
|
|
{ path: '/', label: t.nav.home },
|
|
{ path: '/about', label: t.nav.about },
|
|
{ path: '/contact', label: t.nav.contact },
|
|
];
|
|
|
|
// Find active index
|
|
const activeIndex = navLinks.findIndex((link) => link.path === location.pathname);
|
|
|
|
useEffect(() => {
|
|
let ticking = false;
|
|
// Optimization: Track last state to avoid redundant setState calls
|
|
let prevIsScrolled = window.scrollY > 20;
|
|
|
|
// Set initial state correctly if page is loaded scrolled down
|
|
if (prevIsScrolled) {
|
|
setIsScrolled(true);
|
|
}
|
|
|
|
const handleScroll = () => {
|
|
if (!ticking) {
|
|
window.requestAnimationFrame(() => {
|
|
const currentIsScrolled = window.scrollY > 20;
|
|
if (currentIsScrolled !== prevIsScrolled) {
|
|
setIsScrolled(currentIsScrolled);
|
|
prevIsScrolled = currentIsScrolled;
|
|
}
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
};
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setIsMobileMenuOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
// Calculate indicator position
|
|
useEffect(() => {
|
|
const updateIndicatorPosition = () => {
|
|
const activeLink = linkRefs.current[activeIndex];
|
|
const container = navLinksRef.current;
|
|
|
|
if (activeLink && container) {
|
|
const containerRect = container.getBoundingClientRect();
|
|
const linkRect = activeLink.getBoundingClientRect();
|
|
// Calculate center of the link relative to the container
|
|
const centerX = linkRect.left - containerRect.left + linkRect.width / 2;
|
|
setIndicatorX(centerX);
|
|
}
|
|
};
|
|
|
|
updateIndicatorPosition();
|
|
|
|
const debouncedUpdate = debounce(updateIndicatorPosition, 150);
|
|
window.addEventListener('resize', debouncedUpdate);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', debouncedUpdate);
|
|
debouncedUpdate.cancel();
|
|
};
|
|
}, [activeIndex, language]); // Recalculate when active link or language changes
|
|
|
|
const toggleLanguage = () => {
|
|
setLanguage(language === 'de' ? 'en' : 'de');
|
|
};
|
|
|
|
return (
|
|
<motion.header
|
|
className={`${styles.header} ${isScrolled ? styles.scrolled : ''}`}
|
|
initial={{ y: -100 }}
|
|
animate={{ y: 0 }}
|
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
|
>
|
|
<nav className={`${styles.nav} container`}>
|
|
<Link to="/" className={styles.logo}>
|
|
<img src="/logo.svg" alt="RagusaIT" className={styles.logoImage} />
|
|
</Link>
|
|
|
|
<div
|
|
ref={navLinksRef}
|
|
className={`${styles.navLinks} ${isMobileMenuOpen ? styles.open : ''}`}
|
|
>
|
|
{navLinks.map((link, index) => (
|
|
<Link
|
|
key={link.path}
|
|
ref={(el) => { linkRefs.current[index] = el; }}
|
|
to={link.path}
|
|
className={`${styles.navLink} ${location.pathname === link.path ? styles.active : ''}`}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
))}
|
|
{activeIndex !== -1 && (
|
|
<motion.div
|
|
className={styles.activeIndicator}
|
|
animate={{ x: indicatorX }}
|
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.actions}>
|
|
<button
|
|
onClick={toggleLanguage}
|
|
className={styles.langToggle}
|
|
aria-label={`Switch to ${language === 'de' ? 'English' : 'German'}`}
|
|
>
|
|
<span className={language === 'de' ? styles.activeLang : ''}>DE</span>
|
|
<span className={styles.langDivider}>/</span>
|
|
<span className={language === 'en' ? styles.activeLang : ''}>EN</span>
|
|
</button>
|
|
|
|
<button
|
|
className={styles.mobileMenuBtn}
|
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
aria-label="Toggle menu"
|
|
aria-expanded={isMobileMenuOpen}
|
|
>
|
|
<span className={`${styles.hamburger} ${isMobileMenuOpen ? styles.open : ''}`} />
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
</motion.header>
|
|
);
|
|
}
|