Skip to content

Commit 32f3d40

Browse files
thomasahleclaude
andcommitted
Replace Sheet drawer with shadcn Sidebar component for mobile nav
The layout now uses SidebarProvider + Sidebar + SidebarTrigger from shadcn-svelte with two key adaptations to our custom breakpoint (980px): - is-mobile.svelte.ts: default breakpoint 768 → 980 so isMobile matches our narrow layout threshold - sidebar.svelte: md: → narrow: CSS breakpoints so the panel shows at ≥980px and the drawer renders below 980px - app.css: @plugin tailwindcss-animate + sidebar color tokens mapped to our warm palette (bg-sidebar = bg-surface, etc.) - +layout.svelte: SidebarProvider wraps layout; Sidebar/SidebarContent hold nav; SidebarTrigger replaces custom toggle button - e2e/mobile-nav.spec.js: 5 Playwright tests all pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3934f0a commit 32f3d40

5 files changed

Lines changed: 317 additions & 151 deletions

File tree

e2e/mobile-nav.spec.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Mobile navigation drawer tests.
3+
* Tests the overlay drawer (shadcn Sidebar) that appears in narrow mode (<980px).
4+
*/
5+
import { test, expect } from '@playwright/test';
6+
7+
const NARROW = { width: 600, height: 900 };
8+
const WIDE = { width: 1280, height: 800 };
9+
10+
test.describe('mobile nav drawer', () => {
11+
test('sidebar is hidden in narrow mode initially', async ({ page }) => {
12+
await page.setViewportSize(NARROW);
13+
await page.goto('/');
14+
// No drawer open on load
15+
await expect(page.getByRole('dialog')).not.toBeVisible();
16+
});
17+
18+
test('sidebar trigger button opens the drawer in narrow mode', async ({ page }) => {
19+
await page.setViewportSize(NARROW);
20+
await page.goto('/');
21+
22+
await page.getByRole('button', { name: /toggle sidebar/i }).click();
23+
24+
// Drawer dialog should now be visible
25+
const dialog = page.getByRole('dialog');
26+
await expect(dialog).toBeVisible();
27+
28+
// Lesson buttons are accessible inside the drawer (scope to dialog to avoid ambiguity)
29+
await expect(dialog.getByRole('button', { name: /Welcome/ })).toBeVisible();
30+
});
31+
32+
test('clicking a lesson inside the drawer navigates', async ({ page }) => {
33+
await page.setViewportSize(NARROW);
34+
await page.goto('/');
35+
36+
// Open the drawer
37+
await page.getByRole('button', { name: /toggle sidebar/i }).click();
38+
const dialog = page.getByRole('dialog');
39+
await expect(dialog).toBeVisible();
40+
41+
// "Introduction" chapter is auto-expanded; click "Modules and Ports" scoped to dialog
42+
await dialog.getByRole('button', { name: /Modules and Ports/i }).click();
43+
44+
// Page navigated
45+
await expect(page.getByTestId('lesson-title')).toHaveText('Modules and Ports', { timeout: 10_000 });
46+
});
47+
48+
test('desktop sidebar has nav buttons visible', async ({ page }) => {
49+
await page.setViewportSize(WIDE);
50+
await page.goto('/');
51+
// The inner sidebar container should be in the DOM and visible
52+
const sidebar = page.locator('[data-sidebar="sidebar"]');
53+
await expect(sidebar).toBeVisible();
54+
// Chapter buttons are accessible directly (not in a dialog)
55+
await expect(page.locator('[data-sidebar="sidebar"]').getByRole('button', { name: 'Introduction' })).toBeVisible();
56+
});
57+
58+
test('desktop sidebar toggle collapses and restores the panel', async ({ page }) => {
59+
await page.setViewportSize(WIDE);
60+
await page.goto('/');
61+
62+
const gap = page.locator('[data-slot="sidebar-gap"]');
63+
// Sidebar starts expanded — gap has a non-zero width
64+
const initialWidth = await gap.evaluate(el => el.getBoundingClientRect().width);
65+
expect(initialWidth).toBeGreaterThan(0);
66+
67+
// Toggle collapses (200ms transition — wait for it)
68+
await page.getByRole('button', { name: /toggle sidebar/i }).click();
69+
await page.waitForTimeout(300);
70+
const collapsedWidth = await gap.evaluate(el => el.getBoundingClientRect().width);
71+
expect(collapsedWidth).toBe(0);
72+
73+
// Toggle restores
74+
await page.getByRole('button', { name: /toggle sidebar/i }).click();
75+
await page.waitForTimeout(300); // allow transition
76+
const restoredWidth = await gap.evaluate(el => el.getBoundingClientRect().width);
77+
expect(restoredWidth).toBeGreaterThan(0);
78+
});
79+
});

