molecule-core/canvas/src/components/SearchDialog.tsx
Hongming Wang 2049870057 fix(canvas): address all code review findings on PR #482
- Reconcile TIER_CONFIG/TIER_COLORS into single TIER_CONFIG with both
  `color` (pill style) and `border` (bordered badge style) fields
- Remove TemplatePalette alias indirection (TIER_LABELS_SHARED → direct import)
- Extract inline spinner SVGs to shared Spinner component (3 copies → 1)
- Migrate status dot colors from 6 remaining files to shared tokens:
  SearchDialog, StatusDot, Legend, ContextMenu, Toolbar + add statusDotClass()
- Add COMM_TYPE_LABELS to design-tokens, used by CommunicationOverlay sr-only
- Update reduced-motion tests: components that delegate to design-tokens
  pass the guard check via import detection; add design-tokens.ts own test
- 507/507 tests pass, build clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:48:47 -07:00

177 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
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>
);
}