Merge pull request #32 from Molecule-AI/fix/a11y-landmarks
fix: add main landmark, skip link, and aria-label to canvas (WCAG 2.4.1/2.4.6)
This commit is contained in:
commit
9ec566ad3d
@ -226,7 +226,14 @@ function CanvasInner() {
|
||||
const settingsWorkspaceId = selectedNodeId ?? "global";
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-zinc-950">
|
||||
<>
|
||||
<a
|
||||
href="#canvas-main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-zinc-900 focus:text-zinc-100 focus:rounded-lg focus:border focus:border-zinc-700"
|
||||
>
|
||||
Skip to canvas
|
||||
</a>
|
||||
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
|
||||
<ReactFlow
|
||||
colorMode="dark"
|
||||
nodes={nodes}
|
||||
@ -244,6 +251,7 @@ function CanvasInner() {
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
aria-label="Molecule AI workspace canvas"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
@ -310,6 +318,7 @@ function CanvasInner() {
|
||||
{/* Settings Panel — global secrets management drawer */}
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
138
canvas/src/components/__tests__/Canvas.a11y.test.tsx
Normal file
138
canvas/src/components/__tests__/Canvas.a11y.test.tsx
Normal file
@ -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;
|
||||
}) => (
|
||||
<div role="application" data-testid="react-flow" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
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 <main> landmark with id='canvas-main'", () => {
|
||||
render(<Canvas />);
|
||||
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(<Canvas />);
|
||||
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(<Canvas />);
|
||||
const flow = document.querySelector('[data-testid="react-flow"]');
|
||||
expect(flow?.getAttribute("aria-label")).toBe(
|
||||
"Molecule AI workspace canvas"
|
||||
);
|
||||
});
|
||||
|
||||
it("skip link appears before <main> in the DOM", () => {
|
||||
render(<Canvas />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user