Files
Webklar.com/components/PillNav.tsx
Basilosaurusrex f027651f9b main repo
2025-11-24 18:09:40 +01:00

366 lines
12 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { gsap } from 'gsap';
import './PillNav.css';
interface PillNavItem {
label: string;
href: string;
ariaLabel?: string;
}
interface PillNavProps {
logo?: string;
logoAlt?: string;
items: PillNavItem[];
activeHref?: string;
className?: string;
ease?: string;
baseColor?: string;
pillColor?: string;
hoveredPillTextColor?: string;
pillTextColor?: string;
onMobileMenuClick?: () => void;
initialLoadAnimation?: boolean;
}
const PillNav = ({
logo,
logoAlt = 'Logo',
items,
activeHref,
className = '',
ease = 'power3.easeOut',
baseColor = '#fff',
pillColor = '#060010',
hoveredPillTextColor = '#060010',
pillTextColor,
onMobileMenuClick,
initialLoadAnimation = true
}: PillNavProps) => {
const resolvedPillTextColor = pillTextColor ?? baseColor;
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const circleRefs = useRef<(HTMLSpanElement | null)[]>([]);
const tlRefs = useRef<gsap.core.Timeline[]>([]);
const activeTweenRefs = useRef<gsap.core.Tween[]>([]);
const logoImgRef = useRef<HTMLImageElement | null>(null);
const logoTweenRef = useRef<gsap.core.Tween | null>(null);
const hamburgerRef = useRef<HTMLButtonElement | null>(null);
const mobileMenuRef = useRef<HTMLDivElement | null>(null);
const navItemsRef = useRef<HTMLDivElement | null>(null);
const logoRef = useRef<HTMLAnchorElement | null>(null);
const hasAnimatedRef = useRef(false);
useEffect(() => {
const layout = () => {
circleRefs.current.forEach(circle => {
if (!circle?.parentElement) return;
const pill = circle.parentElement;
const rect = pill.getBoundingClientRect();
const { width: w, height: h } = rect;
const R = ((w * w) / 4 + h * h) / (2 * h);
const D = Math.ceil(2 * R) + 2;
const delta = Math.ceil(R - Math.sqrt(Math.max(0, R * R - (w * w) / 4))) + 1;
const originY = D - delta;
circle.style.width = `${D}px`;
circle.style.height = `${D}px`;
circle.style.bottom = `-${delta}px`;
gsap.set(circle, {
xPercent: -50,
scale: 0,
transformOrigin: `50% ${originY}px`
});
const label = pill.querySelector('.pill-label');
const white = pill.querySelector('.pill-label-hover');
if (label) gsap.set(label, { y: 0 });
if (white) gsap.set(white, { y: h + 12, opacity: 0 });
const index = circleRefs.current.indexOf(circle);
if (index === -1) return;
tlRefs.current[index]?.kill();
const tl = gsap.timeline({ paused: true });
tl.to(circle, { scale: 1.2, xPercent: -50, duration: 2, ease, overwrite: 'auto' }, 0);
if (label) {
tl.to(label, { y: -(h + 8), duration: 2, ease, overwrite: 'auto' }, 0);
}
if (white) {
gsap.set(white, { y: Math.ceil(h + 100), opacity: 0 });
tl.to(white, { y: 0, opacity: 1, duration: 2, ease, overwrite: 'auto' }, 0);
}
tlRefs.current[index] = tl;
});
};
layout();
const onResize = () => layout();
window.addEventListener('resize', onResize);
if (document.fonts?.ready) {
document.fonts.ready.then(layout).catch(() => {});
}
const menu = mobileMenuRef.current;
if (menu) {
gsap.set(menu, { visibility: 'hidden', opacity: 0, scaleY: 1 });
}
if (initialLoadAnimation && !hasAnimatedRef.current) {
const logoEl = logoRef.current;
const navItems = navItemsRef.current;
if (logoEl) {
gsap.set(logoEl, { scale: 0 });
gsap.to(logoEl, {
scale: 1,
duration: 0.6,
ease
});
}
if (navItems) {
gsap.set(navItems, { width: 0, overflow: 'hidden' });
gsap.to(navItems, {
width: 'auto',
duration: 0.6,
ease
});
}
hasAnimatedRef.current = true;
} else if (navItemsRef.current && !hasAnimatedRef.current) {
// Wenn initialLoadAnimation false ist, setze die Items sofort auf sichtbar
const navItems = navItemsRef.current;
gsap.set(navItems, { width: 'auto', overflow: 'visible' });
if (logoRef.current) {
gsap.set(logoRef.current, { scale: 1 });
}
hasAnimatedRef.current = true;
}
return () => window.removeEventListener('resize', onResize);
}, [items, ease]); // initialLoadAnimation entfernt, damit es nur einmal läuft
const handleEnter = (i: number) => {
const tl = tlRefs.current[i];
if (!tl) return;
activeTweenRefs.current[i]?.kill();
activeTweenRefs.current[i] = tl.tweenTo(tl.duration(), {
duration: 0.3,
ease,
overwrite: 'auto'
});
};
const handleLeave = (i: number) => {
const tl = tlRefs.current[i];
if (!tl) return;
activeTweenRefs.current[i]?.kill();
activeTweenRefs.current[i] = tl.tweenTo(0, {
duration: 0.2,
ease,
overwrite: 'auto'
});
};
const handleLogoEnter = () => {
const img = logoImgRef.current;
if (!img) return;
logoTweenRef.current?.kill();
gsap.set(img, { rotate: 0 });
logoTweenRef.current = gsap.to(img, {
rotate: 360,
duration: 0.2,
ease,
overwrite: 'auto'
});
};
const toggleMobileMenu = () => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
const hamburger = hamburgerRef.current;
const menu = mobileMenuRef.current;
if (hamburger) {
const lines = hamburger.querySelectorAll('.hamburger-line');
if (newState) {
gsap.to(lines[0], { rotation: 45, y: 3, duration: 0.3, ease });
gsap.to(lines[1], { rotation: -45, y: -3, duration: 0.3, ease });
} else {
gsap.to(lines[0], { rotation: 0, y: 0, duration: 0.3, ease });
gsap.to(lines[1], { rotation: 0, y: 0, duration: 0.3, ease });
}
}
if (menu) {
if (newState) {
gsap.set(menu, { visibility: 'visible' });
gsap.fromTo(
menu,
{ opacity: 0, y: 10, scaleY: 1 },
{
opacity: 1,
y: 0,
scaleY: 1,
duration: 0.3,
ease,
transformOrigin: 'top center'
}
);
} else {
gsap.to(menu, {
opacity: 0,
y: 10,
scaleY: 1,
duration: 0.2,
ease,
transformOrigin: 'top center',
onComplete: () => {
gsap.set(menu, { visibility: 'hidden' });
}
});
}
}
onMobileMenuClick?.();
};
const isExternalLink = (href: string) =>
href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('//') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#');
const cssVars = {
['--base']: baseColor,
['--pill-bg']: pillColor,
['--hover-text']: hoveredPillTextColor,
['--pill-text']: resolvedPillTextColor
} as React.CSSProperties;
return (
<div className="pill-nav-container">
<nav className={`pill-nav ${className}`} aria-label="Primary" style={cssVars}>
{logo && (
<Link
className="pill-logo"
href="#"
aria-label="Home"
onMouseEnter={handleLogoEnter}
role="menuitem"
ref={logoRef}
onClick={(e) => {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<img src={logo} alt={logoAlt} ref={logoImgRef} />
</Link>
)}
<div className="pill-nav-items desktop-only" ref={navItemsRef}>
<ul className="pill-list" role="menubar">
{items.map((item, i) => (
<li key={item.href || `item-${i}`} role="none">
{isExternalLink(item.href) ? (
<a
role="menuitem"
href={item.href}
className={`pill${activeHref === item.href ? ' is-active' : ''}`}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)}
onMouseLeave={() => handleLeave(i)}
>
<span
className="hover-circle"
aria-hidden="true"
ref={el => {
circleRefs.current[i] = el;
}}
/>
<span className="label-stack">
<span className="pill-label">{item.label}</span>
<span className="pill-label-hover" aria-hidden="true">
{item.label}
</span>
</span>
</a>
) : (
<Link
role="menuitem"
href={item.href}
className={`pill${activeHref === item.href ? ' is-active' : ''}`}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)}
onMouseLeave={() => handleLeave(i)}
onClick={(e) => {
if (item.href.startsWith('#')) {
e.preventDefault();
const element = document.getElementById(item.href.substring(1));
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}}
>
<span
className="hover-circle"
aria-hidden="true"
ref={el => {
circleRefs.current[i] = el;
}}
/>
<span className="label-stack">
<span className="pill-label">{item.label}</span>
<span className="pill-label-hover" aria-hidden="true">
{item.label}
</span>
</span>
</Link>
)}
</li>
))}
</ul>
</div>
<button
className="mobile-menu-button mobile-only"
onClick={toggleMobileMenu}
aria-label="Toggle menu"
ref={hamburgerRef}
>
<span className="hamburger-line" />
<span className="hamburger-line" />
</button>
</nav>
<div className="mobile-menu-popover mobile-only" ref={mobileMenuRef} style={cssVars}>
<ul className="mobile-menu-list">
{items.map((item, i) => (
<li key={item.href || `mobile-item-${i}`}>
{isExternalLink(item.href) ? (
<a
href={item.href}
className={`mobile-menu-link${activeHref === item.href ? ' is-active' : ''}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{item.label}
</a>
) : (
<Link
href={item.href}
className={`mobile-menu-link${activeHref === item.href ? ' is-active' : ''}`}
onClick={() => {
setIsMobileMenuOpen(false);
if (item.href.startsWith('#')) {
const element = document.getElementById(item.href.substring(1));
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}}
>
{item.label}
</Link>
)}
</li>
))}
</ul>
</div>
</div>
);
};
export default PillNav;