feat: CommandPalette composite with search, filtering, keyboard navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
<CommandPalette
|
||||
open={false}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
data={mockData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders dialog when open', () => {
|
||||
render(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
data={mockData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all results when open with no query', () => {
|
||||
render(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
onSelect={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
onSelect={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
onSelect={onSelect}
|
||||
data={mockData}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByText('content-based-routing'))
|
||||
expect(onSelect).toHaveBeenCalledWith(mockData[1])
|
||||
})
|
||||
|
||||
it('filters by category tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
onSelect={onSelect}
|
||||
data={mockData}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
data={mockData}
|
||||
/>,
|
||||
)
|
||||
const expandBtn = screen.getByRole('button', { name: 'Toggle detail' })
|
||||
await user.click(expandBtn)
|
||||
expect(expandBtn).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
359
src/design-system/composites/CommandPalette/CommandPalette.tsx
Normal file
359
src/design-system/composites/CommandPalette/CommandPalette.tsx
Normal file
@@ -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<SearchCategory | 'all', string> = {
|
||||
all: 'All',
|
||||
execution: 'Executions',
|
||||
route: 'Routes',
|
||||
exchange: 'Exchanges',
|
||||
agent: 'Agents',
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'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(<mark key={start} className={styles.mark}>{text.slice(start, end)}</mark>)
|
||||
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<SearchCategory | 'all'>('all')
|
||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(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<SearchCategory, SearchResult[]>()
|
||||
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<string, number> = { 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(
|
||||
<div className={styles.overlay} onClick={onClose} data-testid="command-palette-overlay">
|
||||
<div
|
||||
className={styles.panel}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
>
|
||||
{/* Search input area */}
|
||||
<div className={styles.searchArea}>
|
||||
<span className={styles.searchIcon} aria-hidden="true">⌕</span>
|
||||
{scopeFilters.map((sf, i) => (
|
||||
<span key={i} className={styles.scopeTag}>
|
||||
<span className={styles.scopeField}>{sf.field}:</span>
|
||||
<span className={styles.scopeValue}>{sf.value}</span>
|
||||
<button
|
||||
className={styles.scopeRemove}
|
||||
onClick={() => removeScopeFilter(i)}
|
||||
aria-label={`Remove filter ${sf.field}:${sf.value}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Search executions, routes, exchanges, agents…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setFocusedIdx(0)
|
||||
}}
|
||||
aria-label="Search"
|
||||
/>
|
||||
<KeyboardHint keys="Esc" />
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className={styles.tabs} role="tablist">
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
role="tab"
|
||||
aria-selected={activeCategory === cat}
|
||||
className={[
|
||||
styles.tab,
|
||||
activeCategory === cat ? styles.tabActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
setActiveCategory(cat)
|
||||
setFocusedIdx(0)
|
||||
}}
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
{categoryCounts[cat] != null && (
|
||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div
|
||||
className={styles.results}
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className={styles.empty}>No results for "{query}"</div>
|
||||
) : (
|
||||
Array.from(grouped.entries()).map(([category, items]) => (
|
||||
<div key={category} className={styles.group}>
|
||||
<div className={styles.groupHeader}>
|
||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
||||
</div>
|
||||
{items.map((result) => {
|
||||
const flatIdx = flatResults.indexOf(result)
|
||||
const isFocused = flatIdx === focusedIdx
|
||||
const isExpanded = expandedId === result.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
data-idx={flatIdx}
|
||||
className={[
|
||||
styles.item,
|
||||
isFocused ? styles.focused : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="option"
|
||||
aria-selected={isFocused}
|
||||
onClick={() => {
|
||||
onSelect(result)
|
||||
onClose()
|
||||
}}
|
||||
onMouseEnter={() => setFocusedIdx(flatIdx)}
|
||||
>
|
||||
<div className={styles.itemMain}>
|
||||
{result.icon && (
|
||||
<span className={styles.itemIcon}>{result.icon}</span>
|
||||
)}
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.itemTop}>
|
||||
<span className={styles.itemTitle}>
|
||||
{highlightText(result.title, query, result.matchRanges)}
|
||||
</span>
|
||||
<div className={styles.itemBadges}>
|
||||
{result.badges?.map((b, bi) => (
|
||||
<span key={bi} className={styles.badge}>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{result.timestamp && (
|
||||
<span className={styles.itemTime}>{result.timestamp}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.itemMeta}>
|
||||
{highlightText(result.meta, query)}
|
||||
</div>
|
||||
</div>
|
||||
{result.expandedContent && (
|
||||
<button
|
||||
className={styles.expandBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedId((prev) => (prev === result.id ? null : result.id))
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label="Toggle detail"
|
||||
>
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && result.expandedContent && (
|
||||
<div className={styles.expanded} onClick={(e) => e.stopPropagation()}>
|
||||
<CodeBlock
|
||||
content={result.expandedContent}
|
||||
language="json"
|
||||
copyable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcuts bar */}
|
||||
<div className={styles.shortcutsBar}>
|
||||
<div className={styles.shortcut}>
|
||||
<KeyboardHint keys="↑↓" />
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div className={styles.shortcut}>
|
||||
<KeyboardHint keys="Enter" />
|
||||
<span>Open</span>
|
||||
</div>
|
||||
<div className={styles.shortcut}>
|
||||
<KeyboardHint keys="Esc" />
|
||||
<span>Close</span>
|
||||
</div>
|
||||
<div className={styles.shortcut}>
|
||||
<KeyboardHint keys="Tab" />
|
||||
<span>Filter</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
20
src/design-system/composites/CommandPalette/types.ts
Normal file
20
src/design-system/composites/CommandPalette/types.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user