diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 38ec8a9d..4c33c001 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -53,13 +53,50 @@ let showPalette = $state(false); // Mobile category drawer (replaces the cramped icon rail on narrow screens). let navDrawerOpen = $state(false); + // The shell root — also our container-query context (see the wrapper below). + // A ResizeObserver on it reconciles the drawer when the *container* (not the + // viewport) crosses the desktop breakpoint: `@3xl:hidden` would otherwise only + // CSS-hide an open drawer, leaving a hidden aria-modal dialog mounted and its + // focus-restore (use:drawerFocus) un-run. Container width, not viewport, is + // what actually decides whether the persistent rail is showing. + let shellEl = $state(null); + // The desktop icon rail wrapper — used as a focus fallback when a breakpoint + // close hides the drawer's original trigger (see drawerFocus above). + let railEl = $state(null); + // Mirror of the `@3xl` container breakpoint, expressed in rem so it tracks the + // CSS even when a host sets a non-16px root font size. `rem` (and therefore + // Tailwind's container breakpoints) resolve against the *root* font size, so + // convert to px against that — a hardcoded 768 would drift from `@3xl` the + // moment `html { font-size }` isn't the 16px default. + const DESKTOP_SHELL_REM = 48; + function desktopShellPx(): number { + const root = + typeof document !== "undefined" + ? parseFloat(getComputedStyle(document.documentElement).fontSize) + : NaN; + return DESKTOP_SHELL_REM * (Number.isFinite(root) && root > 0 ? root : 16); + } // When the drawer (a modal dialog) opens, move focus into it so keyboard users // land inside the modal — the dialog's Escape handler then receives the event, // and screen readers announce it. On close, focus returns to the trigger. function drawerFocus(node: HTMLElement) { const prev = document.activeElement as HTMLElement | null; node.focus(); - return { destroy() { prev?.focus?.(); } }; + return { + destroy() { + // Restore focus to whatever was focused before the drawer opened — + // normally the mobile trigger. But when the drawer closes because the + // shell reached desktop width (the ResizeObserver below), that trigger + // is now `@3xl:hidden` (offsetParent === null); focusing a hidden node + // drops focus to . In that case move focus to the visible desktop + // rail's current item instead so keyboard focus stays in the nav. + if (prev && prev.offsetParent !== null) { prev.focus?.(); return; } + const target = + railEl?.querySelector('[aria-current="page"]') ?? + railEl?.querySelector("button"); + target?.focus?.(); + }, + }; } // Close on Escape and trap Tab/Shift+Tab inside the modal drawer so keyboard // focus can't wander to the controls behind an aria-modal dialog. @@ -331,8 +368,25 @@ } }; window.addEventListener("keydown", handler); + + // Close the mobile drawer once the shell is wide enough to show the + // persistent rail. Without this, growing the container past the breakpoint + // (e.g. an embedded host resizing, or rotating to landscape) merely + // `@3xl:hidden`s an open drawer — the aria-modal dialog stays in the DOM and + // use:drawerFocus never restores focus to the trigger. Observing the shell's + // own box (our @container element) matches exactly what the CSS reacts to. + let ro: ResizeObserver | undefined; + if (shellEl && typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 0; + if (width >= desktopShellPx() && navDrawerOpen) navDrawerOpen = false; + }); + ro.observe(shellEl); + } + return () => { window.removeEventListener("keydown", handler); + ro?.disconnect(); if (saveStateTimer) clearTimeout(saveStateTimer); if (importStatusTimer) clearTimeout(importStatusTimer); }; @@ -354,7 +408,15 @@ {/snippet} -
+ +