feat(stammbaum): keyboard pan/zoom on the canvas (#692)

+/- zoom by the fixed step and arrow keys pan by a tenth of the visible
extent, emitted via onPanZoom. Provides the keyboard-only alternative path
required by NFR-A11Y-002. Nodes keep their own Enter/Space selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:39:55 +02:00
parent 0422af8980
commit da1984b916
3 changed files with 91 additions and 2 deletions

View File

@@ -8,7 +8,7 @@ import {
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
import type { PanZoomState } from '$lib/person/genealogy/panZoom';
import { type PanZoomState, clampZoom, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -18,6 +18,8 @@ interface Props {
edges: RelationshipDTO[];
selectedId: string | null;
panZoom: PanZoomState;
/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
onPanZoom?: (state: PanZoomState) => void;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -28,7 +30,15 @@ interface Props {
showGutter?: boolean;
}
let { nodes, edges, selectedId, panZoom, onSelect, showGutter }: Props = $props();
let {
nodes,
edges,
selectedId,
panZoom,
onPanZoom = () => {},
onSelect,
showGutter
}: Props = $props();
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
@@ -112,6 +122,38 @@ function handleNodeKey(event: KeyboardEvent, id: string) {
}
}
// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
function handleCanvasKey(event: KeyboardEvent) {
const stepX = (baseDims.w / panZoom.z) * 0.1;
const stepY = (baseDims.h / panZoom.z) * 0.1;
switch (event.key) {
case '+':
case '=':
onPanZoom({ ...panZoom, z: clampZoom(panZoom.z + ZOOM_STEP_KB) });
break;
case '-':
case '_':
onPanZoom({ ...panZoom, z: clampZoom(panZoom.z - ZOOM_STEP_KB) });
break;
case 'ArrowLeft':
onPanZoom({ ...panZoom, x: panZoom.x - stepX });
break;
case 'ArrowRight':
onPanZoom({ ...panZoom, x: panZoom.x + stepX });
break;
case 'ArrowUp':
onPanZoom({ ...panZoom, y: panZoom.y - stepY });
break;
case 'ArrowDown':
onPanZoom({ ...panZoom, y: panZoom.y + stepY });
break;
default:
return;
}
event.preventDefault();
}
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
@@ -182,11 +224,18 @@ const parentLinks = $derived.by<ParentLinks>(() => {
});
</script>
<!-- The canvas is a custom interactive pan/zoom region: `tabindex` lets keyboard
users focus it and the keydown handler is the keyboard-only alternative to
touch/mouse gestures (NFR-A11Y-002). The visible focus outline is kept. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Stammbaum"
tabindex="0"
onkeydown={handleCanvasKey}
class="block h-full w-full"
>
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating

View File

@@ -509,6 +509,45 @@ describe('StammbaumTree node rendering branches', () => {
node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(onSelect).toHaveBeenCalledWith(ID_A);
});
});
describe('StammbaumTree keyboard pan/zoom (#692)', () => {
const renderTree = (onPanZoom: ReturnType<typeof vi.fn>) =>
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
onPanZoom,
onSelect: () => {}
});
it('zooms in on "+" and out on "-" by the keyboard step (OQ-002)', async () => {
const onPanZoom = vi.fn();
renderTree(onPanZoom);
const svg = document.querySelector('svg')!;
svg.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true }));
expect(onPanZoom).toHaveBeenCalledTimes(1);
expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(1.1, 6);
onPanZoom.mockClear();
svg.dispatchEvent(new KeyboardEvent('keydown', { key: '-', bubbles: true }));
expect(onPanZoom.mock.calls[0][0].z).toBeCloseTo(0.9, 6);
});
it('pans right/down on arrow keys (REQ-PAN-004)', async () => {
const onPanZoom = vi.fn();
renderTree(onPanZoom);
const svg = document.querySelector('svg')!;
svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(onPanZoom.mock.calls[0][0].x).toBeGreaterThan(0);
onPanZoom.mockClear();
svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(onPanZoom.mock.calls[0][0].y).toBeGreaterThan(0);
});
it('does not call onSelect for other keys', async () => {
const onSelect = vi.fn();

View File

@@ -104,6 +104,7 @@ function zoomOut() {
edges={data.edges}
selectedId={selectedId}
panZoom={view}
onPanZoom={(v) => (view = v)}
onSelect={(id) => (selectedId = id)}
/>
</div>