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>
178 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|