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,74 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { trapFocus } from '$lib/shared/actions/trapFocus';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
interface Props {
node: PersonNodeDTO;
canWrite: boolean;
onClose: () => void;
}
let { node, canWrite, onClose }: Props = $props();
// Swipe the sheet down past this threshold to dismiss it (Leonie).
const SWIPE_DISMISS_PX = 80;
let dragY = $state(0);
let dragging = false;
let startY = 0;
function onHandleDown(event: PointerEvent) {
dragging = true;
startY = event.clientY;
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
}
function onHandleMove(event: PointerEvent) {
if (dragging) dragY = Math.max(0, event.clientY - startY);
}
function onHandleUp() {
if (!dragging) return;
dragging = false;
if (dragY >= SWIPE_DISMISS_PX) onClose();
dragY = 0;
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<!-- Backdrop: a full-screen button so tap-outside dismiss is keyboard- and
screen-reader-accessible without a static-element click handler. -->
<button
type="button"
class="fixed inset-0 z-30 bg-black/30 md:hidden"
aria-label={m.stammbaum_close_panel()}
onclick={onClose}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={node.displayName}
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto rounded-t-xl border-t border-line bg-surface shadow-lg md:hidden"
style="transform: translateY({dragY}px);"
use:trapFocus
onkeydown={onKeydown}
>
<!-- Drag handle grip — swipe down to dismiss. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex cursor-grab justify-center py-2 active:cursor-grabbing"
onpointerdown={onHandleDown}
onpointermove={onHandleMove}
onpointerup={onHandleUp}
onpointercancel={onHandleUp}
>
<div class="h-1 w-10 rounded-full bg-line" aria-hidden="true"></div>
</div>
<StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} />
</div>

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();
});
});

View File

@@ -5,6 +5,7 @@ import { page } from '$app/state';
import { replaceState } from '$app/navigation'; import { replaceState } from '$app/navigation';
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte'; import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte'; import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte';
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte'; import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
import { import {
type PanZoomState, type PanZoomState,
@@ -127,16 +128,12 @@ $effect(() => {
onClose={() => (selectedId = null)} onClose={() => (selectedId = null)}
/> />
</aside> </aside>
<!-- Mobile: fixed bottom sheet --> <!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
<div <StammbaumBottomSheet
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto border-t border-line bg-surface shadow-lg md:hidden" node={selectedNode}
> canWrite={canWrite}
<StammbaumSidePanel onClose={() => (selectedId = null)}
node={selectedNode} />
canWrite={canWrite}
onClose={() => (selectedId = null)}
/>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}