Skip to content

Commit 84965e0

Browse files
Add keyboard navigation for graph nodes (accessibility)
Enable keyboard navigation for git visualization nodes: - Arrow keys: Navigate between nodes spatially - Tab/Shift+Tab: Jump between node type groups - Home/End: Jump to first/last node - Escape: Clear focus Accessibility features: - Canvas is now focusable with tabindex - ARIA live region announces focused nodes for screen readers - Visual focus ring (dashed white border) indicates selected node - sr-only CSS class for visually hidden announcements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c8e91fe commit 84965e0

File tree

7 files changed

+361
-15
lines changed

7 files changed

+361
-15
lines changed

electron/src/renderer/canvas/DrawCircle.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import type { GitNode } from "./Types";
1313
export function DrawCircle(
1414
ctx: CanvasRenderingContext2D,
1515
node: GitNode,
16-
dragging: boolean
16+
dragging: boolean,
17+
isFocused: boolean = false
1718
) {
1819
var innerRadius = 1,
1920
outerRadius = 20;
@@ -84,4 +85,24 @@ export function DrawCircle(
8485

8586
DrawText(ctx, node);
8687
ctx.closePath();
88+
89+
// Draw focus ring for keyboard navigation
90+
if (isFocused) {
91+
ctx.beginPath();
92+
let focusRadius = radius;
93+
if (node.type === NodeType.head) focusRadius = radiusHEAD;
94+
else if (node.type === NodeType.branch) focusRadius = radiusBRANCH;
95+
else if (node.type === NodeType.tag) focusRadius = radiusTAG;
96+
else if (node.type === NodeType.remotebranch) focusRadius = radiusBRANCH;
97+
else if (node.type === NodeType.tree) focusRadius = radiusTREE;
98+
else if (node.type === NodeType.blob) focusRadius = radiusBLOB;
99+
100+
ctx.arc(node.xPos, node.yPos, focusRadius + 8, 0, 2 * Math.PI);
101+
ctx.lineWidth = 4;
102+
ctx.strokeStyle = "#fff";
103+
ctx.setLineDash([8, 4]);
104+
ctx.stroke();
105+
ctx.setLineDash([]);
106+
ctx.closePath();
107+
}
87108
}

electron/src/renderer/canvas/DrawNodes.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,23 @@ export function DrawText(ctx: CanvasRenderingContext2D, node: GitNode) {
4848
}
4949
}
5050

