forked from molecule-ai/molecule-core
- 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>
177 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|