Files
design-system/src/components/SearchPalette.tsx
T
developer 72ff61f94d fix(SearchPalette): return null while closed
The Overlay refactor dropped the early return, so the fixed-inset backdrop
stayed mounted permanently: the palette covered the page and swallowed
every click. Restore the guard and cover it with tests.
2026-09-15 18:28:38 +00:00

321 lines
10 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { Icon, type IconName } from './Icon'
import { Badge } from './ui/Badge'
import { Kbd } from './ui/Kbd'
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
import type { PatternId } from './AIWorkflowSection'
export type SearchItem = {
id: string
title: string
description: string
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
icon: IconName
targetId: string
patternId?: PatternId
}
const SEARCH_INDEX: SearchItem[] = [
// Patterns
{
id: 'pat-metrics',
title: 'Metrics Dashboard',
description: 'Telemetry dashboard with StatCards, time range picker, and progress gauges',
category: 'Patterns',
icon: 'sparkles',
targetId: 'application-patterns',
patternId: 'metrics',
},
{
id: 'pat-settings',
title: 'Settings & Safeguards',
description: 'Preferences panel with API keys, toggle switches, and alert banners',
category: 'Patterns',
icon: 'settings',
targetId: 'application-patterns',
patternId: 'settings',
},
{
id: 'pat-table',
title: 'Service Monitor Table',
description: 'Real-time service health table with status filtering and search',
category: 'Patterns',
icon: 'layers',
targetId: 'application-patterns',
patternId: 'table',
},
{
id: 'pat-deploy',
title: 'Canary Deployment Flow',
description: 'Interactive rollout modal with live progress simulation',
category: 'Patterns',
icon: 'terminal',
targetId: 'application-patterns',
patternId: 'deploy',
},
// Components
{
id: 'comp-buttons',
title: 'Buttons & Action States',
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
category: 'Components',
icon: 'arrow-right',
targetId: 'buttons',
},
{
id: 'comp-icons',
title: 'Lucide Icons Explorer',
description: 'Over 1,400+ tree-shaken SVG icons with search, category filtering and 1-click copy',
category: 'Components',
icon: 'sparkles',
targetId: 'icons',
},
{
id: 'comp-forms',
title: 'Form Controls & Inputs',
description: 'Accessible text inputs, secret key revealers, and toggle switches',
category: 'Components',
icon: 'eye',
targetId: 'form-controls',
},
{
id: 'comp-code',
title: 'Composable UI Primitives',
description: 'Zero-drift Dialog, Sheet, Tabs, Table, Alert, Progress, Switch, and StatCard',
category: 'Components',
icon: 'code',
targetId: 'composable-by-default',
},
// Foundations
{
id: 'found-colors',
title: 'Color Tokens (CSS Color 4 lab())',
description: 'Pitch black dark theme and pure white daylight light theme tokens',
category: 'Foundations',
icon: 'sun',
targetId: 'color-tokens',
},
{
id: 'found-type',
title: 'Typography & Scale',
description: 'Modular rem-based 9px to 70px scale with Manrope and DM Mono families',
category: 'Foundations',
icon: 'file',
targetId: 'typography',
},
// Documentation
{
id: 'doc-md',
title: 'Markdown Editorial Layer',
description: 'Changelogs, release notes, and documentation typography',
category: 'Documentation',
icon: 'file',
targetId: 'markdown-rendering',
},
{
id: 'doc-cm',
title: 'CodeMirror Editor Theme',
description: 'Syntax-highlighted code editor preview with line numbers and token palette',
category: 'Documentation',
icon: 'terminal',
targetId: 'codemirror-theme',
},
]
type SearchPaletteProps = {
isOpen: boolean
onClose: () => void
onSelect: (item: SearchItem) => void
initialQuery?: string
}
const LISTBOX_ID = 'search-palette-listbox'
/** Command-palette modal built on the same overlay behavior as Dialog/Sheet:
focus trap, focus restore, scroll lock, Escape — plus combobox semantics
(role=listbox + aria-activedescendant) for the results. */
export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: SearchPaletteProps) {
const [query, setQuery] = useState(initialQuery)
const [selectedIndex, setSelectedIndex] = useState(0)
const panelRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
/* Same lifecycle as DialogContent: scroll lock + focus capture/restore. */
useOverlayLifecycle(isOpen, panelRef)
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
}
}, [isOpen, initialQuery])
const results = useMemo(() => {
if (!query.trim()) return SEARCH_INDEX
const q = query.toLowerCase()
return SEARCH_INDEX.filter(
item =>
item.title.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.category.toLowerCase().includes(q),
)
}, [query])
/* Keep the selection inside the result set when the query shrinks it. */
const activeIndex = results.length === 0 ? -1 : Math.min(selectedIndex, results.length - 1)
const activeItem = activeIndex >= 0 ? results[activeIndex] : undefined
// Keyboard navigation (combobox pattern: arrows move aria-activedescendant)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(prev => (results.length ? (prev + 1) % results.length : 0))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0))
} else if (e.key === 'Enter' && activeItem) {
e.preventDefault()
onSelect(activeItem)
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, results.length, activeItem, onClose, onSelect])
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
trapTabKey(e, panelRef.current)
}
/* Closed palette must not render at all: the backdrop is `position: fixed;
inset: 0`, so rendering it unconditionally left the modal permanently on
screen (and ate every click on the page underneath). */
if (!isOpen) return null
return (
<div className="search-palette-backdrop" onClick={onClose}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Search"
tabIndex={-1}
className="search-palette-modal"
onKeyDown={handlePanelKeyDown}
onClick={e => e.stopPropagation()}
>
{/* Search Input Bar */}
<div className="search-palette-head">
<Icon name="search" size={18} className="search-palette-icon" />
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={results.length > 0}
aria-controls={LISTBOX_ID}
aria-activedescendant={activeItem ? `search-option-${activeItem.id}` : undefined}
aria-label="Search components, tokens, and docs"
className="search-palette-input"
placeholder="Type a command, component, or token..."
value={query}
onChange={e => {
setQuery(e.target.value)
setSelectedIndex(0)
}}
/>
{query && (
<button
type="button"
className="icon-button"
onClick={() => {
setQuery('')
setSelectedIndex(0)
}}
aria-label="Clear search"
>
<Icon name="close" size={14} />
</button>
)}
<Kbd>ESC</Kbd>
</div>
{/* Results List */}
<div className="search-palette-body">
{results.length > 0 ? (
<ul id={LISTBOX_ID} role="listbox" aria-label="Search results" className="search-palette-results">
{results.map((item, idx) => {
const isSelected = idx === activeIndex
return (
<li
key={item.id}
id={`search-option-${item.id}`}
role="option"
aria-selected={isSelected}
className={`search-palette-item ${isSelected ? 'selected' : ''}`}
onClick={() => {
onSelect(item)
onClose()
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="search-item-glyph">
<Icon name={item.icon} size={16} />
</div>
<div className="search-item-content">
<div className="search-item-title-row">
<span className="search-item-title">{item.title}</span>
<Badge variant="outline">{item.category}</Badge>
</div>
<p className="search-item-desc">{item.description}</p>
</div>
{isSelected && (
<div className="search-item-action">
<Kbd>↵</Kbd>
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="search-palette-empty">
<Icon name="search" size={24} />
<p>
No results found for &ldquo;{query}&rdquo;
</p>
<span>Try searching for &ldquo;Buttons&rdquo;, &ldquo;Metrics&rdquo;, &ldquo;Tokens&rdquo;, or &ldquo;Icons&rdquo;.</span>
</div>
)}
</div>
{/* Footer info */}
<div className="search-palette-footer">
<div className="row-xs" style={{ alignItems: 'center' }}>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>to navigate</span>
</div>
<div className="row-xs" style={{ alignItems: 'center' }}>
<Kbd>↵</Kbd>
<span>to select</span>
</div>
<div className="row-xs" style={{ alignItems: 'center' }}>
<Kbd>ESC</Kbd>
<span>to close</span>
</div>
</div>
</div>
</div>
)
}