fix(a11y): migrate ConversationTraceModal to Radix Dialog (Issue M)
Custom <div> modal lacked focus trap, Escape handling, aria-modal, and
aria-labelledby. Migrated to the codebase-standard Radix Dialog pattern
(same as CreateWorkspaceDialog and SettingsPanel) which provides all
required WCAG 2.1 modal semantics automatically:
• Dialog.Root + Dialog.Portal + Dialog.Overlay + Dialog.Content
→ role="dialog", aria-labelledby, focus trap, Escape key
• Dialog.Title wraps "Conversation Trace" heading
→ aria-labelledby points to the title element
• Dialog.Close asChild on ✕ button with aria-label="Close conversation trace"
→ accessible name for the dismiss button (WCAG 4.1.2)
• Dialog.Close asChild on footer Close button
• Backdrop → Dialog.Overlay (z-[59]) + Content wrapper (z-[60])
• All timeline/body content unchanged; only modal scaffolding replaced
Added 10 WCAG tests in ConversationTraceModal.a11y.test.tsx covering:
dialog presence, accessible name, aria-labelledby, data-state, ✕ button
aria-label, close button click, Escape key, and loading indicator. All
732 tests pass, build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a8fcff947d
commit
f6fa527d58
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ActivityEntry } from "@/types/activity";
|
||||
@ -46,7 +47,7 @@ function extractMessageText(body: Record<string, unknown> | null): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) {
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
@ -83,205 +84,214 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
});
|
||||
}, [open, nodes]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const isA2A = (e: ActivityEntry) =>
|
||||
e.activity_type === "a2a_receive" || e.activity_type === "a2a_send";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<Dialog.Root open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<Dialog.Portal>
|
||||
{/* Overlay replaces the old manual backdrop div */}
|
||||
<Dialog.Overlay className="fixed inset-0 z-[59] bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full mx-4 max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
Conversation Trace
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{entries.length} events across all workspaces
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
{/* Content wraps the entire centred modal panel */}
|
||||
<Dialog.Content
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{/* Modal panel */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
|
||||
<div>
|
||||
<Dialog.Title className="text-sm font-semibold text-zinc-100">
|
||||
Conversation Trace
|
||||
</Dialog.Title>
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{entries.length} events across all workspaces
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
aria-label="Close conversation trace"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => {
|
||||
const time = new Date(entry.created_at).toLocaleTimeString();
|
||||
const wsName = resolveName(entry.workspace_id);
|
||||
const sourceName = resolveName(entry.source_id);
|
||||
const targetName = resolveName(entry.target_id);
|
||||
const requestText = extractMessageText(entry.request_body);
|
||||
const responseText = extractMessageText(entry.response_body);
|
||||
const isError = entry.status === "error";
|
||||
const isSend = entry.activity_type === "a2a_send";
|
||||
const isReceive = entry.activity_type === "a2a_receive";
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="group">
|
||||
{/* Event header */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Timeline dot + line */}
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
isError
|
||||
? "bg-red-500"
|
||||
: isSend
|
||||
? "bg-cyan-500"
|
||||
: isReceive
|
||||
? "bg-blue-500"
|
||||
: "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{entries.map((entry) => {
|
||||
const time = new Date(entry.created_at).toLocaleTimeString();
|
||||
const wsName = resolveName(entry.workspace_id);
|
||||
const sourceName = resolveName(entry.source_id);
|
||||
const targetName = resolveName(entry.target_id);
|
||||
const requestText = extractMessageText(entry.request_body);
|
||||
const responseText = extractMessageText(entry.response_body);
|
||||
const isError = entry.status === "error";
|
||||
const isSend = entry.activity_type === "a2a_send";
|
||||
const isReceive = entry.activity_type === "a2a_receive";
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] text-zinc-400 font-mono">
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
|
||||
isError
|
||||
? "bg-red-950/50 text-red-400"
|
||||
: isSend
|
||||
? "bg-cyan-950/50 text-cyan-400"
|
||||
: isReceive
|
||||
? "bg-blue-950/50 text-blue-400"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isSend
|
||||
? "SEND"
|
||||
: isReceive
|
||||
? "RECEIVE"
|
||||
: entry.activity_type.toUpperCase()}
|
||||
</span>
|
||||
{entry.duration_ms != null && entry.duration_ms > 0 && (
|
||||
<span className="text-[9px] text-zinc-400">
|
||||
{entry.duration_ms > 1000
|
||||
? `${Math.round(entry.duration_ms / 1000)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div key={entry.id} className="group">
|
||||
{/* Event header */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Timeline dot + line */}
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
isError
|
||||
? "bg-red-500"
|
||||
: isSend
|
||||
? "bg-cyan-500"
|
||||
: isReceive
|
||||
? "bg-blue-500"
|
||||
: "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
{isA2A(entry) && (
|
||||
<div className="text-[11px] mt-1">
|
||||
{isSend ? (
|
||||
<span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName || wsName}
|
||||
</span>
|
||||
<span className="text-zinc-400"> → </span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName}
|
||||
</span>
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] text-zinc-400 font-mono">
|
||||
{time}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName || wsName}
|
||||
<span
|
||||
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
|
||||
isError
|
||||
? "bg-red-950/50 text-red-400"
|
||||
: isSend
|
||||
? "bg-cyan-950/50 text-cyan-400"
|
||||
: isReceive
|
||||
? "bg-blue-950/50 text-blue-400"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isSend
|
||||
? "SEND"
|
||||
: isReceive
|
||||
? "RECEIVE"
|
||||
: entry.activity_type.toUpperCase()}
|
||||
</span>
|
||||
{entry.duration_ms != null && entry.duration_ms > 0 && (
|
||||
<span className="text-[9px] text-zinc-400">
|
||||
{entry.duration_ms > 1000
|
||||
? `${Math.round(entry.duration_ms / 1000)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
</span>
|
||||
{sourceName && (
|
||||
<>
|
||||
<span className="text-zinc-400">
|
||||
{" "}← {" "}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
{isA2A(entry) && (
|
||||
<div className="text-[11px] mt-1">
|
||||
{isSend ? (
|
||||
<span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName}
|
||||
{sourceName || wsName}
|
||||
</span>
|
||||
</>
|
||||
<span className="text-zinc-400"> → </span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName || wsName}
|
||||
</span>
|
||||
{sourceName && (
|
||||
<>
|
||||
<span className="text-zinc-400">
|
||||
{" "}← {" "}
|
||||
</span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{entry.summary && !isA2A(entry) && (
|
||||
<div className="text-[10px] text-zinc-400 mt-1">
|
||||
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
|
||||
{entry.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[10px] text-red-400/80 mt-1 truncate">
|
||||
{entry.error_detail.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content — show request and/or response */}
|
||||
{requestText && (
|
||||
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-zinc-500 uppercase mb-1">
|
||||
{isSend ? "Task" : "Request"}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{requestText.slice(0, 2000)}
|
||||
{requestText.length > 2000 && (
|
||||
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{responseText && (
|
||||
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{responseText.slice(0, 2000)}
|
||||
{responseText.length > 2000 && (
|
||||
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{entry.summary && !isA2A(entry) && (
|
||||
<div className="text-[10px] text-zinc-400 mt-1">
|
||||
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
|
||||
{entry.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[10px] text-red-400/80 mt-1 truncate">
|
||||
{entry.error_detail.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content — show request and/or response */}
|
||||
{requestText && (
|
||||
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-zinc-500 uppercase mb-1">
|
||||
{isSend ? "Task" : "Request"}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{requestText.slice(0, 2000)}
|
||||
{requestText.length > 2000 && (
|
||||
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{responseText && (
|
||||
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{responseText.slice(0, 2000)}
|
||||
{responseText.length > 2000 && (
|
||||
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WCAG 2.1 / Issue M — ConversationTraceModal accessibility
|
||||
*
|
||||
* Migrated from custom <div> to Radix Dialog, which provides:
|
||||
* - role="dialog" + aria-modal="true" automatically (WCAG 4.1.2)
|
||||
* - aria-labelledby pointing to Dialog.Title (WCAG 1.3.1)
|
||||
* - Focus trap (WCAG 2.1.2 / 2.4.3)
|
||||
* - Escape key closes the dialog (WCAG 2.1.1)
|
||||
* - ✕ close button has aria-label="Close conversation trace"
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Mocks must be declared before importing the component ────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: (selector: (s: { nodes: unknown[] }) => unknown) =>
|
||||
selector({ nodes: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWorkspaceName", () => ({
|
||||
useWorkspaceName: () => () => "Test WS",
|
||||
}));
|
||||
|
||||
import { ConversationTraceModal } from "../ConversationTraceModal";
|
||||
|
||||
// Helper: renders the modal in open state with a spy for onClose
|
||||
function renderOpen() {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ConversationTraceModal
|
||||
open={true}
|
||||
workspaceId="ws-1"
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Presence / absence
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — dialog presence (Issue M)", () => {
|
||||
it("dialog is absent when open=false", () => {
|
||||
render(
|
||||
<ConversationTraceModal open={false} workspaceId="ws-1" onClose={vi.fn()} />
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("dialog is present when open=true", () => {
|
||||
renderOpen();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ARIA attributes provided by Radix Dialog
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — ARIA attributes (Issue M)", () => {
|
||||
it("dialog element is accessible via role='dialog' with a non-empty accessible name", () => {
|
||||
renderOpen();
|
||||
// Radix Dialog.Content renders role="dialog" with aria-labelledby pointing
|
||||
// to Dialog.Title. Verify the role is present and the name is non-empty
|
||||
// (testing-library computes the accessible name from aria-labelledby).
|
||||
const dialog = screen.getByRole("dialog", { name: /conversation trace/i });
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to 'Conversation Trace' title", () => {
|
||||
renderOpen();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Conversation Trace");
|
||||
});
|
||||
|
||||
it("dialog has data-state='open' (Radix state attribute)", () => {
|
||||
renderOpen();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("data-state")).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Close button accessible name
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — close button (Issue M)", () => {
|
||||
it("✕ close button has aria-label='Close conversation trace'", () => {
|
||||
renderOpen();
|
||||
const closeBtn = screen.getByRole("button", {
|
||||
name: /close conversation trace/i,
|
||||
});
|
||||
expect(closeBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking ✕ button calls onClose", async () => {
|
||||
const { onClose } = renderOpen();
|
||||
const closeBtn = screen.getByRole("button", {
|
||||
name: /close conversation trace/i,
|
||||
});
|
||||
fireEvent.click(closeBtn);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("footer 'Close' button also closes the dialog", async () => {
|
||||
const { onClose } = renderOpen();
|
||||
const closeBtn = screen.getByRole("button", { name: /^Close$/i });
|
||||
fireEvent.click(closeBtn);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Escape key closes the dialog (WCAG 2.1.1 — Keyboard)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — Escape key (Issue M)", () => {
|
||||
it("Escape key triggers onClose via Radix onOpenChange", async () => {
|
||||
const { onClose } = renderOpen();
|
||||
// Radix Dialog automatically closes on Escape and fires onOpenChange(false)
|
||||
// which our handler converts to onClose(). Dispatch on the document so
|
||||
// Radix's own keydown listener picks it up.
|
||||
fireEvent.keyDown(document, { key: "Escape", code: "Escape" });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Empty state
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ConversationTraceModal — loading state (Issue M)", () => {
|
||||
it("shows loading indicator when dialog opens and fetch is in progress", () => {
|
||||
renderOpen();
|
||||
// After render + effects (flushed by act inside render), loading=true
|
||||
// because useEffect fired setLoading(true). The loading text should
|
||||
// be visible at this synchronous point.
|
||||
expect(screen.getByText(/loading trace from all workspaces/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user