fix(canvas/ChatTab): add WCAG 2.4.7 focus-visible ring to the talk_to_user Enable button
PR #1256 has an outstanding WCAG blocker: the "Enable" button that re-enables agent-to-user messaging lacks a focus-visible ring, making keyboard navigation invisible for sighted keyboard users. Adds focus-visible:ring-2 (with matching accent colour and zinc-900 offset) to the Enable button className, satisfying WCAG 2.4.7 (Focus Visible). Also adds ChatTab.talkToUserBanner.test.tsx with 5 test cases: - Banner hidden when talkToUserEnabled=true - Banner shown when talkToUserEnabled=false - Enable button renders - Enable button calls PATCH /workspaces/:id/abilities with correct payload - Enable button has focus-visible:ring-2 class (WCAG 2.4.7) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6cfe76b6dd
commit
6383276e51
@ -383,7 +383,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
// ignore — user will see no change and can retry
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
|
||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Tests for the talk_to_user disabled banner in ChatTab.
|
||||
//
|
||||
// When a workspace has talk_to_user_enabled=false, the agent cannot send
|
||||
// canvas messages to the user. A banner appears with an "Enable" button that
|
||||
// calls PATCH /workspaces/:id/abilities with { talk_to_user_enabled: true }.
|
||||
//
|
||||
// Covers:
|
||||
// - Banner hidden when talkToUserEnabled=true
|
||||
// - Banner shown when talkToUserEnabled=false
|
||||
// - "Enable" button calls PATCH /workspaces/:id/abilities with correct payload
|
||||
// - "Enable" button has focus-visible:ring class (WCAG 2.4.7)
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Track patch calls for assertions so tests can inspect them.
|
||||
const patchCalls: { path: string; body: unknown }[] = [];
|
||||
|
||||
// var: declaration hoisted to top of file (before vi.mock calls run), and
|
||||
// initializer runs eagerly at parse time — available to hoisted factory bodies.
|
||||
var mockUpdateNodeData = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
const apiGet = vi.fn(() => Promise.resolve([]));
|
||||
const apiPost = vi.fn(() => Promise.resolve({}));
|
||||
const apiPatch = vi.fn(() => Promise.resolve({}));
|
||||
return {
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
post: (path: string, body: unknown) => {
|
||||
patchCalls.push({ path, body });
|
||||
return apiPost(path, body);
|
||||
},
|
||||
del: vi.fn(),
|
||||
patch: (path: string, body: unknown) => {
|
||||
patchCalls.push({ path, body });
|
||||
return apiPatch(path, body);
|
||||
},
|
||||
put: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/store/canvas", () => {
|
||||
const state = {
|
||||
agentMessages: {} as Record<string, unknown[]>,
|
||||
consumeAgentMessages: () => [] as unknown[],
|
||||
updateNodeData: mockUpdateNodeData,
|
||||
};
|
||||
return {
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector?: (s: typeof state) => unknown) =>
|
||||
selector ? selector(state) : state,
|
||||
),
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateNodeData.mockReset();
|
||||
patchCalls.length = 0;
|
||||
// jsdom doesn't implement scrollIntoView; ChatTab calls it after render.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
// Stub IntersectionObserver — lazy-history sentinel uses it.
|
||||
class FakeIO {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
|
||||
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
|
||||
});
|
||||
|
||||
import { ChatTab } from "../ChatTab";
|
||||
|
||||
const minimalData = {
|
||||
status: "online" as const,
|
||||
runtime: "claude-code",
|
||||
currentTask: null,
|
||||
} as unknown as Parameters<typeof ChatTab>[0]["data"];
|
||||
|
||||
describe("ChatTab — talk_to_user disabled banner", () => {
|
||||
it("is hidden when talkToUserEnabled is true", () => {
|
||||
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: true }} />);
|
||||
expect(screen.queryByText(/not enabled to chat/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the banner when talkToUserEnabled is false", () => {
|
||||
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: false }} />);
|
||||
expect(screen.getByText(/not enabled to chat/i)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Enable button", () => {
|
||||
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: false }} />);
|
||||
const btns = screen.getAllByRole("button");
|
||||
const enableBtn = btns.find((b) => b.textContent?.trim() === "Enable");
|
||||
expect(enableBtn).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("Enable button calls PATCH /workspaces/:id/abilities with talk_to_user_enabled: true", async () => {
|
||||
render(<ChatTab workspaceId="ws-test-456" data={{ ...minimalData, talkToUserEnabled: false }} />);
|
||||
const btns = screen.getAllByRole("button");
|
||||
const enableBtn = btns.find((b) => b.textContent?.trim() === "Enable")!;
|
||||
fireEvent.click(enableBtn);
|
||||
await waitFor(() => {
|
||||
expect(patchCalls).toContainEqual({ path: "/workspaces/ws-test-456/abilities", body: { talk_to_user_enabled: true } });
|
||||
});
|
||||
});
|
||||
|
||||
// Note: we cannot test the "banner disappears after store update" DOM
|
||||
// outcome here because MyChatPanel reads data.talkToUserEnabled from its
|
||||
// props (passed from ChatTab), not from the store. The store update is
|
||||
// a side-effect that updates the canvas nodes array; it does not flow
|
||||
// back into the ChatTab prop chain. The PATCH call (verified above) is
|
||||
// the primary integration point — the store update is an implementation
|
||||
// detail that callers verify via the canvas-level integration test suite.
|
||||
|
||||
it("Enable button has focus-visible:ring-2 class (WCAG 2.4.7)", () => {
|
||||
render(<ChatTab workspaceId="ws-1" data={{ ...minimalData, talkToUserEnabled: false }} />);
|
||||
const btns = screen.getAllByRole("button");
|
||||
const enableBtn = btns.find((b) => b.textContent?.trim() === "Enable")!;
|
||||
// The fix adds focus-visible:ring-2 (not the shorthand focus-visible:ring).
|
||||
// Both satisfy WCAG 2.4.7 by making keyboard focus clearly visible.
|
||||
expect(enableBtn.classList.contains("focus-visible:ring-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user