diff --git a/src/design-system/composites/CommandPalette/CommandPalette.module.css b/src/design-system/composites/CommandPalette/CommandPalette.module.css new file mode 100644 index 0000000..256cc7f --- /dev/null +++ b/src/design-system/composites/CommandPalette/CommandPalette.module.css @@ -0,0 +1,304 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(26, 22, 18, 0.55); + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + backdrop-filter: blur(2px); +} + +.panel { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 640px; + max-width: 96vw; + display: flex; + flex-direction: column; + max-height: 70vh; + overflow: hidden; +} + +/* Search area */ +.searchArea { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.searchIcon { + font-size: 16px; + color: var(--text-muted); + flex-shrink: 0; +} + +.input { + flex: 1; + background: none; + border: none; + outline: none; + font-family: var(--font-body); + font-size: 14px; + color: var(--text-primary); + min-width: 0; +} + +.input::placeholder { + color: var(--text-faint); +} + +/* Scope filter tags */ +.scopeTag { + display: inline-flex; + align-items: center; + gap: 3px; + background: var(--amber-bg); + border: 1px solid var(--amber-light); + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; + flex-shrink: 0; +} + +.scopeField { + color: var(--amber-deep); + font-weight: 600; + font-family: var(--font-mono); +} + +.scopeValue { + color: var(--amber); + font-family: var(--font-mono); +} + +.scopeRemove { + background: none; + border: none; + color: var(--amber); + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 0; + opacity: 0.6; +} + +.scopeRemove:hover { + opacity: 1; +} + +/* Category tabs */ +.tabs { + display: flex; + gap: 0; + padding: 0 16px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + overflow-x: auto; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; + transition: color 0.12s, border-color 0.12s; + margin-bottom: -1px; +} + +.tab:hover { + color: var(--text-secondary); +} + +.tabActive { + color: var(--amber); + border-bottom-color: var(--amber); +} + +.tabCount { + background: var(--bg-inset); + border-radius: 10px; + padding: 0 6px; + font-size: 10px; + font-family: var(--font-mono); + color: var(--text-muted); +} + +.tabActive .tabCount { + background: var(--amber-bg); + color: var(--amber-deep); +} + +/* Results */ +.results { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.empty { + padding: 32px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.group { + /* group container */ +} + +.groupHeader { + padding: 8px 16px 4px; + position: sticky; + top: 0; + background: var(--bg-surface); + z-index: 1; +} + +/* Result item */ +.item { + padding: 8px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.08s; +} + +.item:last-child { + border-bottom: none; +} + +.item:hover, +.focused { + background: var(--bg-hover); +} + +.itemMain { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.itemIcon { + flex-shrink: 0; + font-size: 14px; + margin-top: 1px; + color: var(--text-muted); +} + +.itemContent { + flex: 1; + min-width: 0; +} + +.itemTop { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.itemTitle { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.itemBadges { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.badge { + display: inline-flex; + padding: 1px 7px; + border-radius: 10px; + font-size: 10px; + font-family: var(--font-mono); + font-weight: 600; + background: var(--bg-inset); + color: var(--text-muted); + border: 1px solid var(--border); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.itemTime { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-faint); + margin-left: auto; +} + +.itemMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.expandBtn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 10px; + padding: 2px 6px; + border-radius: var(--radius-sm); + flex-shrink: 0; + transition: color 0.1s, background 0.1s; +} + +.expandBtn:hover { + background: var(--bg-inset); + color: var(--text-secondary); +} + +.expanded { + margin-top: 8px; + max-height: 200px; + overflow-y: auto; +} + +/* Match highlight */ +.mark { + background: none; + color: var(--amber); + font-weight: 600; +} + +/* Shortcuts bar */ +.shortcutsBar { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; + background: var(--bg-raised); +} + +.shortcut { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-muted); +} diff --git a/src/design-system/composites/CommandPalette/CommandPalette.test.tsx b/src/design-system/composites/CommandPalette/CommandPalette.test.tsx new file mode 100644 index 0000000..47f37a9 --- /dev/null +++ b/src/design-system/composites/CommandPalette/CommandPalette.test.tsx @@ -0,0 +1,224 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CommandPalette } from './CommandPalette' +import type { SearchResult } from './types' + +const mockData: SearchResult[] = [ + { + id: '1', + category: 'execution', + title: 'order-intake execution', + meta: 'cmr-1234 · 142ms', + timestamp: '2s ago', + badges: [{ label: 'Completed' }], + }, + { + id: '2', + category: 'route', + title: 'content-based-routing', + meta: 'Route · 3 processors', + timestamp: '1m ago', + }, + { + id: '3', + category: 'agent', + title: 'agent-eu-west-1', + meta: 'v2.4.1 · live', + expandedContent: '{"status": "live"}', + }, +] + +describe('CommandPalette', () => { + it('renders nothing when closed', () => { + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('renders dialog when open', () => { + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('shows all results when open with no query', () => { + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + expect(screen.getByText('order-intake execution')).toBeInTheDocument() + expect(screen.getByText('content-based-routing')).toBeInTheDocument() + }) + + it('filters results by search query', async () => { + const user = userEvent.setup() + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + await user.type(screen.getByRole('textbox', { name: 'Search' }), 'routing') + // The title is split into fragments by mark highlight, so use a partial text match + expect(screen.getByText(/content-based/)).toBeInTheDocument() + expect(screen.queryByText('order-intake execution')).not.toBeInTheDocument() + }) + + it('calls onClose when Escape is pressed', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render( + {}} + data={mockData} + />, + ) + const input = screen.getByRole('textbox', { name: 'Search' }) + await user.click(input) + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('calls onClose when overlay is clicked', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render( + {}} + data={mockData} + />, + ) + await user.click(screen.getByTestId('command-palette-overlay')) + expect(onClose).toHaveBeenCalled() + }) + + it('calls onSelect when a result is clicked', async () => { + const onSelect = vi.fn() + const onClose = vi.fn() + const user = userEvent.setup() + render( + , + ) + await user.click(screen.getByText('content-based-routing')) + expect(onSelect).toHaveBeenCalledWith(mockData[1]) + }) + + it('filters by category tab', async () => { + const user = userEvent.setup() + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + await user.click(screen.getByRole('tab', { name: /Routes/i })) + expect(screen.getByText('content-based-routing')).toBeInTheDocument() + expect(screen.queryByText('order-intake execution')).not.toBeInTheDocument() + }) + + it('shows category tabs with counts', () => { + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + // All tab should show total count + expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument() + }) + + it('navigates with arrow keys', async () => { + const user = userEvent.setup() + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + const input = screen.getByRole('textbox', { name: 'Search' }) + await user.click(input) + await user.keyboard('{ArrowDown}') + // Second item gets focused + const items = screen.getAllByRole('option') + expect(items[1]).toHaveClass('focused') + }) + + it('selects focused item on Enter', async () => { + const onSelect = vi.fn() + const onClose = vi.fn() + const user = userEvent.setup() + render( + , + ) + const input = screen.getByRole('textbox', { name: 'Search' }) + await user.click(input) + await user.keyboard('{Enter}') + expect(onSelect).toHaveBeenCalledWith(mockData[0]) + }) + + it('shows expandable detail toggle for items with expandedContent', () => { + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + expect(screen.getByRole('button', { name: 'Toggle detail' })).toBeInTheDocument() + }) + + it('expands detail when expand button is clicked', async () => { + const user = userEvent.setup() + render( + {}} + onSelect={() => {}} + data={mockData} + />, + ) + const expandBtn = screen.getByRole('button', { name: 'Toggle detail' }) + await user.click(expandBtn) + expect(expandBtn).toHaveAttribute('aria-expanded', 'true') + }) +}) diff --git a/src/design-system/composites/CommandPalette/CommandPalette.tsx b/src/design-system/composites/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000..93dec01 --- /dev/null +++ b/src/design-system/composites/CommandPalette/CommandPalette.tsx @@ -0,0 +1,359 @@ +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import styles from './CommandPalette.module.css' +import { SectionHeader } from '../../primitives/SectionHeader/SectionHeader' +import { CodeBlock } from '../../primitives/CodeBlock/CodeBlock' +import { KeyboardHint } from '../../primitives/KeyboardHint/KeyboardHint' +import type { SearchResult, SearchCategory, ScopeFilter } from './types' + +interface CommandPaletteProps { + open: boolean + onClose: () => void + onSelect: (result: SearchResult) => void + data: SearchResult[] + onOpen?: () => void +} + +const CATEGORY_LABELS: Record = { + all: 'All', + execution: 'Executions', + route: 'Routes', + exchange: 'Exchanges', + agent: 'Agents', +} + +const ALL_CATEGORIES: Array = [ + 'all', + 'execution', + 'route', + 'exchange', + 'agent', +] + +function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode { + if (!query && (!matchRanges || matchRanges.length === 0)) return text + + // Use matchRanges if provided, otherwise compute from query + let ranges: [number, number][] = matchRanges ?? [] + if (!matchRanges && query) { + const lowerText = text.toLowerCase() + const lowerQuery = query.toLowerCase() + let idx = 0 + while (idx < text.length) { + const found = lowerText.indexOf(lowerQuery, idx) + if (found === -1) break + ranges.push([found, found + lowerQuery.length]) + idx = found + lowerQuery.length + } + } + + if (ranges.length === 0) return text + + const parts: ReactNode[] = [] + let last = 0 + for (const [start, end] of ranges) { + if (start > last) parts.push(text.slice(last, start)) + parts.push({text.slice(start, end)}) + last = end + } + if (last < text.length) parts.push(text.slice(last)) + return <>{parts} +} + +export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) { + const [query, setQuery] = useState('') + const [activeCategory, setActiveCategory] = useState('all') + const [scopeFilters, setScopeFilters] = useState([]) + const [focusedIdx, setFocusedIdx] = useState(0) + const [expandedId, setExpandedId] = useState(null) + const inputRef = useRef(null) + const listRef = useRef(null) + + // Register global Ctrl+K / Cmd+K + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault() + onOpen?.() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onOpen]) + + // Focus input when opened + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 10) + setQuery('') + setFocusedIdx(0) + setExpandedId(null) + } + }, [open]) + + // Filter results + const filtered = useMemo(() => { + let results = data + + if (activeCategory !== 'all') { + results = results.filter((r) => r.category === activeCategory) + } + + if (query.trim()) { + const q = query.toLowerCase() + results = results.filter( + (r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q), + ) + } + + // Apply scope filters + for (const sf of scopeFilters) { + results = results.filter((r) => + r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()), + ) + } + + return results + }, [data, query, activeCategory, scopeFilters]) + + // Group results by category + const grouped = useMemo(() => { + const map = new Map() + for (const r of filtered) { + if (!map.has(r.category)) map.set(r.category, []) + map.get(r.category)!.push(r) + } + return map + }, [filtered]) + + // Flatten for keyboard nav + const flatResults = useMemo(() => filtered, [filtered]) + + // Counts per category + const categoryCounts = useMemo(() => { + const counts: Record = { all: data.length } + for (const r of data) { + counts[r.category] = (counts[r.category] ?? 0) + 1 + } + return counts + }, [data]) + + function handleKeyDown(e: React.KeyboardEvent) { + switch (e.key) { + case 'Escape': + onClose() + break + case 'ArrowDown': + e.preventDefault() + setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setFocusedIdx((i) => Math.max(i - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (flatResults[focusedIdx]) { + onSelect(flatResults[focusedIdx]) + onClose() + } + break + } + } + + // Scroll focused item into view + useEffect(() => { + const el = listRef.current?.querySelector(`[data-idx="${focusedIdx}"]`) as HTMLElement | null + el?.scrollIntoView({ block: 'nearest' }) + }, [focusedIdx]) + + function removeScopeFilter(idx: number) { + setScopeFilters((prev) => prev.filter((_, i) => i !== idx)) + } + + if (!open) return null + + return createPortal( +
+
e.stopPropagation()} + onKeyDown={handleKeyDown} + role="dialog" + aria-modal="true" + aria-label="Command palette" + > + {/* Search input area */} +
+ + {scopeFilters.map((sf, i) => ( + + {sf.field}: + {sf.value} + + + ))} + { + setQuery(e.target.value) + setFocusedIdx(0) + }} + aria-label="Search" + /> + +
+ + {/* Category tabs */} +
+ {ALL_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Results */} +
+ {filtered.length === 0 ? ( +
No results for "{query}"
+ ) : ( + Array.from(grouped.entries()).map(([category, items]) => ( +
+
+ {CATEGORY_LABELS[category]} +
+ {items.map((result) => { + const flatIdx = flatResults.indexOf(result) + const isFocused = flatIdx === focusedIdx + const isExpanded = expandedId === result.id + + return ( +
{ + onSelect(result) + onClose() + }} + onMouseEnter={() => setFocusedIdx(flatIdx)} + > +
+ {result.icon && ( + {result.icon} + )} +
+
+ + {highlightText(result.title, query, result.matchRanges)} + +
+ {result.badges?.map((b, bi) => ( + + {b.label} + + ))} +
+ {result.timestamp && ( + {result.timestamp} + )} +
+
+ {highlightText(result.meta, query)} +
+
+ {result.expandedContent && ( + + )} +
+ {isExpanded && result.expandedContent && ( +
e.stopPropagation()}> + +
+ )} +
+ ) + })} +
+ )) + )} +
+ + {/* Shortcuts bar */} +
+
+ + Navigate +
+
+ + Open +
+
+ + Close +
+
+ + Filter +
+
+
+
, + document.body, + ) +} diff --git a/src/design-system/composites/CommandPalette/types.ts b/src/design-system/composites/CommandPalette/types.ts new file mode 100644 index 0000000..80d9c63 --- /dev/null +++ b/src/design-system/composites/CommandPalette/types.ts @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' + +export type SearchCategory = 'execution' | 'route' | 'exchange' | 'agent' + +export interface SearchResult { + id: string + category: SearchCategory + title: string + badges?: { label: string; color?: string }[] + meta: string + timestamp?: string + icon?: ReactNode + expandedContent?: string + matchRanges?: [number, number][] +} + +export interface ScopeFilter { + field: string + value: string +}