Files
design-system/src/design-system/composites/CommandPalette/CommandPalette.tsx
hsiegeln 03ec34bb5c
All checks were successful
Build & Publish / publish (push) Successful in 1m33s
feat(command-palette): open SearchCategory to arbitrary strings
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>
2026-04-02 23:21:28 +02:00

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