import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Link } from "react-router"; import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher"; import { Brand } from "./Brand"; import { EuBadge } from "./EuBadge"; 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(", "); 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. */ export function MobileNav({ items, pathname, search, hash, locale, strings, surface, homeHref, showLanguageSwitcher, className, }: MobileNavProps) { const [open, setOpen] = 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), []); /* 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 (the drawer is itself rendered inside the header, so inerting the header would inert the drawer), every ancestor up to has its OTHER children inerted. `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, ); return ( <> ); }