"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { currentNavHref, type ChromeLink } from "@/lib/i18n/links";
import { Icon } from "./icon";
import { ArrowLabel } from "./arrow-label";
export function MobileMenu({
links,
moreLinks,
installHref,
installLabel,
signInHref,
signInLabel,
openLabel,
closeLabel,
navAria,
}: {
links: ChromeLink[];
/** The second group on the sheet: start, install, FAQ, community, contribute. */
moreLinks: ChromeLink[];
installHref: string;
installLabel: string;
signInHref: string;
signInLabel: string;
openLabel: string;
closeLabel: string;
/** Accessible name for the dialog's navigation landmark. */
navAria: string;
}) {
const [open, setOpen] = useState(false);
// `closing` holds the panel mounted for a short exit fade; the unmount —
// and the focus hand-back in the effect below — then happens on the
// timeout, not on the click.
const [closing, setClosing] = useState(false);
const closeTimer = useRef<number | null>(null);
const pathname = usePathname();
// One link is the page; ancestors are not. See currentNavHref. Both
// groups compete for the one current mark.
const currentHref = currentNavHref([...links, ...moreLinks], pathname);
const toggleRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const closeImmediately = useCallback(() => {
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
closeTimer.current = null;
setOpen(false);
setClosing(false);
}, []);
const close = useCallback(() => {
// Reduced motion keeps the original instant mount/unmount.
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
closeImmediately();
return;
}
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
setClosing(true);
closeTimer.current = window.setTimeout(() => {
closeTimer.current = null;
setOpen(false);
setClosing(false);
}, 180);
}, [closeImmediately]);
const onToggle = () => {
if (!open) {
setOpen(true);
return;
}
if (closing) {
// Re-open mid-exit: cancel the pending unmount and stay open.
window.clearTimeout(closeTimer.current ?? undefined);
closeTimer.current = null;
setClosing(false);
return;
}
close();
};
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
// aria-modal promises the dialog owns interaction. Keep background roots
// inert, contain keyboard focus, and hand it back to the toggle on close.
const dialog = menuRef.current;
const backgroundRoots = Array.from(document.body.children)
.filter((element): element is HTMLElement =>
element instanceof HTMLElement && element !== dialog)
.map((element) => ({ element, wasInert: element.inert }));
for (const { element } of backgroundRoots) element.inert = true;
// The toggle node is captured now: reading toggleRef.current inside the
// cleanup would race React clearing the ref.
const toggle = toggleRef.current;
const focusable = () => Array.from(dialog?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? []).filter((element) => element.getClientRects().length > 0);
focusable()[0]?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
close();
return;
}
if (e.key !== "Tab") return;
const candidates = focusable();
const first = candidates[0];
const last = candidates[candidates.length - 1];
if (!first || !last) {
e.preventDefault();
return;
}
const active = document.activeElement;
if (e.shiftKey && (active === first || !dialog?.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !dialog?.contains(active))) {
e.preventDefault();
first.focus();
}
};
// Tailwind's lg boundary hides the compact controls. Close immediately
// when a live resize crosses it so an invisible sheet cannot retain the
// body's scroll lock.
const desktop = window.matchMedia("(min-width: 1024px)");
const onDesktop = (event: MediaQueryListEvent | MediaQueryList) => {
if (event.matches) closeImmediately();
};
window.addEventListener("keydown", onKey);
desktop.addEventListener("change", onDesktop);
if (desktop.matches) closeImmediately();
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
desktop.removeEventListener("change", onDesktop);
for (const { element, wasInert } of backgroundRoots) element.inert = wasInert;
if (toggle?.getClientRects().length) toggle.focus();
};
}, [close, closeImmediately, open]);
// A pending exit timer must not outlive the component (locale switches
// remount the nav).
useEffect(() => {
return () => {
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
};
}, []);
return (
<>
<button
ref={toggleRef}
type="button"
onClick={onToggle}
className="nav-icon-button lg:hidden"
aria-label={open ? closeLabel : openLabel}
aria-expanded={open}
aria-controls="mobile-menu"
>
<Icon name={open ? "x" : "menu"} className="nav-icon" />
</button>
{open && typeof document !== "undefined" &&
createPortal(<div
ref={menuRef}
id="mobile-menu"
className={`mm-panel lg:hidden fixed inset-0 z-40 bg-canvas overflow-y-auto${closing ? " mm-closing" : ""}`}
role="dialog"
aria-modal="true"
aria-label={navAria}
>
<div className="flex min-h-[3.85rem] items-center justify-end px-4 hairline-b">
<button type="button" onClick={close} className="nav-icon-button" aria-label={closeLabel}>
<Icon name="x" className="nav-icon" />
</button>
</div>
{/* Only one nav landmark is exposed at a time (the desktop nav is
display:none at these widths), so the named dialog carries the
landmark name and the inner nav stays unlabeled — two nested
"Primary" landmarks would read as duplication. */}
<nav className="px-6 py-4">
<ul className="mm-list">
{[...links, ...moreLinks].map((l) => {
const isActive = l.href === currentHref;
return (
<li key={l.href}>
<Link
href={l.href}
onClick={() => setOpen(false)}
className="mm-link"
aria-current={isActive ? "page" : undefined}
>
<span>{l.label}</span>
<Icon name="chevron-right" className="nav-icon mm-link-chevron icon-flip" />
</Link>
</li>
);
})}
</ul>
<Link
href={installHref}
onClick={() => setOpen(false)}
className="mm-action paper-install-cta"
>
<ArrowLabel text={installLabel} />
</Link>
<Link
href={signInHref}
data-usage="login"
onClick={() => setOpen(false)}
className="mm-action paper-auth-signin"
>
{signInLabel}
</Link>
</nav>
</div>, document.body)}
</>
);
}