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 <body>, 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
<body>, 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 11:14:29 +02:00
co-authored by Claude Opus 5
parent 19fab4a783
commit 2c51c35fe8
@@ -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<HTMLElement>(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 <body>, 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<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const drawerRef = useRef<HTMLDivElement>(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 <body> 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 <body> 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 = (
<div
id={drawerId}
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={strings.primaryNavLabel}
hidden={!open}
data-surface={surface}
onKeyDown={handleDrawerKeyDown}
className={cx(
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
"bg-[var(--surface-page)] xl:hidden",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(TAP_TARGET, FOCUS_RING)}
>
<MenuBars open />
</button>
</div>
<nav
aria-label={strings.primaryNavLabel}
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
>
<ul className="flex flex-col gap-xl">
{items.map((item) =>
isNavGroup(item) ? (
<li key={item.id}>
<h2
id={headingId(item.id)}
className="body-md-bold text-[var(--text-primary)]"
>
{item.label}
</h2>
<ul
aria-labelledby={headingId(item.id)}
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
onClick={close}
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md text-[var(--text-secondary)]",
)}
>
{child.label}
</Link>
</li>
))}
</ul>
</li>
) : (
<li key={item.id}>
<Link
to={item.href}
onClick={close}
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md-bold text-[var(--text-primary)]",
)}
>
{item.label}
</Link>
</li>
),
)}
</ul>
</nav>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
{showLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
pathname={pathname}
search={search}
hash={hash}
size="md"
/>
)}
</div>
</div>
);
return (
<>
<button
@@ -224,123 +367,12 @@ export function MobileNav({
aria-controls={drawerId}
aria-label={open ? strings.closeMenu : strings.openMenu}
onClick={() => setOpen((value) => !value)}
className={cx(
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control xl:hidden",
FOCUS_RING,
className,
)}
className={cx(TAP_TARGET, "xl:hidden", FOCUS_RING, className)}
>
<MenuBars />
</button>
<div
id={drawerId}
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={strings.primaryNavLabel}
hidden={!open}
data-surface={surface}
onKeyDown={handleDrawerKeyDown}
className={cx(
"fixed inset-0 z-[var(--layer-modal)] flex flex-col",
"bg-[var(--surface-page)] xl:hidden",
)}
>
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<button
ref={closeRef}
type="button"
aria-label={strings.closeMenu}
onClick={close}
className={cx(
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
FOCUS_RING,
)}
>
<MenuBars open />
</button>
</div>
<nav
aria-label={strings.primaryNavLabel}
className="flex-1 overflow-y-auto px-[var(--layout-gutter)] pt-md pb-2xl"
>
<ul className="flex flex-col gap-xl">
{items.map((item) =>
isNavGroup(item) ? (
<li key={item.id}>
<h2
id={headingId(item.id)}
className="body-md-bold text-[var(--text-primary)]"
>
{item.label}
</h2>
<ul
aria-labelledby={headingId(item.id)}
className="mt-sm flex flex-col gap-2xs border-s border-[var(--border-subtle)] ps-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
onClick={close}
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md text-[var(--text-secondary)]",
)}
>
{child.label}
</Link>
</li>
))}
</ul>
</li>
) : (
<li key={item.id}>
<Link
to={item.href}
onClick={close}
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
className={cx(
linkClasses,
"body-md-bold text-[var(--text-primary)]",
)}
>
{item.label}
</Link>
</li>
),
)}
</ul>
</nav>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-md border-t border-[var(--border-subtle)] px-[var(--layout-gutter)] py-lg">
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
{showLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
pathname={pathname}
search={search}
hash={hash}
size="md"
/>
)}
</div>
</div>
{mounted ? createPortal(panel, document.body) : null}
</>
);
}