feat(planner): add RecipePickerDrawer slide-in drawer

Wraps RecipePicker in a fixed right-side drawer with backdrop.
Slide-in/out transition, backdrop click closes, purely presentational
(open + onclose props from parent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:52:56 +02:00
parent d20cd53be2
commit 2cebf504f2
2 changed files with 157 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { Recipe, Suggestion } from '$lib/planner/types';
import RecipePicker from './RecipePicker.svelte';
let {
open,
slotDate,
planId,
suggestions,
allRecipes,
isLoading,
onpick,
onclose,
excludeRecipeId,
replacingRecipe
}: {
open: boolean;
slotDate: string;
planId: string;
suggestions: Suggestion[];
allRecipes: Recipe[];
isLoading: boolean;
onpick: (recipeId: string, recipeName: string) => void;
onclose: () => void;
excludeRecipeId?: string;
replacingRecipe?: { name: string; meta?: string };
} = $props();
let drawerTransform = $derived(open ? 'translateX(0)' : 'translateX(100%)');
let backdropVisibility = $derived(open ? 'visible' : 'hidden');
let backdropOpacity = $derived(open ? '1' : '0');
</script>
<!-- Backdrop -->
<div
data-testid="drawer-backdrop"
aria-hidden="true"
onclick={onclose}
style="position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 40; visibility: {backdropVisibility}; opacity: {backdropOpacity}; transition: opacity 0.2s, visibility 0.2s;"
></div>
<!-- Drawer panel -->
<div
data-testid="recipe-picker-drawer"
aria-hidden={!open}
style="position: fixed; right: 0; top: 0; height: 100%; width: min(480px, 90vw); background: var(--color-page); border-left: 1px solid var(--color-border); z-index: 50; transform: {drawerTransform}; transition: transform 0.25s ease; display: flex; flex-direction: column;"
>
<!-- Header -->
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;">
<p style="margin: 0; font-family: var(--font-display); font-size: 15px; font-weight: 500; color: var(--color-text);">
Rezept wählen
</p>
<button
type="button"
aria-label="Schließen"
onclick={onclose}
style="background: none; border: none; cursor: pointer; font-size: 20px; line-height: 1; color: var(--color-text-muted); padding: 4px 8px;"
>
&times;
</button>
</div>
<!-- RecipePicker content -->
<div style="overflow-y: auto; flex: 1;">
<RecipePicker
{planId}
date={slotDate}
dateLabel={slotDate}
{suggestions}
{allRecipes}
{isLoading}
{onpick}
{excludeRecipeId}
{replacingRecipe}
/>
</div>
</div>

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import RecipePickerDrawer from './RecipePickerDrawer.svelte';
const baseProps = {
open: true,
slotDate: '2026-04-14',
planId: 'plan-1',
suggestions: [],
allRecipes: [
{ id: 'r1', name: 'Pasta Bolognese', cookTimeMin: 45, effort: 'mittel' },
{ id: 'r2', name: 'Lachs', cookTimeMin: 20, effort: 'einfach' }
],
isLoading: false,
onpick: vi.fn(),
onclose: vi.fn()
};
describe('RecipePickerDrawer', () => {
describe('visibility', () => {
it('renders drawer content when open=true', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByTestId('recipe-picker-drawer')).toBeTruthy();
});
it('drawer is not visible when open=false', () => {
render(RecipePickerDrawer, { props: { ...baseProps, open: false } });
const drawer = screen.getByTestId('recipe-picker-drawer');
// Drawer exists in DOM but should be off-screen / aria-hidden
expect(drawer.getAttribute('aria-hidden')).toBe('true');
});
it('renders recipe list inside drawer', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
});
});
describe('backdrop', () => {
it('renders backdrop when open', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByTestId('drawer-backdrop')).toBeTruthy();
});
it('calls onclose when backdrop is clicked', async () => {
const onclose = vi.fn();
const user = userEvent.setup();
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
await user.click(screen.getByTestId('drawer-backdrop'));
expect(onclose).toHaveBeenCalledOnce();
});
});
describe('close button', () => {
it('renders a close button inside the drawer', () => {
render(RecipePickerDrawer, { props: baseProps });
expect(screen.getByRole('button', { name: /schließen|close/i })).toBeTruthy();
});
it('calls onclose when close button clicked', async () => {
const onclose = vi.fn();
const user = userEvent.setup();
render(RecipePickerDrawer, { props: { ...baseProps, onclose } });
await user.click(screen.getByRole('button', { name: /schließen|close/i }));
expect(onclose).toHaveBeenCalledOnce();
});
});
describe('recipe picking', () => {
it('calls onpick when a recipe is selected', async () => {
const onpick = vi.fn();
const user = userEvent.setup();
render(RecipePickerDrawer, { props: { ...baseProps, onpick } });
const pickButtons = screen.getAllByRole('button', { name: /Wählen/i });
await user.click(pickButtons[0]);
expect(onpick).toHaveBeenCalledOnce();
});
});
});