From 2c51c35fe8aba4b9ea1bdf8caed8d85dafa8f54d Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Sun, 20 Sep 2026 11:14:29 +0200 Subject: [PATCH] Fix the mobile navigation drawer, which painted a 64px strip The bar carries 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). The drawer was rendered inside that header, so its `fixed inset-0` resolved against the 64px bar rather than the viewport. Measured on the built page at 390x844, the panel came out 390x64: a strip of drawer background at the top, the menu spilling over the hero with nothing painted behind it, and the page underneath scroll-locked. The header is also a stacking context (z-index: var(--layer-header)), so an aria-modal dialog nested inside it could never rise above anything painted above the bar. The drawer is now portalled to , which resolves the containing block and the stacking context at once, and is where a modal dialog belongs regardless. The portal mounts one commit after hydration so the first client render still matches the server's; nothing is lost, because the trigger is a button with an onClick - without JavaScript the drawer could not be opened at all - and every link in it already ships in the server-rendered DesktopNav markup. The inert walk benefits too. It inerts each ancestor's OTHER children up to , which previously could not reach the header without inerting the drawer inside it; from the portal, body's other children are exactly the header, main and footer. Also gives the hamburger and its close twin a 44x44 hit area. They were 24x24, under the WCAG 2.5.5 target size; a logical negative inline-end margin keeps the glyph on the same page gutter the wordmark opposite it uses, so it still mirrors in Arabic. Verified in a headless browser against the production build at 390x844: the panel fills the viewport in both locales and mirrors under dir="rtl", focus moves to Close on open and back to the trigger on Escape, the scroll lock and the inert flags are set and cleared, and the desktop nav is unchanged - three group triggers and a working dropdown at 1440px, the hamburger at 1279px. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ui/Navigation/MobileNav.tsx | 268 ++++++++++-------- 1 file changed, 150 insertions(+), 118 deletions(-) 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} ); }