"use client"; import { usePathname } from "next/navigation"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { flags, legal as legalDe, site } from "@/lib/site"; import type { Lang } from "@/lib/i18n"; import { LangSwitch } from "./LangSwitch"; import { chrome as chromeDe, type Chrome } from "@/content/chrome"; import { gsap, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap"; import { TransitionLink } from "@/components/motion/TransitionLink"; import { Container } from "./Container"; export interface OverlayMenuProps { open: boolean; onClose: () => void; /** Texte der Hülle. Ohne Angabe die deutsche Fassung. */ chrome?: Chrome; /** Impressum/Datenschutz der jeweiligen Sprache. */ legal?: readonly { href: string; label: string }[]; /** Aktuelle Sprache – für den Umschalter im Fuß des Menüs. */ lang?: Lang; } /** The header's „Menü“ button – part of the dialog's operable set (see the trap below). */ export const MENU_TOGGLE_ID = "menue-toggle"; /** * Fullscreen ivory sheet from the top (0.5 s power3.inOut): numbered ledger * 01–05, contact row at the bottom. `data-lenis-prevent`, `lenis.stop()`, * focus trap, Escape closes, closes on route change, rest of the page `inert`. * * The trap spans the panel AND the header's toggle: the toggle stays visible on * top of the sheet (header z-50 > panel z-40), so it has to stay reachable by Tab * as well as by mouse. Every other header control is `inert` while the sheet is * open, so the mouse-operable and the keyboard-operable set are the same one. */ export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe, lang = "de" }: OverlayMenuProps) { const panel = useRef(null); const pathname = usePathname(); const onCloseRef = useRef(onClose); /* The panel has to outlive `open` for the length of its exit tween. State adjusted during render (the sanctioned pattern), not in an effect – an effect would cost a cascading render on every open. */ const [exiting, setExiting] = useState(false); const [prevOpen, setPrevOpen] = useState(open); if (prevOpen !== open) { setPrevOpen(open); setExiting(!open); } const mounted = open || exiting; useEffect(() => { onCloseRef.current = onClose; }); // Close on route change. const lastPath = useRef(pathname); useEffect(() => { if (lastPath.current !== pathname) { lastPath.current = pathname; onCloseRef.current(); } }, [pathname]); /* Enter / exit on GSAP (the site's only animation runtime). The panel is a single element, so an is not load-bearing – `exiting` keeps it in the DOM until the exit tween has run. */ useLayoutEffect(() => { const el = panel.current; if (!mounted || !el) return; const rows = gsap.utils.toArray("[data-menu-row]", el); const foot = el.querySelector("[data-menu-foot]"); const mm = gsap.matchMedia(); mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => { const reduce = Boolean(ctx.conditions?.reduce); if (open) { gsap.set(el, { yPercent: reduce ? 0 : -100 }); gsap.set(rows, { yPercent: reduce ? 0 : 100 }); gsap.set(foot, { opacity: reduce ? 1 : 0 }); if (reduce) return; const tl = gsap.timeline(); tl.to(el, { yPercent: 0, duration: 0.5, ease: EASE.inOut }, 0) .to(rows, { yPercent: 0, duration: 0.6, ease: EASE.out, stagger: 0.06 }, 0.25) .to(foot, { opacity: 1, duration: 0.6 }, 0.6); return; } /* Reduced motion still goes through the tween, at zero duration: `onComplete` must fire off the current render, never synchronously inside this effect. */ gsap.to(el, { yPercent: -100, duration: reduce ? 0 : 0.4, ease: EASE.inOut, onComplete: () => setExiting(false), }); }); return () => mm.revert(); }, [open, mounted]); useEffect(() => { if (!open) return; const lenis = getLenis(); lenis?.stop(); const previous = document.activeElement as HTMLElement | null; const toggle = document.getElementById(MENU_TOGGLE_ID) as HTMLElement | null; /* Everything the reader can still SEE but must no longer reach: the rest of the page plus the header's own links, which float above the ivory sheet. */ const others = Array.from( document.querySelectorAll("main, footer, header [data-menu-inert]"), ); others.forEach((el) => { el.inert = true; }); const inPanel = () => Array.from(panel.current?.querySelectorAll("a[href], button:not([disabled])") ?? []); /* The toggle first: it sits above the sheet, so Shift+Tab from the first ledger row lands on it and Tab from the last row cycles back to it. */ const focusables = () => (toggle ? [toggle, ...inPanel()] : inPanel()); const t = window.setTimeout(() => inPanel()[0]?.focus(), 50); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCloseRef.current(); return; } if (e.key !== "Tab") return; const items = focusables(); if (!items.length) return; const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement as HTMLElement | null; const inside = active ? items.includes(active) : false; if (e.shiftKey && (active === first || !inside)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (active === last || !inside)) { e.preventDefault(); first.focus(); } }; document.addEventListener("keydown", onKey); return () => { window.clearTimeout(t); document.removeEventListener("keydown", onKey); others.forEach((el) => { el.inert = false; }); lenis?.start(); previous?.focus?.(); refreshSoon(); }; }, [open]); if (!mounted) return null; return ( ); } export default OverlayMenu;