fix(stammbaum): order keyboard tab stops by visual layout, not DB order (#718)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m21s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m40s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m5s

Person nodes rendered in `nodes` array order (backend/DB row order), so
Tab focus hopped between nodes unrelated to their on-screen position,
failing WCAG 2.4.3 Focus Order (Level A).

Render the node loop in reading order instead: sort by layout y (top
generation first) then x (left-to-right within a row), via a
`nodesInReadingOrder` derived. Nodes without a layout position sort last
(mirroring the `{#if pos}` guard); node.id is the final tie-break for a
total, deterministic comparator. Shift+Tab and reload-stability fall out
for free (reversed render order; x/y independent of backend order).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #720.
This commit is contained in:
Marcel
2026-06-02 20:31:43 +02:00
committed by marcel
parent 7d37e610da
commit e259908d6a
2 changed files with 147 additions and 2 deletions

View File

@@ -70,6 +70,31 @@ let {
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
// Keyboard tab order follows DOM source order (#718). Render the nodes in
// reading order — top generation first (by layout y), left-to-right within a
// row (by layout x) — so focus sweeps the visual grid instead of the backend
// row order in which `nodes` arrives. Nodes without a layout position sort last
// (mirroring the `{#if pos}` render guard); node.id is the final tie-break for a
// total, deterministic comparator.
//
// LOAD-BEARING: relies on every node in a generation sharing the same y
// (buildLayout assigns y = g * (NODE_H + ROW_GAP)). If the layout is ever
// changed to stagger nodes vertically within a generation, this y-then-x sort
// would silently regress tab order — revisit here.
const nodesInReadingOrder = $derived.by(() =>
[...nodes].sort((a, b) => {
const pa = layout.positions.get(a.id);
const pb = layout.positions.get(b.id);
const ay = pa ? pa.y : Infinity;
const by = pb ? pb.y : Infinity;
if (ay !== by) return ay - by;
const ax = pa ? pa.x : Infinity;
const bx = pb ? pb.x : Infinity;
if (ax !== bx) return ax - bx;
return a.id.localeCompare(b.id);
})
);
// Lineage highlight (#703). The adjacency index is rebuilt only when the edges
// change; the cheap walk re-runs whenever the selection changes. A null
// highlight (no selection) means full strength everywhere — nothing dims.
@@ -312,8 +337,8 @@ function handleCanvasKey(event: KeyboardEvent) {
isConnectorActive={isConnectorActive}
/>
<!-- Nodes -->
{#each nodes as node (node.id)}
<!-- Nodes (reading order so keyboard Tab follows the visual grid, #718) -->
{#each nodesInReadingOrder as node (node.id)}
{@const pos = layout.positions.get(node.id)}
{#if pos}
<StammbaumNode