feat(#90): add hamburger menu and mobile nav drawer below 640px #104
Reference in New Issue
Block a user
Delete Branch "feature/90-mobile-hamburger-nav"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #90
Summary
hidden sm:flex) — users below 640px had zero navigationsm:hidden) with inline SVG hamburger/X icons and full ARIA (aria-label,aria-expanded,aria-controls)top-[68px],z-40) with full-width nav links (min-h-[44px] touch targets each), backdrop-click to close, Escape key to close, and$effectauto-close on route changeTest plan
aria-expandedstate on the toggle buttonLeonie Voss (@leonievoss) — UI/UX Review
I ran a full Playwright test suite against this PR at 320px viewport and also reviewed the code. Here's what I found.
What passes ✓
aria-expandedtoggles correctly on click ✓aria-labelswitches between "Menü öffnen" / "Menü schließen" ✓The structure and ARIA wiring are solid. The intentions are right.
Critical bug — drawer resets immediately at
/🔴4 of 11 E2E tests fail. The drawer does open — I can see it in the test screenshot for the
aria-expandedtest (X icon, all four nav links visible). But by the time any assertion on#mobile-navruns, the element is gone from the DOM and the button is back to ≡. Something resetsmobileNavOpen = falsewithin milliseconds of it being set totrue.The culprit is the
$effectinAppNav.svelte:The intention is correct — close the nav after route changes. But
pagefrom$app/stateis a deeply reactive proxy. At the home page (/), SvelteKit appears to trigger a reactive update topage(possibly from the document searchloadfunction completing its client-side revalidation, or from Paraglide updating locale metadata) that re-evaluatespage.url.pathnameafter the user's click. This re-runs the$effectand resetsmobileNavOpen = false.This does NOT happen at
/persons(the persons list is simpler) — that's why the active-route test at/personspasses while the equivalent test at/fails.Fix: Replace the
$effectwithafterNavigate, which only fires on real navigation events, not on reactive re-renders ofpage:This is also cleaner semantics — it says exactly what it means: "close the nav after navigating."
Medium —
block flexis redundant on every nav link 🟡All four nav links in the mobile drawer use:
flexsetsdisplay: flex.blocksetsdisplay: block. They fight over the same CSS property;flexwins because it appears later in Tailwind's generated output.blockis dead code here. Remove it from all four links.Medium — Escape key handler won't fire from outside the overlay 🟡
onkeydownon a<div>only catches keyboard events from elements inside that div. When the drawer opens, focus remains on the hamburger button (which is outside the overlay). Pressing Escape fires on the button → bubbles to the<header>→ stops there. It never reaches this div.This means the Escape key does nothing. The test confirms it: the drawer never closes.
Fix option A — add a global keydown listener in an
$effect:Fix option B — move focus into the panel on open (also better for screen readers):
Low — suppressed a11y warnings on the backdrop 🟡
Suppressing the warning doesn't fix the underlying issue: keyboard users and screen reader users who don't have a pointer cannot click a
<div>. Once the Escape key handler is fixed (see above), this becomes less critical — keyboard users will have their dismissal path. But the ignore comments should be accompanied by a code comment explaining why it's acceptable (backdrop click is a progressive enhancement; keyboard path is via Escape).z-index note — no bug, but worth knowing
The header has
z-50, the overlay hasz-40. The overlay is a DOM descendant of the header, so it lives inside the header's stacking context.position: fixedtakes it out of layout flow, but NOT out of the stacking context. In practice this is fine — the overlay sits below the header visually (starts attop-[68px]) and above the page content. But if any future<main>content creates a stacking context withz-index > 40, the overlay could appear behind it. Consider moving the overlay render to a portal or raising it toz-50to match the header.Summary
/— 4 tests failAppNav.svelte:11–18$effectwithafterNavigateblock flexon all four mobile nav linksAppNav.svelte:145,155,165,177blockAppNav.svelte:134documentlistener or focus managementAppNav.svelte:138The hamburger bones are good. Touch targets, ARIA wiring, and desktop/mobile breakpoint split are all correct. Fix
afterNavigateand this is shippable.