feat(onboarding): add ProgressSidebar component with 3-step active/completed/future states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
71
frontend/src/lib/components/ProgressSidebar.svelte
Normal file
71
frontend/src/lib/components/ProgressSidebar.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
const { currentStep }: { currentStep: number } = $props();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
label: 'Haushalt benennen',
|
||||
subtitle: 'Deiner Familie einen Namen geben'
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
label: 'Vorräte einrichten',
|
||||
subtitle: 'Was ihr immer zu Hause habt'
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
label: 'Mitglieder einladen',
|
||||
subtitle: 'Haushalt teilen'
|
||||
}
|
||||
];
|
||||
|
||||
function circleClass(n: number): string {
|
||||
if (n === currentStep) return 'bg-[var(--green)] text-white';
|
||||
if (n < currentStep) return 'bg-[var(--green-tint)] text-[var(--green-dark)]';
|
||||
return 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]';
|
||||
}
|
||||
|
||||
function labelClass(n: number): string {
|
||||
if (n === currentStep) return 'text-[13px] font-medium text-[var(--color-text)]';
|
||||
return 'text-[13px] text-[var(--color-text-muted)]';
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<!-- Logo row -->
|
||||
<div class="flex items-center gap-[8px] mb-[40px]">
|
||||
<div
|
||||
class="w-[28px] h-[28px] rounded-[6px] bg-[var(--green)] flex items-center justify-center text-[14px]"
|
||||
>
|
||||
🥗
|
||||
</div>
|
||||
<span class="font-[var(--font-display)] text-[16px] font-medium">Mealplan</span>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="flex flex-col gap-[24px]">
|
||||
{#each steps as step (step.number)}
|
||||
<div
|
||||
class="flex gap-[12px] items-start"
|
||||
data-testid="step-{step.number}"
|
||||
data-state={step.number < currentStep
|
||||
? 'completed'
|
||||
: step.number === currentStep
|
||||
? 'current'
|
||||
: 'future'}
|
||||
aria-current={step.number === currentStep ? 'step' : undefined}
|
||||
>
|
||||
<div
|
||||
class="w-[28px] h-[28px] rounded-full flex items-center justify-center text-[12px] font-medium flex-shrink-0 {circleClass(step.number)}"
|
||||
aria-label="Schritt {step.number}"
|
||||
>
|
||||
{step.number < currentStep ? '✓' : step.number}
|
||||
</div>
|
||||
<div>
|
||||
<div class={labelClass(step.number)}>{step.label}</div>
|
||||
<div class="text-[11px] text-[var(--color-text-muted)] mt-[2px]">{step.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
56
frontend/src/lib/components/ProgressSidebar.test.ts
Normal file
56
frontend/src/lib/components/ProgressSidebar.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ProgressSidebar from './ProgressSidebar.svelte';
|
||||
|
||||
describe('ProgressSidebar', () => {
|
||||
it('renders the app logo and name', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 1 } });
|
||||
expect(screen.getByText('Mealplan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all 3 step labels', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 1 } });
|
||||
expect(screen.getByText('Haushalt benennen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vorräte einrichten')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mitglieder einladen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('step 1 active: renders green circle for step 1', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 1 } });
|
||||
const step1 = screen.getByTestId('step-1');
|
||||
expect(step1).toHaveAttribute('aria-current', 'step');
|
||||
});
|
||||
|
||||
it('step 1 active: steps 2 and 3 are not current', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 1 } });
|
||||
expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('step 2 active: step 1 is completed (checkmark), step 2 is current, step 3 is future', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 2 } });
|
||||
expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed');
|
||||
expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step');
|
||||
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('step 1 completed has accessible label', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 2 } });
|
||||
const step1 = screen.getByTestId('step-1');
|
||||
expect(step1).toHaveAttribute('data-state', 'completed');
|
||||
expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('each step has an accessible aria-label', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 1 } });
|
||||
expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schritt 2/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schritt 3/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('future steps do not have aria-current', () => {
|
||||
render(ProgressSidebar, { props: { currentStep: 1 } });
|
||||
expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user