feat(stammbaum): dismissible accessible mobile bottom sheet (#692)

Wrap the mobile person panel in StammbaumBottomSheet: drag-handle grip with
swipe-down-to-dismiss (≥80px), full-screen backdrop button for tap-outside
dismiss, role=dialog + aria-label, focus trap, and Escape (NFR-A11Y-004).
Pan/zoom state is untouched by open/close (US-PANEL-001/002).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 17:06:55 +02:00
parent ccc37fe1bb
commit 1e5a45a027
3 changed files with 127 additions and 10 deletions

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumBottomSheet from './StammbaumBottomSheet.svelte';
const node = { id: 'p-1', displayName: 'Anna Schmidt', familyMember: true };
describe('StammbaumBottomSheet (#692)', () => {
it('renders as a dialog with the person name as its accessible name', async () => {
render(StammbaumBottomSheet, { node, canWrite: false, onClose: () => {} });
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog).toBeTruthy();
expect(dialog.getAttribute('aria-label')).toBe('Anna Schmidt');
});
it('dismisses on Escape', async () => {
const onClose = vi.fn();
render(StammbaumBottomSheet, { node, canWrite: false, onClose });
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onClose).toHaveBeenCalled();
});
it('dismisses when the backdrop is tapped', async () => {
const onClose = vi.fn();
render(StammbaumBottomSheet, { node, canWrite: false, onClose });
const backdrop = document.querySelector('button[aria-label]') as HTMLButtonElement;
backdrop.click();
expect(onClose).toHaveBeenCalled();
});
it('dismisses on a downward swipe past the threshold', async () => {
const onClose = vi.fn();
render(StammbaumBottomSheet, { node, canWrite: false, onClose });
const handle = document.querySelector('[role="dialog"] > div') as HTMLElement;
handle.dispatchEvent(
new PointerEvent('pointerdown', { pointerId: 1, clientY: 100, bubbles: true })
);
handle.dispatchEvent(
new PointerEvent('pointermove', { pointerId: 1, clientY: 220, bubbles: true })
);
handle.dispatchEvent(
new PointerEvent('pointerup', { pointerId: 1, clientY: 220, bubbles: true })
);
expect(onClose).toHaveBeenCalled();
});
});