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