Template
Gate hardening:
- strict + noUncheckedIndexedAccess in tsconfig.app.json; fix resulting errors
- check-tokens.mjs: scan inline style={{ }} in ts/tsx, ban named colors and
rgb()/hsl()/oklch()/lab(), read multi-line values, fail on undefined var(--x)
- vitest (24 tests: overlay, icon-registry, useTimeout, token guard) wired into
npm run check; GitHub Actions CI workflow
Single-source refactors:
- new Overlay primitive owns focus trap/Escape/scroll-lock/focus-restore and
conditional aria-labelledby/aria-describedby; Dialog + Sheet are thin skins
(overlayBehavior.ts exports trapTabKey/useOverlayLifecycle for SearchPalette)
- icon-registry.ts is the one icon map: strict IconName, aliases typed, dev
throws on unknown names instead of silently rendering <Layers/>; IconMatrix
consumes the shared registry (41 curated)
- useTimeout hook replaces fire-and-forget setTimeout in DeploymentModal,
SettingsPanel, IconMatrix, CodeSample (timers die on close/unmount)
Bug fixes:
- SearchPalette: role=dialog/aria-modal, focus trap + restore, combobox/listbox
semantics, labelled input, selection clamped to results
- theme: pre-paint script in index.html (localStorage -> prefers-color-scheme ->
dark), useTheme no longer writes DOM during state init
- StatCard: direction (arrow) decoupled from trend (sentiment); latency drop
renders green "↓ -4.2ms" instead of "↑ -4.2ms"
- sidebar collapse button wired (aria-pressed + collapsed rail CSS); pattern
routing keys on patternId from library.ts, not English labels
- remove 25 dead legacy tokens (--panel*, --line*, --text-soft, --critical,
--pink, --blue, ...); migrate --bg/--text/--positive/--warning usages
- title "Northstar Design System", real README, AGENTS.md structure/theme/
overlay/registry/test docs updated; dev error sink (#dev-errors +
ErrorBoundary) so agents can read runtime errors via inspect
142 lines
4.8 KiB
TypeScript
142 lines
4.8 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { useTimeout } from '../utils/useTimeout'
|
|
import { Icon } from './Icon'
|
|
import { iconRegistry, type IconRegistryEntry } from './icon-registry'
|
|
import { Kbd } from './ui/Kbd'
|
|
import { Badge } from './ui/Badge'
|
|
|
|
type IconItem = IconRegistryEntry
|
|
|
|
/* The curated showcase set — components come straight from the shared
|
|
registry in Icon.tsx, so there is no second icon list to drift. */
|
|
const ICONS: IconItem[] = iconRegistry
|
|
|
|
const CATEGORIES = ['All', 'Navigation', 'Interface', 'System', 'Status'] as const
|
|
type Category = (typeof CATEGORIES)[number]
|
|
|
|
export function IconMatrix() {
|
|
const [activeCategory, setActiveCategory] = useState<Category>('All')
|
|
const [search, setSearch] = useState('')
|
|
const [size, setSize] = useState<number>(20)
|
|
const [copiedName, setCopiedName] = useState<string | null>(null)
|
|
const { schedule } = useTimeout()
|
|
|
|
const filteredIcons = useMemo(() => {
|
|
return ICONS.filter(icon => {
|
|
const matchesCategory = activeCategory === 'All' || icon.category === activeCategory
|
|
const matchesSearch =
|
|
icon.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
icon.pascalName.toLowerCase().includes(search.toLowerCase())
|
|
return matchesCategory && matchesSearch
|
|
})
|
|
}, [activeCategory, search])
|
|
|
|
const copyUsage = (icon: IconItem) => {
|
|
const code = `<${icon.pascalName} size={${size}} />`
|
|
navigator.clipboard?.writeText(code)
|
|
setCopiedName(icon.name)
|
|
schedule(() => setCopiedName(null), 2000)
|
|
}
|
|
|
|
return (
|
|
<div className="icon-matrix-container">
|
|
{/* Header controls */}
|
|
<div className="icon-matrix-header">
|
|
<div className="icon-search-bar">
|
|
<Icon name="search" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search icons (e.g. arrow, check, sparkles)..."
|
|
aria-label="Search icons"
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
className="icon-search-input"
|
|
/>
|
|
{search && (
|
|
<button className="icon-clear-btn" onClick={() => setSearch('')}>
|
|
<Icon name="close" size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="icon-size-picker">
|
|
<span className="mono muted">Size:</span>
|
|
{[16, 20, 24].map(s => (
|
|
<button
|
|
key={s}
|
|
className={`icon-size-btn ${size === s ? 'active' : ''}`}
|
|
onClick={() => setSize(s)}
|
|
>
|
|
{s}px
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category tabs */}
|
|
<div className="icon-category-tabs">
|
|
{CATEGORIES.map(cat => (
|
|
<button
|
|
key={cat}
|
|
className={`icon-cat-btn ${activeCategory === cat ? 'active' : ''}`}
|
|
onClick={() => setActiveCategory(cat)}
|
|
>
|
|
{cat}
|
|
<span className="icon-cat-count">
|
|
{cat === 'All' ? ICONS.length : ICONS.filter(i => i.category === cat).length}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grid of icons */}
|
|
<div className="icon-grid">
|
|
{filteredIcons.map(icon => {
|
|
const isCopied = copiedName === icon.name
|
|
return (
|
|
<button
|
|
key={icon.name}
|
|
className={`icon-tile ${isCopied ? 'copied' : ''}`}
|
|
onClick={() => copyUsage(icon)}
|
|
title={`Click to copy <${icon.pascalName} />`}
|
|
>
|
|
<div className="icon-tile-glyph">
|
|
{isCopied ? (
|
|
<Icon name="check" size={size} className="accent" />
|
|
) : (
|
|
<icon.component size={size} strokeWidth={1.8} />
|
|
)}
|
|
</div>
|
|
<span className="icon-tile-name">{icon.name}</span>
|
|
<span className="icon-tile-action">
|
|
{isCopied ? 'Copied!' : <Kbd>Copy</Kbd>}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{filteredIcons.length === 0 && (
|
|
<div className="empty-state">
|
|
<div className="empty-state-icon">
|
|
<Icon name="search" size={24} />
|
|
</div>
|
|
<h4 style={{ margin: '0 0 var(--space-2)' }}>No icons found</h4>
|
|
<p className="muted" style={{ margin: 0, fontSize: 'var(--text-xs)' }}>
|
|
No icon matches "{search}". You can use any of Lucide's 1,400+ icons directly via{' '}
|
|
<code>lucide-react</code>.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer usage tip */}
|
|
<div className="icon-matrix-footer">
|
|
<div className="icon-footer-pill">
|
|
<Badge variant="outline">lucide-react</Badge>
|
|
<span>Over 1,400+ SVG icons available. Fully tree-shaken and styled via <code>currentColor</code>.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|