From a7ea544d28e457174ac2067497d5bc1b0b473e1a Mon Sep 17 00:00:00 2001 From: Dev Lead Agent Date: Tue, 14 Apr 2026 06:24:57 +0000 Subject: [PATCH] fix: add main landmark, skip link, and aria-label to canvas (WCAG 2.4.1/2.4.6) - Wrap CanvasInner return in React Fragment to host skip-nav link as sibling of
- Add skip link (sr-only, revealed on focus) before
- Add id="canvas-main" to
element - Add aria-label="Molecule AI workspace canvas" to ReactFlow wrapper - Add Canvas.a11y.test.tsx: 4 jsdom tests covering all three a11y landmarks 369/369 tests pass; next build clean. Co-Authored-By: Claude Sonnet 4.6 --- canvas/src/components/Canvas.tsx | 13 +- .../components/__tests__/Canvas.a11y.test.tsx | 138 ++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 canvas/src/components/__tests__/Canvas.a11y.test.tsx 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(); + }); +});