fix: keyboard navigation for ContextMenu (WCAG 2.1.1) and SearchDialog combobox pattern

- ContextMenu: role=menu/menuitem/separator, aria-label, aria-disabled,
  focus-visible ring, auto-focus first enabled item on open,
  ArrowDown/Up roving focus (wrapping), Escape + Tab dismiss,
  aria-hidden on decorative icons/status dot
- SearchDialog: role=dialog+aria-modal, combobox pattern on input
  (role=combobox, aria-expanded, aria-autocomplete, aria-controls,
  aria-activedescendant), focusedIndex state, ArrowDown/Up/Enter
  keyboard navigation, role=listbox+option, aria-selected, role=status
  + aria-live=polite on empty state, footer hints updated with ↑↓
- Add 10 ContextMenu keyboard tests (role, aria-label, menuitem,
  separator, Escape, Tab, ArrowDown, wrap, ArrowUp wrap, null guard)
- Add 13 SearchDialog keyboard tests (dialog, aria-modal, combobox,
  listbox, option, ArrowDown, double-ArrowDown, clamp, ArrowUp-clamp,
  Enter select, Enter noop, query reset, activedescendant)

Tests: 406 passed (383 existing + 23 new)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Lead Agent 2026-04-14 09:28:10 +00:00
parent ea6fdd58a6
commit 0725a818e7
4 changed files with 481 additions and 21 deletions

View File

