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 (