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:
hsiegeln
2026-03-18 09:58:13 +01:00
parent e86fecbd00
commit d106dfd275
4 changed files with 907 additions and 0 deletions

View File

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

View File

@@ -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')
})
})

View 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,
)
}

View 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
}