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:
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.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 {
|
||||
type PanZoomState,
|
||||
@@ -127,16 +128,12 @@ $effect(() => {
|
||||
onClose={() => (selectedId = null)}
|
||||
/>
|
||||
</aside>
|
||||
<!-- Mobile: fixed bottom sheet -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<StammbaumSidePanel
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
|
||||
<StammbaumBottomSheet
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user