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