From e1504f8e6c08ae8dec96c7a8ef294b723dcdc6a7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 22:20:18 -0400 Subject: [PATCH] feat(web): add Shell component with header, sidebar, and user menu Persistent chrome used by every authenticated route. Sidebar hides below md:; mobile nav is a separate concern for a later plan. --- web/src/lib/components/Shell.svelte | 80 ++++++++++++++++++++++++++++ web/src/lib/components/Shell.test.ts | 45 ++++++++++++++++ web/tsconfig.json | 23 +++++++- web/vitest.config.ts | 3 +- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/Shell.svelte create mode 100644 web/src/lib/components/Shell.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte new file mode 100644 index 00000000..b37d41fd --- /dev/null +++ b/web/src/lib/components/Shell.svelte @@ -0,0 +1,80 @@ + + + (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + +
+
+
Minstrel
+
+ + {#if menuOpen} +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="menu" + tabindex="-1" + > + +
+ {/if} +
+
+ + + +
+ {@render children?.()} +
+
diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts new file mode 100644 index 00000000..1ad5a48d --- /dev/null +++ b/web/src/lib/components/Shell.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +vi.mock('$app/state', () => ({ + page: { url: new URL('http://localhost/') } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: '1', username: 'alice', is_admin: false } }, + logout: vi.fn().mockResolvedValue(undefined) +})); + +import Shell from './Shell.svelte'; +import { logout } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Shell', () => { + test('renders the username in the header', () => { + render(Shell); + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + + test('renders three nav items: Library, Search, Playlists', () => { + render(Shell); + expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); + expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); + }); + + test('user-menu "Log out" calls logout() and navigates to /login', async () => { + render(Shell); + await fireEvent.click(screen.getByRole('button', { name: /alice/i })); + await fireEvent.click(screen.getByRole('button', { name: /log out/i })); + expect(logout).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 43447105..4660e525 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -9,6 +9,25 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" - } + "moduleResolution": "bundler", + "types": ["node", "vitest"] + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.js", + "vite.config.ts", + "vitest.config.ts", + "vitest.setup.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "test/**/*.js", + "test/**/*.ts", + "test/**/*.svelte", + "tests/**/*.js", + "tests/**/*.ts", + "tests/**/*.svelte" + ] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 7ec8c2f0..a6bc5c75 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,8 +1,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteTesting } from '@testing-library/svelte/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [sveltekit(), svelteTesting()], test: { environment: 'jsdom', include: ['src/**/*.test.ts'],