fix: keyboard navigation for ContextMenu (WCAG 2.1.1) and SearchDialog combobox pattern
- ContextMenu: role=menu/menuitem/separator, aria-label, aria-disabled, focus-visible ring, auto-focus first enabled item on open, ArrowDown/Up roving focus (wrapping), Escape + Tab dismiss, aria-hidden on decorative icons/status dot - SearchDialog: role=dialog+aria-modal, combobox pattern on input (role=combobox, aria-expanded, aria-autocomplete, aria-controls, aria-activedescendant), focusedIndex state, ArrowDown/Up/Enter keyboard navigation, role=listbox+option, aria-selected, role=status + aria-live=polite on empty state, footer hints updated with ↑↓ - Add 10 ContextMenu keyboard tests (role, aria-label, menuitem, separator, Escape, Tab, ArrowDown, wrap, ArrowUp wrap, null guard) - Add 13 SearchDialog keyboard tests (dialog, aria-modal, combobox, listbox, option, ArrowDown, double-ArrowDown, clamp, ArrowUp-clamp, Enter select, Enter noop, query reset, activedescendant) Tests: 406 passed (383 existing + 23 new) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea6fdd58a6
commit
0725a818e7
@ -34,6 +34,15 @@ export function ContextMenu() {
|
||||
if (!contextMenu) setDeleteConfirm(null);
|
||||
}, [contextMenu]);
|
||||
|
||||
// Auto-focus first enabled item when menu opens
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
requestAnimationFrame(() => {
|
||||
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
first?.focus();
|
||||
});
|
||||
}, [contextMenu?.nodeId]);
|
||||
|
||||
// Close on click outside or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
@ -53,6 +62,38 @@ export function ContextMenu() {
|
||||
};
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
// Arrow-key navigation within the menu
|
||||
const handleMenuKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
const buttons = Array.from(
|
||||
ref.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []
|
||||
);
|
||||
const active = document.activeElement as HTMLButtonElement;
|
||||
const idx = buttons.indexOf(active);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? idx === -1
|
||||
? 0
|
||||
: (idx + 1) % buttons.length
|
||||
: idx <= 0
|
||||
? buttons.length - 1
|
||||
: idx - 1;
|
||||
buttons[next]?.focus();
|
||||
},
|
||||
[closeContextMenu]
|
||||
);
|
||||
|
||||
const handleExportBundle = useCallback(async () => {
|
||||
if (!contextMenu || actionLoading) return;
|
||||
setActionLoading(true);
|
||||
@ -224,6 +265,9 @@ export function ContextMenu() {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
aria-label={`Actions for ${contextMenu.nodeData.name}`}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
@ -231,29 +275,34 @@ export function ContextMenu() {
|
||||
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
|
||||
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${
|
||||
isOnline ? "bg-emerald-400" : isOfflineOrFailed ? "bg-red-400" : "bg-zinc-500"
|
||||
}`} />
|
||||
<span className="text-[9px] text-zinc-500">{contextMenu.nodeData.status}</span>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isOnline ? "bg-emerald-400" : isOfflineOrFailed ? "bg-red-400" : "bg-zinc-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-500">{contextMenu.nodeData.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item, i) => {
|
||||
if (item.divider) {
|
||||
return <div key={i} className="h-px bg-zinc-800/60 my-1" />;
|
||||
return <div key={i} role="separator" className="h-px bg-zinc-800/60 my-1" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
role="menuitem"
|
||||
onClick={item.action}
|
||||
disabled={item.disabled}
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
aria-disabled={item.disabled}
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
item.danger
|
||||
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
|
||||
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
|
||||
<span aria-hidden="true" className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@ export function SearchDialog() {
|
||||
const open = useCanvasStore((s) => s.searchOpen);
|
||||
const setOpen = useCanvasStore((s) => s.setSearchOpen);
|
||||
const [query, setQuery] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const selectNode = useCanvasStore((s) => s.selectNode);
|
||||
@ -34,6 +35,11 @@ export function SearchDialog() {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset focused index when query changes
|
||||
useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
const filtered = nodes.filter((n) => {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@ -50,27 +56,61 @@ export function SearchDialog() {
|
||||
setPanelTab("details");
|
||||
setOpen(false);
|
||||
},
|
||||
[selectNode, setPanelTab]
|
||||
[selectNode, setPanelTab, setOpen]
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && focusedIndex >= 0 && filtered[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelect(filtered[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
[filtered, focusedIndex, handleSelect]
|
||||
);
|
||||
|
||||
const activeDescendant =
|
||||
focusedIndex >= 0 && filtered[focusedIndex]
|
||||
? `search-result-${filtered[focusedIndex].id}`
|
||||
: undefined;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="w-[420px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/40">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
role="combobox"
|
||||
aria-label="Search workspaces"
|
||||
aria-expanded={filtered.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results-list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search workspaces..."
|
||||
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none"
|
||||
/>
|
||||
@ -78,31 +118,49 @@ export function SearchDialog() {
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[300px] overflow-y-auto py-1">
|
||||
<div
|
||||
id="search-results-list"
|
||||
role="listbox"
|
||||
aria-label="Workspace results"
|
||||
className="max-h-[300px] overflow-y-auto py-1"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
{query ? "No workspaces match" : "No workspaces yet"}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((node) => (
|
||||
filtered.map((node, index) => (
|
||||
<button
|
||||
key={node.id}
|
||||
id={`search-result-${node.id}`}
|
||||
role="option"
|
||||
aria-selected={index === focusedIndex}
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className="w-full px-4 py-2.5 flex items-center gap-3 text-left hover:bg-zinc-800/40 transition-colors"
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||
index === focusedIndex ? "bg-zinc-800/60" : "hover:bg-zinc-800/40"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
node.data.status === "online" ? "bg-emerald-400" :
|
||||
node.data.status === "failed" ? "bg-red-400" :
|
||||
node.data.status === "provisioning" ? "bg-sky-400 animate-pulse" :
|
||||
"bg-zinc-500"
|
||||
}`} />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
node.data.status === "online" ? "bg-emerald-400" :
|
||||
node.data.status === "failed" ? "bg-red-400" :
|
||||
node.data.status === "provisioning" ? "bg-sky-400 animate-pulse" :
|
||||
"bg-zinc-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-zinc-200 truncate">{node.data.name}</div>
|
||||
{node.data.role && (
|
||||
<div className="text-[10px] text-zinc-500 truncate">{node.data.role}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-zinc-600">T{node.data.tier}</span>
|
||||
<span
|
||||
className="text-[9px] font-mono text-zinc-600"
|
||||
aria-label={`Tier ${node.data.tier}`}
|
||||
>
|
||||
T{node.data.tier}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@ -112,6 +170,7 @@ export function SearchDialog() {
|
||||
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-zinc-600">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="flex gap-2">
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↵ select</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
166
canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx
Normal file
166
canvas/src/components/__tests__/ContextMenu.keyboard.test.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), del: vi.fn(), patch: vi.fn() },
|
||||
}));
|
||||
|
||||
const closeContextMenu = vi.fn();
|
||||
const mockStore = {
|
||||
contextMenu: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
nodeId: "ws-1",
|
||||
nodeData: {
|
||||
name: "Alpha Workspace",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
parentId: null,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "dev",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
currentTask: "",
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
} as {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
nodeData: Record<string, unknown>;
|
||||
} | null,
|
||||
closeContextMenu,
|
||||
removeNode: vi.fn(),
|
||||
updateNodeData: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
setPanelTab: vi.fn(),
|
||||
nestNode: vi.fn(),
|
||||
nodes: [] as Array<{ id: string; data: { parentId: string | null } }>,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn(
|
||||
(selector: (s: typeof mockStore) => unknown) => selector(mockStore)
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Component under test — imported AFTER mocks ───────────────────────────────
|
||||
import { ContextMenu } from "../ContextMenu";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const onlineMenu = {
|
||||
x: 100,
|
||||
y: 200,
|
||||
nodeId: "ws-1",
|
||||
nodeData: {
|
||||
name: "Alpha Workspace",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
parentId: null,
|
||||
agentCard: null,
|
||||
activeTasks: 0,
|
||||
collapsed: false,
|
||||
role: "dev",
|
||||
lastErrorRate: 0,
|
||||
lastSampleError: "",
|
||||
url: "",
|
||||
currentTask: "",
|
||||
runtime: "claude-code",
|
||||
needsRestart: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe("ContextMenu — keyboard accessibility", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStore.contextMenu = onlineMenu;
|
||||
mockStore.nodes = [];
|
||||
});
|
||||
|
||||
it("renders with role='menu'", () => {
|
||||
render(<ContextMenu />);
|
||||
expect(screen.getByRole("menu")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("menu has aria-label containing the workspace name", () => {
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu.getAttribute("aria-label")).toContain("Alpha Workspace");
|
||||
});
|
||||
|
||||
it("menu items have role='menuitem'", () => {
|
||||
render(<ContextMenu />);
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dividers have role='separator'", () => {
|
||||
render(<ContextMenu />);
|
||||
const separators = document.querySelectorAll('[role="separator"]');
|
||||
expect(separators.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Escape key calls closeContextMenu", () => {
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
fireEvent.keyDown(menu, { key: "Escape" });
|
||||
// Both the document keydown listener and the menu onKeyDown handler fire
|
||||
// on the same event — both call closeContextMenu. Two calls is correct.
|
||||
expect(closeContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Tab key calls closeContextMenu", () => {
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
fireEvent.keyDown(menu, { key: "Tab" });
|
||||
expect(closeContextMenu).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ArrowDown with nothing focused moves focus to the first enabled button", () => {
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
const buttons = menu.querySelectorAll<HTMLButtonElement>(
|
||||
"button:not(:disabled)"
|
||||
);
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it("ArrowDown wraps from the last enabled button to the first", () => {
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
const buttons = menu.querySelectorAll<HTMLButtonElement>(
|
||||
"button:not(:disabled)"
|
||||
);
|
||||
buttons[buttons.length - 1].focus();
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it("ArrowUp wraps from the first enabled button to the last", () => {
|
||||
render(<ContextMenu />);
|
||||
const menu = screen.getByRole("menu");
|
||||
const buttons = menu.querySelectorAll<HTMLButtonElement>(
|
||||
"button:not(:disabled)"
|
||||
);
|
||||
buttons[0].focus();
|
||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
||||
expect(document.activeElement).toBe(buttons[buttons.length - 1]);
|
||||
});
|
||||
|
||||
it("returns null when contextMenu is null", () => {
|
||||
mockStore.contextMenu = null;
|
||||
const { container } = render(<ContextMenu />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
186
canvas/src/components/__tests__/SearchDialog.keyboard.test.tsx
Normal file
186
canvas/src/components/__tests__/SearchDialog.keyboard.test.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ── Mock store data ───────────────────────────────────────────────────────────
|
||||
const setOpen = vi.fn();
|
||||
const selectNode = vi.fn();
|
||||
const setPanelTab = vi.fn();
|
||||
|
||||
const mockNodes = [
|
||||
{
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Alpha",
|
||||
status: "online",
|
||||
tier: 1,
|
||||
role: "dev",
|
||||
parentId: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ws-2",
|
||||
data: {
|
||||
name: "Beta",
|
||||
status: "offline",
|
||||
tier: 2,
|
||||
role: "ops",
|
||||
parentId: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ws-3",
|
||||
data: {
|
||||
name: "Gamma",
|
||||
status: "provisioning",
|
||||
tier: 1,
|
||||
role: "qa",
|
||||
parentId: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockStore = {
|
||||
searchOpen: true,
|
||||
setSearchOpen: setOpen,
|
||||
nodes: mockNodes as typeof mockNodes,
|
||||
selectNode,
|
||||
setPanelTab,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn(
|
||||
(selector: (s: typeof mockStore) => unknown) => selector(mockStore)
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Component under test — imported AFTER mocks ───────────────────────────────
|
||||
import { SearchDialog } from "../SearchDialog";
|
||||
|
||||
describe("SearchDialog — keyboard accessibility", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStore.searchOpen = true;
|
||||
mockStore.nodes = mockNodes;
|
||||
});
|
||||
|
||||
it("renders with role='dialog' and aria-modal='true'", () => {
|
||||
render(<SearchDialog />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("dialog has an aria-label", () => {
|
||||
render(<SearchDialog />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-label")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("search input has role='combobox'", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it("results container has role='listbox'", () => {
|
||||
render(<SearchDialog />);
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeTruthy();
|
||||
});
|
||||
|
||||
it("result items have role='option'", () => {
|
||||
render(<SearchDialog />);
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options.length).toBe(3);
|
||||
});
|
||||
|
||||
it("ArrowDown sets aria-selected='true' on the first option", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
||||
expect(options[1].getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("ArrowDown twice sets aria-selected='true' on the second option", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("false");
|
||||
expect(options[1].getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("ArrowDown clamps at the last option — does not wrap", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
// Press ArrowDown 5 times with only 3 items — should stop at index 2
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
}
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[2].getAttribute("aria-selected")).toBe("true");
|
||||
// first two must not be selected
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("false");
|
||||
expect(options[1].getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("ArrowUp from index 0 stays at 0 (Math.max clamp)", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" }); // Math.max(0-1, 0) = 0, stays at 0
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("Enter key selects the currently focused option", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // focus index 0 (ws-1)
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("Enter at focusedIndex=-1 does not select anything", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
// No ArrowDown — focusedIndex is -1
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("typing a new query resets focusedIndex to -1", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
|
||||
// Verify selection before reset
|
||||
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
|
||||
// Change query — triggers the useEffect that resets focusedIndex
|
||||
fireEvent.change(input, { target: { value: "Alpha" } });
|
||||
// After reset all options must have aria-selected="false"
|
||||
screen.getAllByRole("option").forEach((opt) => {
|
||||
expect(opt.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("aria-activedescendant matches the focused option's id", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0 (ws-1)
|
||||
expect(input.getAttribute("aria-activedescendant")).toBe(
|
||||
"search-result-ws-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when searchOpen is false", () => {
|
||||
mockStore.searchOpen = false;
|
||||
const { container } = render(<SearchDialog />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user