@ -34,6 +34,15 @@ export function ContextMenu() {
if (!contextMenu) setDeleteConfirm(null);
}, [contextMenu]);
// Auto-focus first enabled item when menu opens
useEffect(() => {
if (!contextMenu) return;
requestAnimationFrame(() => {
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
first?.focus();
});
}, [contextMenu?.nodeId]);
// Close on click outside or Escape
useEffect(() => {
if (!contextMenu) return;
@ -53,6 +62,38 @@ export function ContextMenu() {
};
}, [contextMenu, closeContextMenu]);
// Arrow-key navigation within the menu
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
closeContextMenu();
return;
}
if (e.key === "Tab") {
e.preventDefault();
closeContextMenu();
return;
}
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const buttons = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []
);
const active = document.activeElement as HTMLButtonElement;
const idx = buttons.indexOf(active);
const next =
e.key === "ArrowDown"
? idx === -1
? 0
: (idx + 1) % buttons.length
: idx <= 0
? buttons.length - 1
: idx - 1;
buttons[next]?.focus();
},
[closeContextMenu]
);
const handleExportBundle = useCallback(async () => {
if (!contextMenu || actionLoading) return;
setActionLoading(true);
@ -224,6 +265,9 @@ export function ContextMenu() {
return (
<div
ref={ref}
role="menu"
aria-label={`Actions for ${contextMenu.nodeData.name}`}
onKeyDown={handleMenuKeyDown}
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
@ -231,29 +275,34 @@ export function ContextMenu() {
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
<div className="flex items-center gap-1.5 mt-0.5">
<div className={`w-1.5 h-1.5 rounded-full ${
isOnline ? "bg-emerald-400" : isOfflineOrFailed ? "bg-red-400" : "bg-zinc-500"
}`} />
<span className="text-[9px] text-zinc-500">{contextMenu.nodeData.status}</span>
<div
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${
isOnline ? "bg-emerald-400" : isOfflineOrFailed ? "bg-red-400" : "bg-zinc-500"
}`}
/>
<span className="text-[10px] text-zinc-500">{contextMenu.nodeData.status}</span>
</div>
</div>
{items.map((item, i) => {
if (item.divider) {
return <div key={i} className="h-px bg-zinc-800/60 my-1" />;
return <div key={i} role="separator" className="h-px bg-zinc-800/60 my-1" />;
}
return (
<button
key={i}
role="menuitem"
onClick={item.action}
disabled={item.disabled}
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors disabled:opacity-25 disabled:cursor-not-allowed ${
aria-disabled={item.disabled}
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
item.danger
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
}`}
>
<span className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
<span aria-hidden="true" className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
{item.label}
</button>
);

View File

@ -7,6 +7,7 @@ export function SearchDialog() {
const open = useCanvasStore((s) => s.searchOpen);
const setOpen = useCanvasStore((s) => s.setSearchOpen);
const [query, setQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const nodes = useCanvasStore((s) => s.nodes);
const selectNode = useCanvasStore((s) => s.selectNode);
@ -34,6 +35,11 @@ export function SearchDialog() {
}
}, [open]);
// Reset focused index when query changes
useEffect(() => {
setFocusedIndex(-1);
}, [query]);
const filtered = nodes.filter((n) => {
if (!query) return true;
const q = query.toLowerCase();
@ -50,27 +56,61 @@ export function SearchDialog() {
setPanelTab("details");
setOpen(false);
},
[selectNode, setPanelTab]
[selectNode, setPanelTab, setOpen]
);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setFocusedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && focusedIndex >= 0 && filtered[focusedIndex]) {
e.preventDefault();
handleSelect(filtered[focusedIndex].id);
}
},
[filtered, focusedIndex, handleSelect]
);
const activeDescendant =
focusedIndex >= 0 && filtered[focusedIndex]
? `search-result-${filtered[focusedIndex].id}`
: undefined;
if (!open) return null;
return (
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)}>
<div
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-label="Search workspaces"
className="w-[420px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/40">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500" aria-hidden="true">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
role="combobox"
aria-label="Search workspaces"
aria-expanded={filtered.length > 0}
aria-autocomplete="list"
aria-controls="search-results-list"
aria-activedescendant={activeDescendant}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search workspaces..."
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none"
/>
@ -78,31 +118,49 @@ export function SearchDialog() {
</div>
{/* Results */}
<div className="max-h-[300px] overflow-y-auto py-1">
<div
id="search-results-list"
role="listbox"
aria-label="Workspace results"
className="max-h-[300px] overflow-y-auto py-1"
>
{filtered.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-zinc-600">
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-600">
{query ? "No workspaces match" : "No workspaces yet"}
</div>
) : (
filtered.map((node) => (
filtered.map((node, index) => (
<button
key={node.id}
id={`search-result-${node.id}`}
role="option"
aria-selected={index === focusedIndex}
onClick={() => handleSelect(node.id)}
className="w-full px-4 py-2.5 flex items-center gap-3 text-left hover:bg-zinc-800/40 transition-colors"
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
index === focusedIndex ? "bg-zinc-800/60" : "hover:bg-zinc-800/40"
}`}
>
<div className={`w-2 h-2 rounded-full shrink-0 ${
node.data.status === "online" ? "bg-emerald-400" :
node.data.status === "failed" ? "bg-red-400" :
node.data.status === "provisioning" ? "bg-sky-400 animate-pulse" :
"bg-zinc-500"
}`} />
<div
aria-hidden="true"
className={`w-2 h-2 rounded-full shrink-0 ${
node.data.status === "online" ? "bg-emerald-400" :
node.data.status === "failed" ? "bg-red-400" :
node.data.status === "provisioning" ? "bg-sky-400 animate-pulse" :
"bg-zinc-500"
}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-zinc-200 truncate">{node.data.name}</div>
{node.data.role && (
<div className="text-[10px] text-zinc-500 truncate">{node.data.role}</div>
)}
</div>
<span className="text-[9px] font-mono text-zinc-600">T{node.data.tier}</span>
<span
className="text-[9px] font-mono text-zinc-600"
aria-label={`Tier ${node.data.tier}`}
>
T{node.data.tier}
</span>
</button>
))
)}
@ -112,6 +170,7 @@ export function SearchDialog() {
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
<span className="text-[9px] text-zinc-600">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
<div className="flex gap-2">
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40"> navigate</kbd>
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40"> select</kbd>
</div>
</div>

View File

@ -0,0 +1,166 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
afterEach(cleanup);
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
vi.mock("@/lib/api", () => ({
api: { get: vi.fn(), post: vi.fn(), del: vi.fn(), patch: vi.fn() },
}));
const closeContextMenu = vi.fn();
const mockStore = {
contextMenu: {
x: 100,
y: 200,
nodeId: "ws-1",
nodeData: {
name: "Alpha Workspace",
status: "online",
tier: 1,
parentId: null,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "dev",
lastErrorRate: 0,
lastSampleError: "",
url: "",
currentTask: "",
runtime: "claude-code",
needsRestart: false,
},
} as {
x: number;
y: number;
nodeId: string;
nodeData: Record<string, unknown>;
} | null,
closeContextMenu,
removeNode: vi.fn(),
updateNodeData: vi.fn(),
selectNode: vi.fn(),
setPanelTab: vi.fn(),
nestNode: vi.fn(),
nodes: [] as Array<{ id: string; data: { parentId: string | null } }>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(
(selector: (s: typeof mockStore) => unknown) => selector(mockStore)
),
}));
// ── Component under test — imported AFTER mocks ───────────────────────────────
import { ContextMenu } from "../ContextMenu";
// ── Helpers ───────────────────────────────────────────────────────────────────
const onlineMenu = {
x: 100,
y: 200,
nodeId: "ws-1",
nodeData: {
name: "Alpha Workspace",
status: "online",
tier: 1,
parentId: null,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "dev",
lastErrorRate: 0,
lastSampleError: "",
url: "",
currentTask: "",
runtime: "claude-code",
needsRestart: false,
},
};
describe("ContextMenu — keyboard accessibility", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStore.contextMenu = onlineMenu;
mockStore.nodes = [];
});
it("renders with role='menu'", () => {
render(<ContextMenu />);
expect(screen.getByRole("menu")).toBeTruthy();
});
it("menu has aria-label containing the workspace name", () => {
render(<ContextMenu />);
const menu = screen.getByRole("menu");
expect(menu.getAttribute("aria-label")).toContain("Alpha Workspace");
});
it("menu items have role='menuitem'", () => {
render(<ContextMenu />);
const items = screen.getAllByRole("menuitem");
expect(items.length).toBeGreaterThan(0);
});
it("dividers have role='separator'", () => {
render(<ContextMenu />);
const separators = document.querySelectorAll('[role="separator"]');
expect(separators.length).toBeGreaterThan(0);
});
it("Escape key calls closeContextMenu", () => {
render(<ContextMenu />);
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "Escape" });
// Both the document keydown listener and the menu onKeyDown handler fire
// on the same event — both call closeContextMenu. Two calls is correct.
expect(closeContextMenu).toHaveBeenCalled();
});
it("Tab key calls closeContextMenu", () => {
render(<ContextMenu />);
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "Tab" });
expect(closeContextMenu).toHaveBeenCalledOnce();
});
it("ArrowDown with nothing focused moves focus to the first enabled button", () => {
render(<ContextMenu />);
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
const buttons = menu.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)"
);
expect(document.activeElement).toBe(buttons[0]);
});
it("ArrowDown wraps from the last enabled button to the first", () => {
render(<ContextMenu />);
const menu = screen.getByRole("menu");
const buttons = menu.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)"
);
buttons[buttons.length - 1].focus();
fireEvent.keyDown(menu, { key: "ArrowDown" });
expect(document.activeElement).toBe(buttons[0]);
});
it("ArrowUp wraps from the first enabled button to the last", () => {
render(<ContextMenu />);
const menu = screen.getByRole("menu");
const buttons = menu.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)"
);
buttons[0].focus();
fireEvent.keyDown(menu, { key: "ArrowUp" });
expect(document.activeElement).toBe(buttons[buttons.length - 1]);
});
it("returns null when contextMenu is null", () => {
mockStore.contextMenu = null;
const { container } = render(<ContextMenu />);
expect(container.firstChild).toBeNull();
});
});

