diff --git a/apps/web/app/components/ui/Navigation/MobileNav.tsx b/apps/web/app/components/ui/Navigation/MobileNav.tsx index a087ecf..941c1f7 100644 --- a/apps/web/app/components/ui/Navigation/MobileNav.tsx +++ b/apps/web/app/components/ui/Navigation/MobileNav.tsx @@ -6,6 +6,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; +import { createPortal } from "react-dom"; import { Link } from "react-router"; import { EuBadge } from "../EuBadge"; @@ -28,6 +29,18 @@ const FOCUSABLE_SELECTOR = [ '[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, @@ -55,6 +68,23 @@ export interface MobileNavProps { * `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, @@ -69,6 +99,9 @@ export function MobileNav({ 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); @@ -81,6 +114,8 @@ export function MobileNav({ const close = useCallback(() => setOpen(false), []); + useEffect(() => setMounted(true), []); + /* Focus in on open, and back to the hamburger on close. */ useEffect(() => { if (open) { @@ -94,11 +129,11 @@ export function MobileNav({ /* 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. + 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. */ @@ -215,6 +250,114 @@ export function MobileNav({ FOCUS_RING, ); + const panel = ( + + ); + return ( <> - + {mounted ? createPortal(panel, document.body) : null} ); }