From 36ae82af5de0563ef47b14ef6b70778f7a1f5092 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 8 Apr 2026 22:38:59 +0200 Subject: [PATCH] feat(ui): add BottomSheet.svelte shared wrapper component Shared wrapper for C4, C6, and future sheet flows. Handles dim overlay, drag handle, focus trap, Escape dismiss, and backdrop click dismiss. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/BottomSheet.svelte | 95 +++++++++++++++++++ .../src/lib/components/BottomSheet.test.ts | 52 ++++++++++ 2 files changed, 147 insertions(+) create mode 100644 frontend/src/lib/components/BottomSheet.svelte create mode 100644 frontend/src/lib/components/BottomSheet.test.ts diff --git a/frontend/src/lib/components/BottomSheet.svelte b/frontend/src/lib/components/BottomSheet.svelte new file mode 100644 index 0000000..f706f65 --- /dev/null +++ b/frontend/src/lib/components/BottomSheet.svelte @@ -0,0 +1,95 @@ + + +{#if open} +
+ + + + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-modal="true" + tabindex="-1" + > + +
+ + + + + +
+ + +
+ {@render children?.()} +
+
+
+{/if} diff --git a/frontend/src/lib/components/BottomSheet.test.ts b/frontend/src/lib/components/BottomSheet.test.ts new file mode 100644 index 0000000..4c413c3 --- /dev/null +++ b/frontend/src/lib/components/BottomSheet.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import BottomSheet from './BottomSheet.svelte'; + +describe('BottomSheet', () => { + it('is not mounted in DOM when open is false', () => { + render(BottomSheet, { props: { open: false, onclose: vi.fn() } }); + expect(screen.queryByTestId('bottom-sheet')).toBeNull(); + }); + + it('is mounted in DOM when open is true', () => { + render(BottomSheet, { props: { open: true, onclose: vi.fn() } }); + expect(screen.getByTestId('bottom-sheet')).toBeTruthy(); + }); + + it('calls onclose when close button is clicked', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: true, onclose } }); + const closeBtn = screen.getByRole('button', { name: /schließen/i }); + await userEvent.click(closeBtn); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('calls onclose when backdrop is clicked', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: true, onclose } }); + const backdrop = screen.getByTestId('sheet-backdrop'); + await userEvent.click(backdrop); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('calls onclose when Escape is pressed', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: true, onclose } }); + await userEvent.keyboard('{Escape}'); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('drag handle has aria-hidden', () => { + render(BottomSheet, { props: { open: true, onclose: vi.fn() } }); + const handle = screen.getByTestId('drag-handle'); + expect(handle.getAttribute('aria-hidden')).toBe('true'); + }); + + it('does not call onclose when Escape is pressed while closed', async () => { + const onclose = vi.fn(); + render(BottomSheet, { props: { open: false, onclose } }); + await userEvent.keyboard('{Escape}'); + expect(onclose).not.toHaveBeenCalled(); + }); +});