From 13a44690684073eea00d64eb92052aee5923dfdf Mon Sep 17 00:00:00 2001 From: Molecule AI App-FE Date: Tue, 12 May 2026 05:07:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20TabBar=20WCAG=202.1=20AA=20acce?= =?UTF-8?q?ssibility=20=E2=80=94=20ARIA=20tab=20pattern=20+=20keyboard=20n?= =?UTF-8?q?av?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds role=tablist + aria-label to outer container - Adds role=tab, aria-selected, aria-label, aria-hidden(icon) to each tab button - tabIndex: active=0, others=-1 (standard tab pattern) - Keyboard: Arrow keys cycle tabs, Home/End jump to first/last - TabBar.test.tsx: 12 cases covering render states and keyboard interaction 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../mobile/__tests__/TabBar.test.tsx | 154 ++++++++++++++++++ canvas/src/components/mobile/components.tsx | 33 +++- 2 files changed, 186 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..93baf520 --- /dev/null +++ b/canvas/src/components/mobile/__tests__/TabBar.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +/** + * TabBar — mobile bottom navigation bar. + * + * Per WCAG 2.1 AA / ARIA tab pattern: + * - Outer div has role="tablist" + aria-label + * - Each tab button has role="tab", aria-selected, aria-label + * - Icon span has aria-hidden="true" (label text is the accessible name) + * - Keyboard: Arrow keys cycle tabs, Home/End go to first/last + * - tabIndex: active tab is 0, others are -1 + * + * NOTE: No @testing-library/jest-dom — use DOM APIs. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import React from "react"; + +import { TabBar, type MobileTabId } from "../components"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +// ─── Render ─────────────────────────────────────────────────────────────────── + +describe("TabBar — render", () => { + it("renders 4 tab buttons", () => { + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + expect(tabs.length).toBe(4); + }); + + it("outer div has role=tablist and aria-label", () => { + render(); + const tablist = document.querySelector('[role="tablist"]'); + expect(tablist).toBeTruthy(); + expect(tablist?.getAttribute("aria-label")).toBe("Mobile navigation"); + }); + + it("each tab button has role=tab and aria-label", () => { + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + tabs.forEach((tab) => { + expect(tab.getAttribute("role")).toBe("tab"); + expect(tab.getAttribute("aria-label")).toBeTruthy(); + }); + }); + + it("icon spans have aria-hidden=true", () => { + render(); + const icons = document.querySelectorAll('[aria-hidden="true"]'); + expect(icons.length).toBeGreaterThanOrEqual(4); + }); + + it("active tab has aria-selected=true, others false", () => { + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + tabs.forEach((tab) => { + const label = tab.getAttribute("aria-label"); + if (label === "Canvas") { + expect(tab.getAttribute("aria-selected")).toBe("true"); + } else { + expect(tab.getAttribute("aria-selected")).toBe("false"); + } + }); + }); + + it("active tab has tabIndex=0, others tabIndex=-1", () => { + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + tabs.forEach((tab) => { + const label = tab.getAttribute("aria-label"); + if (label === "Comms") { + expect(tab.getAttribute("tabIndex")).toBe("0"); + } else { + expect(tab.getAttribute("tabIndex")).toBe("-1"); + } + }); + }); +}); + +// ─── Interaction ───────────────────────────────────────────────────────────── + +describe("TabBar — interaction", () => { + it("calls onChange with correct id when tab is clicked", () => { + const onChange = vi.fn(); + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + const canvasTab = Array.from(tabs).find((t) => t.getAttribute("aria-label") === "Canvas") as Element; + fireEvent.click(canvasTab); + expect(onChange).toHaveBeenCalledWith("canvas"); + }); + + it("ArrowRight moves focus to next tab and activates it", () => { + const onChange = vi.fn(); + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + const agentsTab = tabs[0] as HTMLElement; + agentsTab.focus(); + expect(document.activeElement).toBe(agentsTab); + + fireEvent.keyDown(agentsTab, { key: "ArrowRight" }); + // onChange called for the next tab + expect(onChange).toHaveBeenCalledWith("canvas"); + // Focus should move to the canvas tab + // Use setTimeout(0) trick — after state update, focus moves + }); + + it("ArrowLeft on first tab wraps to last", () => { + const onChange = vi.fn(); + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + const agentsTab = tabs[0] as HTMLElement; + agentsTab.focus(); + + fireEvent.keyDown(agentsTab, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenCalledWith("me"); + }); + + it("Home key activates first tab", () => { + const onChange = vi.fn(); + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + const commsTab = tabs[2] as HTMLElement; + commsTab.focus(); + + fireEvent.keyDown(commsTab, { key: "Home" }); + expect(onChange).toHaveBeenCalledWith("agents"); + }); + + it("End key activates last tab", () => { + const onChange = vi.fn(); + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + const agentsTab = tabs[0] as HTMLElement; + agentsTab.focus(); + + fireEvent.keyDown(agentsTab, { key: "End" }); + expect(onChange).toHaveBeenCalledWith("me"); + }); + + it("ArrowDown also navigates (aliases ArrowRight)", () => { + const onChange = vi.fn(); + render(); + const tabs = document.querySelectorAll('[role="tab"]'); + const canvasTab = tabs[1] as HTMLElement; + canvasTab.focus(); + + fireEvent.keyDown(canvasTab, { key: "ArrowDown" }); + expect(onChange).toHaveBeenCalledWith("comms"); + }); +}); diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx index 9e1c8780..5bffbf38 100644 --- a/canvas/src/components/mobile/components.tsx +++ b/canvas/src/components/mobile/components.tsx @@ -72,8 +72,33 @@ export function TabBar({ { id: "comms", label: "Comms", icon: "pulse" }, { id: "me", label: "Me", icon: "user" }, ]; + + const handleKeyDown = (e: React.KeyboardEvent, idx: number) => { + let nextIdx: number | null = null; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + nextIdx = (idx + 1) % tabs.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + nextIdx = (idx - 1 + tabs.length) % tabs.length; + } else if (e.key === "Home") { + nextIdx = 0; + } else if (e.key === "End") { + nextIdx = tabs.length - 1; + } + if (nextIdx !== null) { + e.preventDefault(); + onChange(tabs[nextIdx]!.id); + // Move focus to the new tab button after state updates + setTimeout(() => { + const btns = document.querySelectorAll('[role="tab"]'); + (btns[nextIdx!] as HTMLButtonElement | null)?.focus(); + }, 0); + } + }; + return (
- {tabs.map((t) => { + {tabs.map((t, idx) => { const on = active === t.id; return (