Merge pull request #42 from Molecule-AI/fix/a11y-audit-11
fix: ARIA tablist for side panel, Radix Dialog for create modal, aria-live for loading states (audit 11)
This commit is contained in:
commit
36ae95f6c2
@ -288,6 +288,13 @@ function CanvasInner() {
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{nodes.filter((n) => !n.data.parentId).length === 0
|
||||
? "No workspaces on canvas"
|
||||
: `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`}
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface WorkspaceOption {
|
||||
@ -11,25 +12,6 @@ interface WorkspaceOption {
|
||||
|
||||
export function CreateWorkspaceButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="shrink-0">
|
||||
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
New Workspace
|
||||
</button>
|
||||
|
||||
{open && <CreateDialog onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDialog({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [tier, setTier] = useState(1);
|
||||
@ -39,21 +21,28 @@ function CreateDialog({ onClose }: { onClose: () => void }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
|
||||
// Reset form and load workspaces whenever dialog opens
|
||||
useEffect(() => {
|
||||
api.get<WorkspaceOption[]>("/workspaces")
|
||||
if (!open) return;
|
||||
setName("");
|
||||
setRole("");
|
||||
setTier(1);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setError(null);
|
||||
api
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
.then((ws) => setWorkspaces(ws))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post("/workspaces", {
|
||||
name: name.trim(),
|
||||
@ -63,7 +52,7 @@ function CreateDialog({ onClose }: { onClose: () => void }) {
|
||||
parent_id: parentId || undefined,
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
});
|
||||
onClose();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create workspace");
|
||||
} finally {
|
||||
@ -72,80 +61,144 @@ function CreateDialog({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] p-6">
|
||||
<h2 className="text-base font-semibold text-zinc-100 mb-1">Create Workspace</h2>
|
||||
<p className="text-xs text-zinc-500 mb-5">Add a new workspace node to the canvas</p>
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7 1v12M1 7h12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
New Workspace
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
<InputField label="Name" required value={name} onChange={setName} placeholder="e.g. SEO Agent" autoFocus />
|
||||
<InputField label="Role" value={role} onChange={setRole} placeholder="e.g. SEO Specialist" />
|
||||
<InputField label="Template" value={template} onChange={setTemplate} placeholder="e.g. seo-agent (from workspace-configs-templates/)" mono />
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] p-6"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<Dialog.Title className="text-base font-semibold text-zinc-100 mb-1">
|
||||
Create Workspace
|
||||
</Dialog.Title>
|
||||
<p className="text-xs text-zinc-500 mb-5">
|
||||
Add a new workspace node to the canvas
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">Tier</label>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{[
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Full Access" },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setTier(t.value)}
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
tier === t.value
|
||||
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
|
||||
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-mono font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] mt-0.5 opacity-70">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-3.5">
|
||||
<InputField
|
||||
label="Name"
|
||||
required
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. SEO Agent"
|
||||
/>
|
||||
<InputField
|
||||
label="Role"
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
placeholder="e.g. SEO Specialist"
|
||||
/>
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
|
||||
mono
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Workspace tier"
|
||||
className="grid grid-cols-3 gap-1.5"
|
||||
>
|
||||
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
|
||||
Tier
|
||||
</div>
|
||||
{[
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Full Access" },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
role="radio"
|
||||
aria-checked={tier === t.value}
|
||||
onClick={() => setTier(t.value)}
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
tier === t.value
|
||||
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
|
||||
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-mono font-semibold">
|
||||
{t.label}
|
||||
</div>
|
||||
<div className="text-[10px] mt-0.5 opacity-70">
|
||||
{t.desc}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">
|
||||
Parent Workspace
|
||||
</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
>
|
||||
<option value="">None (root level)</option>
|
||||
{workspaces.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
T{ws.tier} · {ws.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">Parent Workspace</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400"
|
||||
>
|
||||
<option value="">None (root level)</option>
|
||||
{workspaces.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
T{ws.tier} · {ws.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<Dialog.Close asChild>
|
||||
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@ -155,7 +208,6 @@ function InputField({
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
autoFocus,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
@ -163,19 +215,25 @@ function InputField({
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
autoFocus?: boolean;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">
|
||||
{label} {required && <span className="text-red-400">*</span>}
|
||||
{label}{" "}
|
||||
{required && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-red-400">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -138,18 +138,39 @@ export function SidePanel() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-zinc-800/40 overflow-x-auto bg-zinc-900/20 px-1">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Workspace panel tabs"
|
||||
className="flex border-b border-zinc-800/40 overflow-x-auto bg-zinc-900/20 px-1"
|
||||
onKeyDown={(e) => {
|
||||
const idx = TABS.findIndex((t) => t.id === panelTab);
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); next = (idx + 1) % TABS.length; }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); next = (idx - 1 + TABS.length) % TABS.length; }
|
||||
else if (e.key === "Home") { e.preventDefault(); next = 0; }
|
||||
else if (e.key === "End") { e.preventDefault(); next = TABS.length - 1; }
|
||||
if (next !== null) {
|
||||
setPanelTab(TABS[next].id);
|
||||
requestAnimationFrame(() => { document.getElementById(`tab-${TABS[next!].id}`)?.focus(); });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
role="tab"
|
||||
aria-selected={panelTab === tab.id}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabIndex={panelTab === tab.id ? 0 : -1}
|
||||
onClick={() => setPanelTab(tab.id)}
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 ${
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 ${
|
||||
panelTab === tab.id
|
||||
? "text-zinc-100 bg-zinc-800/40 border-b-2 border-blue-500"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/20"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-50">{tab.icon}</span>
|
||||
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
@ -183,7 +204,13 @@ export function SidePanel() {
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`panel-${panelTab}`}
|
||||
aria-labelledby={`tab-${panelTab}`}
|
||||
tabIndex={0}
|
||||
className="flex-1 overflow-y-auto focus:outline-none"
|
||||
>
|
||||
{panelTab === "details" && <DetailsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "skills" && <SkillsTab key={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@ -89,7 +89,11 @@ export function OrgTemplatesSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-[10px] text-zinc-500">Loading…</div>}
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="text-[10px] text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && orgs.length === 0 && (
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
@ -350,11 +354,13 @@ export function TemplatePalette() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">Loading...</div>
|
||||
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && templates.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
<div role="status" aria-live="polite" className="text-xs text-zinc-500 text-center py-8">
|
||||
No templates found in<br />workspace-configs-templates/
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import component AFTER mocks
|
||||
import { CreateWorkspaceButton } from "../CreateWorkspaceDialog";
|
||||
|
||||
async function openDialog() {
|
||||
render(<CreateWorkspaceButton />);
|
||||
const trigger = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("New Workspace"));
|
||||
expect(trigger).toBeTruthy();
|
||||
fireEvent.click(trigger!);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy()
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateWorkspaceDialog — accessibility", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("dialog is absent before the trigger is clicked", () => {
|
||||
render(<CreateWorkspaceButton />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking the trigger renders a role=dialog", async () => {
|
||||
await openDialog();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to the 'Create Workspace' title", async () => {
|
||||
await openDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Create Workspace");
|
||||
});
|
||||
|
||||
it("dialog has data-state='open' when visible (Radix modal state)", async () => {
|
||||
await openDialog();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("data-state")).toBe("open");
|
||||
});
|
||||
|
||||
it("Cancel button closes the dialog", async () => {
|
||||
await openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("empty-name submit renders a role=alert error message", async () => {
|
||||
await openDialog();
|
||||
// Click Create without filling in Name
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toBeTruthy()
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain("required");
|
||||
});
|
||||
|
||||
it("tier buttons have role=radio and aria-checked reflects selection", async () => {
|
||||
await openDialog();
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios.length).toBe(3);
|
||||
// T1 is default selection
|
||||
const t1 = radios.find((r) => r.textContent?.includes("T1"));
|
||||
const t2 = radios.find((r) => r.textContent?.includes("T2"));
|
||||
expect(t1?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(t2?.getAttribute("aria-checked")).toBe("false");
|
||||
// Click T2 and verify aria-checked flips
|
||||
fireEvent.click(t2!);
|
||||
await waitFor(() =>
|
||||
expect(t2?.getAttribute("aria-checked")).toBe("true")
|
||||
);
|
||||
});
|
||||
});
|
||||
162
canvas/src/components/__tests__/SidePanel.tabs.test.tsx
Normal file
162
canvas/src/components/__tests__/SidePanel.tabs.test.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Mock all tab content components to null ──────────────────────────────────
|
||||
vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
|
||||
vi.mock("../tabs/MemoryTab", () => ({ MemoryTab: () => null }));
|
||||
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
|
||||
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
|
||||
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
|
||||
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
|
||||
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
|
||||
|
||||
// ── Mock StatusDot and Tooltip ───────────────────────────────────────────────
|
||||
vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
|
||||
vi.mock("../Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// ── Mock canvas store ────────────────────────────────────────────────────────
|
||||
const mockSetPanelTab = vi.fn();
|
||||
|
||||
const mockStoreState = {
|
||||
selectedNodeId: "ws-1",
|
||||
panelTab: "chat",
|
||||
setPanelTab: mockSetPanelTab,
|
||||
selectNode: vi.fn(),
|
||||
nodes: [
|
||||
{
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test WS",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
role: "Engineer",
|
||||
parentId: null,
|
||||
needsRestart: false,
|
||||
currentTask: null,
|
||||
agentCard: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
|
||||
selector(mockStoreState)
|
||||
),
|
||||
{ getState: () => mockStoreState }
|
||||
),
|
||||
summarizeWorkspaceCapabilities: () => ({ runtime: "claude-code", skillCount: 0 }),
|
||||
}));
|
||||
|
||||
// ── Import component under test AFTER all mocks ──────────────────────────────
|
||||
import { SidePanel } from "../SidePanel";
|
||||
|
||||
const TABS = [
|
||||
"chat", "activity", "details", "skills", "terminal",
|
||||
"config", "schedule", "channels", "files", "memory", "traces", "events",
|
||||
];
|
||||
|
||||
describe("SidePanel — ARIA tablist pattern", () => {
|
||||
it("renders a tablist with aria-label='Workspace panel tabs'", () => {
|
||||
render(<SidePanel />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
expect(tablist).toBeTruthy();
|
||||
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
|
||||
});
|
||||
|
||||
it("renders exactly 12 tab buttons", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs.length).toBe(12);
|
||||
});
|
||||
|
||||
it("active tab (chat) has aria-selected='true'", () => {
|
||||
render(<SidePanel />);
|
||||
const chatTab = screen.getAllByRole("tab").find(
|
||||
(t) => t.id === "tab-chat"
|
||||
);
|
||||
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("all other 11 tabs have aria-selected='false'", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const inactive = tabs.filter((t) => t.id !== "tab-chat");
|
||||
expect(inactive.length).toBe(11);
|
||||
for (const tab of inactive) {
|
||||
expect(tab.getAttribute("aria-selected")).toBe("false");
|
||||
}
|
||||
});
|
||||
|
||||
it("active tab has tabIndex=0 and all others have tabIndex=-1 (roving tabIndex)", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const zeros = tabs.filter((t) => t.getAttribute("tabindex") === "0");
|
||||
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
|
||||
expect(zeros.length).toBe(1);
|
||||
expect(zeros[0].id).toBe("tab-chat");
|
||||
expect(minusOnes.length).toBe(11);
|
||||
});
|
||||
|
||||
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
|
||||
render(<SidePanel />);
|
||||
const chatTab = document.getElementById("tab-chat");
|
||||
expect(chatTab).toBeTruthy();
|
||||
expect(chatTab?.getAttribute("aria-controls")).toBe("panel-chat");
|
||||
});
|
||||
|
||||
it("renders a role=tabpanel for the active tab", () => {
|
||||
render(<SidePanel />);
|
||||
const tabpanel = screen.getByRole("tabpanel");
|
||||
expect(tabpanel).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tabpanel has id='panel-chat' and aria-labelledby='tab-chat'", () => {
|
||||
render(<SidePanel />);
|
||||
const panel = document.getElementById("panel-chat");
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel?.getAttribute("aria-labelledby")).toBe("tab-chat");
|
||||
});
|
||||
|
||||
it("ArrowRight calls setPanelTab with 'activity' (next tab after chat)", () => {
|
||||
render(<SidePanel />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
fireEvent.keyDown(tablist, { key: "ArrowRight" });
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("activity");
|
||||
});
|
||||
|
||||
it("ArrowLeft from 'chat' (first) wraps to 'events' (last)", () => {
|
||||
render(<SidePanel />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("events");
|
||||
});
|
||||
|
||||
it("Home key calls setPanelTab with 'chat' (first tab)", () => {
|
||||
render(<SidePanel />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
fireEvent.keyDown(tablist, { key: "Home" });
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("chat");
|
||||
});
|
||||
|
||||
it("End key calls setPanelTab with 'events' (last tab)", () => {
|
||||
render(<SidePanel />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
fireEvent.keyDown(tablist, { key: "End" });
|
||||
expect(mockSetPanelTab).toHaveBeenCalledWith("events");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user