Frontend: App shell, navigation, routing, and design tokens #32
42
frontend/src/lib/nav/DesktopSidebar.svelte
Normal file
42
frontend/src/lib/nav/DesktopSidebar.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { desktopNavSections } from './nav';
|
||||
|
||||
let { appName, householdName }: { appName: string; householdName: string } = $props();
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden lg:flex flex-col sticky top-0 h-screen w-[224px] min-w-[224px] border-r border-[var(--color-border)] bg-white"
|
||||
>
|
||||
<div class="px-[18px] pt-[14px] pb-[14px] border-b border-[var(--color-border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-[22px] h-[22px] bg-[var(--green)] rounded-[var(--radius-sm)]"></div>
|
||||
<span class="font-[var(--font-display)] text-[15px] font-medium">{appName}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-[var(--color-text-muted)]">{householdName}</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Hauptnavigation" class="flex-1 overflow-y-auto px-2 py-1">
|
||||
{#each desktopNavSections as section (section.title)}
|
||||
<p
|
||||
class="text-[8px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-muted)] font-[var(--font-sans)] px-3 pt-4 pb-1"
|
||||
>
|
||||
{section.title}
|
||||
</p>
|
||||
{#each section.items as item (item.href)}
|
||||
{@const active = $page.url.pathname.startsWith(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
|
||||
: ''}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div data-testid="variety-widget-slot" class="mt-auto p-3"></div>
|
||||
</aside>
|
||||
61
frontend/src/lib/nav/DesktopSidebar.test.ts
Normal file
61
frontend/src/lib/nav/DesktopSidebar.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import DesktopSidebar from './DesktopSidebar.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => {
|
||||
const { readable } = require('svelte/store');
|
||||
return {
|
||||
page: readable({ url: new URL('http://localhost/planner') })
|
||||
};
|
||||
});
|
||||
|
||||
describe('DesktopSidebar', () => {
|
||||
it('renders the app name', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
expect(screen.getByText('Mealprep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the household name', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
expect(screen.getByText('Familie Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Plan section with 3 items', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
expect(screen.getByText('Plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Planer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rezepte')).toBeInTheDocument();
|
||||
expect(screen.getByText('Einkauf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Household section with 2 items', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
expect(screen.getByText('Haushalt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mitglieder')).toBeInTheDocument();
|
||||
expect(screen.getByText('Einstellungen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has 5 navigation links total', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('marks active item with aria-current="page"', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
const plannerLink = screen.getByRole('link', { name: /planer/i });
|
||||
expect(plannerLink).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('non-active items do not have aria-current', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
const recipesLink = screen.getByRole('link', { name: /rezepte/i });
|
||||
expect(recipesLink).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('renders a variety widget slot area', () => {
|
||||
render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
|
||||
const widget = screen.getByTestId('variety-widget-slot');
|
||||
expect(widget).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user