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,
|
ROW_GAP,
|
||||||
type Layout
|
type Layout
|
||||||
} from '$lib/person/genealogy/layout/buildLayout';
|
} 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 PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -18,6 +18,8 @@ interface Props {
|
|||||||
edges: RelationshipDTO[];
|
edges: RelationshipDTO[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
panZoom: PanZoomState;
|
panZoom: PanZoomState;
|
||||||
|
/** Emitted when the keyboard, a gesture, or a recentre changes the view. */
|
||||||
|
onPanZoom?: (state: PanZoomState) => void;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||||
@@ -28,7 +30,15 @@ interface Props {
|
|||||||
showGutter?: boolean;
|
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));
|
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 parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
||||||
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
||||||
|
|
||||||
@@ -182,11 +224,18 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</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
|
<svg
|
||||||
viewBox={viewBox}
|
viewBox={viewBox}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Stammbaum"
|
aria-label="Stammbaum"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={handleCanvasKey}
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
>
|
>
|
||||||
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
<!-- 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 }));
|
node.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
||||||
expect(onSelect).toHaveBeenCalledWith(ID_A);
|
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 () => {
|
it('does not call onSelect for other keys', async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ function zoomOut() {
|
|||||||
edges={data.edges}
|
edges={data.edges}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
panZoom={view}
|
panZoom={view}
|
||||||
|
onPanZoom={(v) => (view = v)}
|
||||||
onSelect={(id) => (selectedId = id)}
|
onSelect={(id) => (selectedId = id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user