diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index ede1f418..d4be6ec5 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -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("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) => { + 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("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 (
@@ -231,29 +275,34 @@ export function ContextMenu() {
{contextMenu.nodeData.name}
-
- {contextMenu.nodeData.status} +
{items.map((item, i) => { if (item.divider) { - return
; + return
; } return ( ); diff --git a/canvas/src/components/SearchDialog.tsx b/canvas/src/components/SearchDialog.tsx index c1e28a6b..81902c7b 100644 --- a/canvas/src/components/SearchDialog.tsx +++ b/canvas/src/components/SearchDialog.tsx @@ -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(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) => { + 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 ( -
setOpen(false)}> +
setOpen(false)} + >
e.stopPropagation()} > {/* Search input */}
- +
{/* Results */} -
+
{filtered.length === 0 ? ( -
+
{query ? "No workspaces match" : "No workspaces yet"}
) : ( - filtered.map((node) => ( + filtered.map((node, index) => ( )) )} @@ -112,6 +170,7 @@ export function SearchDialog() {
{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}
+ ↑↓ navigate ↵ select
diff --git a/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx b/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx new file mode 100644 index 00000000..e8a1376f --- /dev/null +++ b/canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx @@ -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; + } | 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(); + expect(screen.getByRole("menu")).toBeTruthy(); + }); + + it("menu has aria-label containing the workspace name", () => { + render(); + const menu = screen.getByRole("menu"); + expect(menu.getAttribute("aria-label")).toContain("Alpha Workspace"); + }); + + it("menu items have role='menuitem'", () => { + render(); + const items = screen.getAllByRole("menuitem"); + expect(items.length).toBeGreaterThan(0); + }); + + it("dividers have role='separator'", () => { + render(); + const separators = document.querySelectorAll('[role="separator"]'); + expect(separators.length).toBeGreaterThan(0); + }); + + it("Escape key calls closeContextMenu", () => { + render(); + 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(); + 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(); + const menu = screen.getByRole("menu"); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + const buttons = menu.querySelectorAll( + "button:not(:disabled)" + ); + expect(document.activeElement).toBe(buttons[0]); + }); + + it("ArrowDown wraps from the last enabled button to the first", () => { + render(); + const menu = screen.getByRole("menu"); + const buttons = menu.querySelectorAll( + "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(); + const menu = screen.getByRole("menu"); + const buttons = menu.querySelectorAll( + "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(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/canvas/src/components/__tests__/SearchDialog.keyboard.test.tsx b/canvas/src/components/__tests__/SearchDialog.keyboard.test.tsx new file mode 100644 index 00000000..45e1bcfc --- /dev/null +++ b/canvas/src/components/__tests__/SearchDialog.keyboard.test.tsx @@ -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(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeTruthy(); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("dialog has an aria-label", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("aria-label")).toBeTruthy(); + }); + + it("search input has role='combobox'", () => { + render(); + const input = screen.getByRole("combobox"); + expect(input).toBeTruthy(); + }); + + it("results container has role='listbox'", () => { + render(); + const listbox = screen.getByRole("listbox"); + expect(listbox).toBeTruthy(); + }); + + it("result items have role='option'", () => { + render(); + const options = screen.getAllByRole("option"); + expect(options.length).toBe(3); + }); + + it("ArrowDown sets aria-selected='true' on the first option", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(container.firstChild).toBeNull(); + }); +});