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>