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:
77
frontend/src/lib/planner/RecipePickerDrawer.svelte
Normal file
77
frontend/src/lib/planner/RecipePickerDrawer.svelte
Normal 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;"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
80
frontend/src/lib/planner/RecipePickerDrawer.test.ts
Normal file
80
frontend/src/lib/planner/RecipePickerDrawer.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user