All checks were successful
Build & Publish / publish (push) Successful in 1m33s
Widen SearchCategory from a closed union to string. Known categories (application, exchange, attribute, route, agent) keep their labels. Unknown categories render with title-cased labels and appear as dynamic tabs derived from the data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Search, X, ChevronUp, ChevronDown } from 'lucide-react'
|
|
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
|
|
onQueryChange?: (query: string) => void
|
|
/** Called when Enter is pressed without the user explicitly selecting a result (arrow keys/click).
|
|
* Useful for applying the query as a full-text search filter. */
|
|
onSubmit?: (query: string) => void
|
|
}
|
|
|
|
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
|
application: 'Applications',
|
|
exchange: 'Exchanges',
|
|
attribute: 'Attributes',
|
|
route: 'Routes',
|
|
agent: 'Agents',
|
|
}
|
|
|
|
/** Preferred display order for known categories */
|
|
const KNOWN_CATEGORY_ORDER: string[] = [
|
|
'application',
|
|
'exchange',
|
|
'attribute',
|
|
'route',
|
|
'agent',
|
|
]
|
|
|
|
function categoryLabel(cat: string): string {
|
|
if (cat === 'all') return 'All'
|
|
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
|
|
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
|
|
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
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, onQueryChange, onSubmit }: CommandPaletteProps) {
|
|
const [query, setQuery] = useState('')
|
|
const [activeCategory, setActiveCategory] = useState<string>('all')
|
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const userNavigated = useRef(false)
|
|
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)
|
|
userNavigated.current = false
|
|
}
|
|
}, [open])
|
|
|
|
// Stage 1: apply text query + scope filters (used for counts)
|
|
const queryFiltered = useMemo(() => {
|
|
let results = data
|
|
|
|
if (query.trim()) {
|
|
const q = query.toLowerCase()
|
|
results = results.filter(
|
|
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
|
)
|
|
}
|
|
|
|
for (const sf of scopeFilters) {
|
|
results = results.filter((r) =>
|
|
r.category === sf.field || r.title.toLowerCase().includes(sf.value.toLowerCase()),
|
|
)
|
|
}
|
|
|
|
return results
|
|
}, [data, query, scopeFilters])
|
|
|
|
// Stage 2: apply category filter (used for display)
|
|
const filtered = useMemo(() => {
|
|
if (activeCategory === 'all') return queryFiltered
|
|
return queryFiltered.filter((r) => r.category === activeCategory)
|
|
}, [queryFiltered, activeCategory])
|
|
|
|
// Group results by category
|
|
const grouped = useMemo(() => {
|
|
const map = new Map<string, 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 (from query-filtered, before category filter)
|
|
const categoryCounts = useMemo(() => {
|
|
const counts: Record<string, number> = { all: queryFiltered.length }
|
|
for (const r of queryFiltered) {
|
|
counts[r.category] = (counts[r.category] ?? 0) + 1
|
|
}
|
|
return counts
|
|
}, [queryFiltered])
|
|
|
|
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
|
const visibleCategories = useMemo(() => {
|
|
const dataCategories = new Set(data.map((r) => r.category))
|
|
const tabs: string[] = ['all']
|
|
for (const cat of KNOWN_CATEGORY_ORDER) {
|
|
if (dataCategories.has(cat)) tabs.push(cat)
|
|
}
|
|
for (const cat of dataCategories) {
|
|
if (!tabs.includes(cat)) tabs.push(cat)
|
|
}
|
|
return tabs
|
|
}, [data])
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
switch (e.key) {
|
|
case 'Escape':
|
|
onClose()
|
|
break
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
userNavigated.current = true
|
|
setFocusedIdx((i) => Math.min(i + 1, flatResults.length - 1))
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
userNavigated.current = true
|
|
setFocusedIdx((i) => Math.max(i - 1, 0))
|
|
break
|
|
case 'Enter':
|
|
e.preventDefault()
|
|
if (!userNavigated.current && onSubmit && query.trim()) {
|
|
onSubmit(query.trim())
|
|
onClose()
|
|
} else 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))
|
|
}
|
|
|
|
function toggleExpanded(e: React.MouseEvent, id: string) {
|
|
e.stopPropagation()
|
|
setExpandedId((prev) => (prev === id ? null : id))
|
|
}
|
|
|
|
if (!open) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
className={styles.overlay}
|
|
onClick={onClose}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClose() }}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="Close command palette"
|
|
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"><Search size={14} /></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}`}
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
className={styles.input}
|
|
placeholder="Search exchanges, routes, agents…"
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
setFocusedIdx(0)
|
|
userNavigated.current = false
|
|
onQueryChange?.(e.target.value)
|
|
}}
|
|
aria-label="Search"
|
|
/>
|
|
<KeyboardHint keys="Esc" />
|
|
</div>
|
|
|
|
{/* Category tabs */}
|
|
<div className={styles.tabs} role="tablist">
|
|
{visibleCategories.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)
|
|
}}
|
|
>
|
|
{categoryLabel(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>{categoryLabel(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()
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
onSelect(result)
|
|
onClose()
|
|
}
|
|
}}
|
|
onMouseEnter={() => { userNavigated.current = true; 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>
|
|
{result.matchContext && (
|
|
<div
|
|
className={styles.matchContext}
|
|
dangerouslySetInnerHTML={{ __html: result.matchContext }}
|
|
/>
|
|
)}
|
|
</div>
|
|
{result.expandedContent && (
|
|
<button
|
|
className={styles.expandBtn}
|
|
onClick={(e) => toggleExpanded(e, result.id)}
|
|
aria-expanded={isExpanded}
|
|
aria-label="Toggle detail"
|
|
>
|
|
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</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>Search</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,
|
|
)
|
|
}
|