diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 24c6c2fa..07aea51a 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -226,7 +226,14 @@ function CanvasInner() { const settingsWorkspaceId = selectedNodeId ?? "global"; return ( -
+ <> + + Skip to canvas + +
-
+ + ); } diff --git a/canvas/src/components/__tests__/Canvas.a11y.test.tsx b/canvas/src/components/__tests__/Canvas.a11y.test.tsx new file mode 100644 index 00000000..792e121c --- /dev/null +++ b/canvas/src/components/__tests__/Canvas.a11y.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); + +// ── Mock @xyflow/react ──────────────────────────────────────────────────────── +vi.mock("@xyflow/react", () => { + const ReactFlow = ({ + children, + "aria-label": ariaLabel, + }: { + children?: React.ReactNode; + "aria-label"?: string; + }) => ( +
+ {children} +
+ ); + return { + __esModule: true, + default: ReactFlow, + ReactFlow, + ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + Background: () => null, + Controls: () => null, + MiniMap: () => null, + BackgroundVariant: { Dots: "dots" }, + useReactFlow: () => ({ + fitView: vi.fn(), + setViewport: vi.fn(), + getIntersectingNodes: vi.fn(() => []), + fitBounds: vi.fn(), + setCenter: vi.fn(), + }), + applyNodeChanges: vi.fn((_: unknown, nodes: unknown) => nodes), + useStore: vi.fn(() => ({ width: 800, height: 600 })), + }; +}); + +// ── Mock the canvas store ───────────────────────────────────────────────────── +const mockStoreState = { + nodes: [], + edges: [], + selectedNodeId: null, + panelTab: "chat", + dragOverNodeId: null, + contextMenu: null, + viewport: { x: 0, y: 0, zoom: 1 }, + searchOpen: false, + onNodesChange: vi.fn(), + savePosition: vi.fn(), + saveViewport: vi.fn(), + selectNode: vi.fn(), + openContextMenu: vi.fn(), + closeContextMenu: vi.fn(), + setDragOverNode: vi.fn(), + nestNode: vi.fn(), + isDescendant: vi.fn(() => false), + setSearchOpen: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState) + ), + { getState: () => mockStoreState } + ), +})); + +// ── Mock the socket store ───────────────────────────────────────────────────── +vi.mock("@/store/socket", () => ({ + connectSocket: vi.fn(), + disconnectSocket: vi.fn(), +})); + +// ── Mock all heavy child components to null ─────────────────────────────────── +vi.mock("../Toolbar", () => ({ Toolbar: () => null })); +vi.mock("../SidePanel", () => ({ SidePanel: () => null })); +vi.mock("../EmptyState", () => ({ EmptyState: () => null })); +vi.mock("../ContextMenu", () => ({ ContextMenu: () => null })); +vi.mock("../SearchDialog", () => ({ SearchDialog: () => null })); +vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null })); +vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null })); +vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null })); +vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null })); +vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null })); +vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null })); +vi.mock("../settings", () => ({ + SettingsPanel: () => null, + DeleteConfirmDialog: () => null, +})); +vi.mock("../Toaster", () => ({ Toaster: () => null })); +vi.mock("../WorkspaceNode", () => ({ WorkspaceNode: () => null })); + +// ── Import the component under test AFTER mocks ─────────────────────────────── +import { Canvas } from "../Canvas"; + +describe("Canvas — accessibility landmarks", () => { + it("renders a
landmark with id='canvas-main'", () => { + render(); + const main = screen.getByRole("main"); + expect(main).toBeTruthy(); + expect(main.id).toBe("canvas-main"); + }); + + it("renders a skip-to-content link pointing at #canvas-main", () => { + render(); + const skipLink = document.querySelector('a[href="#canvas-main"]'); + expect(skipLink).toBeTruthy(); + expect(skipLink?.textContent?.trim()).toBe("Skip to canvas"); + }); + + it("ReactFlow wrapper receives aria-label describing the canvas", () => { + render(); + const flow = document.querySelector('[data-testid="react-flow"]'); + expect(flow?.getAttribute("aria-label")).toBe( + "Molecule AI workspace canvas" + ); + }); + + it("skip link appears before
in the DOM", () => { + render(); + const body = document.body; + const skipLink = body.querySelector('a[href="#canvas-main"]'); + const main = body.querySelector("main"); + expect(skipLink).toBeTruthy(); + expect(main).toBeTruthy(); + // skip link must come before main in the DOM order + const position = skipLink!.compareDocumentPosition(main!); + expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +});