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
111 changes: 91 additions & 20 deletions configurator/src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { SlidersHorizontal, Eye, RotateCcw } from '@lucide/svelte';
import { SlidersHorizontal, Eye, RotateCcw, ChevronDown } from '@lucide/svelte';
import type { PreviewTemplate, SlashedToken, ApiIndex } from './types';
import { PANEL_TO_TAB } from './lib/preview';
import StudioHeader from './components/shell/StudioHeader.svelte';
Expand Down Expand Up @@ -51,6 +51,32 @@

let domain = $state("home");
let showPalette = $state(false);
// Mobile category drawer (replaces the cramped icon rail on narrow screens).
let navDrawerOpen = $state(false);
// 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?.(); } };
}
// 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.
function onDrawerKeydown(e: KeyboardEvent) {
if (e.key === "Escape") { navDrawerOpen = false; return; }
if (e.key !== "Tab") return;
const root = e.currentTarget as HTMLElement;
const items = [...root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)].filter((el) => el.offsetParent !== null);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
}
// One-shot deep-link request from search: navigate to a domain AND focus a
// specific token's row in its All-tokens list. The nonce lets the same token
// be re-focused (a second search for it still scrolls/highlights).
Expand All @@ -59,17 +85,6 @@
// Transient feedback after an import (the old flow failed silently).
let importStatus = $state<string | null>(null);
let importStatusTimer: ReturnType<typeof setTimeout> | null = null;

function navigateTo(domainId: string, token?: string) {
domain = domainId;
if (token) {
focusNonce += 1;
focusRequest = { token, nonce: focusNonce };
} else {
focusRequest = null;
}
mobileView = "controls";
}
// On narrow screens the controls panel and the live preview can't both fit, so
// we show one at a time and let the user fold between them (desktop shows both).
let mobileView = $state<"controls" | "preview">("controls");
Expand Down Expand Up @@ -269,6 +284,11 @@

