since3-portal/modules/templates/catalog/blocks/navigation.tsx
2026-10-11 18:22:32 +02:00

236 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "motion/react"
import { useEffect, useId, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { ease, useScrollNav, wordmarkSize } from "../shared/motion"
import type { NavGlasData, NavLeisteData, NavMastheadData } from "./definitions"
import type { BlockProps } from "./types"
import { actionClass, Brand, container, linkProps, MobileMenu, useDarkSurface } from "./ui"
function MenuButton({ open, onClick, className }: { open: boolean; onClick: () => void; className?: string }) {
return (
<button type="button" onClick={onClick} aria-expanded={open} aria-label="Menü öffnen" className={cn("flex cursor-pointer items-center gap-3 text-sm", className)}>
Menü
<span aria-hidden className="flex w-6 flex-col items-end gap-1.5">
<span className="h-px w-6 bg-current" />
<span className="h-px w-4 bg-current" />
</span>
</button>
)
}
// Klassische Leiste: transparent oben, Milchglas nach dem ersten Scrollen, versteckt sich beim Runterscrollen.
// Über dunklen Bildflächen (data-surface="dark") wird die Schrift hell.
export function NavLeiste({ data, brand, links }: BlockProps<NavLeisteData>) {
const { scrolled, hidden } = useScrollNav()
const onImage = useDarkSurface()
const [open, setOpen] = useState(false)
const light = onImage && !scrolled
// Menüpunkt mit demselben Ziel wie der Button nicht doppelt zeigen (z. B. „Kontakt“)
const inline = links.filter((link) => link.href !== data.href.trim())
return (
<>
<motion.header
data-nav
animate={{ y: hidden ? "-110%" : "0%" }}
transition={{ duration: 0.45, ease }}
className="fixed inset-x-0 top-0 z-50"
>
<div
className={cn(
"border-b transition-[background-color,border-color] duration-500",
scrolled ? "border-t-fg/10 bg-t-bg/75 backdrop-blur-xl" : "border-transparent"
)}
>
<nav className={cn(container, "flex h-16 items-center justify-between gap-6 transition-colors duration-500 md:h-20", light ? "text-white" : "text-t-fg")}>
<Brand brand={brand} />
<div className="hidden items-center gap-8 md:flex">
{inline.map((link) => (
<a key={link.href} href={link.href} className="group relative text-sm opacity-75 transition-opacity hover:opacity-100">
{link.label}
<span aria-hidden className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-current transition-transform duration-300 group-hover:origin-left group-hover:scale-x-100" />
</a>
))}
{data.cta.trim() && (
<a {...linkProps(data.href)} className={cn(actionClass(light ? "light" : "solid"), "px-5 py-2.5")}>
{data.cta}
</a>
)}
</div>
<MenuButton open={open} onClick={() => setOpen(true)} className="md:hidden" />
</nav>
</div>
</motion.header>
<MobileMenu open={open} onClose={() => setOpen(false)} links={links} brand={brand} cta={{ label: data.cta, href: data.href }} />
</>
)
}
// Schwebende Glas-Pille; die Hervorhebung gleitet von Menüpunkt zu Menüpunkt
export function NavGlas({ data, brand, links }: BlockProps<NavGlasData>) {
const ref = useRef<HTMLDivElement>(null)
const pill = useId()
const [hovered, setHovered] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const inline = links.filter((link) => link.href !== data.href.trim())
useEffect(() => {
if (!open) return
const close = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener("pointerdown", close)
return () => document.removeEventListener("pointerdown", close)
}, [open])
return (
<motion.header
data-nav
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.15 }}
className="fixed inset-x-0 top-3 z-50 flex justify-center px-3 md:top-5"
>
<div ref={ref} className="relative w-full max-w-3xl">
<nav className="flex items-center justify-between gap-2 rounded-full border border-t-fg/10 bg-t-bg/65 py-1.5 pr-1.5 pl-5 text-t-fg shadow-[0_12px_40px_-18px_rgba(0,0,0,0.45)] backdrop-blur-xl">
<Brand brand={brand} className="text-lg md:text-xl" />
<ul className="hidden items-center md:flex" onPointerLeave={() => setHovered(null)}>
{inline.map((link) => (
<li key={link.href} className="relative">
{hovered === link.href && (
<motion.span
layoutId={pill}
className="absolute inset-0 rounded-full bg-t-fg/[0.08]"
transition={{ type: "spring", bounce: 0.18, duration: 0.45 }}
/>
)}
<a href={link.href} onPointerEnter={() => setHovered(link.href)} className="relative block px-4 py-2 text-sm text-t-fg/75 transition-colors hover:text-t-fg">
{link.label}
</a>
</li>
))}
</ul>
<div className="flex items-center gap-1">
{data.cta.trim() && (
<a {...linkProps(data.href)} className={cn(actionClass("solid"), "px-4 py-2.5 md:px-5")}>
{data.cta}
</a>
)}
{links.length > 0 && (
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-label={open ? "Menü schließen" : "Menü öffnen"}
className="grid size-10 cursor-pointer place-items-center rounded-full transition-colors hover:bg-t-fg/[0.06] md:hidden"
>
<span aria-hidden className="relative block h-3 w-5">
<span className={cn("absolute left-0 h-px w-5 bg-current transition-transform duration-300", open ? "top-1.5 rotate-45" : "top-0")} />
<span className={cn("absolute left-0 h-px w-5 bg-current transition-transform duration-300", open ? "top-1.5 -rotate-45" : "top-3")} />
</span>
</button>
)}
</div>
</nav>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98, transition: { duration: 0.15 } }}
transition={{ duration: 0.25, ease }}
style={{ transformOrigin: "top right" }}
className="absolute inset-x-0 top-full mt-2 rounded-[1.75rem] border border-t-fg/10 bg-t-bg/90 p-2 text-t-fg shadow-2xl backdrop-blur-xl md:hidden"
>
{links.map((link) => (
<a key={link.href} href={link.href} onClick={() => setOpen(false)} className="block rounded-2xl px-4 py-3 text-lg transition-colors hover:bg-t-fg/[0.06]">
{link.label}
</a>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.header>
)
}
// Magazin-Kopf: großer Schriftzug im Fluss der Seite, danach eine schmale Leiste oben
export function NavMasthead({ data, brand, links }: BlockProps<NavMastheadData>) {
const ref = useRef<HTMLElement>(null)
const { scrollY } = useScroll()
const [compact, setCompact] = useState(false)
const [open, setOpen] = useState(false)
const letters = Array.from(brand.name)
useMotionValueEvent(scrollY, "change", (y) => {
setCompact(y > (ref.current?.offsetHeight ?? 320) - 24)
})
return (
<>
<header ref={ref} className={cn(container, "relative pt-4 md:pt-5")}>
<div className="flex items-center justify-between gap-4 border-b border-t-fg/15 pb-3 text-[11px] uppercase tracking-[0.22em] text-t-muted md:pb-4">
<span>{data.edition}</span>
<nav className="hidden gap-7 md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="transition-colors hover:text-t-fg">
{link.label}
</a>
))}
</nav>
<MenuButton open={open} onClick={() => setOpen(true)} className="text-[11px] tracking-[0.22em] uppercase md:hidden" />
</div>
<a
href="#top"
aria-label={brand.name}
className="block overflow-hidden pt-[0.08em] pb-[0.12em] text-center font-t-display leading-[0.9] tracking-[-0.04em]"
style={{ fontSize: wordmarkSize(brand.name, 19) }}
>
{letters.map((char, i) => (
<motion.span
key={i}
aria-hidden
className="inline-block"
initial={{ y: "105%" }}
animate={{ y: "0%" }}
transition={{ duration: 1.1, ease, delay: 0.1 + i * 0.045 }}
>
{char === " " ? " " : char}
</motion.span>
))}
</a>
<div className="flex items-center justify-between gap-4 border-y border-t-fg/15 py-3 text-[11px] uppercase tracking-[0.22em] text-t-muted">
<span>{brand.tagline}</span>
{data.cta.trim() && (
<a {...linkProps(data.href)} className="shrink-0 text-t-fg underline-offset-4 hover:underline">
{data.cta} →
</a>
)}
</div>
</header>
<motion.div
data-nav
initial={false}
animate={{ y: compact ? "0%" : "-110%" }}
transition={{ duration: 0.5, ease }}
className="fixed inset-x-0 top-0 z-50 border-b border-t-fg/10 bg-t-bg/85 text-t-fg backdrop-blur-xl"
>
<div className={cn(container, "flex h-14 items-center justify-between gap-6")}>
<Brand brand={brand} className="text-xl md:text-xl" />
<nav className="hidden gap-7 text-sm md:flex">
{links.map((link) => (
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-fg">
{link.label}
</a>
))}
</nav>
<MenuButton open={open} onClick={() => setOpen(true)} className="md:hidden" />
</div>
</motion.div>
<MobileMenu open={open} onClose={() => setOpen(false)} links={links} brand={brand} cta={{ label: data.cta, href: data.href }} />
</>
)
}