51-
export function DrawNodes<T>(ctx: CanvasRenderingContext2D, nodes: Array<T>) {
51+
export function DrawNodes<T>(
52+
ctx: CanvasRenderingContext2D,
53+
nodes: Array<T>,
54+
focusedNode: GitNode | null = null
55+
) {
5256
nodes.forEach((node) => {
53-
DrawNode(ctx, node as unknown as GitNode, false);
57+
const n = node as unknown as GitNode;
58+
const isFocused = focusedNode !== null && n.hash === focusedNode.hash && n.type === focusedNode.type;
59+
DrawNode(ctx, n, false, isFocused);
5460
});
5561
}
5662

5763
export function DrawNode(
5864
ctx: CanvasRenderingContext2D,
5965
node: GitNode,
60-
dragging: boolean
66+
dragging: boolean,
67+
isFocused: boolean = false
6168
) {
62-
DrawCircle(ctx, node, dragging);
69+
DrawCircle(ctx, node, dragging, isFocused);
6370
}
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import type { GitNode, GitCommit, GitTree, GitBlob, GitBranch, GitTag } from "./Types";
2+
import { NodeType } from "./Types";
3+
import type { VisState } from "./Redraw";
4+
5+
type AnyNode = GitNode | GitCommit | GitTree | GitBlob | GitBranch | GitTag;
6+
7+
let focusedNode: AnyNode | null = null;
8+
let focusableNodes: AnyNode[] = [];
9+
let focusIndex = -1;
10+
11+
const TYPE_NAMES = ["Branch", "Commit", "Tree", "Blob", "HEAD", "Remote Branch", "Tag"];
12+
13+
export function getFocusedNode(): AnyNode | null {
14+
return focusedNode;
15+
}
16+
17+
export function setFocusedNode(node: AnyNode | null): void {
18+
focusedNode = node;
19+
if (node) {
20+
focusIndex = focusableNodes.indexOf(node);
21+
announceNode(node);
22+
} else {
23+
focusIndex = -1;
24+
clearAnnouncement();
25+
}
26+
}
27+
28+
export function buildFocusableList(state: VisState): AnyNode[] {
29+
const nodes: AnyNode[] = [];
30+
31+
// Add in visual column order (left to right)
32+
nodes.push(...state.HEADNodes);
33+
nodes.push(...state.BranchNodes);
34+
if (state.showTags) nodes.push(...state.TagNodes);
35+
nodes.push(...state.CommitNodes);
36+
if (state.showTrees) nodes.push(...state.TreeNodes);
37+
if (state.showBlobs) nodes.push(...state.BlobNodes);
38+
nodes.push(...state.RemoteBranchNodes);
39+
40+
// Sort by x position first, then y position for consistent navigation
41+
focusableNodes = nodes.sort((a, b) => {
42+
const ax = a.xPos ?? 0;
43+
const bx = b.xPos ?? 0;
44+
const ay = a.yPos ?? 0;
45+
const by = b.yPos ?? 0;
46+
return ax - bx || ay - by;
47+
});
48+
49+
return focusableNodes;
50+
}
51+
52+
function announceNode(node: AnyNode): void {
53+
const announcer = document.getElementById("node-announcer");
54+
if (!announcer) return;
55+
56+
const typeName = TYPE_NAMES[node.type] || "Node";
57+
let label = "";
58+
59+
if ("name" in node && node.name) {
60+
label = node.name;
61+
} else if ("filename" in node && node.filename) {
62+
label = node.filename;
63+
} else if (node.hash) {
64+
label = node.hash.substring(0, 7);
65+
}
66+
67+
const text = node.text ? `, ${node.text}` : "";
68+
announcer.textContent = `${typeName}: ${label}${text}`;
69+
}
70+
71+
function clearAnnouncement(): void {
72+
const announcer = document.getElementById("node-announcer");
73+
if (announcer) {
74+
announcer.textContent = "";
75+
}
76+
}
77+
78+
function findNearestNode(
79+
currentNode: AnyNode,
80+
direction: "left" | "right" | "up" | "down"
81+
): AnyNode | null {
82+
if (focusableNodes.length === 0) return null;
83+
84+
const cx = currentNode.xPos ?? 0;
85+
const cy = currentNode.yPos ?? 0;
86+
87+
let candidates: AnyNode[] = [];
88+
89+
switch (direction) {
90+
case "left":
91+
candidates = focusableNodes.filter(n => (n.xPos ?? 0) < cx - 10);
92+
break;
93+
case "right":
94+
candidates = focusableNodes.filter(n => (n.xPos ?? 0) > cx + 10);
95+
break;
96+
case "up":
97+
candidates = focusableNodes.filter(n => (n.yPos ?? 0) < cy - 10);
98+
break;
99+
case "down":
100+
candidates = focusableNodes.filter(n => (n.yPos ?? 0) > cy + 10);
101+
break;
102+
}
103+
104+
if (candidates.length === 0) return null;
105+
106+
// Find nearest by distance
107+
let nearest = candidates[0];
108+
let minDist = Infinity;
109+
110+
for (const n of candidates) {
111+
const nx = n.xPos ?? 0;
112+
const ny = n.yPos ?? 0;
113+
const dist = Math.sqrt((nx - cx) ** 2 + (ny - cy) ** 2);
114+
if (dist < minDist) {
115+
minDist = dist;
116+
nearest = n;
117+
}
118+
}
119+
120+
return nearest;
121+
}
122+
123+
function getNodesByTypeGroup(state: VisState): Map<string, AnyNode[]> {
124+
const groups = new Map<string, AnyNode[]>();
125+
groups.set("head", state.HEADNodes);
126+
groups.set("branch", state.BranchNodes);
127+
if (state.showTags) groups.set("tag", state.TagNodes);
128+
groups.set("commit", state.CommitNodes);
129+
if (state.showTrees) groups.set("tree", state.TreeNodes);
130+
if (state.showBlobs) groups.set("blob", state.BlobNodes);
131+
groups.set("remote", state.RemoteBranchNodes);
132+
return groups;
133+
}
134+
135+
function getNextTypeGroup(currentType: NodeType, state: VisState, reverse: boolean): AnyNode | null {
136+
const typeOrder = [
137+
NodeType.head,
138+
NodeType.branch,
139+
NodeType.tag,
140+
NodeType.commit,
141+
NodeType.tree,
142+
NodeType.blob,
143+
NodeType.remotebranch,
144+
];
145+
146+
const currentIndex = typeOrder.indexOf(currentType);
147+
const direction = reverse ? -1 : 1;
148+
149+
for (let i = 1; i <= typeOrder.length; i++) {
150+
const nextIndex = (currentIndex + i * direction + typeOrder.length) % typeOrder.length;
151+
const nextType = typeOrder[nextIndex];
152+
153+
// Check if this type is visible
154+
if (nextType === NodeType.tag && !state.showTags) continue;
155+
if (nextType === NodeType.tree && !state.showTrees) continue;
156+
if (nextType === NodeType.blob && !state.showBlobs) continue;
157+
158+
// Find first node of this type
159+
const node = focusableNodes.find(n => n.type === nextType);
160+
if (node) return node;
161+
}
162+
163+
return null;
164+
}
165+
166+
export function createKeyboardHandler(
167+
canvas: HTMLCanvasElement,
168+
state: VisState,
169+
onFocusChange: () => void
170+
): void {
171+
canvas.addEventListener("keydown", (e: KeyboardEvent) => {
172+
// Only handle keys when canvas is focused
173+
if (document.activeElement !== canvas) return;
174+
175+
let handled = false;
176+
177+
switch (e.key) {
178+
case "ArrowLeft":
179+
if (focusedNode) {
180+
const next = findNearestNode(focusedNode, "left");
181+
if (next) {
182+
setFocusedNode(next);
183+
handled = true;
184+
}
185+
}
186+
break;
187+
188+
case "ArrowRight":
189+
if (focusedNode) {
190+
const next = findNearestNode(focusedNode, "right");
191+
if (next) {
192+
setFocusedNode(next);
193+
handled = true;
194+
}
195+
} else if (focusableNodes.length > 0) {
196+
// First focus - start with first node
197+
setFocusedNode(focusableNodes[0]);
198+
handled = true;
199+
}
200+
break;
201+
202+
case "ArrowUp":
203+
if (focusedNode) {
204+
const next = findNearestNode(focusedNode, "up");
205+
if (next) {
206+
setFocusedNode(next);
207+
handled = true;
208+
}
209+
}
210+
break;
211+
212+
case "ArrowDown":
213+
if (focusedNode) {
214+
const next = findNearestNode(focusedNode, "down");
215+
if (next) {
216+
setFocusedNode(next);
217+
handled = true;
218+
}
219+
} else if (focusableNodes.length > 0) {
220+
// First focus - start with first node
221+
setFocusedNode(focusableNodes[0]);
222+
handled = true;
223+
}
224+
break;
225+
226+
case "Tab":
227+
if (focusedNode) {
228+
const next = getNextTypeGroup(focusedNode.type, state, e.shiftKey);
229+
if (next) {
230+
setFocusedNode(next);
231+
handled = true;
232+
}
233+
} else if (focusableNodes.length > 0) {
234+
setFocusedNode(focusableNodes[0]);
235+
handled = true;
236+
}
237+
break;
238+
239+
case "Home":
240+
if (focusableNodes.length > 0) {
241+
setFocusedNode(focusableNodes[0]);
242+
handled = true;
243+
}
244+
break;
245+
246+
case "End":
247+
if (focusableNodes.length > 0) {
248+
setFocusedNode(focusableNodes[focusableNodes.length - 1]);
249+
handled = true;
250+
}
251+
break;
252+
253+
case "Escape":
254+
if (focusedNode) {
255+
setFocusedNode(null);
256+
handled = true;
257+
}
258+
break;
259+
260+
case "Enter":
261+
case " ":
262+
// Select/activate the focused node (currently just keeps focus)
263+
if (focusedNode) {
264+
handled = true;
265+
}
266+
break;
267+
}
268+
269+
if (handled) {
270+
e.preventDefault();
271+
e.stopPropagation();
272+
onFocusChange();
273+
}
274+
});
275+
276+
// Clear focus when canvas loses focus
277+
canvas.addEventListener("blur", () => {
278+
if (focusedNode) {
279+
setFocusedNode(null);
280+
onFocusChange();
281+
}
282+
});
283+
}

electron/src/renderer/canvas/Redraw.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,27 @@ export interface VisState {
3535
export function ReDraw(
3636
canvas: HTMLCanvasElement,
3737
ctx: CanvasRenderingContext2D,
38-
state: VisState
38+
state: VisState,
39+
focusedNode: GitNode | null = null
3940
) {
4041
ctx.clearRect(0, 0, canvas.width, canvas.height);
4142

42-
DrawNodes(ctx, state.CommitNodes);
43+
DrawNodes(ctx, state.CommitNodes, focusedNode);
4344

4445
if (state.showTrees) {
45-
DrawNodes(ctx, state.TreeNodes);
46+
DrawNodes(ctx, state.TreeNodes, focusedNode);
4647
}
4748

4849
if (state.showBlobs) {
49-
DrawNodes(ctx, state.BlobNodes);
50+
DrawNodes(ctx, state.BlobNodes, focusedNode);
5051
}
5152

52-
DrawNodes(ctx, state.BranchNodes);
53+
DrawNodes(ctx, state.BranchNodes, focusedNode);
5354
if (state.showTags) {
54-
DrawNodes(ctx, state.TagNodes);
55+
DrawNodes(ctx, state.TagNodes, focusedNode);
5556
}
56-
DrawNodes(ctx, state.RemoteBranchNodes);
57-
DrawNodes(ctx, state.HEADNodes);
57+
DrawNodes(ctx, state.RemoteBranchNodes, focusedNode);
58+
DrawNodes(ctx, state.HEADNodes, focusedNode);
5859

5960
if (state.showTrees) {
6061
DrawCommitToTreeLinks(ctx, state.CommitNodes, state.TreeNodes);

0 commit comments

Comments
 (0)