236 lines
9.1 KiB
TypeScript
236 lines
9.1 KiB
TypeScript
"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<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;
|