onMount(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && navDrawerOpen) {
e.preventDefault();
navDrawerOpen = false;
return;
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === "z") {
e.preventDefault();
handleUndo();
Expand Down Expand Up @@ -349,11 +369,13 @@

<!-- Main body: sidebar + left panel + preview -->
<div class="flex flex-1 min-h-0">
<!-- Icon nav rail — hidden on mobile while the preview is folded open -->
<div class={`shrink-0 ${mobileView === "preview" ? "hidden md:flex" : "flex"}`}>
<!-- 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">
<SidebarNav
activeId={domain}
onSelect={(d) => { navigateTo(d); }}
onSelect={(d) => { domain = d; focusRequest = null; }}
overridesByDomain={domainBadges}
/>
</div>
Expand All @@ -366,9 +388,26 @@
}`}>
<!-- Panel heading -->
<div class="h-9 flex items-center px-4 border-b border-black/6 dark:border-white/6 shrink-0 gap-2">
<span data-testid="panel-heading" class="text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-widest flex-1">
{DOMAIN_LABELS[domain] ?? domain}
</span>
<!-- On mobile this is the category-drawer trigger (chevron); on desktop
it's a static label (the rail handles navigation there). -->
<div data-testid="panel-heading" class="flex items-center gap-1.5 flex-1 min-w-0">
<!-- Mobile: the interactive category-drawer trigger. -->
<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"
>
<span class="text-[11px] font-bold text-slate-700 dark:text-slate-300 uppercase tracking-widest truncate">
{DOMAIN_LABELS[domain] ?? domain}
</span>
<ChevronDown class="w-3 h-3 text-slate-400 shrink-0" />
</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">
{DOMAIN_LABELS[domain] ?? domain}
</span>
</div>
{#if domainOverridesCount > 0}
<button
onclick={handleResetDomain}
Expand All @@ -393,7 +432,7 @@
onReset={handleReset}
onBulkChange={handleBulkChange}
onApplyTheme={handleApplyTheme}
onSelectDomain={(d) => { navigateTo(d); }}
onSelectDomain={(d) => { domain = d; focusRequest = null; }}
onResetAll={handleResetAll}
/>
</div>
Expand Down Expand Up @@ -425,13 +464,45 @@
domain={DOMAIN_LABELS[domain] ?? domain}
/>

<!-- Mobile category drawer — labelled, grouped navigation (the desktop rail
equivalent). md:hidden so it never appears on desktop. -->
{#if navDrawerOpen}
<div
class="md:hidden fixed inset-0 z-40 flex"
role="dialog"
aria-modal="true"
aria-label="Choose a panel"
tabindex="-1"
use:drawerFocus
onkeydown={onDrawerKeydown}
>
<div class="w-64 max-w-[80vw] h-full shadow-2xl overflow-y-auto">
<SidebarNav
expanded
activeId={domain}
onSelect={(d) => { domain = d; navDrawerOpen = false; mobileView = "controls"; focusRequest = null; }}
overridesByDomain={domainBadges}
/>
</div>
<button
class="flex-1 h-full bg-black/50 backdrop-blur-sm cursor-pointer"
aria-label="Close menu"
onclick={() => { navDrawerOpen = false; }}
></button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
{/if}

<!-- Command palette -->
{#if showPalette}
<CommandPalette
tokens={ALL_TOKENS}
{overrides}
onNavigate={(d, token) => {
navigateTo(d, token);
domain = d;
if (token) { focusNonce += 1; focusRequest = { token, nonce: focusNonce }; }
else focusRequest = null;
// On mobile, deep-linking into a token means we want the controls side.
mobileView = "controls";
}}
onClose={() => { showPalette = false; }}
/>
Expand Down
12 changes: 2 additions & 10 deletions configurator/src/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,11 @@

// Navigation destinations — makes this a real command palette (jump to any
// panel/tool), not just a token search.
const NAV_ALIASES: Record<string, string[]> = {
borders: ["border", "radius", "shape"], shadows: ["shadow", "depth"],
effects: ["effect"], wcag: ["accessibility", "contrast"],
themes: ["theme", "preset"], setup: ["install", "export"],
cheatsheet: ["reference", "classes"], misc: ["system"],
};
const NAV = [
"home", "colors", "typography", "spacing", "borders", "motion",
"layout", "depth", "macros", "components", "misc",
"changes", "wcag", "themes", "setup", "cheatsheet",
].map((id) => ({ id, label: DOMAIN_LABELS[id] ?? id, terms: [id, ...(NAV_ALIASES[id] ?? [])] }));
].map((id) => ({ id, label: DOMAIN_LABELS[id] ?? id }));

type Result =
| { kind: "nav"; id: string; label: string }
Expand All @@ -44,9 +38,7 @@
const q = query.trim().toLowerCase();
// Navigation matches (all destinations when empty, so the palette is useful
// before typing).
const nav: Result[] = (q ? NAV.filter((n) =>
n.label.toLowerCase().includes(q) || n.terms.some((term) => term.includes(q))
) : NAV)
const nav: Result[] = (q ? NAV.filter((n) => n.label.toLowerCase().includes(q)) : NAV)
.map((n) => ({ kind: "nav", id: n.id, label: n.label }));

const tokenMatches: Result[] = [];
Expand Down
39 changes: 24 additions & 15 deletions configurator/src/components/shell/SidebarNav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
Puzzle, Component, SwatchBook, ShieldCheck, Package, BookOpen, ListChecks,
} from '@lucide/svelte';

let { activeId, onSelect, overridesByDomain = {} }: {
let { activeId, onSelect, overridesByDomain = {}, expanded = false }: {
activeId: string;
onSelect: (id: string) => void;
overridesByDomain?: Record<string, number>;
/** Force the labelled layout at all widths (used inside the mobile drawer). */
expanded?: boolean;
} = $props();

// 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; the
// labels are shown on desktop and collapse to tooltips on mobile.
// of decoding an unlabelled icon rail. `home` sits above the groups.
type NavItem = { id: string; icon: typeof Home; label: string };
const HOME: NavItem = { id: "home", icon: Home, label: "Home" };
const GROUPS: { label: string; items: NavItem[] }[] = [
Expand Down Expand Up @@ -62,7 +63,11 @@
</script>

<nav
class="w-14 md:w-52 bg-slate-50 dark:bg-[#0a0a0f] border-r border-black/8 dark:border-white/8 flex flex-col items-center md:items-stretch py-3 gap-1 shrink-0 overflow-y-auto overflow-x-hidden"
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"
}`}
aria-label="Panels"
>
{#snippet navButton(item: NavItem)}
Expand All @@ -73,51 +78,55 @@
onclick={() => onSelect(item.id)}
title={item.label}
aria-current={isActive ? "page" : undefined}
class={`relative flex items-center justify-center md:justify-start gap-2.5 w-10 md:w-full h-10 md:h-8 px-0 md:px-2.5 rounded-xl transition-all cursor-pointer group ${
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"
} ${
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="hidden md:block text-[11px] font-semibold truncate">{item.label}</span>
<span class={`${expanded ? "block" : "hidden md:block"} text-[11px] font-semibold truncate`}>{item.label}</span>

{#if count > 0}
<!-- Mobile: corner dot. Desktop: trailing count pill. -->
<!-- Collapsed rail: corner dot. Labelled layout: trailing count pill. -->
<span
class={`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" : "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 ${
isActive ? "bg-white text-indigo-700" : "bg-indigo-500 text-white"
}`}
>
{count > 9 ? "+" : count}
</span>
<span
class={`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 md: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"
}`}
>
{count > 99 ? "99+" : count}
</span>
{/if}

<!-- Tooltip: mobile only (desktop shows the label inline). -->
<span class="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">
<!-- 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`}>
{item.label}
</span>
</button>
{/snippet}

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

{#each GROUPS as group (group.label)}
<div class="w-8 md:w-auto md:mx-2.5 h-px bg-black/8 dark:bg-white/8 my-2"></div>
<div class="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 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`}>
{group.label}
</div>
<div class="flex flex-col items-center md:items-stretch gap-1 w-full px-2">
<div class={`flex flex-col gap-1 w-full px-2 ${expanded ? "items-stretch" : "items-center md:items-stretch"}`}>
{#each group.items as item (item.id)}
{@render navButton(item)}
{/each}
Expand Down