From efa48181d00ca6fa7428c241ca34c6c03e79362e Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 05:15:34 +0000 Subject: [PATCH] feat(canvas/mobile): TabBar WCAG 2.1 AA accessibility (issue #675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add role="tablist"/"tab", aria-selected, roving tabIndex, and aria-label to the mobile TabBar per WCAG §4.10.3. Per-button onKeyDown handlers drive Arrow/Home/End navigation; icon spans carry aria-hidden="true". 16 new test cases cover ARIA attributes and keyboard navigation. Co-Authored-By: Claude Opus 4.7 --- .../mobile/__tests__/TabBar.test.tsx | 171 ++++++++++++++++++ canvas/src/components/mobile/components.tsx | 34 +++- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 canvas/src/components/mobile/__tests__/TabBar.test.tsx diff --git a/canvas/src/components/mobile/__tests__/TabBar.test.tsx b/canvas/src/components/mobile/__tests__/TabBar.test.tsx new file mode 100644 index 00000000..05d8a86f --- /dev/null +++ b/canvas/src/components/mobile/__tests__/TabBar.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +/** + * Tests for TabBar — WCAG 2.1 AA ARIA tab pattern + keyboard navigation. + * + * Per spec §4.10.3: roving tabindex, arrow key cycling, Home/End support. + * + * Covers: + * - role=tablist with aria-label on container + * - role=tab, aria-selected, aria-label, tabIndex on each button + * - aria-hidden on icon spans + * - Active tab has tabIndex=0, others tabIndex=-1 + * - ArrowRight/ArrowDown cycles forward + * - ArrowLeft/ArrowUp cycles backward + * - Home jumps to first tab + * - End jumps to last tab + * - onChange called with correct tab id on click + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; + +import { TabBar, type MobileTabId } from "../components"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function renderTabBar(active: MobileTabId = "agents") { + const onChange = vi.fn<(id: MobileTabId) => void>(); + const result = render( + , + ); + return { onChange, ...result }; +} + +// ── ARIA attributes ──────────────────────────────────────────────────── + +describe("TabBar — ARIA attributes", () => { + afterEach(() => { + cleanup(); + }); + + it("container has role=tablist and aria-label", () => { + renderTabBar(); + const list = document.querySelector('[role="tablist"]'); + expect(list).toBeTruthy(); + expect(list?.getAttribute("aria-label")).toBe("Mobile navigation"); + }); + + it("each button has role=tab", () => { + renderTabBar(); + const tabs = document.querySelectorAll('[role="tab"]'); + expect(tabs).toHaveLength(4); + }); + + it("each button has aria-label matching its tab name", () => { + renderTabBar(); + const labels = ["Agents", "Canvas", "Comms", "Me"]; + for (const label of labels) { + expect(document.querySelector(`[role="tab"][aria-label="${label}"]`)).toBeTruthy(); + } + }); + + it("active tab has aria-selected=true, others have aria-selected=false", () => { + renderTabBar("agents"); + const tabs = Array.from(document.querySelectorAll('[role="tab"]')); + expect(tabs[0]?.getAttribute("aria-selected")).toBe("true"); + expect(tabs[1]?.getAttribute("aria-selected")).toBe("false"); + expect(tabs[2]?.getAttribute("aria-selected")).toBe("false"); + expect(tabs[3]?.getAttribute("aria-selected")).toBe("false"); + }); + + it("active tab has tabIndex=0, others have tabIndex=-1", () => { + renderTabBar("canvas"); + const tabs = Array.from(document.querySelectorAll('[role="tab"]')); + expect(tabs[1]?.getAttribute("tabindex")).toBe("0"); + expect(tabs[0]?.getAttribute("tabindex")).toBe("-1"); + expect(tabs[2]?.getAttribute("tabindex")).toBe("-1"); + expect(tabs[3]?.getAttribute("tabindex")).toBe("-1"); + }); + + it("icon spans have aria-hidden=true", () => { + renderTabBar(); + const icons = document.querySelectorAll('[role="tab"] [aria-hidden="true"]'); + expect(icons).toHaveLength(4); + }); + + it("clicking a tab calls onChange with correct id", () => { + const { onChange } = renderTabBar("agents"); + const canvasTab = document.querySelector('[role="tab"][aria-label="Canvas"]') as HTMLButtonElement; + fireEvent.click(canvasTab); + expect(onChange).toHaveBeenCalledWith("canvas"); + }); + + it("renders all four tabs: Agents, Canvas, Comms, Me", () => { + renderTabBar(); + const labels = ["Agents", "Canvas", "Comms", "Me"]; + for (const label of labels) { + expect(screen.getByRole("tab", { name: new RegExp(label) })).toBeTruthy(); + } + }); +}); + +// ── Keyboard navigation ───────────────────────────────────────────────── + +describe("TabBar — keyboard navigation", () => { + afterEach(() => { + cleanup(); + }); + + // Per-button handler: dispatch on the specific tab button that has focus. + // Each button captures its own tabIdx from the .map() closure, so we + // must target the correct button element for the key to resolve correctly. + function dispatchKey(btn: Element, key: string) { + fireEvent.keyDown(btn, { key }); + } + + it("ArrowRight moves focus to next tab", () => { + const { onChange } = renderTabBar("agents"); + const btn = document.querySelector('[role="tab"][aria-label="Agents"]') as HTMLElement; + dispatchKey(btn, "ArrowRight"); + expect(onChange).toHaveBeenCalledWith("canvas"); + }); + + it("ArrowLeft moves focus to previous tab (wrapping to last)", () => { + const { onChange } = renderTabBar("agents"); + const btn = document.querySelector('[role="tab"][aria-label="Agents"]') as HTMLElement; + dispatchKey(btn, "ArrowLeft"); + expect(onChange).toHaveBeenCalledWith("me"); // wraps to last + }); + + it("ArrowDown moves focus to next tab", () => { + const { onChange } = renderTabBar("agents"); + const btn = document.querySelector('[role="tab"][aria-label="Agents"]') as HTMLElement; + dispatchKey(btn, "ArrowDown"); + expect(onChange).toHaveBeenCalledWith("canvas"); + }); + + it("ArrowUp moves focus to previous tab (wrapping to last)", () => { + const { onChange } = renderTabBar("agents"); + const btn = document.querySelector('[role="tab"][aria-label="Agents"]') as HTMLElement; + dispatchKey(btn, "ArrowUp"); + expect(onChange).toHaveBeenCalledWith("me"); // wraps to last + }); + + it("Home jumps to first tab", () => { + const { onChange } = renderTabBar("comms"); + const btn = document.querySelector('[role="tab"][aria-label="Comms"]') as HTMLElement; + dispatchKey(btn, "Home"); + expect(onChange).toHaveBeenCalledWith("agents"); + }); + + it("End jumps to last tab", () => { + const { onChange } = renderTabBar("agents"); + const btn = document.querySelector('[role="tab"][aria-label="Agents"]') as HTMLElement; + dispatchKey(btn, "End"); + expect(onChange).toHaveBeenCalledWith("me"); + }); + + it("ArrowRight from last tab wraps to first", () => { + const { onChange } = renderTabBar("me"); + const btn = document.querySelector('[role="tab"][aria-label="Me"]') as HTMLElement; + dispatchKey(btn, "ArrowRight"); + expect(onChange).toHaveBeenCalledWith("agents"); + }); + + it("ArrowLeft from first tab wraps to last", () => { + const { onChange } = renderTabBar("agents"); + const btn = document.querySelector('[role="tab"][aria-label="Agents"]') as HTMLElement; + dispatchKey(btn, "ArrowLeft"); + expect(onChange).toHaveBeenCalledWith("me"); + }); +}); diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx index 9e1c8780..abb5f9d6 100644 --- a/canvas/src/components/mobile/components.tsx +++ b/canvas/src/components/mobile/components.tsx @@ -72,8 +72,34 @@ export function TabBar({ { id: "comms", label: "Comms", icon: "pulse" }, { id: "me", label: "Me", icon: "user" }, ]; + + // WCAG 2.1 AA §4.10.3 — roving tabindex. Arrow keys on the focused tab + // button move focus to the next/previous tab; Home/End jump to first/last. + // This per-button handler is simpler to test than a container-level handler. + function handleKeyDown(e: React.KeyboardEvent, tabIdx: number) { + let next: number | null = null; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + next = (tabIdx + 1) % tabs.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + next = (tabIdx - 1 + tabs.length) % tabs.length; + } else if (e.key === "Home") { + e.preventDefault(); + next = 0; + } else if (e.key === "End") { + e.preventDefault(); + next = tabs.length - 1; + } + if (next !== null) { + onChange(tabs[next]!.id); + } + } + return (
- {tabs.map((t) => { + {tabs.map((t, idx) => { const on = active === t.id; return (