molecule-core/canvas/src/components/SearchDialog.tsx
Molecule AI Core-UIUX 6a96641c37 fix(canvas/a11y): add type="button" to remaining canvas component buttons (batch 3)
WCAG 4.1.2 / bug #1669 follow-up — final batch completing the campaign.
Added type="button" to all buttons missing it across 14 canvas components.

Files changed (14, all additions):
- Toolbar.tsx: Stop All, Restart All, A2A toggle, Audit shortcut, Quick help, Search shortcut, Help close (7)
- MemoryInspectorPanel.tsx: scope tabs, refresh, search clear ×2, expand, delete (6)
- TemplatePalette.tsx: org refresh, toggle, Import Agent, org import, deploy template, palette refresh (6)
- ProvisioningTimeout.tsx: Retry, Cancel Request, View Logs, Keep, Remove Workspace (5)
- ConsoleModal.tsx: close, Copy output, Close (3)
- OnboardingWizard.tsx: Skip guide, action, Next (3)
- ConversationTraceModal.tsx: close ×2 (2)
- WorkspaceNode.tsx: Restart banner, Extract from team (2)
- CommunicationOverlay.tsx: toggle, close panel (2)
- Toaster.tsx: dismiss ×2 (2)
- SearchDialog.tsx: search result button (1)
- TermsGate.tsx: accept (1)
- ErrorBoundary.tsx: Reload (1)
- BundleDropZone.tsx: import trigger (1)

Total campaign (batches 1-3): 27 + 42 = 69 buttons fixed across 24 components.
All 477 canvas vitest tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:40:52 +00:00

178 lines
6.5 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useCanvasStore } from "@/store/canvas";
import { statusDotClass } from "@/lib/design-tokens";
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);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
// Cmd+K to open
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(true);
setQuery("");
}
if (e.key === "Escape" && open) {
setOpen(false);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, setOpen]);
useEffect(() => {
if (open) {
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
// Reset focused index when query changes
useEffect(() => {
setFocusedIndex(-1);
}, [query]);
const filtered = nodes.filter((n) => {
if (!query) return true;
const q = query.toLowerCase();
return (
n.data.name.toLowerCase().includes(q) ||
(n.data.role || "").toLowerCase().includes(q) ||
n.data.status.toLowerCase().includes(q)
);
});
const handleSelect = useCallback(
(nodeId: string) => {
selectNode(nodeId);
setPanelTab("details");
setOpen(false);
},
[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
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" 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-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus:outline-none rounded"
/>
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
</div>
{/* Results */}
<div
id="search-results-list"
role="listbox"
aria-label="Workspace results"
className="max-h-[300px] overflow-y-auto py-1"
>
{filtered.length === 0 ? (
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-400">
{query ? "No workspaces match" : "No workspaces yet"}
</div>
) : (
filtered.map((node, index) => (
<button
type="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 transition-colors ${
index === focusedIndex ? "bg-zinc-800/60" : "hover:bg-zinc-800/40"
}`}
>
<div
aria-hidden="true"
className={`w-2 h-2 rounded-full shrink-0 ${statusDotClass(node.data.status)}`}
/>
<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-400"
aria-label={`Tier ${node.data.tier}`}
>
T{node.data.tier}
</span>
</button>
))
)}
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
<span className="text-[9px] text-zinc-400">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
<div className="flex gap-2">
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40"> navigate</kbd>
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40"> select</kbd>
</div>
</div>
</div>
</div>
);
}