feat(planner): add MealActionSheet component for mobile swap trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
111
frontend/src/lib/planner/MealActionSheet.svelte
Normal file
111
frontend/src/lib/planner/MealActionSheet.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe: SlotRecipe | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
slot: Slot;
|
||||
onswap: () => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let { open, slot, onswap, oncancel }: Props = $props();
|
||||
|
||||
const meta = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (slot.recipe?.cookTimeMin != null) parts.push(`${slot.recipe.cookTimeMin} min`);
|
||||
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
|
||||
return parts.join(' · ');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') oncancel();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="presentation"
|
||||
data-testid="sheet-backdrop"
|
||||
style="position:fixed;inset:0;z-index:50;background:rgba(28,28,24,0.4)"
|
||||
onclick={oncancel}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
style="position:absolute;bottom:0;left:0;right:0;background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;box-shadow:var(--shadow-overlay)"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div style="display:flex;justify-content:center;margin-top:12px">
|
||||
<div style="width:32px;height:4px;background:var(--color-border);border-radius:9999px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Meal title -->
|
||||
<p style="font-family:var(--font-display);font-size:15px;font-weight:500;color:var(--color-text);padding:0 16px;margin:12px 0 4px">
|
||||
{slot.recipe?.name ?? ''}
|
||||
</p>
|
||||
|
||||
<!-- Metadata -->
|
||||
{#if meta}
|
||||
<p style="font-family:var(--font-sans);font-size:11px;color:var(--color-text-muted);padding:0 16px 12px;margin:0">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="padding:0 16px 16px;display:flex;flex-direction:column;gap:6px">
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;background:var(--orange-tint);border:1px solid #FBCDA4;color:var(--orange-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
|
||||
onclick={onswap}
|
||||
>
|
||||
↻ Swap this meal
|
||||
</button>
|
||||
|
||||
{#if slot.recipe}
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}/cook"
|
||||
style="display:block;width:100%;background:var(--green-tint);border:1px solid var(--green-light);color:var(--green-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
|
||||
>
|
||||
🍳 Cook now
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}"
|
||||
style="display:block;width:100%;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
|
||||
>
|
||||
👁 View recipe
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;background:none;border:none;color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;text-align:center;cursor:pointer;padding:12px"
|
||||
onclick={oncancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
79
frontend/src/lib/planner/MealActionSheet.test.ts
Normal file
79
frontend/src/lib/planner/MealActionSheet.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import MealActionSheet from './MealActionSheet.svelte';
|
||||
|
||||
const slot = {
|
||||
id: 's1',
|
||||
slotDate: '2026-04-08',
|
||||
recipe: { id: 'r1', name: 'Tomato pasta', effort: 'easy', cookTimeMin: 45 }
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
slot,
|
||||
onswap: vi.fn(),
|
||||
oncancel: vi.fn()
|
||||
};
|
||||
|
||||
describe('MealActionSheet', () => {
|
||||
it('renders meal title', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
expect(screen.getByText('Tomato pasta')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders meal metadata', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
expect(screen.getByText(/45 min/i)).toBeTruthy();
|
||||
expect(screen.getByText(/easy/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders all 4 action buttons', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
expect(screen.getByRole('button', { name: /Swap this meal/i })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /Cook now/i })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /View recipe/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Cook now links to the cook route', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
const link = screen.getByRole('link', { name: /Cook now/i });
|
||||
expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
|
||||
});
|
||||
|
||||
it('View recipe links to the recipe detail route', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
const link = screen.getByRole('link', { name: /View recipe/i });
|
||||
expect(link.getAttribute('href')).toBe('/recipes/r1');
|
||||
});
|
||||
|
||||
it('clicking Swap this meal calls onswap', async () => {
|
||||
const onswap = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, onswap } });
|
||||
await user.click(screen.getByRole('button', { name: /Swap this meal/i }));
|
||||
expect(onswap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clicking Cancel calls oncancel', async () => {
|
||||
const oncancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, oncancel } });
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
expect(oncancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clicking backdrop calls oncancel', async () => {
|
||||
const oncancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, oncancel } });
|
||||
await user.click(screen.getByTestId('sheet-backdrop'));
|
||||
expect(oncancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(MealActionSheet, { props: { ...baseProps, open: false } });
|
||||
expect(screen.queryByText('Tomato pasta')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user