Files
ragusaitweb/src/components/layout/Navbar.tsx
google-labs-jules[bot] 1f21b7bcb9 Optimize Navbar resize handler with debounce
*   💡 **What:** Implemented a generic `debounce` utility and applied it to the `Navbar` component's window resize event listener (150ms delay). Added a `.cancel()` method to the debounce utility to prevent memory leaks/errors on unmount.
*   🎯 **Why:** The `resize` event fires rapidly, causing `getBoundingClientRect` (a layout-thrashing operation) to run excessively, impacting performance.
*   📊 **Measured Improvement:** In a benchmark test simulating 100 rapid resize events:
    *   **Baseline:** 200 calls to `getBoundingClientRect`.
    *   **Optimized:** 2 calls to `getBoundingClientRect`.
    *   **Result:** ~99% reduction in layout calculations during rapid resizing.
    *   Added `src/components/layout/Navbar.perf.test.tsx` to prevent regression.
2026-01-24 10:07:28 +00:00

134 lines
4.4 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;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
setIsScrolled(window.scrollY > 20);
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>
);
}