src/app.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import "tailwindcss";
2+
@plugin "tailwindcss-animate";
23

34
@theme {
45
/* App color palette — direct color tokens */
@@ -60,6 +61,16 @@
6061
--color-border: rgb(var(--border));
6162
--color-input: rgb(var(--input));
6263
--color-ring: rgb(var(--ring));
64+
65+
/* Sidebar tokens — map to our app palette so the Sidebar component is themed */
66+
--color-sidebar: var(--color-surface);
67+
--color-sidebar-foreground: var(--color-ink);
68+
--color-sidebar-border: rgb(var(--border));
69+
--color-sidebar-accent: var(--color-surface-2);
70+
--color-sidebar-accent-foreground: var(--color-ink);
71+
--color-sidebar-primary: var(--color-teal);
72+
--color-sidebar-primary-foreground: var(--color-surface);
73+
--color-sidebar-ring: var(--color-teal);
6374
}
6475

6576
@layer base {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script lang="ts">
2+
import * as Sheet from "$lib/components/ui/sheet/index.js";
3+
import { cn, type WithElementRef } from "$lib/utils.js";
4+
import type { HTMLAttributes } from "svelte/elements";
5+
import { SIDEBAR_WIDTH_MOBILE } from "./constants.js";
6+
import { useSidebar } from "./context.svelte.js";
7+
8+
let {
9+
ref = $bindable(null),
10+
side = "left",
11+
variant = "sidebar",
12+
collapsible = "offcanvas",
13+
class: className,
14+
children,
15+
...restProps
16+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
17+
side?: "left" | "right";
18+
variant?: "sidebar" | "floating" | "inset";
19+
collapsible?: "offcanvas" | "icon" | "none";
20+
} = $props();
21+
22+
const sidebar = useSidebar();
23+
</script>
24+
25+
{#if collapsible === "none"}
26+
<div
27+
class={cn(
28+
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
29+
className
30+
)}
31+
bind:this={ref}
32+
{...restProps}
33+
>
34+
{@render children?.()}
35+
</div>
36+
{:else if sidebar.isMobile}
37+
<Sheet.Root
38+
bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
39+
{...restProps}
40+
>
41+
<Sheet.Content
42+
data-sidebar="sidebar"
43+
data-slot="sidebar"
44+
data-mobile="true"
45+
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
46+
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
47+
{side}
48+
>
49+
<Sheet.Header class="sr-only">
50+
<Sheet.Title>Sidebar</Sheet.Title>
51+
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
52+
</Sheet.Header>
53+
<div class="flex h-full w-full flex-col">
54+
{@render children?.()}
55+
</div>
56+
</Sheet.Content>
57+
</Sheet.Root>
58+
{:else}
59+
<div
60+
bind:this={ref}
61+
class="text-sidebar-foreground group peer hidden narrow:block"
62+
data-state={sidebar.state}
63+
data-collapsible={sidebar.state === "collapsed" ? collapsible : ""}
64+
data-variant={variant}
65+
data-side={side}
66+
data-slot="sidebar"
67+
>
68+
<!-- This is what handles the sidebar gap on desktop -->
69+
<div
70+
data-slot="sidebar-gap"
71+
class={cn(
72+
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
73+
"group-data-[collapsible=offcanvas]:w-0",
74+
"group-data-[side=right]:rotate-180",
75+
variant === "floating" || variant === "inset"
76+
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
77+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
78+
)}
79+
></div>
80+
<div
81+
data-slot="sidebar-container"
82+
class={cn(
83+
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear narrow:flex",
84+
side === "left"
85+
? "start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]"
86+
: "end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]",
87+
// Adjust the padding for floating and inset variants.
88+
variant === "floating" || variant === "inset"
89+
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
90+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-e group-data-[side=right]:border-s",
91+
className
92+
)}
93+
{...restProps}
94+
>
95+
<div
96+
data-sidebar="sidebar"
97+
data-slot="sidebar-inner"
98+
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
99+
>
100+
{@render children?.()}
101+
</div>
102+
</div>
103+
</div>
104+
{/if}

src/lib/hooks/is-mobile.svelte.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { MediaQuery } from "svelte/reactivity";
2+
3+
const DEFAULT_MOBILE_BREAKPOINT = 980;
4+
5+
export class IsMobile extends MediaQuery {
6+
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
7+
super(`max-width: ${breakpoint - 1}px`);
8+
}
9+
}

0 commit comments

Comments
 (0)