feat(command-palette): open SearchCategory to arbitrary strings
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>
This commit is contained in:
hsiegeln
2026-04-02 23:21:28 +02:00
parent 2f1df869db
commit 03ec34bb5c
2 changed files with 30 additions and 10 deletions

View File

@@ -19,8 +19,7 @@ interface CommandPaletteProps {
onSubmit?: (query: string) => void onSubmit?: (query: string) => void
} }
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = { const KNOWN_CATEGORY_LABELS: Record<string, string> = {
all: 'All',
application: 'Applications', application: 'Applications',
exchange: 'Exchanges', exchange: 'Exchanges',
attribute: 'Attributes', attribute: 'Attributes',
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
agent: 'Agents', agent: 'Agents',
} }
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [ /** Preferred display order for known categories */
'all', const KNOWN_CATEGORY_ORDER: string[] = [
'application', 'application',
'exchange', 'exchange',
'attribute', 'attribute',
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'agent', '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 { function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
if (!query && (!matchRanges || matchRanges.length === 0)) return text if (!query && (!matchRanges || matchRanges.length === 0)) return text
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) { export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all') const [activeCategory, setActiveCategory] = useState<string>('all')
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([]) const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
const [focusedIdx, setFocusedIdx] = useState(0) const [focusedIdx, setFocusedIdx] = useState(0)
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
// Group results by category // Group results by category
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map = new Map<SearchCategory, SearchResult[]>() const map = new Map<string, SearchResult[]>()
for (const r of filtered) { for (const r of filtered) {
if (!map.has(r.category)) map.set(r.category, []) if (!map.has(r.category)) map.set(r.category, [])
map.get(r.category)!.push(r) map.get(r.category)!.push(r)
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
return counts return counts
}, [queryFiltered]) }, [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) { function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
@@ -246,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
{/* Category tabs */} {/* Category tabs */}
<div className={styles.tabs} role="tablist"> <div className={styles.tabs} role="tablist">
{ALL_CATEGORIES.map((cat) => ( {visibleCategories.map((cat) => (
<button <button
key={cat} key={cat}
role="tab" role="tab"
@@ -262,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
setFocusedIdx(0) setFocusedIdx(0)
}} }}
> >
{CATEGORY_LABELS[cat]} {categoryLabel(cat)}
{categoryCounts[cat] != null && ( {categoryCounts[cat] != null && (
<span className={styles.tabCount}>{categoryCounts[cat]}</span> <span className={styles.tabCount}>{categoryCounts[cat]}</span>
)} )}
@@ -283,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
Array.from(grouped.entries()).map(([category, items]) => ( Array.from(grouped.entries()).map(([category, items]) => (
<div key={category} className={styles.group}> <div key={category} className={styles.group}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader> <SectionHeader>{categoryLabel(category)}</SectionHeader>
</div> </div>
{items.map((result) => { {items.map((result) => {
const flatIdx = flatResults.indexOf(result) const flatIdx = flatResults.indexOf(result)

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent' /** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
export type SearchCategory = string
export interface SearchResult { export interface SearchResult {
id: string id: string