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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -104,6 +104,7 @@ function zoomOut() {
|
||||
edges={data.edges}
|
||||
selectedId={selectedId}
|
||||
panZoom={view}
|
||||
onPanZoom={(v) => (view = v)}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user