fix(canvas): dynamic aria-label + title on TeamMemberChip eject button (issue #854)

- EjectIcon now accepts React.SVGProps<SVGSVGElement> so aria-hidden can be passed
- Eject button: aria-label and title both use `Extract ${data.name} from team`
  (previously title was static 'Extract from team'; aria-label was absent)
- <EjectIcon aria-hidden="true"> prevents assistive tech from double-announcing
  the icon content inside the already-labelled button
- Added WorkspaceNode.eject.test.tsx (4 tests) covering aria-label, title,
  label==title invariant, and aria-hidden on the SVG

Closes #854

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-17 21:54:51 +00:00
parent a8fcff947d
commit ee07380ae0
2 changed files with 190 additions and 4 deletions

View File

@ -29,9 +29,9 @@ function useHierarchyInfo(parentId: string) {
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
function EjectIcon() {
function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M3 7L7 3" />
<path d="M4 3H7V6" />
</svg>
@ -377,14 +377,15 @@ function TeamMemberChip({
{tierCfg.label}
</span>
<button
aria-label={`Extract ${data.name} from team`}
title={`Extract ${data.name} from team`}
onClick={(e) => {
e.stopPropagation();
onExtract(node.id);
}}
title="Extract from team"
className="opacity-0 group-hover/child:opacity-100 text-zinc-500 hover:text-sky-400 transition-all"
>
<EjectIcon />
<EjectIcon aria-hidden="true" />
</button>
</div>
</div>

View File

@ -0,0 +1,185 @@
// @vitest-environment jsdom
/**
* Tests for issue #854 TeamMemberChip eject button:
* - aria-label must be dynamic: `Extract ${childName} from team`
* - title must be dynamic: `Extract ${childName} from team`
* - EjectIcon svg must carry aria-hidden="true"
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import type { Node } from "@xyflow/react";
import type { WorkspaceNodeData } from "@/store/canvas";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Mock @xyflow/react ─────────────────────────────────────────────────────────
vi.mock("@xyflow/react", () => ({
Handle: () => null,
Position: { Bottom: "bottom", Top: "top" },
useReactFlow: vi.fn(),
}));
// ── Mock Toaster ───────────────────────────────────────────────────────────────
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
// ── Mock Tooltip ───────────────────────────────────────────────────────────────
vi.mock("@/components/Tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// ── Mock design tokens ─────────────────────────────────────────────────────────
vi.mock("@/lib/design-tokens", () => ({
STATUS_CONFIG: {
online: { label: "Online", dot: "bg-emerald-400", bar: "from-emerald-500/10" },
offline: { label: "Offline", dot: "bg-zinc-600", bar: "from-zinc-700/10" },
provisioning: { label: "Provisioning", dot: "bg-sky-400", bar: "from-sky-500/10" },
degraded: { label: "Degraded", dot: "bg-amber-400", bar: "from-amber-500/10" },
failed: { label: "Failed", dot: "bg-red-400", bar: "from-red-500/10" },
paused: { label: "Paused", dot: "bg-zinc-500", bar: "from-zinc-600/10" },
},
TIER_CONFIG: {
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
2: { label: "T2", color: "text-blue-400 bg-blue-900/40" },
},
}));
// ── Canvas store mock state ────────────────────────────────────────────────────
const PARENT_ID = "parent-ws";
const CHILD_ID = "child-ws";
const CHILD_NAME = "Child Workspace";
function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
return {
name: "Test WS",
role: "agent",
tier: 1,
status: "online",
agentCard: null,
url: "http://localhost:9000",
parentId: null,
activeTasks: 0,
lastErrorRate: 0,
lastSampleError: "",
uptimeSeconds: 60,
currentTask: "",
collapsed: false,
runtime: "",
needsRestart: false,
budgetLimit: null,
...overrides,
};
}
const parentNodeData = makeNodeData({ name: "Parent WS", parentId: null });
const childNodeData = makeNodeData({ name: CHILD_NAME, parentId: PARENT_ID });
const allNodes: Node<WorkspaceNodeData>[] = [
{ id: PARENT_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: parentNodeData },
{ id: CHILD_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: childNodeData, hidden: true },
];
// Build a selector-compatible mock of useCanvasStore
const mockStoreState = {
nodes: allNodes,
edges: [],
selectedNodeId: null,
panelTab: "chat",
dragOverNodeId: null,
contextMenu: null,
searchOpen: false,
viewport: { x: 0, y: 0, zoom: 1 },
selectNode: vi.fn(),
openContextMenu: vi.fn(),
nestNode: vi.fn(),
isDescendant: vi.fn(() => false),
restartWorkspace: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
selector(mockStoreState)
),
{ getState: () => mockStoreState }
),
}));
// ── Mock zustand/react/shallow ─────────────────────────────────────────────────
vi.mock("zustand/react/shallow", () => ({
useShallow: (fn: (s: typeof mockStoreState) => unknown) => fn,
}));
// ── Import component AFTER mocks ───────────────────────────────────────────────
import { WorkspaceNode } from "../WorkspaceNode";
// ── Helpers ────────────────────────────────────────────────────────────────────
function renderParentNode() {
return render(
<WorkspaceNode
id={PARENT_ID}
data={parentNodeData}
// NodeProps required but unused in our mock env
type="workspaceNode"
selected={false}
isConnectable={true}
zIndex={0}
positionAbsoluteX={0}
positionAbsoluteY={0}
dragging={false}
/>
);
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("TeamMemberChip eject button — aria-label (issue #854)", () => {
it("eject button has a dynamic aria-label containing the child workspace name", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.includes("Extract") && b.getAttribute("aria-label")?.includes("from team")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("aria-label")).toBe(`Extract ${CHILD_NAME} from team`);
});
});
describe("TeamMemberChip eject button — title tooltip (issue #854)", () => {
it("eject button has a dynamic title tooltip containing the child workspace name", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("title")?.includes("Extract") && b.getAttribute("title")?.includes("from team")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("title")).toBe(`Extract ${CHILD_NAME} from team`);
});
it("aria-label and title are identical (both use child workspace name)", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
);
expect(ejectBtn).toBeTruthy();
expect(ejectBtn?.getAttribute("aria-label")).toBe(ejectBtn?.getAttribute("title"));
});
});
describe("TeamMemberChip eject button — aria-hidden on EjectIcon (issue #854)", () => {
it("EjectIcon svg has aria-hidden='true' to prevent AT double-announcement", () => {
const { container } = renderParentNode();
const buttons = container.querySelectorAll("button");
const ejectBtn = Array.from(buttons).find(
(b) => b.getAttribute("aria-label")?.startsWith("Extract")
);
expect(ejectBtn).toBeTruthy();
const svg = ejectBtn?.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
});