Files
Anelia-Wolf/src/components/layout/OverlayMenu.tsx
2026-09-06 17:21:52 +02:00

236 lines
9.1 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 { 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
* 0105, 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<HTMLDivElement>(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 <AnimatePresence> 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<HTMLElement>("[data-menu-row]", el);
const foot = el.querySelector<HTMLElement>("[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<HTMLElement>("main, footer, header [data-menu-inert]"),
);
others.forEach((el) => {
el.inert = true;
});
const inPanel = () =>
Array.from(panel.current?.querySelectorAll<HTMLElement>("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 (
<div
ref={panel}
id="menue"
role="dialog"
aria-modal="true"
aria-label={chrome.header.menu.ariaLabel}
data-theme="light"
data-lenis-prevent=""
className="fixed inset-0 z-40 flex flex-col overflow-y-auto overscroll-contain bg-ivory text-ink"
>
<Container className="flex min-h-full flex-1 flex-col pb-10 pt-[calc(var(--header-h,60px)+24px)]">
<ol className="border-t border-line-light">
{chrome.menu.items.map((item) => (
<li key={item.href} className="overflow-hidden border-b border-line-light">
<div data-menu-row="">
<TransitionLink
href={item.href}
label={item.label}
onClick={() => onCloseRef.current()}
className="group flex items-baseline gap-5 py-5 lg:gap-8 lg:py-7"
>
<span className="font-mono text-xs tracking-[0.06em] tabular-nums text-gold-deep">{item.numeral}</span>
<span className="font-display text-[clamp(2rem,1.4rem+3vw,4rem)] leading-none text-ink transition-[font-style] group-hover:italic">
{item.label}
</span>
<span
aria-hidden="true"
className="ml-auto text-gold-deep transition-transform duration-300 group-hover:translate-x-2"
>
</span>
</TransitionLink>
</div>
</li>
))}
</ol>
<div
data-menu-foot=""
className="mt-auto flex flex-col gap-4 pt-12 lg:flex-row lg:items-end lg:justify-between"
>
<div className="flex flex-col gap-2">
<a href={site.phoneHref} className="font-display text-[1.75rem] leading-none text-ink">
{site.phoneDisplay}
</a>
<a href={`mailto:${site.email}`} className="font-sans text-sm text-grey-deep">
{site.email}
</a>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs tracking-[0.06em] text-grey-deep">
{/* Unter 1024 px versteckt der Kopf den Umschalter (dort ist kein Platz neben
der Wortmarke). Ohne diese Zeile gäbe es auf dem Telefon gar keinen Weg in
die andere Sprache er steht deshalb genau dort, wo er im Kopf fehlt. */}
{flags.showLangSwitch && <LangSwitch lang={lang} className="lg:hidden" />}
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className="hover:text-gold-deep">
{chrome.menu.linkedin} <span aria-hidden="true"></span>
</a>
<span>
{legal.map((l, i) => (
<span key={l.href}>
{i > 0 && <span aria-hidden="true"> · </span>}
<TransitionLink href={l.href} label={l.label} onClick={() => onCloseRef.current()} className="hover:text-gold-deep">
{l.label}
</TransitionLink>
</span>
))}
</span>
</div>
</div>
</Container>
</div>
);
}
export default OverlayMenu;