Template
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.
321 lines
10 KiB
TypeScript
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 “{query}”
|
|
</p>
|
|
<span>Try searching for “Buttons”, “Metrics”, “Tokens”, or “Icons”.</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>
|
|
)
|
|
}
|