Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 73 additions & 10 deletions configurator/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>(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<HTMLElement | null>(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 <body>. 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<HTMLElement>('[aria-current="page"]') ??
railEl?.querySelector<HTMLElement>("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.
Expand Down Expand Up @@ -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);
};
Expand All @@ -354,7 +408,15 @@
</button>
{/snippet}

<div class="{embedded ? 'w-full h-full' : 'w-screen h-screen'} flex flex-col overflow-hidden bg-slate-50 dark:bg-[#0a0a0f] text-slate-800 dark:text-slate-200 font-sans">
<!-- @container: the shell is a size-query container so its inner layout adapts
to the width it's actually given, not the browser viewport. Standalone we
own the viewport (w-screen), but embedded hosts (WP admin page beside the
~160px admin menu, or a narrow slide-in panel) size us into a container far
narrower than the viewport — viewport `md:` rules would then force the full
desktop three-column layout into a box that can't hold it. The @3xl/… (48rem
= 768px) variants below query THIS element's width instead, so the same
768px threshold now measures the space we truly have. -->
<div bind:this={shellEl} class="{embedded ? 'w-full h-full' : 'w-screen h-screen'} @container flex flex-col overflow-hidden bg-slate-50 dark:bg-[#0a0a0f] text-slate-800 dark:text-slate-200 font-sans">
<!-- Top header bar -->
<StudioHeader
{overrides}
Expand Down Expand Up @@ -385,7 +447,7 @@
visible without scrolling and doesn't compete with the status bar.
Hidden on tool screens, which have no preview to fold to. -->
{#if !hidePreview}
<div class="md:hidden flex items-stretch border-b border-black/8 dark:border-white/8 bg-slate-50 dark:bg-[#0d0d14] shrink-0">
<div class="@3xl:hidden flex items-stretch border-b border-black/8 dark:border-white/8 bg-slate-50 dark:bg-[#0d0d14] shrink-0">
{@render foldToggleButton("controls", SlidersHorizontal, "Controls")}
{@render foldToggleButton("preview", Eye, "Preview")}
</div>
Expand All @@ -396,7 +458,7 @@
<!-- Icon nav rail — desktop only. On mobile the category drawer (opened
from the panel heading) replaces it, so the narrow screen isn't eaten
by an unlabelled 56px strip. -->
<div class="shrink-0 hidden md:flex">
<div bind:this={railEl} class="shrink-0 hidden @3xl:flex">
<SidebarNav
activeId={domain}
onSelect={(d) => { navigateTo(d); }}
Expand All @@ -408,7 +470,7 @@
on tool screens where the preview is hidden. On mobile it fills the row
(the icon rail already claims its own space). -->
<div class={`min-w-0 bg-slate-50 dark:bg-[#0c0c15] border-r border-black/8 dark:border-white/8 flex-col min-h-0 ${
hidePreview ? "flex-1 flex" : `flex-1 md:flex-none md:w-[360px] ${mobileView === "preview" ? "hidden md:flex" : "flex"}`
hidePreview ? "flex-1 flex" : `flex-1 @3xl:flex-none @3xl:w-[360px] ${mobileView === "preview" ? "hidden @3xl:flex" : "flex"}`
}`}>
<!-- Panel heading -->
<div class="h-9 flex items-center px-4 border-b border-black/6 dark:border-white/6 shrink-0 gap-2">
Expand All @@ -419,7 +481,7 @@
<button
onclick={() => { navDrawerOpen = true; }}
aria-label="Choose a panel"
class="md:hidden flex items-center gap-1.5 flex-1 min-w-0 text-left cursor-pointer"
class="@3xl:hidden flex items-center gap-1.5 flex-1 min-w-0 text-left cursor-pointer"
>
<span class="text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-widest truncate">
{DOMAIN_LABELS[domain] ?? domain}
Expand All @@ -428,7 +490,7 @@
</button>
<!-- Desktop: a static label (the rail handles navigation there); no
button, so it never enters the tab order or opens a hidden drawer. -->
<span class="hidden md:inline text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-widest truncate">
<span class="hidden @3xl:inline text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-widest truncate">
{DOMAIN_LABELS[domain] ?? domain}
</span>
</div>
Expand Down Expand Up @@ -466,7 +528,7 @@
entirely on tool screens (Changes / Presets / Install & export /
Reference), which have no live sample. -->
{#if !hidePreview}
<div class={`flex-1 flex-col min-h-0 min-w-0 ${mobileView === "controls" ? "hidden md:flex" : "flex"}`}>
<div class={`flex-1 flex-col min-h-0 min-w-0 ${mobileView === "controls" ? "hidden @3xl:flex" : "flex"}`}>
<PreviewPanel
{overrides}
{previewTheme}
Expand All @@ -489,10 +551,11 @@
/>

<!-- Mobile category drawer — labelled, grouped navigation (the desktop rail
equivalent). md:hidden so it never appears on desktop. -->
equivalent). @3xl:hidden so it never appears once the shell is wide
enough (>=768px) to show the persistent rail. -->
{#if navDrawerOpen}
<div
class="md:hidden fixed inset-0 z-40 flex"
class="@3xl:hidden fixed inset-0 z-40 flex"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Drawer state survives desktop transition

When an open drawer's container grows past 768px, @3xl:hidden hides the dialog without clearing navDrawerOpen, so the focus action is not destroyed, focus is not restored to the trigger, and a hidden aria-modal dialog remains mounted.

role="dialog"
aria-modal="true"
aria-label="Choose a panel"
Expand Down
31 changes: 21 additions & 10 deletions configurator/src/components/shell/SidebarNav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@
expanded?: boolean;
} = $props();

// Responsive note: the collapsed rail's icon-only↔labelled switch uses the
// `@3xl:` (48rem = 768px) *container* variant, so it reacts to the width of
// the nearest `@container` ancestor — the shell — not the browser viewport.
// That keeps the rail an unlabelled 56px strip inside narrow embeds (e.g. the
// WP frontend overlay's ~420px panel) even on a wide desktop viewport, and
// only expands to the 208px labelled layout when the host actually gives us
// ≥768px. Mount this component under an element carrying the `@container`
// utility, or pass `expanded` to force the labelled layout. Without any
// `@container` ancestor the `@3xl:` rules never match, so it safely defaults
// to the compact 56px rail.

// Grouped information architecture. Every destination lives under one of four
// named groups so a user reasons about *areas of the design system* instead
// of decoding an unlabelled icon rail. `home` sits above the groups.
Expand Down Expand Up @@ -66,7 +77,7 @@
class={`bg-slate-50 dark:bg-[#0a0a0f] flex flex-col py-3 gap-1 shrink-0 overflow-y-auto overflow-x-hidden ${
expanded
? "w-full items-stretch"
: "w-14 md:w-52 items-center md:items-stretch border-r border-black/8 dark:border-white/8"
: "w-14 @3xl:w-52 items-center @3xl:items-stretch border-r border-black/8 dark:border-white/8"
}`}
aria-label="Panels"
>
Expand All @@ -81,27 +92,27 @@
class={`relative flex items-center gap-2.5 rounded-xl transition-all cursor-pointer group ${
expanded
? "justify-start w-full h-9 px-2.5"
: "justify-center md:justify-start w-10 md:w-full h-10 md:h-8 px-0 md:px-2.5"
: "justify-center @3xl:justify-start w-10 @3xl:w-full h-10 @3xl:h-8 px-0 @3xl:px-2.5"
} ${
isActive
? "bg-indigo-600 text-white shadow-lg shadow-indigo-600/30"
: "text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-black/8 dark:hover:bg-white/8"
}`}
>
<Icon class="w-4 h-4 shrink-0" />
<span class={`${expanded ? "block" : "hidden md:block"} text-[11px] font-semibold truncate`}>{item.label}</span>
<span class={`${expanded ? "block" : "hidden @3xl:block"} text-[11px] font-semibold truncate`}>{item.label}</span>

{#if count > 0}
<!-- Collapsed rail: corner dot. Labelled layout: trailing count pill. -->
<span
class={`${expanded ? "hidden" : "md:hidden"} absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full text-[8px] font-black flex items-center justify-center ${
class={`${expanded ? "hidden" : "@3xl:hidden"} absolute -top-0.5 -right-0.5 w-3.5 h-3.5 rounded-full text-[8px] font-black flex items-center justify-center ${
isActive ? "bg-white text-indigo-700" : "bg-indigo-500 text-white"
}`}
>
{count > 9 ? "+" : count}
</span>
<span
class={`${expanded ? "flex" : "hidden md:flex"} ml-auto min-w-4 h-4 px-1 rounded-full text-[9px] font-black items-center justify-center ${
class={`${expanded ? "flex" : "hidden @3xl:flex"} ml-auto min-w-4 h-4 px-1 rounded-full text-[9px] font-black items-center justify-center ${
isActive ? "bg-white/25 text-white" : "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400"
}`}
>
Expand All @@ -110,23 +121,23 @@
{/if}

<!-- Tooltip: only for the collapsed icon rail (labels are inline otherwise). -->
<span class={`${expanded ? "hidden" : "md:hidden"} absolute left-12 bg-slate-800 text-white text-[10px] font-semibold px-2 py-1 rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-50 border border-white/10`}>
<span class={`${expanded ? "hidden" : "@3xl:hidden"} absolute left-12 bg-slate-800 text-white text-[10px] font-semibold px-2 py-1 rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-50 border border-white/10`}>
{item.label}
</span>
</button>
{/snippet}

<!-- Home -->
<div class={`flex flex-col gap-1 w-full px-2 ${expanded ? "items-stretch" : "items-center md:items-stretch"}`}>
<div class={`flex flex-col gap-1 w-full px-2 ${expanded ? "items-stretch" : "items-center @3xl:items-stretch"}`}>
{@render navButton(HOME)}
</div>

{#each GROUPS as group (group.label)}
<div class={`h-px bg-black/8 dark:bg-white/8 my-2 ${expanded ? "mx-2.5" : "w-8 md:w-auto md:mx-2.5"}`}></div>
<div class={`${expanded ? "block" : "hidden md:block"} px-3 pb-1 text-[9px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-600`}>
<div class={`h-px bg-black/8 dark:bg-white/8 my-2 ${expanded ? "mx-2.5" : "w-8 @3xl:w-auto @3xl:mx-2.5"}`}></div>
<div class={`${expanded ? "block" : "hidden @3xl:block"} px-3 pb-1 text-[9px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-600`}>
{group.label}
</div>
<div class={`flex flex-col gap-1 w-full px-2 ${expanded ? "items-stretch" : "items-center md:items-stretch"}`}>
<div class={`flex flex-col gap-1 w-full px-2 ${expanded ? "items-stretch" : "items-center @3xl:items-stretch"}`}>
{#each group.items as item (item.id)}
{@render navButton(item)}
{/each}
Expand Down