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