since3-website/modules/shared/ui/components/site-header.tsx
2026-09-25 18:53:25 +02:00

167 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useRef, useState } from "react"
import Image from "next/image"
import { play } from "@/lib/sound"
import { cn } from "@/lib/utils"
import { mainNav, site } from "@/modules/shared/config/site"
import { LiveClock } from "./live-clock"
import { ScrambleLink } from "./scramble-link"
import { SoundToggle } from "./sound-toggle"
/**
* Fixed header in `mix-blend-mode: difference`, so it stays readable on black
* and on white sections. Hides while scrolling down, returns when scrolling up.
*/
export function SiteHeader() {
const [hidden, setHidden] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const firstLinkRef = useRef<HTMLAnchorElement>(null)
const toggleRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
let lastY = window.scrollY
const onScroll = () => {
const y = window.scrollY
if (Math.abs(y - lastY) < 6) return
setHidden(y > lastY && y > 160)
lastY = y
}
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [])
useEffect(() => {
if (!menuOpen) return
firstLinkRef.current?.focus()
const onKey = (event: KeyboardEvent) => {
// Focus would otherwise fall through to <body> once #mobile-menu goes inert
if (event.key === "Escape") {
setMenuOpen(false)
toggleRef.current?.focus()
}
}
document.documentElement.style.overflow = "hidden"
window.addEventListener("keydown", onKey)
return () => {
document.documentElement.style.overflow = ""
window.removeEventListener("keydown", onKey)
}
}, [menuOpen])
const close = () => setMenuOpen(false)
return (
<>
<header
data-hidden={hidden && !menuOpen}
className="site-header fixed inset-x-0 top-0 z-[var(--z-header)]"
>
<nav
aria-label="Hauptnavigation"
className="container-site grid h-20 grid-cols-[1fr_auto] items-center gap-6 md:h-24 lg:grid-cols-[1fr_auto_1fr]"
>
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- Plain anchor on purpose: Lenis smooth-scrolls same-page hash links, next/link would scroll as well */}
<a href="/#top" aria-label="since3 – zur Startseite" className="w-fit">
<Image
src="/pictures/since3-logo-weiss.svg"
alt=""
width={1617}
height={332}
loading="eager"
className="h-5 w-auto md:h-6"
/>
</a>
<ul className="hidden items-center gap-8 text-label lg:flex">
{mainNav.map((item) => (
<li key={item.href}>
<ScrambleLink href={item.href} className="py-2 opacity-80 hover:opacity-100">
{item.label}
</ScrambleLink>
</li>
))}
</ul>
<div className="flex items-center justify-end gap-2 md:gap-4">
<LiveClock className="hidden font-mono text-label tabular-nums opacity-70 xl:block" />
<SoundToggle />
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- Plain anchor on purpose: Lenis smooth-scrolls same-page hash links, next/link would scroll as well */}
<a
href="/#kontakt"
className="hidden h-10 items-center rounded-full border border-white px-5 text-sm font-medium transition-colors duration-300 hover:bg-white hover:text-black sm:inline-flex"
>
Projekt starten
</a>
<button
ref={toggleRef}
type="button"
data-sound="off"
onClick={() => {
play("whoosh")
setMenuOpen((open) => !open)
}}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
className="grid size-10 place-items-center lg:hidden"
>
<span className="sr-only">{menuOpen ? "Menü schließen" : "Menü öffnen"}</span>
<span aria-hidden="true" className="relative block h-3 w-6">
<span
className={cn(
"absolute inset-x-0 top-0 h-0.5 bg-white transition-[translate,rotate] duration-500 ease-in-out-quart",
menuOpen && "translate-y-1.25 rotate-45"
)}
/>
<span
className={cn(
"absolute inset-x-0 bottom-0 h-0.5 bg-white transition-[translate,rotate] duration-500 ease-in-out-quart",
menuOpen && "-translate-y-1.25 -rotate-45"
)}
/>
</span>
</button>
</div>
</nav>
</header>
{/* Outside the header so it does not get blended */}
<div
id="mobile-menu"
data-theme="dark"
inert={!menuOpen}
className={cn(
"fixed inset-0 z-[var(--z-mobile-menu)] flex flex-col justify-end pb-10 transition-[clip-path] duration-700 ease-in-out-quart lg:hidden",
menuOpen ? "[clip-path:inset(0_0_0_0)]" : "[clip-path:inset(0_0_100%_0)]"
)}
>
<nav aria-label="Mobile Navigation" className="container-site">
<ul className="flex flex-col">
{[...mainNav, { label: "Kontakt", href: "/#kontakt" }].map((item, index) => (
<li key={item.href} className="overflow-clip border-b border-line">
<a
ref={index === 0 ? firstLinkRef : undefined}
href={item.href}
onClick={close}
style={{ transitionDelay: menuOpen ? `${150 + index * 60}ms` : "0ms" }}
className={cn(
"block py-3 text-display type-poster transition-[translate] duration-700 ease-out-expo",
menuOpen ? "translate-y-0" : "translate-y-full"
)}
>
{item.label}
</a>
</li>
))}
</ul>
<div className="mt-8 flex justify-between font-mono text-label text-fg-muted">
<a href={`mailto:${site.email}`}>{site.email}</a>
<LiveClock className="tabular-nums" />
</div>
</nav>
</div>
</>
)
}