import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { createPortal } from "react-dom"; import { Link } from "react-router"; import { EuBadge } from "../EuBadge"; import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher"; import { Brand } from "./Brand"; import { MenuBars } from "./MenuBars"; import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles"; import { isNavGroup, type NavItem, type NavigationStrings, type NavSurface } from "./types"; /** Must stay in step with the `xl:` variants below (Tailwind xl = 80rem). */ const DESKTOP_MEDIA_QUERY = "(min-width: 80rem)"; const FOCUSABLE_SELECTOR = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", '[tabindex]:not([tabindex="-1"])', ].join(", "); /** * The hamburger and its close twin. The glyph is the drafts' 24px box, which is * well under the 44x44 WCAG 2.5.5 target size, so the control is a 44px square * around it. The negative inline-end margin gives back the 10px of padding that * would otherwise push the glyph off the page gutter the wordmark opposite it * lines up with - logical, so it mirrors in Arabic. */ const TAP_TARGET = cx( "-me-[0.625rem] inline-flex size-11 shrink-0 cursor-pointer", "items-center justify-center rounded-control", ); function focusablesIn(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0, ); } export interface MobileNavProps { items: readonly NavItem[]; pathname: string; search?: string; hash?: string; locale: Locale; strings: NavigationStrings; surface: NavSurface; /** Locale-prefixed home path for the logo inside the drawer. */ homeHref: string; showLanguageSwitcher: boolean; className?: string; } /** * Hamburger trigger plus the full-screen drawer it opens. * * While the drawer is open, focus is trapped inside it, everything behind it is * `inert` (so assistive technology cannot browse the page under an aria-modal * dialog either), the page behind cannot scroll, Escape closes it, and focus * returns to the hamburger on close. * * THE DRAWER IS PORTALLED TO , AND HAS TO BE. * The bar it hangs off paints the drafts' translucent ground with * `backdrop-filter: blur(14px)`, and a filtered element becomes the containing * block for every `position: fixed` descendant (CSS Filter Effects 1 s.6.1). * Rendered in place, `fixed inset-0` therefore resolved against the 64px bar * rather than the viewport: the panel painted a 64px strip and its menu spilled * over the page with no background behind it. The header also carries * `z-index: var(--layer-header)`, which is a stacking context, so an * `aria-modal` dialog nested inside it could never rise above anything painted * above the bar either. A portal fixes both at once, and it is where a modal * dialog belongs regardless. * * The portal is client-only, so the panel joins the DOM on mount. Nothing is * lost: the trigger is a button with an onClick, so without JavaScript the * drawer could not be opened anyway, and every link in it is already in the * server-rendered DesktopNav markup. */ export function MobileNav({ items, pathname, search, hash, locale, strings, surface, homeHref, showLanguageSwitcher, className, }: MobileNavProps) { const [open, setOpen] = useState(false); /* The portal target only exists in a browser, and the first client render has to match the server's, so the panel is mounted one commit later. */ const [mounted, setMounted] = useState(false); const triggerRef = useRef(null); const closeRef = useRef(null); const drawerRef = useRef(null); const wasOpen = useRef(false); const rawId = useId(); const baseId = `sharper-drawer-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`; const drawerId = `${baseId}-panel`; const headingId = (groupId: string) => `${baseId}-${groupId}-heading`; const close = useCallback(() => setOpen(false), []); useEffect(() => setMounted(true), []); /* Focus in on open, and back to the hamburger on close. */ useEffect(() => { if (open) { wasOpen.current = true; closeRef.current?.focus(); } else if (wasOpen.current) { wasOpen.current = false; triggerRef.current?.focus(); } }, [open]); /* The drawer is aria-modal, so the rest of the page has to be inert as well - otherwise a screen reader still browses the header, main and footer behind it. Rather than naming those landmarks, every ancestor up to has its OTHER children inerted; from the portal that is simply body's other children, which is exactly the header, main and footer. `inert` is set as an attribute so no DOM-lib typing is needed, and an element that was already inert is skipped so the restore never clears someone else's flag. This runs after the focus effect above on purpose: focus is already inside the drawer by the time the element it came from goes inert. */ useEffect(() => { const drawer = drawerRef.current; if (!open || !drawer) return; const inerted: Element[] = []; let node: Element | null = drawer; while (node !== null) { const parent: HTMLElement | null = node.parentElement; if (parent === null) break; const siblings: Element[] = Array.from(parent.children); for (const sibling of siblings) { if (sibling === node || sibling.hasAttribute("inert")) continue; sibling.setAttribute("inert", ""); inerted.push(sibling); } node = parent === document.body ? null : parent; } return () => { for (const element of inerted) element.removeAttribute("inert"); }; }, [open]); /* Body scroll lock. The scrollbar's width is handed back as inline-end padding so the page behind does not jump sideways when it disappears. */ useEffect(() => { if (!open) return; const { body, documentElement } = document; const previousOverflow = body.style.overflow; const previousPadding = body.style.paddingInlineEnd; const scrollbar = window.innerWidth - documentElement.clientWidth; body.style.overflow = "hidden"; if (scrollbar > 0) body.style.paddingInlineEnd = `${scrollbar}px`; return () => { body.style.overflow = previousOverflow; body.style.paddingInlineEnd = previousPadding; }; }, [open]); /* Keeps focus inside the drawer even when something moves it programmatically (the Tab handler below covers ordinary keyboard travel). */ useEffect(() => { if (!open) return; const handleFocusIn = (event: FocusEvent) => { const drawer = drawerRef.current; if (drawer && !drawer.contains(event.target as Node)) { focusablesIn(drawer)[0]?.focus(); } }; document.addEventListener("focusin", handleFocusIn); return () => document.removeEventListener("focusin", handleFocusIn); }, [open]); /* The drawer is desktop-hidden; close it rather than stranding a locked body if the viewport grows past the breakpoint while it is open. */ useEffect(() => { if (!open || typeof window.matchMedia !== "function") return; const query = window.matchMedia(DESKTOP_MEDIA_QUERY); if (query.matches) { setOpen(false); return; } const handleChange = (event: MediaQueryListEvent) => { if (event.matches) setOpen(false); }; query.addEventListener("change", handleChange); return () => query.removeEventListener("change", handleChange); }, [open]); const handleDrawerKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); close(); return; } if (event.key !== "Tab") return; const drawer = drawerRef.current; if (!drawer) return; const stops = focusablesIn(drawer); if (stops.length === 0) { event.preventDefault(); return; } const first = stops[0]; const last = stops[stops.length - 1]; const active = document.activeElement; const inside = drawer.contains(active); if (event.shiftKey && (active === first || !inside)) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && (active === last || !inside)) { event.preventDefault(); first?.focus(); } }; const linkClasses = cx( "block rounded-control py-2xs no-underline", "hover:text-[var(--text-accent)]", "aria-[current=page]:text-[var(--text-accent)]", HOVER_TRANSITION, FOCUS_RING, ); const panel = ( ); return ( <> {mounted ? createPortal(panel, document.body) : null} ); }