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
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:
parent
5e2a998cac
commit
efa48181d0
171
canvas/src/components/mobile/__tests__/TabBar.test.tsx
Normal file
171
canvas/src/components/mobile/__tests__/TabBar.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user