feat(stammbaum): pinned generation-label rail on all viewports (#692)

Generation labels are no longer drawn in-SVG (where they panned/zoomed off
screen and were desktop-only). A new StammbaumGenerationRail overlays the canvas
left edge, mapping each generation row's centre through the SVG's live
getScreenCTM so chips stay pinned horizontally and track their row vertically at
any pan/zoom — on phones too. The desktop stripe underlay stays (gated on the
gutter breakpoint); the #689 label tests are rewritten against the rail.
Verified live: labels stay at left=4px while the canvas pans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 18:39:22 +02:00
parent bb2a89da58
commit a458d3508b
4 changed files with 322 additions and 231 deletions

View File

@@ -831,10 +831,13 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
});
});
describe('StammbaumTree generation gutter (#689)', () => {
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
// showGutter overrides the matchMedia detection so the test never
// depends on the vitest-browser iframe viewport width.
describe('StammbaumTree generation rail (#689, #692)', () => {
const railLabels = () =>
Array.from(document.querySelectorAll('[role="text"]')).map((el) =>
el.getAttribute('aria-label')
);
it('renders a G{n} label per occupied generation row on the pinned rail', async () => {
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
@@ -843,35 +846,37 @@ describe('StammbaumTree generation gutter (#689)', () => {
edges: [],
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
onSelect: () => {}
});
const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
g.getAttribute('aria-label')
);
expect(labels).toContain('Generation 2');
expect(labels).toContain('Generation 3');
await vi.waitFor(() => {
const labels = railLabels();
expect(labels).toContain('Generation 2');
expect(labels).toContain('Generation 3');
});
});
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
it('labels the chip so screen readers announce "Generation" and shows the G{n} glyph', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
onSelect: () => {}
});
const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
(g) => g.getAttribute('aria-label') === 'Generation 3'
);
expect(g3).toBeDefined();
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
await vi.waitFor(() => {
const g3 = Array.from(document.querySelectorAll('[role="text"]')).find(
(el) => el.getAttribute('aria-label') === 'Generation 3'
);
expect(g3).toBeDefined();
expect(g3!.textContent).toMatch(/G\s*3/);
});
});
it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => {
it('keeps showing generation labels on the pinned rail even on mobile (showGutter false)', async () => {
// The rail is viewport-independent (the #692 point); only the desktop
// stripe underlay is gated on the gutter breakpoint.
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
@@ -881,7 +886,6 @@ describe('StammbaumTree generation gutter (#689)', () => {
showGutter: false
});
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
expect(labelGroups).toHaveLength(0);
await vi.waitFor(() => expect(railLabels()).toContain('Generation 3'));
});
});