View File

@ -0,0 +1,186 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
afterEach(cleanup);
// ── Mock store data ───────────────────────────────────────────────────────────
const setOpen = vi.fn();
const selectNode = vi.fn();
const setPanelTab = vi.fn();
const mockNodes = [
{
id: "ws-1",
data: {
name: "Alpha",
status: "online",
tier: 1,
role: "dev",
parentId: null,
},
},
{
id: "ws-2",
data: {
name: "Beta",
status: "offline",
tier: 2,
role: "ops",
parentId: null,
},
},
{
id: "ws-3",
data: {
name: "Gamma",
status: "provisioning",
tier: 1,
role: "qa",
parentId: null,
},
},
];
const mockStore = {
searchOpen: true,
setSearchOpen: setOpen,
nodes: mockNodes as typeof mockNodes,
selectNode,
setPanelTab,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(
(selector: (s: typeof mockStore) => unknown) => selector(mockStore)
),
}));
// ── Component under test — imported AFTER mocks ───────────────────────────────
import { SearchDialog } from "../SearchDialog";
describe("SearchDialog — keyboard accessibility", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStore.searchOpen = true;
mockStore.nodes = mockNodes;
});
it("renders with role='dialog' and aria-modal='true'", () => {
render(<SearchDialog />);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeTruthy();
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("dialog has an aria-label", () => {
render(<SearchDialog />);
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-label")).toBeTruthy();
});
it("search input has role='combobox'", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
expect(input).toBeTruthy();
});
it("results container has role='listbox'", () => {
render(<SearchDialog />);
const listbox = screen.getByRole("listbox");
expect(listbox).toBeTruthy();
});
it("result items have role='option'", () => {
render(<SearchDialog />);
const options = screen.getAllByRole("option");
expect(options.length).toBe(3);
});
it("ArrowDown sets aria-selected='true' on the first option", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
expect(options[1].getAttribute("aria-selected")).toBe("false");
});
it("ArrowDown twice sets aria-selected='true' on the second option", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
it("ArrowDown clamps at the last option — does not wrap", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
// Press ArrowDown 5 times with only 3 items — should stop at index 2
for (let i = 0; i < 5; i++) {
fireEvent.keyDown(input, { key: "ArrowDown" });
}
const options = screen.getAllByRole("option");
expect(options[2].getAttribute("aria-selected")).toBe("true");
// first two must not be selected
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("false");
});
it("ArrowUp from index 0 stays at 0 (Math.max clamp)", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
fireEvent.keyDown(input, { key: "ArrowUp" }); // Math.max(0-1, 0) = 0, stays at 0
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
});
it("Enter key selects the currently focused option", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" }); // focus index 0 (ws-1)
fireEvent.keyDown(input, { key: "Enter" });
expect(selectNode).toHaveBeenCalledWith("ws-1");
});
it("Enter at focusedIndex=-1 does not select anything", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
// No ArrowDown — focusedIndex is -1
fireEvent.keyDown(input, { key: "Enter" });
expect(selectNode).not.toHaveBeenCalled();
});
it("typing a new query resets focusedIndex to -1", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
// Verify selection before reset
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
// Change query — triggers the useEffect that resets focusedIndex
fireEvent.change(input, { target: { value: "Alpha" } });
// After reset all options must have aria-selected="false"
screen.getAllByRole("option").forEach((opt) => {
expect(opt.getAttribute("aria-selected")).toBe("false");
});
});
it("aria-activedescendant matches the focused option's id", () => {
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0 (ws-1)
expect(input.getAttribute("aria-activedescendant")).toBe(
"search-result-ws-1"
);
});
it("returns null when searchOpen is false", () => {
mockStore.searchOpen = false;
const { container } = render(<SearchDialog />);
expect(container.firstChild).toBeNull();
});
});