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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user