feat(mobile): FilterChips + AgentCard WCAG 2.1 AA accessibility
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
qa-review / approved (pull_request) Failing after 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 17s
security-review / approved (pull_request) Failing after 10s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 18s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
sop-tier-check / tier-check (pull_request) Successful in 12s
gate-check-v3 / gate-check (pull_request) Successful in 16s

FilterChips:
- Add role=toolbar + aria-label="Filter agents" on container
- Add role=radio + aria-checked on each button
- Add aria-hidden on count spans
- FilterChips.test.tsx: 9 cases

AgentCard:
- Add aria-label composing name, status, tier, remote flag
- AgentCard.test.tsx: 8 cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Molecule AI · app-fe 2026-05-12 05:51:05 +00:00
parent a9d23cfdb6
commit aced72b316
3 changed files with 241 additions and 0 deletions

View File

@ -0,0 +1,115 @@
// @vitest-environment jsdom
/**
* AgentCard mobile agent row card.
*
* Per WCAG 2.1 AA:
* - Rendered as <button> with aria-label composing accessible name
* - aria-label includes: name, status, tier, remote flag
*
* NOTE: No @testing-library/jest-dom use DOM APIs.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { AgentCard, type MobileAgent } from "../components";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.resetModules();
});
const onlineAgent: MobileAgent = {
id: "ws-1",
name: "My Agent",
tag: "claude-code",
tier: "T2",
status: "online",
remote: false,
runtime: "claude-code",
skills: 3,
calls: 12,
desc: "Handles customer support",
parentId: null,
};
const remoteFailedAgent: MobileAgent = {
id: "ws-2",
name: "Remote Worker",
tag: "external",
tier: "T4",
status: "failed",
remote: true,
runtime: "external",
skills: 5,
calls: 0,
desc: "",
parentId: "ws-1",
};
// ─── Render ───────────────────────────────────────────────────────────────────
describe("AgentCard — render", () => {
it("renders as a button", () => {
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
expect(document.querySelector("button")).toBeTruthy();
});
it("button has aria-label with name, status, tier", () => {
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
const btn = document.querySelector("button") as HTMLButtonElement;
const label = btn.getAttribute("aria-label") ?? "";
expect(label).toContain("My Agent");
expect(label).toContain("online");
expect(label).toContain("T2");
});
it("aria-label includes remote for remote agents", () => {
render(<AgentCard agent={remoteFailedAgent} dark={false} onClick={vi.fn()} />);
const btn = document.querySelector("button") as HTMLButtonElement;
const label = btn.getAttribute("aria-label") ?? "";
expect(label).toContain("Remote Worker");
expect(label).toContain("failed");
expect(label).toContain("T4");
expect(label).toContain("remote");
});
it("aria-label omits remote for non-remote agents", () => {
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
const btn = document.querySelector("button") as HTMLButtonElement;
const label = btn.getAttribute("aria-label") ?? "";
expect(label).not.toContain("remote");
});
it("renders agent name text inside the button", () => {
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
const btn = document.querySelector("button") as HTMLButtonElement;
expect(btn.textContent).toContain("My Agent");
});
it("compact prop reduces padding", () => {
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} compact={true} />);
const btn = document.querySelector("button") as HTMLButtonElement;
const style = btn.getAttribute("style") ?? "";
// compact uses "12px 14px" padding vs "14px 16px" default
expect(style).toContain("padding");
});
});
// ─── Interaction ─────────────────────────────────────────────────────────────
describe("AgentCard — interaction", () => {
it("calls onClick when button is clicked", () => {
const onClick = vi.fn();
render(<AgentCard agent={onlineAgent} dark={false} onClick={onClick} />);
const btn = document.querySelector("button") as HTMLButtonElement;
btn.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("renders without onClick (optional prop)", () => {
// Should not throw
expect(() => render(<AgentCard agent={onlineAgent} dark={false} />)).not.toThrow();
});
});

View File

@ -0,0 +1,118 @@
// @vitest-environment jsdom
/**
* FilterChips mobile agent filter toolbar.
*
* Per WCAG 2.1 AA / ARIA radio group pattern:
* - Container has role="toolbar" + aria-label
* - Each button has role="radio" + aria-checked
* - Icon spans have aria-hidden="true"
* - Only one radio can be checked at a time (single-select filter)
*
* 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 { FilterChips, type AgentFilter } from "../components";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.resetModules();
});
const defaultCounts = { all: 12, online: 8, issue: 2, paused: 2 };
// ─── Render ───────────────────────────────────────────────────────────────────
describe("FilterChips — render", () => {
it("renders 4 filter buttons", () => {
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
const buttons = document.querySelectorAll('[role="radio"]');
expect(buttons.length).toBe(4);
});
it("container has role=toolbar and aria-label", () => {
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
const toolbar = document.querySelector('[role="toolbar"]');
expect(toolbar).toBeTruthy();
expect(toolbar?.getAttribute("aria-label")).toBe("Filter agents");
});
it("each button has role=radio", () => {
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
const buttons = document.querySelectorAll('[role="radio"]');
buttons.forEach((btn) => {
expect(btn.getAttribute("role")).toBe("radio");
});
});
it("active filter has aria-checked=true, others false", () => {
render(<FilterChips value="issue" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
const buttons = document.querySelectorAll('[role="radio"]');
buttons.forEach((btn) => {
const label = btn.textContent ?? "";
if (label.startsWith("Issues")) {
expect(btn.getAttribute("aria-checked")).toBe("true");
} else {
expect(btn.getAttribute("aria-checked")).toBe("false");
}
});
});
it("count spans have aria-hidden=true", () => {
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
const hidden = document.querySelectorAll('[aria-hidden="true"]');
// Each chip has one count span marked aria-hidden
expect(hidden.length).toBeGreaterThanOrEqual(4);
});
});
// ─── Interaction ─────────────────────────────────────────────────────────────
describe("FilterChips — interaction", () => {
it("calls onChange with correct filter id when clicked", () => {
const onChange = vi.fn();
render(<FilterChips value="all" onChange={onChange} dark={false} counts={defaultCounts} />);
const buttons = document.querySelectorAll('[role="radio"]');
const onlineBtn = Array.from(buttons).find((b) => b.textContent?.startsWith("Online")) as Element;
fireEvent.click(onlineBtn);
expect(onChange).toHaveBeenCalledWith("online");
});
it("calls onChange when the already-active filter is clicked (component does not guard)", () => {
const onChange = vi.fn();
render(<FilterChips value="all" onChange={onChange} dark={false} counts={defaultCounts} />);
const buttons = document.querySelectorAll('[role="radio"]');
const allBtn = Array.from(buttons).find((b) => b.textContent?.startsWith("All")) as Element;
fireEvent.click(allBtn);
// Component calls onChange even for the already-active filter;
// the guard belongs at the consumer level (MobileHome) if needed.
expect(onChange).toHaveBeenCalledWith("all");
});
it("updating value prop changes aria-checked", () => {
const { rerender } = render(
<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />,
);
const allBtn = document.querySelector('[id="filter-all"]') as Element;
expect(allBtn.getAttribute("aria-checked")).toBe("true");
rerender(<FilterChips value="paused" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
expect(allBtn.getAttribute("aria-checked")).toBe("false");
const pausedBtn = document.querySelector('[id="filter-paused"]') as Element;
expect(pausedBtn.getAttribute("aria-checked")).toBe("true");
});
it("all filter labels are present", () => {
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
const texts = Array.from(document.querySelectorAll('[role="radio"]')).map((b) =>
b.textContent?.trim(),
);
expect(texts.some((t) => t?.startsWith("All"))).toBe(true);
expect(texts.some((t) => t?.startsWith("Online"))).toBe(true);
expect(texts.some((t) => t?.startsWith("Issues"))).toBe(true);
expect(texts.some((t) => t?.startsWith("Paused"))).toBe(true);
});
});

View File

@ -287,6 +287,7 @@ export function AgentCard({
return (
<button
type="button"
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
onClick={onClick}
style={{
display: "block",
@ -420,6 +421,9 @@ export function FilterChips({
];
return (
<div
role="toolbar"
aria-label="Filter agents"
aria-activedescendant={value ? `filter-${value}` : undefined}
style={{
display: "flex",
gap: 6,
@ -433,7 +437,10 @@ export function FilterChips({
return (
<button
key={o.id}
id={`filter-${o.id}`}
role="radio"
type="button"
aria-checked={on}
onClick={() => onChange(o.id)}
style={{
display: "inline-flex",
@ -453,6 +460,7 @@ export function FilterChips({
>
{o.label}
<span
aria-hidden="true"
style={{
fontSize: 10.5,
opacity: 0.7,