feat(canvas/mobile): TabBar WCAG 2.1 AA accessibility (issue #675)
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 31s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 30s
Harness Replays / Harness Replays (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 36s
qa-review / approved (pull_request) Failing after 15s
gate-check-v3 / gate-check (pull_request) Successful in 19s
security-review / approved (pull_request) Failing after 16s
CI / Platform (Go) (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m45s
CI / Canvas (Next.js) (pull_request) Successful in 9m48s
CI / all-required (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Failing after 14m17s

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 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-uiux 2026-05-12 05:15:34 +00:00
parent 5e2a998cac
commit efa48181d0
2 changed files with 204 additions and 1 deletions

View File

@ -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(
<TabBar active={active} onChange={onChange} dark={true} />,
);
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");
});
});

View File

@ -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<HTMLButtonElement>, 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 (
<div
role="tablist"
aria-label="Mobile navigation"
style={{
position: "absolute",
left: 14,
@ -95,13 +121,18 @@ export function TabBar({
padding: "0 10px",
}}
>
{tabs.map((t) => {
{tabs.map((t, idx) => {
const on = active === t.id;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={on}
aria-label={t.label}
tabIndex={on ? 0 : -1}
onClick={() => onChange(t.id)}
onKeyDown={(e) => handleKeyDown(e, idx)}
style={{
background: "none",
border: "none",
@ -116,6 +147,7 @@ export function TabBar({
}}
>
<span
aria-hidden="true"
style={{
width: 36,
height: 28,