Template
Harden agent guardrails and fix audited bugs
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
This commit is contained in:
1 parent
c0a94d3b6d
commit
f6e81afe5e
48 files changed
+3299
-696
No files matched your search
+25
-13
@@ -202,6 +202,28 @@
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Collapsed via the sidebar heading's ⌘ button — a slim rail with the
|
||||
expand button stays visible. */
|
||||
.body-layout.sidebar-collapsed {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.body-layout.sidebar-collapsed .sidebar {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.body-layout.sidebar-collapsed .sidebar-heading {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.body-layout.sidebar-collapsed .sidebar-intro,
|
||||
.body-layout.sidebar-collapsed .sidebar nav,
|
||||
.body-layout.sidebar-collapsed .sidebar-bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
@@ -766,19 +788,6 @@
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid color-mix(in srgb, var(--muted-foreground) 25%, transparent);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.showcase-foot {
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 0;
|
||||
@@ -1885,6 +1894,9 @@ footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.search-palette-item {
|
||||
|
||||
+39
-11
@@ -16,6 +16,7 @@ import { Section } from './components/Section'
|
||||
import { RightRail } from './components/RightRail'
|
||||
import { SearchPalette, type SearchItem } from './components/SearchPalette'
|
||||
import { Icon } from './components/Icon'
|
||||
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui'
|
||||
import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
|
||||
|
||||
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [pattern, setPattern] = useState<PatternId>('metrics')
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -39,15 +41,12 @@ export default function App() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const handleNavigate = (item: { label: string; id?: string }) => {
|
||||
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
|
||||
setActive(item.label)
|
||||
setMenu(false)
|
||||
|
||||
// Synchronize active pattern when clicking pattern items
|
||||
if (item.label === 'Metrics Dashboard') setPattern('metrics')
|
||||
else if (item.label === 'Settings Panel') setPattern('settings')
|
||||
else if (item.label === 'Service Table') setPattern('table')
|
||||
else if (item.label === 'Deployment Flow') setPattern('deploy')
|
||||
// Synchronize active pattern — routing keys on patternId, never on display labels.
|
||||
if (item.patternId) setPattern(item.patternId)
|
||||
|
||||
const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const el = document.getElementById(targetId)
|
||||
@@ -73,11 +72,13 @@ export default function App() {
|
||||
onSelect={handleSearchSelect}
|
||||
/>
|
||||
|
||||
<div className={`body-layout ${menu ? 'menu-open' : ''}`}>
|
||||
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
<Sidebar
|
||||
active={active}
|
||||
groups={navigationGroups}
|
||||
onSelect={handleNavigate}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
@@ -121,7 +122,7 @@ export default function App() {
|
||||
index="03 / COMPONENTS"
|
||||
title="Icons (Lucide)"
|
||||
description="Crisp, scalable SVG icons powered by Lucide. Tree-shakeable, stroke-matched, and token-aware."
|
||||
meta={<span className="api-pill">lucide-react · 1,400+ icons</span>}
|
||||
meta={<span className="api-pill">iconRegistry · 41 curated · lucide 1,400+</span>}
|
||||
>
|
||||
<IconMatrix />
|
||||
</Section>
|
||||
@@ -173,10 +174,37 @@ export default function App() {
|
||||
<b>Enable notifications</b>
|
||||
<small>Get updates about deployments and incidents.</small>
|
||||
</span>
|
||||
<button className="toggle on" aria-label="Toggle notifications">
|
||||
<span />
|
||||
</button>
|
||||
<Switch defaultChecked aria-label="Toggle notifications" />
|
||||
</label>
|
||||
<div className="toggle-field">
|
||||
<span>
|
||||
<b>Primitives in action</b>
|
||||
<small>Spinner, Avatar and Switch — token-driven, theme-aware components/ui primitives.</small>
|
||||
</span>
|
||||
<span className="row-md">
|
||||
<Avatar name="Mats Tornberg" size="sm" status="online" />
|
||||
<Avatar name="Grace Hopper" size="sm" />
|
||||
<Spinner size="sm" label="Loading session" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="toggle-field">
|
||||
<span>
|
||||
<b>Tabs primitive</b>
|
||||
<small>Roving tabindex, arrow-key navigation, aria-selected wiring — try the keyboard.</small>
|
||||
</span>
|
||||
<Tabs defaultValue="preview" className="stack-xs">
|
||||
<TabsList>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
<TabsTrigger value="code">Code</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="preview">
|
||||
<small>Token-driven preview pane.</small>
|
||||
</TabsContent>
|
||||
<TabsContent value="code">
|
||||
<small>import {'{ Tabs }'} from components/ui</small>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from './Button'
|
||||
import { Spinner } from './ui'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export type ButtonExample = {
|
||||
@@ -52,7 +53,7 @@ export function ButtonMatrix({ examples, tabs, filter = '', activeTab, onTabChan
|
||||
<Icon name="copy" size={15} />
|
||||
</Button>
|
||||
<Button variant={example.variant} className="loading">
|
||||
<span className="spinner" />
|
||||
<Spinner size="xs" />
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useTimeout } from '../utils/useTimeout'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
|
||||
@@ -19,11 +20,12 @@ const CODE_LINES: Segment[] = [
|
||||
|
||||
export function CodeSample() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { schedule } = useTimeout()
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(INSTALL_CMD)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1600)
|
||||
schedule(() => setCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+15
-104
@@ -1,108 +1,10 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Sun,
|
||||
Moon,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Menu,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
SlidersHorizontal,
|
||||
Code2,
|
||||
Cpu,
|
||||
Database,
|
||||
User,
|
||||
Shield,
|
||||
Folder,
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
type LucideIcon,
|
||||
type LucideProps,
|
||||
} from 'lucide-react'
|
||||
import { Layers, type LucideIcon, type LucideProps } from 'lucide-react'
|
||||
import { iconMap, type IconName } from './icon-registry'
|
||||
|
||||
export type { IconName, IconCategory, IconRegistryEntry } from './icon-registry'
|
||||
export type { LucideIcon, LucideProps }
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
// Northstar legacy shorthands
|
||||
search: Search,
|
||||
sun: Sun,
|
||||
moon: Moon,
|
||||
copy: Copy,
|
||||
check: Check,
|
||||
eye: Eye,
|
||||
arrow: ArrowRight,
|
||||
'arrow-right': ArrowRight,
|
||||
'arrow-left': ArrowLeft,
|
||||
menu: Menu,
|
||||
layers: Layers,
|
||||
sparkle: Sparkles,
|
||||
sparkles: Sparkles,
|
||||
terminal: Terminal,
|
||||
|
||||
// Common UI primitives
|
||||
close: X,
|
||||
x: X,
|
||||
plus: Plus,
|
||||
minus: Minus,
|
||||
trash: Trash2,
|
||||
'trash-2': Trash2,
|
||||
settings: Settings,
|
||||
filter: Filter,
|
||||
sliders: SlidersHorizontal,
|
||||
refresh: RefreshCw,
|
||||
download: Download,
|
||||
upload: Upload,
|
||||
external: ExternalLink,
|
||||
'external-link': ExternalLink,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
'alert-triangle': AlertTriangle,
|
||||
alert: AlertCircle,
|
||||
'alert-circle': AlertCircle,
|
||||
'check-circle': CheckCircle2,
|
||||
help: HelpCircle,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-right': ChevronRight,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-up': ChevronUp,
|
||||
code: Code2,
|
||||
cpu: Cpu,
|
||||
database: Database,
|
||||
user: User,
|
||||
shield: Shield,
|
||||
folder: Folder,
|
||||
file: FileText,
|
||||
calendar: Calendar,
|
||||
clock: Clock,
|
||||
}
|
||||
|
||||
export type IconName = keyof typeof iconMap | string
|
||||
|
||||
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
name?: IconName
|
||||
icon?: LucideIcon | ComponentType<{ size?: number | string; strokeWidth?: number; className?: string }>
|
||||
@@ -113,8 +15,9 @@ export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
|
||||
/**
|
||||
* Universal Icon component backed by lucide-react.
|
||||
* Accepts either a shorthand name (e.g. name="sparkle", name="check")
|
||||
* or a direct Lucide icon component (e.g. icon={Sparkles}).
|
||||
* Accepts either a registry name (e.g. name="sparkles") or a direct Lucide
|
||||
* icon component (e.g. icon={Sparkles}). Unknown names throw in dev — so the
|
||||
* dev error sink surfaces them — and fall back to <Layers /> in prod.
|
||||
*/
|
||||
export function Icon({
|
||||
name,
|
||||
@@ -124,7 +27,15 @@ export function Icon({
|
||||
className,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const Component = IconComponent || (name ? iconMap[name.toLowerCase()] : undefined) || Layers
|
||||
const namedIcon = name ? iconMap[name] : undefined
|
||||
|
||||
if (!IconComponent && !namedIcon) {
|
||||
const message = `[Icon] Unknown icon name "${String(name)}". Add it to iconMap in src/components/icon-registry.ts or pass an explicit \`icon\` prop.`
|
||||
if (import.meta.env.DEV) throw new Error(message)
|
||||
console.error(message)
|
||||
}
|
||||
|
||||
const Component = IconComponent ?? namedIcon ?? Layers
|
||||
|
||||
return (
|
||||
<Component
|
||||
|
||||
@@ -1,109 +1,15 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Sun,
|
||||
Moon,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Menu,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
SlidersHorizontal,
|
||||
Code2,
|
||||
Cpu,
|
||||
Database,
|
||||
User,
|
||||
Shield,
|
||||
Folder,
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-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 = {
|
||||
name: string
|
||||
pascalName: string
|
||||
category: 'Navigation' | 'Interface' | 'System' | 'Status'
|
||||
component: LucideIcon
|
||||
}
|
||||
type IconItem = IconRegistryEntry
|
||||
|
||||
const ICONS: IconItem[] = [
|
||||
// Navigation
|
||||
{ name: 'arrow-right', pascalName: 'ArrowRight', category: 'Navigation', component: ArrowRight },
|
||||
{ name: 'arrow-left', pascalName: 'ArrowLeft', category: 'Navigation', component: ArrowLeft },
|
||||
{ name: 'chevron-right', pascalName: 'ChevronRight', category: 'Navigation', component: ChevronRight },
|
||||
{ name: 'chevron-left', pascalName: 'ChevronLeft', category: 'Navigation', component: ChevronLeft },
|
||||
{ name: 'chevron-down', pascalName: 'ChevronDown', category: 'Navigation', component: ChevronDown },
|
||||
{ name: 'chevron-up', pascalName: 'ChevronUp', category: 'Navigation', component: ChevronUp },
|
||||
{ name: 'external-link', pascalName: 'ExternalLink', category: 'Navigation', component: ExternalLink },
|
||||
{ name: 'menu', pascalName: 'Menu', category: 'Navigation', component: Menu },
|
||||
|
||||
// System & AI
|
||||
{ name: 'sparkles', pascalName: 'Sparkles', category: 'System', component: Sparkles },
|
||||
{ name: 'terminal', pascalName: 'Terminal', category: 'System', component: Terminal },
|
||||
{ name: 'code', pascalName: 'Code2', category: 'System', component: Code2 },
|
||||
{ name: 'cpu', pascalName: 'Cpu', category: 'System', component: Cpu },
|
||||
{ name: 'layers', pascalName: 'Layers', category: 'System', component: Layers },
|
||||
{ name: 'sun', pascalName: 'Sun', category: 'System', component: Sun },
|
||||
{ name: 'moon', pascalName: 'Moon', category: 'System', component: Moon },
|
||||
{ name: 'database', pascalName: 'Database', category: 'System', component: Database },
|
||||
|
||||
// Interface & Actions
|
||||
{ name: 'search', pascalName: 'Search', category: 'Interface', component: Search },
|
||||
{ name: 'copy', pascalName: 'Copy', category: 'Interface', component: Copy },
|
||||
{ name: 'settings', pascalName: 'Settings', category: 'Interface', component: Settings },
|
||||
{ name: 'sliders', pascalName: 'SlidersHorizontal', category: 'Interface', component: SlidersHorizontal },
|
||||
{ name: 'filter', pascalName: 'Filter', category: 'Interface', component: Filter },
|
||||
{ name: 'plus', pascalName: 'Plus', category: 'Interface', component: Plus },
|
||||
{ name: 'minus', pascalName: 'Minus', category: 'Interface', component: Minus },
|
||||
{ name: 'trash', pascalName: 'Trash2', category: 'Interface', component: Trash2 },
|
||||
{ name: 'refresh', pascalName: 'RefreshCw', category: 'Interface', component: RefreshCw },
|
||||
{ name: 'download', pascalName: 'Download', category: 'Interface', component: Download },
|
||||
{ name: 'upload', pascalName: 'Upload', category: 'Interface', component: Upload },
|
||||
{ name: 'folder', pascalName: 'Folder', category: 'Interface', component: Folder },
|
||||
{ name: 'file', pascalName: 'FileText', category: 'Interface', component: FileText },
|
||||
{ name: 'user', pascalName: 'User', category: 'Interface', component: User },
|
||||
{ name: 'calendar', pascalName: 'Calendar', category: 'Interface', component: Calendar },
|
||||
{ name: 'clock', pascalName: 'Clock', category: 'Interface', component: Clock },
|
||||
|
||||
// Status & Feedback
|
||||
{ name: 'check', pascalName: 'Check', category: 'Status', component: Check },
|
||||
{ name: 'check-circle', pascalName: 'CheckCircle2', category: 'Status', component: CheckCircle2 },
|
||||
{ name: 'warning', pascalName: 'AlertTriangle', category: 'Status', component: AlertTriangle },
|
||||
{ name: 'alert', pascalName: 'AlertCircle', category: 'Status', component: AlertCircle },
|
||||
{ name: 'info', pascalName: 'Info', category: 'Status', component: Info },
|
||||
{ name: 'close', pascalName: 'X', category: 'Status', component: X },
|
||||
{ name: 'shield', pascalName: 'Shield', category: 'Status', component: Shield },
|
||||
{ name: 'help', pascalName: 'HelpCircle', category: 'Status', component: HelpCircle },
|
||||
{ name: 'eye', pascalName: 'Eye', category: 'Status', component: Eye },
|
||||
]
|
||||
/* 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]
|
||||
@@ -113,6 +19,7 @@ export function IconMatrix() {
|
||||
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 => {
|
||||
@@ -128,7 +35,7 @@ export function IconMatrix() {
|
||||
const code = `<${icon.pascalName} size={${size}} />`
|
||||
navigator.clipboard?.writeText(code)
|
||||
setCopiedName(icon.name)
|
||||
setTimeout(() => setCopiedName(null), 2000)
|
||||
schedule(() => setCopiedName(null), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -140,6 +47,7 @@ export function IconMatrix() {
|
||||
<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"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
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 = {
|
||||
@@ -9,7 +10,7 @@ export type SearchItem = {
|
||||
title: string
|
||||
description: string
|
||||
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
|
||||
icon: string
|
||||
icon: IconName
|
||||
targetId: string
|
||||
patternId?: PatternId
|
||||
}
|
||||
@@ -59,7 +60,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
||||
title: 'Buttons & Action States',
|
||||
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
|
||||
category: 'Components',
|
||||
icon: 'arrow',
|
||||
icon: 'arrow-right',
|
||||
targetId: 'buttons',
|
||||
},
|
||||
{
|
||||
@@ -131,11 +132,20 @@ type SearchPaletteProps = {
|
||||
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()
|
||||
@@ -153,7 +163,11 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
)
|
||||
}, [query])
|
||||
|
||||
// Keyboard navigation
|
||||
/* 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
|
||||
|
||||
@@ -163,32 +177,49 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev + 1) % (results.length || 1))
|
||||
setSelectedIndex(prev => (results.length ? (prev + 1) % results.length : 0))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev - 1 + results.length) % (results.length || 1))
|
||||
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
||||
setSelectedIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0))
|
||||
} else if (e.key === 'Enter' && activeItem) {
|
||||
e.preventDefault()
|
||||
onSelect(results[selectedIndex])
|
||||
onSelect(activeItem)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, results, selectedIndex, onClose, onSelect])
|
||||
}, [isOpen, results.length, activeItem, onClose, onSelect])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
trapTabKey(e, panelRef.current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-palette-backdrop" onClick={onClose}>
|
||||
<div className="search-palette-modal" onClick={e => e.stopPropagation()}>
|
||||
<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}
|
||||
@@ -198,7 +229,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button className="icon-button" onClick={() => setQuery('')} aria-label="Clear search">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
@@ -208,12 +247,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
{/* Results List */}
|
||||
<div className="search-palette-body">
|
||||
{results.length > 0 ? (
|
||||
<div className="search-palette-results">
|
||||
<ul id={LISTBOX_ID} role="listbox" aria-label="Search results" className="search-palette-results">
|
||||
{results.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
const isSelected = idx === activeIndex
|
||||
return (
|
||||
<div
|
||||
<li
|
||||
key={item.id}
|
||||
id={`search-option-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`search-palette-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
@@ -236,14 +278,16 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
<Kbd>↵</Kbd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
) : (
|
||||
<div className="search-palette-empty">
|
||||
<Icon name="search" size={24} />
|
||||
<p>No results found for “{query}”</p>
|
||||
<p>
|
||||
No results found for “{query}”
|
||||
</p>
|
||||
<span>Try searching for “Buttons”, “Metrics”, “Tokens”, or “Icons”.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { Icon } from './Icon'
|
||||
import type { PatternId } from './AIWorkflowSection'
|
||||
|
||||
export type NavigationItem = { label: string; count?: number; id?: string }
|
||||
export type NavigationItem = { label: string; count?: number; id?: string; patternId?: PatternId }
|
||||
export type NavigationGroup = { label: string; items: NavigationItem[] }
|
||||
|
||||
type SidebarProps = {
|
||||
active: string
|
||||
groups: NavigationGroup[]
|
||||
onSelect: (item: NavigationItem) => void
|
||||
collapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ active, groups, onSelect }: SidebarProps) {
|
||||
export function Sidebar({ active, groups, onSelect, collapsed = false, onToggleCollapse }: SidebarProps) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-heading">
|
||||
<div className="sidebar-library">
|
||||
<Icon name="layers" size={13} /> Library
|
||||
</div>
|
||||
<button className="sidebar-collapse" aria-label="Collapse sidebar">
|
||||
⌘
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-collapse"
|
||||
onClick={onToggleCollapse}
|
||||
aria-pressed={collapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? '⌘+' : '⌘−'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+54
-38
@@ -1,45 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
/**
|
||||
* Read the theme the pre-paint script in index.html already applied to
|
||||
* <html> — that keeps first paint flash-free and honors both the saved
|
||||
* preference and the OS `prefers-color-scheme` when nothing is saved.
|
||||
* This hook never touches the DOM during render/state-initialization.
|
||||
*/
|
||||
function getInitialTheme(): Theme {
|
||||
const attr = document.documentElement.getAttribute('data-theme')
|
||||
return attr === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
/** App theme is a data attribute + class on <html>; tokens.css defines both palettes. */
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = theme
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
root.classList.toggle('light', theme === 'light')
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeState(next)
|
||||
persistTheme(next)
|
||||
}, [theme])
|
||||
|
||||
return { theme, toggle }
|
||||
}
|
||||
|
||||
function persistTheme(theme: Theme) {
|
||||
try {
|
||||
localStorage.setItem('ns-theme', theme)
|
||||
} catch {
|
||||
/* private mode — theme just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
type TopbarProps = {
|
||||
onOpenSearch: () => void
|
||||
onMenuToggle: () => void
|
||||
}
|
||||
|
||||
/** App theme is a data attribute on <html>; tokens.css defines both palettes. */
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('ns-theme') : null
|
||||
const initial = saved === 'light' ? 'light' : 'dark'
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.dataset.theme = initial
|
||||
if (initial === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.classList.remove('light')
|
||||
} else {
|
||||
document.documentElement.classList.add('light')
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
document.documentElement.dataset.theme = next
|
||||
if (next === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.classList.remove('light')
|
||||
} else {
|
||||
document.documentElement.classList.add('light')
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('ns-theme', next)
|
||||
}
|
||||
return { theme, toggle }
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
|
||||
@@ -67,13 +78,18 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
<nav className="top-links">
|
||||
<a href="#docs">Docs</a>
|
||||
<a href="#github">GitHub ↗</a>
|
||||
<button className="icon-button" onClick={toggle} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
|
||||
</button>
|
||||
<button className="avatar">MS</button>
|
||||
<button type="button" className="avatar">MS</button>
|
||||
</nav>
|
||||
|
||||
<button className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
|
||||
<button type="button" className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTimeout } from '../../utils/useTimeout'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -22,13 +23,21 @@ export function DeploymentModal() {
|
||||
const [deploying, setDeploying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [deployed, setDeployed] = useState(false)
|
||||
const { schedule: scheduleDeploy, clear: clearDeploy } = useTimeout()
|
||||
const { schedule: scheduleReset } = useTimeout()
|
||||
|
||||
/* Closing cancels in-flight rollout timers so they never fire on a hidden dialog.
|
||||
The post-close content reset uses its own timer so it survives this cleanup. */
|
||||
useEffect(() => {
|
||||
if (!open) clearDeploy()
|
||||
}, [open, clearDeploy])
|
||||
|
||||
const handleStartDeploy = () => {
|
||||
setDeploying(true)
|
||||
setProgress(15)
|
||||
setTimeout(() => setProgress(45), 500)
|
||||
setTimeout(() => setProgress(85), 1000)
|
||||
setTimeout(() => {
|
||||
scheduleDeploy(() => setProgress(45), 500)
|
||||
scheduleDeploy(() => setProgress(85), 1000)
|
||||
scheduleDeploy(() => {
|
||||
setProgress(100)
|
||||
setDeploying(false)
|
||||
setDeployed(true)
|
||||
@@ -37,7 +46,7 @@ export function DeploymentModal() {
|
||||
|
||||
const handleReset = () => {
|
||||
setOpen(false)
|
||||
setTimeout(() => {
|
||||
scheduleReset(() => {
|
||||
setDeploying(false)
|
||||
setProgress(0)
|
||||
setDeployed(false)
|
||||
@@ -46,7 +55,7 @@ export function DeploymentModal() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<button className="button primary">
|
||||
<Icon name="sparkles" size={15} /> Promote Release to Canary
|
||||
</button>
|
||||
@@ -109,7 +118,7 @@ export function DeploymentModal() {
|
||||
<DialogFooter>
|
||||
{!deployed ? (
|
||||
<>
|
||||
<DialogClose>
|
||||
<DialogClose asChild>
|
||||
<button className="button secondary" disabled={deploying}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useTimeout } from '../../utils/useTimeout'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card'
|
||||
import { Switch } from '../ui/Switch'
|
||||
import { Badge } from '../ui/Badge'
|
||||
@@ -18,16 +19,17 @@ export function SettingsPanel() {
|
||||
const [autoRollback, setAutoRollback] = useState(false)
|
||||
|
||||
const apiKey = 'nsk_live_98a72ef01bc34d89a2'
|
||||
const { schedule } = useTimeout()
|
||||
|
||||
const copyKey = () => {
|
||||
navigator.clipboard?.writeText(apiKey)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
schedule(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2500)
|
||||
schedule(() => setSaved(false), 2500)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/* The one icon registry — canonical names (lucide-style kebab) + components.
|
||||
IconMatrix, SearchPalette and every <Icon name="…"/> consumer type-check
|
||||
against this map. Kept in its own module so it can be imported without
|
||||
pulling in components (and without breaking react-fast-refresh). */
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Code2,
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
Filter,
|
||||
Folder,
|
||||
HelpCircle,
|
||||
Info,
|
||||
Layers,
|
||||
Menu,
|
||||
Minus,
|
||||
Moon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
User,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
const iconComponents = {
|
||||
// Navigation
|
||||
'arrow-right': ArrowRight,
|
||||
'arrow-left': ArrowLeft,
|
||||
'chevron-right': ChevronRight,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-up': ChevronUp,
|
||||
'external-link': ExternalLink,
|
||||
menu: Menu,
|
||||
|
||||
// System & AI
|
||||
sparkles: Sparkles,
|
||||
terminal: Terminal,
|
||||
code: Code2,
|
||||
cpu: Cpu,
|
||||
layers: Layers,
|
||||
sun: Sun,
|
||||
moon: Moon,
|
||||
database: Database,
|
||||
|
||||
// Interface & actions
|
||||
search: Search,
|
||||
copy: Copy,
|
||||
settings: Settings,
|
||||
sliders: SlidersHorizontal,
|
||||
filter: Filter,
|
||||
plus: Plus,
|
||||
minus: Minus,
|
||||
trash: Trash2,
|
||||
refresh: RefreshCw,
|
||||
download: Download,
|
||||
upload: Upload,
|
||||
folder: Folder,
|
||||
file: FileText,
|
||||
user: User,
|
||||
calendar: Calendar,
|
||||
clock: Clock,
|
||||
|
||||
// Status & feedback
|
||||
check: Check,
|
||||
'check-circle': CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
alert: AlertCircle,
|
||||
info: Info,
|
||||
close: X,
|
||||
shield: Shield,
|
||||
help: HelpCircle,
|
||||
eye: Eye,
|
||||
} as const
|
||||
|
||||
/** Legacy Northstar shorthands kept as aliases so old call sites keep working.
|
||||
Values are direct references so iconMap stays Record<IconName, LucideIcon>. */
|
||||
const iconAliases = {
|
||||
arrow: ArrowRight,
|
||||
sparkle: Sparkles,
|
||||
x: X,
|
||||
'trash-2': Trash2,
|
||||
'alert-triangle': AlertTriangle,
|
||||
'alert-circle': AlertCircle,
|
||||
} as const
|
||||
|
||||
export const iconMap = { ...iconComponents, ...iconAliases }
|
||||
|
||||
export type IconName = keyof typeof iconMap
|
||||
|
||||
export type IconCategory = 'Navigation' | 'Interface' | 'System' | 'Status'
|
||||
|
||||
export type IconRegistryEntry = {
|
||||
name: IconName
|
||||
pascalName: string
|
||||
category: IconCategory
|
||||
component: LucideIcon
|
||||
}
|
||||
|
||||
/* Curated showcase metadata — the compiler forces one entry per canonical
|
||||
icon, so a rename in `iconComponents` breaks here loudly (and in tests). */
|
||||
const iconMeta: Record<keyof typeof iconComponents, { pascalName: string; category: IconCategory }> = {
|
||||
'arrow-right': { pascalName: 'ArrowRight', category: 'Navigation' },
|
||||
'arrow-left': { pascalName: 'ArrowLeft', category: 'Navigation' },
|
||||
'chevron-right': { pascalName: 'ChevronRight', category: 'Navigation' },
|
||||
'chevron-left': { pascalName: 'ChevronLeft', category: 'Navigation' },
|
||||
'chevron-down': { pascalName: 'ChevronDown', category: 'Navigation' },
|
||||
'chevron-up': { pascalName: 'ChevronUp', category: 'Navigation' },
|
||||
'external-link': { pascalName: 'ExternalLink', category: 'Navigation' },
|
||||
menu: { pascalName: 'Menu', category: 'Navigation' },
|
||||
sparkles: { pascalName: 'Sparkles', category: 'System' },
|
||||
terminal: { pascalName: 'Terminal', category: 'System' },
|
||||
code: { pascalName: 'Code2', category: 'System' },
|
||||
cpu: { pascalName: 'Cpu', category: 'System' },
|
||||
layers: { pascalName: 'Layers', category: 'System' },
|
||||
sun: { pascalName: 'Sun', category: 'System' },
|
||||
moon: { pascalName: 'Moon', category: 'System' },
|
||||
database: { pascalName: 'Database', category: 'System' },
|
||||
search: { pascalName: 'Search', category: 'Interface' },
|
||||
copy: { pascalName: 'Copy', category: 'Interface' },
|
||||
settings: { pascalName: 'Settings', category: 'Interface' },
|
||||
sliders: { pascalName: 'SlidersHorizontal', category: 'Interface' },
|
||||
filter: { pascalName: 'Filter', category: 'Interface' },
|
||||
plus: { pascalName: 'Plus', category: 'Interface' },
|
||||
minus: { pascalName: 'Minus', category: 'Interface' },
|
||||
trash: { pascalName: 'Trash2', category: 'Interface' },
|
||||
refresh: { pascalName: 'RefreshCw', category: 'Interface' },
|
||||
download: { pascalName: 'Download', category: 'Interface' },
|
||||
upload: { pascalName: 'Upload', category: 'Interface' },
|
||||
folder: { pascalName: 'Folder', category: 'Interface' },
|
||||
file: { pascalName: 'FileText', category: 'Interface' },
|
||||
user: { pascalName: 'User', category: 'Interface' },
|
||||
calendar: { pascalName: 'Calendar', category: 'Interface' },
|
||||
clock: { pascalName: 'Clock', category: 'Interface' },
|
||||
check: { pascalName: 'Check', category: 'Status' },
|
||||
'check-circle': { pascalName: 'CheckCircle2', category: 'Status' },
|
||||
warning: { pascalName: 'AlertTriangle', category: 'Status' },
|
||||
alert: { pascalName: 'AlertCircle', category: 'Status' },
|
||||
info: { pascalName: 'Info', category: 'Status' },
|
||||
close: { pascalName: 'X', category: 'Status' },
|
||||
shield: { pascalName: 'Shield', category: 'Status' },
|
||||
help: { pascalName: 'HelpCircle', category: 'Status' },
|
||||
eye: { pascalName: 'Eye', category: 'Status' },
|
||||
}
|
||||
|
||||
/** Curated icon explorer data (used by IconMatrix). Aliases are not listed. */
|
||||
export const iconRegistry: IconRegistryEntry[] = (
|
||||
Object.entries(iconComponents) as [keyof typeof iconComponents, LucideIcon][]
|
||||
).map(([name, component]) => ({
|
||||
name: name as IconName,
|
||||
pascalName: iconMeta[name].pascalName,
|
||||
category: iconMeta[name].category,
|
||||
component,
|
||||
}))
|
||||
@@ -39,7 +39,7 @@ export function AlertTitle({ className, children, ...props }: HTMLAttributes<HTM
|
||||
|
||||
export function AlertDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<div
|
||||
<p
|
||||
className={cn(className)}
|
||||
style={{
|
||||
margin: 0,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg'
|
||||
export type AvatarStatus = 'online' | 'away' | 'offline'
|
||||
|
||||
export type AvatarProps = {
|
||||
name: string
|
||||
src?: string
|
||||
size?: AvatarSize
|
||||
status?: AvatarStatus
|
||||
className?: string
|
||||
}
|
||||
|
||||
const dimensions: Record<AvatarSize, string> = {
|
||||
xs: 'var(--space-5)',
|
||||
sm: 'var(--space-6)',
|
||||
md: 'var(--space-7)',
|
||||
lg: 'var(--space-9)',
|
||||
}
|
||||
|
||||
const fontSizes: Record<AvatarSize, string> = {
|
||||
xs: 'var(--text-3xs)',
|
||||
sm: 'var(--text-2xs)',
|
||||
md: 'var(--text-xs)',
|
||||
lg: 'var(--text-base)',
|
||||
}
|
||||
|
||||
const dotSizes: Record<AvatarSize, string> = {
|
||||
xs: 'var(--space-1)',
|
||||
sm: 'var(--space-1)',
|
||||
md: 'var(--space-2)',
|
||||
lg: 'var(--space-2)',
|
||||
}
|
||||
|
||||
const statusColors: Record<AvatarStatus, string> = {
|
||||
online: 'var(--chart-2)',
|
||||
away: 'var(--chart-3)',
|
||||
offline: 'var(--muted-foreground)',
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const initials = name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return initials || '?'
|
||||
}
|
||||
|
||||
export function Avatar({ name, src, size = 'md', status, className }: AvatarProps) {
|
||||
const dimension = dimensions[size]
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('flex-center', className)}
|
||||
role={src ? undefined : 'img'}
|
||||
aria-label={src ? undefined : name || 'Avatar'}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
width: dimension,
|
||||
height: dimension,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'color-mix(in srgb, var(--primary) 16%, var(--background))',
|
||||
color: 'var(--primary)',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: fontSizes[size],
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '0.02em',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{getInitials(name)}</span>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
boxSizing: 'content-box',
|
||||
width: dotSizes[size],
|
||||
height: dotSizes[size],
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: statusColors[status],
|
||||
border: '2px solid var(--background)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline'
|
||||
|
||||
type BadgeProps = {
|
||||
children: ReactNode
|
||||
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: BadgeVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'primary', className }: BadgeProps) {
|
||||
export function Badge({ variant = 'primary', className, children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn('badge', `badge-${variant}`, className)}>
|
||||
<span className={cn('badge', `badge-${variant}`, className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
+54
-119
@@ -1,18 +1,17 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
||||
import {
|
||||
Overlay,
|
||||
OverlayClose,
|
||||
OverlayContent,
|
||||
OverlayDescription,
|
||||
OverlayFooter,
|
||||
OverlayHeader,
|
||||
OverlayTitle,
|
||||
OverlayTrigger,
|
||||
} from './Overlay'
|
||||
|
||||
type DialogContextType = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | null>(null)
|
||||
|
||||
function useDialog() {
|
||||
const ctx = useContext(DialogContext)
|
||||
if (!ctx) throw new Error('Dialog components must be used within a <Dialog>')
|
||||
return ctx
|
||||
}
|
||||
/* Dialog is a thin skin over the shared Overlay primitive — all behavior
|
||||
(focus trap, Escape, scroll lock, focus restore, aria wiring) lives there. */
|
||||
|
||||
export type DialogProps = {
|
||||
children: ReactNode
|
||||
@@ -21,124 +20,60 @@ export type DialogProps = {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function Dialog({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: DialogProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
export function Dialog(props: DialogProps) {
|
||||
return <Overlay {...props} />
|
||||
}
|
||||
|
||||
export function DialogTrigger({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useDialog()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function DialogTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
|
||||
return <OverlayTrigger {...props} />
|
||||
}
|
||||
|
||||
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { open, setOpen } = useDialog()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open, setOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" onClick={() => setOpen(false)}>
|
||||
<div className={cn('dialog-panel', className)} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="dialog-close-btn"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-4)' }} {...props} />
|
||||
}
|
||||
|
||||
export function DialogTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(className)}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-xl)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--foreground)',
|
||||
letterSpacing: '-0.03em',
|
||||
}}
|
||||
{...props}
|
||||
<OverlayContent
|
||||
backdropClassName="dialog-backdrop"
|
||||
panelClassName="dialog-panel"
|
||||
closeButtonLabel="Close dialog"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
</OverlayContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(className)}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function DialogHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-4)', ...style }
|
||||
return <OverlayHeader className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-between', className)}
|
||||
style={{
|
||||
marginTop: 'var(--space-6)',
|
||||
paddingTop: 'var(--space-4)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
gap: 'var(--space-3)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function DialogTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-xl)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--foreground)',
|
||||
letterSpacing: '-0.03em',
|
||||
...style,
|
||||
}
|
||||
return <OverlayTitle className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogClose({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useDialog()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function DialogDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 1.5,
|
||||
...style,
|
||||
}
|
||||
return <OverlayDescription className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginTop: 'var(--space-6)', ...style }
|
||||
return <OverlayFooter className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogClose(props: Parameters<typeof OverlayClose>[0]) {
|
||||
return <OverlayClose {...props} />
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useId,
|
||||
useMemo,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type ReactNode,
|
||||
type ReactElement,
|
||||
type HTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
type CSSProperties,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import { trapTabKey, useOverlayLifecycle } from './overlayBehavior'
|
||||
|
||||
/* ── Overlay: the one modal primitive ─────────────────────────────
|
||||
Focus trap, Escape-to-close, scroll lock, focus restore, and the
|
||||
aria wiring all live here. Dialog and Sheet are thin wrappers that
|
||||
only contribute class names and type-scale defaults. */
|
||||
|
||||
type OverlayContextType = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
titleId: string
|
||||
descriptionId: string
|
||||
/** True while a <OverlayTitle> is mounted — aria-labelledby is only wired then. */
|
||||
hasTitle: boolean
|
||||
/** True while a <OverlayDescription> is mounted. */
|
||||
hasDescription: boolean
|
||||
registerTitle: (present: boolean) => void
|
||||
registerDescription: (present: boolean) => void
|
||||
}
|
||||
|
||||
const OverlayContext = createContext<OverlayContextType | null>(null)
|
||||
|
||||
function useOverlay() {
|
||||
const ctx = useContext(OverlayContext)
|
||||
if (!ctx) throw new Error('Overlay components must be used within an <Overlay> (Dialog/Sheet)')
|
||||
return ctx
|
||||
}
|
||||
|
||||
/* ── Trigger / Close buttons ────────────────────────────────────── */
|
||||
|
||||
/* Neutralize UA button chrome so the trigger/close buttons stay visually
|
||||
identical to the spans they replaced. */
|
||||
const neutralButtonStyle: CSSProperties = {
|
||||
background: 'none',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
type OverlayActionProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
/** Attach open/close behavior to the caller's own element instead of rendering a wrapper <button>. */
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
/* Shared Trigger/Close body. `asChild` merges the click behavior into the
|
||||
caller's element so styled buttons don't end up nested inside a button. */
|
||||
function OverlayButton({
|
||||
next,
|
||||
setOpen,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
style,
|
||||
asChild = false,
|
||||
...props
|
||||
}: OverlayActionProps & { next: boolean; setOpen: (next: boolean) => void }) {
|
||||
const handleActivate = (e: ReactMouseEvent<HTMLElement>) => {
|
||||
onClick?.(e as ReactMouseEvent<HTMLButtonElement>)
|
||||
if (!e.defaultPrevented) setOpen(next)
|
||||
}
|
||||
|
||||
if (asChild && isValidElement(children)) {
|
||||
const child = children as ReactElement<{
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}>
|
||||
const merged: { onClick: typeof handleActivate; className?: string; style?: CSSProperties } = {
|
||||
onClick: handleActivate,
|
||||
}
|
||||
if (className) merged.className = cn(child.props.className, className)
|
||||
if (style) merged.style = { ...child.props.style, ...style }
|
||||
return cloneElement(child, merged)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className={className}
|
||||
style={{ ...neutralButtonStyle, ...style }}
|
||||
onClick={handleActivate}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Root ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type OverlayProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function Overlay({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: OverlayProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const [hasTitle, setHasTitle] = useState(false)
|
||||
const [hasDescription, setHasDescription] = useState(false)
|
||||
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
onOpenChange?.(next)
|
||||
},
|
||||
[isControlled, onOpenChange],
|
||||
)
|
||||
|
||||
const registerTitle = useCallback((present: boolean) => setHasTitle(present), [])
|
||||
const registerDescription = useCallback((present: boolean) => setHasDescription(present), [])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
titleId,
|
||||
descriptionId,
|
||||
hasTitle,
|
||||
hasDescription,
|
||||
registerTitle,
|
||||
registerDescription,
|
||||
}),
|
||||
[open, setOpen, titleId, descriptionId, hasTitle, hasDescription, registerTitle, registerDescription],
|
||||
)
|
||||
|
||||
return <OverlayContext.Provider value={contextValue}>{children}</OverlayContext.Provider>
|
||||
}
|
||||
|
||||
export function OverlayTrigger(props: OverlayActionProps) {
|
||||
const { setOpen } = useOverlay()
|
||||
return <OverlayButton {...props} setOpen={setOpen} next />
|
||||
}
|
||||
|
||||
/* ── Content: backdrop + panel + close button ───────────────────── */
|
||||
|
||||
export type OverlayContentProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Backdrop class from the wrapper (e.g. `dialog-backdrop`). */
|
||||
backdropClassName: string
|
||||
/** Panel class from the wrapper (e.g. `dialog-panel`). */
|
||||
panelClassName: string
|
||||
/** Accessible label for the ✕ button ("Close dialog" / "Close sheet"). */
|
||||
closeButtonLabel: string
|
||||
}
|
||||
|
||||
export function OverlayContent({
|
||||
children,
|
||||
className,
|
||||
backdropClassName,
|
||||
panelClassName,
|
||||
closeButtonLabel,
|
||||
}: OverlayContentProps) {
|
||||
const { open, setOpen, titleId, descriptionId, hasTitle, hasDescription } = useOverlay()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useOverlayLifecycle(open, panelRef)
|
||||
|
||||
/* Escape-to-close: subscribed once per open, not on every render. */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, setOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
trapTabKey(e, panelRef.current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={backdropClassName} onClick={() => setOpen(false)}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={hasTitle ? titleId : undefined}
|
||||
aria-describedby={hasDescription ? descriptionId : undefined}
|
||||
tabIndex={-1}
|
||||
className={cn(panelClassName, className)}
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close-btn"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label={closeButtonLabel}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Header / Title / Description / Footer ──────────────────────── */
|
||||
|
||||
export function OverlayHeader({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { style?: CSSProperties }) {
|
||||
return <div className={cn('stack-xs', className)} style={style} {...props} />
|
||||
}
|
||||
|
||||
export function OverlayTitle({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadingElement> & { style?: CSSProperties }) {
|
||||
const { titleId, registerTitle } = useOverlay()
|
||||
|
||||
/* Register so the panel's aria-labelledby only points at a mounted title. */
|
||||
useEffect(() => {
|
||||
registerTitle(true)
|
||||
return () => registerTitle(false)
|
||||
}, [registerTitle])
|
||||
|
||||
return <h2 id={titleId} className={cn(className)} style={style} {...props} />
|
||||
}
|
||||
|
||||
export function OverlayDescription({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement> & { style?: CSSProperties }) {
|
||||
const { descriptionId, registerDescription } = useOverlay()
|
||||
|
||||
useEffect(() => {
|
||||
registerDescription(true)
|
||||
return () => registerDescription(false)
|
||||
}, [registerDescription])
|
||||
|
||||
return <p id={descriptionId} className={cn(className)} style={style} {...props} />
|
||||
}
|
||||
|
||||
export function OverlayFooter({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { style?: CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-between', className)}
|
||||
style={{
|
||||
paddingTop: 'var(--space-4)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
gap: 'var(--space-3)',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayClose(props: OverlayActionProps) {
|
||||
const { setOpen } = useOverlay()
|
||||
return <OverlayButton {...props} setOpen={setOpen} next={false} />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useId, type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
type ProgressProps = HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -16,17 +16,28 @@ export function Progress({
|
||||
className,
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const percentage = Math.min(Math.max(Math.round((value / max) * 100), 0), 100)
|
||||
/* Guard NaN from max={0}: clamp against a sane fallback instead. */
|
||||
const safeMax = max > 0 ? max : 100
|
||||
const percentage = Math.min(Math.max(Math.round((value / safeMax) * 100), 0), 100)
|
||||
const labelId = useId()
|
||||
|
||||
return (
|
||||
<div className={cn('stack-xs', className)} {...props}>
|
||||
{(label || !indeterminate) && (
|
||||
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
|
||||
{label && <span>{label}</span>}
|
||||
{label && <span id={labelId}>{label}</span>}
|
||||
{!indeterminate && <span style={{ fontFamily: 'var(--font-mono)' }}>{percentage}%</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="progress-container">
|
||||
<div
|
||||
className="progress-container"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : percentage}
|
||||
aria-busy={indeterminate ? true : undefined}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn('progress-fill', indeterminate && 'progress-indeterminate')}
|
||||
style={!indeterminate ? { width: `${percentage}%` } : undefined}
|
||||
|
||||
+56
-120
@@ -1,144 +1,80 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
||||
import {
|
||||
Overlay,
|
||||
OverlayClose,
|
||||
OverlayContent,
|
||||
OverlayDescription,
|
||||
OverlayFooter,
|
||||
OverlayHeader,
|
||||
OverlayTitle,
|
||||
OverlayTrigger,
|
||||
} from './Overlay'
|
||||
|
||||
type SheetContextType = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
/* Sheet is the slide-over skin of the shared Overlay primitive — identical
|
||||
focus trap / Escape / scroll-lock behavior as Dialog, only different
|
||||
type-scale defaults and panel classes. */
|
||||
|
||||
const SheetContext = createContext<SheetContextType | null>(null)
|
||||
|
||||
function useSheet() {
|
||||
const ctx = useContext(SheetContext)
|
||||
if (!ctx) throw new Error('Sheet components must be used within <Sheet>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
type SheetProps = {
|
||||
export type SheetProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function Sheet({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: SheetProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<SheetContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</SheetContext.Provider>
|
||||
)
|
||||
export function Sheet(props: SheetProps) {
|
||||
return <Overlay {...props} />
|
||||
}
|
||||
|
||||
export function SheetTrigger({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useSheet()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function SheetTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
|
||||
return <OverlayTrigger {...props} />
|
||||
}
|
||||
|
||||
export function SheetContent({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { open, setOpen } = useSheet()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open, setOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="sheet-backdrop" onClick={() => setOpen(false)}>
|
||||
<div className={cn('sheet-panel', className)} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="dialog-close-btn"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close sheet"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SheetHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-6)' }} {...props} />
|
||||
}
|
||||
|
||||
export function SheetTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(className)}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-lg)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--foreground)',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
{...props}
|
||||
<OverlayContent
|
||||
backdropClassName="sheet-backdrop"
|
||||
panelClassName="sheet-panel"
|
||||
closeButtonLabel="Close sheet"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
</OverlayContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function SheetDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(className)}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function SheetHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-6)', ...style }
|
||||
return <OverlayHeader className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-between', className)}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
paddingTop: 'var(--space-4)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
gap: 'var(--space-3)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function SheetTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-lg)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--foreground)',
|
||||
letterSpacing: '-0.02em',
|
||||
...style,
|
||||
}
|
||||
return <OverlayTitle className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetClose({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useSheet()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function SheetDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 1.5,
|
||||
...style,
|
||||
}
|
||||
return <OverlayDescription className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginTop: 'auto', ...style }
|
||||
return <OverlayFooter className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetClose(props: Parameters<typeof OverlayClose>[0]) {
|
||||
return <OverlayClose {...props} />
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import './spinner.css'
|
||||
|
||||
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg'
|
||||
|
||||
export type SpinnerProps = {
|
||||
size?: SpinnerSize
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Ring geometry — sizes come from the spacing scale, never from literals. */
|
||||
const sizeStyles: Record<SpinnerSize, CSSProperties> = {
|
||||
xs: { width: 'var(--space-3)', height: 'var(--space-3)', borderWidth: '1px' },
|
||||
sm: { width: 'var(--space-4)', height: 'var(--space-4)', borderWidth: '2px' },
|
||||
md: { width: 'var(--space-6)', height: 'var(--space-6)', borderWidth: '2px' },
|
||||
lg: { width: 'var(--space-8)', height: 'var(--space-8)', borderWidth: '3px' },
|
||||
}
|
||||
|
||||
/** Screen-reader-only clip pattern, kept inline so no CSS file has to change. */
|
||||
const visuallyHidden: CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderWidth: 0,
|
||||
}
|
||||
|
||||
export function Spinner({ size = 'md', label, className }: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn('ui-spinner', `ui-spinner-${size}`, className)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
boxSizing: 'border-box',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'var(--border)',
|
||||
borderTopColor: 'var(--primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
...sizeStyles[size],
|
||||
}}
|
||||
/>
|
||||
{label ? (
|
||||
<span className="visually-hidden" style={visuallyHidden}>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,21 +7,36 @@ type StatCardProps = {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: string
|
||||
/** Sentiment — drives the badge color. Up = good news, down = bad news. */
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
/**
|
||||
* Arrow direction — which way the number moved. Defaults to the sign of
|
||||
* `change`, so "trend=up" (good) with change="-4.2ms" renders a green
|
||||
* "↓ -4.2ms" instead of the nonsensical "↑ -4.2ms".
|
||||
*/
|
||||
direction?: 'up' | 'down' | 'neutral'
|
||||
caption?: string
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** "↓ good latency" needs an arrow that follows the sign of the change. */
|
||||
function inferDirection(change?: string): 'up' | 'down' | 'neutral' {
|
||||
if (!change || !/[0-9]/.test(change)) return 'neutral'
|
||||
return change.trim().startsWith('-') ? 'down' : 'up'
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
trend = 'up',
|
||||
trend = 'neutral',
|
||||
direction,
|
||||
caption,
|
||||
icon,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
const arrowDirection = direction ?? inferDirection(change)
|
||||
return (
|
||||
<Card className={cn('hover-lift', className)}>
|
||||
<div className="flex-between" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
@@ -65,7 +80,7 @@ export function StatCard({
|
||||
<div className="flex-between" style={{ marginTop: 'var(--space-2)' }}>
|
||||
{change && (
|
||||
<Badge variant={trend === 'up' ? 'positive' : trend === 'down' ? 'destructive' : 'secondary'}>
|
||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} {change}
|
||||
{arrowDirection === 'up' ? '↑' : arrowDirection === 'down' ? '↓' : '→'} {change}
|
||||
</Badge>
|
||||
)}
|
||||
{caption && (
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createContext, useContext, useState, type ReactNode, type HTMLAttributes } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useId,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
type TabsContextType = {
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
baseId: string
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextType | null>(null)
|
||||
@@ -14,6 +23,11 @@ function useTabs() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Stable, SSR-safe pairing between a trigger and its panel. */
|
||||
function partId(baseId: string, kind: 'trigger' | 'content', value: string) {
|
||||
return `${baseId}-${kind}-${value.replace(/[^A-Za-z0-9_-]+/g, '-')}`
|
||||
}
|
||||
|
||||
type TabsProps = {
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
@@ -24,6 +38,7 @@ type TabsProps = {
|
||||
|
||||
export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, children, className }: TabsProps) {
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue)
|
||||
const baseId = useId()
|
||||
const isControlled = controlledValue !== undefined
|
||||
const value = isControlled ? controlledValue : uncontrolledValue
|
||||
|
||||
@@ -33,14 +48,66 @@ export function Tabs({ defaultValue = '', value: controlledValue, onValueChange,
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, setValue }}>
|
||||
<TabsContext.Provider value={{ value, setValue, baseId }}>
|
||||
<div className={cn('ui-tabs-root', className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabsList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('ui-tabs-list', className)} role="tablist" {...props} />
|
||||
const NAV_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'Home', 'End'])
|
||||
|
||||
export function TabsList({ className, onKeyDown, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { setValue } = useTabs()
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented || !NAV_KEYS.has(event.key)) return
|
||||
|
||||
const tabs = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'),
|
||||
)
|
||||
if (tabs.length === 0) return
|
||||
|
||||
const focused = tabs.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const selected = tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true')
|
||||
const current = focused >= 0 ? focused : selected
|
||||
if (current < 0) return
|
||||
|
||||
let next: number
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
next = (current + 1) % tabs.length
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
next = (current - 1 + tabs.length) % tabs.length
|
||||
break
|
||||
case 'Home':
|
||||
next = 0
|
||||
break
|
||||
default:
|
||||
next = tabs.length - 1
|
||||
break
|
||||
}
|
||||
|
||||
const nextTab = tabs[next]
|
||||
if (nextTab === undefined) return
|
||||
const nextValue = nextTab.dataset.value
|
||||
if (nextValue === undefined) return
|
||||
|
||||
event.preventDefault()
|
||||
nextTab.focus()
|
||||
setValue(nextValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('ui-tabs-list', className)}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
@@ -48,14 +115,18 @@ type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
|
||||
const { value: selectedValue, setValue } = useTabs()
|
||||
const { value: selectedValue, setValue, baseId } = useTabs()
|
||||
const isActive = selectedValue === value
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={partId(baseId, 'trigger', value)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={partId(baseId, 'content', value)}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
data-value={value}
|
||||
className={cn('ui-tabs-trigger', isActive && 'active', className)}
|
||||
onClick={() => setValue(value)}
|
||||
{...props}
|
||||
@@ -70,11 +141,19 @@ type TabsContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
}
|
||||
|
||||
export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
|
||||
const { value: selectedValue } = useTabs()
|
||||
if (selectedValue !== value) return null
|
||||
const { value: selectedValue, baseId } = useTabs()
|
||||
const isActive = selectedValue === value
|
||||
|
||||
return (
|
||||
<div className={cn('ui-tabs-content', className)} role="tabpanel" {...props}>
|
||||
<div
|
||||
id={partId(baseId, 'content', value)}
|
||||
role="tabpanel"
|
||||
aria-labelledby={partId(baseId, 'trigger', value)}
|
||||
tabIndex={0}
|
||||
hidden={!isActive}
|
||||
className={cn('ui-tabs-content', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Fragment,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useId,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
type TooltipProps = {
|
||||
@@ -7,11 +15,66 @@ type TooltipProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type ChildProps = {
|
||||
onClick?: unknown
|
||||
tabIndex?: number
|
||||
href?: string
|
||||
'aria-describedby'?: string
|
||||
}
|
||||
|
||||
/** Intrinsic elements that already participate in the tab order. */
|
||||
const NATURALLY_FOCUSABLE = new Set(['button', 'input', 'select', 'textarea', 'summary'])
|
||||
|
||||
function childIsFocusable(child: ReactElement): boolean {
|
||||
const props = child.props as ChildProps
|
||||
if (props.tabIndex !== undefined) return props.tabIndex >= 0
|
||||
if (props.onClick !== undefined) return true
|
||||
if (typeof child.type === 'string') {
|
||||
if (child.type === 'a') return props.href !== undefined
|
||||
return NATURALLY_FOCUSABLE.has(child.type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function Tooltip({ content, children, className }: TooltipProps) {
|
||||
const id = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Plain text / fragments cannot carry ARIA props, so wrap them in a span first.
|
||||
const isPlainText = typeof children === 'string' || typeof children === 'number'
|
||||
const child = (
|
||||
!isPlainText && isValidElement(children) && children.type !== Fragment
|
||||
? children
|
||||
: <span>{children}</span>
|
||||
) as ReactElement<ChildProps>
|
||||
|
||||
const existingDescribedBy = child.props['aria-describedby']
|
||||
const describedBy = existingDescribedBy ? `${existingDescribedBy} ${id}` : id
|
||||
const clonedChild = cloneElement(child, { 'aria-describedby': describedBy })
|
||||
|
||||
// Only make the wrapper focusable when the child itself cannot receive focus.
|
||||
const wrapperNeedsFocus = !childIsFocusable(child)
|
||||
|
||||
return (
|
||||
<span className={cn('tooltip-wrapper', className)} tabIndex={0}>
|
||||
{children}
|
||||
<span className="tooltip-box" role="tooltip">
|
||||
<span
|
||||
className={cn('tooltip-wrapper', className)}
|
||||
tabIndex={wrapperNeedsFocus ? 0 : undefined}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}}
|
||||
>
|
||||
{clonedChild}
|
||||
<span
|
||||
id={id}
|
||||
className="tooltip-box"
|
||||
role="tooltip"
|
||||
aria-hidden={!open}
|
||||
data-open={open ? 'true' : 'false'}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './Badge'
|
||||
export * from './Card'
|
||||
export * from './StatCard'
|
||||
export * from './Kbd'
|
||||
export * from './Overlay'
|
||||
export * from './Dialog'
|
||||
export * from './Alert'
|
||||
export * from './EmptyState'
|
||||
@@ -12,4 +13,7 @@ export * from './Skeleton'
|
||||
export * from './Table'
|
||||
export * from './Sheet'
|
||||
export * from './Tooltip'
|
||||
export * from './Avatar'
|
||||
export * from './Spinner'
|
||||
export * from '../Icon'
|
||||
export * from '../Button'
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
|
||||
/* Behavior shared by every overlay (Dialog, Sheet, SearchPalette).
|
||||
Kept in a behavior module so component files stay fast-refresh friendly. */
|
||||
|
||||
const focusableSelector =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/** Keep Tab / Shift+Tab inside `container`. Call from an onKeyDown handler. */
|
||||
export function trapTabKey(e: ReactKeyboardEvent | KeyboardEvent, container: HTMLElement | null) {
|
||||
if (e.key !== 'Tab' || !container) return
|
||||
const focusable = Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault()
|
||||
container.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (!first || !last) return
|
||||
const active = document.activeElement
|
||||
if (e.shiftKey) {
|
||||
if (active === first || active === container) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal open/close lifecycle shared by every overlay: lock body scroll while
|
||||
* open (restoring the previous value) and restore focus to whatever held it
|
||||
* before the overlay opened. Escape handling stays with the caller so overlays
|
||||
* can ignore or intercept it (e.g. SearchPalette just closes).
|
||||
*/
|
||||
export function useOverlayLifecycle(open: boolean, panelRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
panelRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus()
|
||||
}
|
||||
}, [open, panelRef])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -1,22 +1,22 @@
|
||||
import type { NavigationGroup } from '../components/Sidebar'
|
||||
import type { ButtonExample } from '../components/ButtonMatrix'
|
||||
import type { Token } from '../components/TokenGrid'
|
||||
import type { NavigationGroup } from '../components/Sidebar'
|
||||
|
||||
export const navigationGroups: NavigationGroup[] = [
|
||||
{
|
||||
label: 'Application Patterns',
|
||||
items: [
|
||||
{ label: 'Metrics Dashboard', id: 'application-patterns' },
|
||||
{ label: 'Settings Panel', id: 'application-patterns' },
|
||||
{ label: 'Service Table', id: 'application-patterns' },
|
||||
{ label: 'Deployment Flow', id: 'application-patterns' },
|
||||
{ label: 'Metrics Dashboard', id: 'application-patterns', patternId: 'metrics' },
|
||||
{ label: 'Settings Panel', id: 'application-patterns', patternId: 'settings' },
|
||||
{ label: 'Service Table', id: 'application-patterns', patternId: 'table' },
|
||||
{ label: 'Deployment Flow', id: 'application-patterns', patternId: 'deploy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Components',
|
||||
items: [
|
||||
{ label: 'Buttons', count: 12, id: 'buttons' },
|
||||
{ label: 'Icons', count: 42, id: 'icons' },
|
||||
{ label: 'Icons', count: 41, id: 'icons' },
|
||||
{ label: 'Form controls', id: 'form-controls' },
|
||||
{ label: 'Composable code', id: 'composable-by-default' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type ErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/** Catches render-time crashes: in dev the message + stack are shown inline and
|
||||
mirrored into #dev-errors (see src/dev/errorSink.ts); in prod we degrade to
|
||||
a plain message instead of a white screen. */
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
if (import.meta.env.DEV) {
|
||||
const host = document.getElementById('dev-errors')
|
||||
if (host) {
|
||||
const box = document.createElement('div')
|
||||
box.setAttribute('role', 'alert')
|
||||
box.setAttribute('data-dev-error', 'react-render')
|
||||
box.textContent = `React render error: ${error.message}\n${info.componentStack ?? ''}`
|
||||
host.appendChild(box)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-6)',
|
||||
margin: 'var(--space-6) auto',
|
||||
maxWidth: '42rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--foreground)',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--destructive)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
<strong>Something broke while rendering.</strong>
|
||||
{'\n\n'}
|
||||
{error.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Dev-only error sink.
|
||||
Agents and headless verification often have no console access, so runtime
|
||||
errors are rendered into the #dev-errors element in index.html where they
|
||||
can be read with plain DOM inspection (or by humans — no white screens). */
|
||||
|
||||
type ErrorPart = { title: string; detail: string }
|
||||
|
||||
function renderError({ title, detail }: ErrorPart) {
|
||||
const host = document.getElementById('dev-errors')
|
||||
if (!host) return
|
||||
|
||||
const box = document.createElement('div')
|
||||
box.setAttribute('role', 'alert')
|
||||
box.setAttribute('data-dev-error', 'true')
|
||||
box.style.cssText = [
|
||||
'position: relative',
|
||||
'z-index: 2147483647',
|
||||
'margin: 8px',
|
||||
'padding: 12px 14px',
|
||||
'border: 1px solid var(--destructive, red)',
|
||||
'border-radius: var(--radius-lg, 8px)',
|
||||
'background: var(--destructive, red)',
|
||||
'color: var(--destructive-foreground, white)',
|
||||
'font-family: var(--font-mono, monospace)',
|
||||
'font-size: 12px',
|
||||
'white-space: pre-wrap',
|
||||
'word-break: break-word',
|
||||
].join(';')
|
||||
|
||||
const heading = document.createElement('strong')
|
||||
heading.textContent = title
|
||||
box.appendChild(heading)
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.textContent = detail
|
||||
box.appendChild(body)
|
||||
|
||||
host.appendChild(box)
|
||||
}
|
||||
|
||||
export function installDevErrorSink() {
|
||||
if (!import.meta.env.DEV) return
|
||||
|
||||
window.onerror = (message, source, line, column, error) => {
|
||||
renderError({
|
||||
title: `window.onerror: ${String(message)}`,
|
||||
detail: `${source ?? ''}:${line ?? 0}:${column ?? 0}\n${error?.stack ?? ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
const reason = event.reason instanceof Error ? (event.reason.stack ?? event.reason.message) : String(event.reason)
|
||||
renderError({ title: 'unhandledrejection', detail: reason })
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
|
||||
@import './tokens.css';
|
||||
@import './utils.css';
|
||||
:root { font-family:var(--font-sans); color:var(--text); background:var(--bg); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
|
||||
:root { font-family:var(--font-sans); color:var(--foreground); background:var(--background); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
|
||||
* { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-width:320px; } button,input { font:inherit; } button { cursor:pointer; }
|
||||
+9
-1
@@ -2,9 +2,17 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ErrorBoundary } from './dev/ErrorBoundary.tsx'
|
||||
import { installDevErrorSink } from './dev/errorSink.ts'
|
||||
|
||||
/* Surface runtime errors in #dev-errors — failures render into the DOM so
|
||||
agents without console access (and humans) see messages, not white screens. */
|
||||
installDevErrorSink()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -96,33 +96,6 @@
|
||||
--sidebar-ring: lab(42% 0 0);
|
||||
--dot-color: lab(7.78201% -.0000149012 0 / .05);
|
||||
|
||||
/* ── Legacy Northstar mappings for light theme ── */
|
||||
--bg: var(--background);
|
||||
--bg-deep: var(--sidebar);
|
||||
--panel: var(--card);
|
||||
--panel-2: var(--secondary);
|
||||
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
|
||||
--panel-hover: color-mix(in srgb, var(--card) 70%, var(--border));
|
||||
--panel-active: color-mix(in srgb, var(--secondary) 85%, var(--border));
|
||||
|
||||
--line: var(--border);
|
||||
--line-soft: var(--sidebar-border);
|
||||
--line-hover: var(--ring);
|
||||
--line-focus: var(--ring);
|
||||
|
||||
--text: var(--foreground);
|
||||
--text-bright: var(--foreground);
|
||||
--text-soft: var(--muted-foreground);
|
||||
--text-code: var(--foreground);
|
||||
--subtle: color-mix(in srgb, var(--muted-foreground) 75%, transparent);
|
||||
|
||||
--accent-brand: var(--primary);
|
||||
--accent-contrast: var(--primary-foreground);
|
||||
--positive: var(--chart-2);
|
||||
--warning: var(--chart-3);
|
||||
--critical: var(--destructive);
|
||||
--pink: var(--chart-5);
|
||||
--blue: var(--chart-4);
|
||||
}
|
||||
|
||||
/* ── Dark Palette (Exact lab() Specification) ────────────────── */
|
||||
@@ -165,33 +138,6 @@
|
||||
--sidebar-ring: lab(89.9321% -47.9163 -15.7303);
|
||||
--dot-color: lab(89.9321% -47.9163 -15.7303 / .045);
|
||||
|
||||
/* ── Legacy Northstar mappings for dark theme ── */
|
||||
--bg: var(--background);
|
||||
--bg-deep: var(--sidebar);
|
||||
--panel: var(--card);
|
||||
--panel-2: var(--secondary);
|
||||
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
|
||||
--panel-hover: color-mix(in srgb, var(--card) 60%, var(--border));
|
||||
--panel-active: color-mix(in srgb, var(--secondary) 75%, var(--border));
|
||||
|
||||
--line: var(--border);
|
||||
--line-soft: var(--sidebar-border);
|
||||
--line-hover: color-mix(in srgb, var(--ring) 50%, var(--border));
|
||||
--line-focus: var(--ring);
|
||||
|
||||
--text: var(--foreground);
|
||||
--text-bright: var(--foreground);
|
||||
--text-soft: var(--muted-foreground);
|
||||
--text-code: var(--foreground);
|
||||
--subtle: color-mix(in srgb, var(--muted-foreground) 65%, transparent);
|
||||
|
||||
--accent-brand: var(--primary);
|
||||
--accent-contrast: var(--primary-foreground);
|
||||
--positive: var(--chart-2);
|
||||
--warning: var(--chart-3);
|
||||
--critical: var(--destructive);
|
||||
--pink: var(--chart-5);
|
||||
--blue: var(--chart-4);
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
+7
-2
@@ -516,8 +516,7 @@
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-wrapper:hover .tooltip-box,
|
||||
.tooltip-wrapper:focus-within .tooltip-box {
|
||||
.tooltip-box[data-open='true'] {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
}
|
||||
@@ -543,3 +542,9 @@
|
||||
.skeleton-rect {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ── Shared Focus Ring ──────────────────────────────────────── */
|
||||
:where(.ui-tabs-trigger, .switch-root, .dialog-close-btn):focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* setTimeout that cleans up after itself: every pending timer is cleared when
|
||||
* the component unmounts, and `clear()` cancels them early (e.g. when a dialog
|
||||
* closes mid-animation). Prevents the classic "setState after close/unmount"
|
||||
* leak from fire-and-forget `setTimeout(...)` calls.
|
||||
*
|
||||
* Usage:
|
||||
* const { schedule, clear } = useTimeout()
|
||||
* schedule(() => setCopied(false), 2000)
|
||||
*/
|
||||
export function useTimeout() {
|
||||
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const pending = timers.current
|
||||
return () => {
|
||||
pending.forEach(clearTimeout)
|
||||
pending.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const schedule = useCallback((fn: () => void, ms: number) => {
|
||||
const id = setTimeout(() => {
|
||||
timers.current.delete(id)
|
||||
fn()
|
||||
}, ms)
|
||||
timers.current.add(id)
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
timers.current.forEach(clearTimeout)
|
||||
timers.current.clear()
|
||||
}, [])
|
||||
|
||||
return { schedule, clear }
|
||||
}
|
||||
Reference in new issue
Block a user