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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user