@annondeveloper/ui-kit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
- package/dist/chunk-2DWZVHZS.js.map +1 -0
- package/dist/form.d.ts +6 -6
- package/dist/form.js +1 -1
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +508 -52
- package/dist/index.js +2927 -4
- package/dist/index.js.map +1 -1
- package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
- package/package.json +1 -1
- package/src/components/animated-counter.tsx +2 -1
- package/src/components/avatar.tsx +2 -1
- package/src/components/badge.tsx +3 -2
- package/src/components/button.tsx +3 -2
- package/src/components/card.tsx +13 -12
- package/src/components/checkbox.tsx +3 -2
- package/src/components/color-input.tsx +414 -0
- package/src/components/command-bar.tsx +434 -0
- package/src/components/confidence-bar.tsx +115 -0
- package/src/components/confirm-dialog.tsx +2 -1
- package/src/components/copy-block.tsx +229 -0
- package/src/components/data-table.tsx +2 -1
- package/src/components/diff-viewer.tsx +319 -0
- package/src/components/dropdown-menu.tsx +2 -1
- package/src/components/empty-state.tsx +2 -1
- package/src/components/filter-pill.tsx +2 -1
- package/src/components/form-input.tsx +5 -4
- package/src/components/heatmap-calendar.tsx +213 -0
- package/src/components/infinite-scroll.tsx +243 -0
- package/src/components/kanban-column.tsx +198 -0
- package/src/components/live-feed.tsx +220 -0
- package/src/components/log-viewer.tsx +2 -1
- package/src/components/metric-card.tsx +2 -1
- package/src/components/notification-stack.tsx +226 -0
- package/src/components/pipeline-stage.tsx +2 -1
- package/src/components/popover.tsx +2 -1
- package/src/components/port-status-grid.tsx +2 -1
- package/src/components/progress.tsx +2 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/realtime-value.tsx +283 -0
- package/src/components/select.tsx +2 -1
- package/src/components/severity-timeline.tsx +2 -1
- package/src/components/sheet.tsx +2 -1
- package/src/components/skeleton.tsx +4 -3
- package/src/components/slider.tsx +2 -1
- package/src/components/smart-table.tsx +383 -0
- package/src/components/sortable-list.tsx +268 -0
- package/src/components/sparkline.tsx +2 -1
- package/src/components/status-badge.tsx +2 -1
- package/src/components/status-pulse.tsx +2 -1
- package/src/components/step-wizard.tsx +372 -0
- package/src/components/streaming-text.tsx +163 -0
- package/src/components/success-checkmark.tsx +2 -1
- package/src/components/tabs.tsx +2 -1
- package/src/components/threshold-gauge.tsx +2 -1
- package/src/components/time-range-selector.tsx +2 -1
- package/src/components/toast.tsx +2 -1
- package/src/components/toggle-switch.tsx +2 -1
- package/src/components/tooltip.tsx +2 -1
- package/src/components/truncated-text.tsx +2 -1
- package/src/components/typing-indicator.tsx +123 -0
- package/src/components/uptime-tracker.tsx +2 -1
- package/src/components/utilization-bar.tsx +2 -1
- package/src/utils.ts +1 -1
- package/dist/chunk-5OKSXPWK.js.map +0 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
|
5
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import { Search, Clock, Command, CornerDownLeft } from 'lucide-react'
|
|
7
|
+
import type { LucideIcon } from 'lucide-react'
|
|
8
|
+
import { cn } from '../utils'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** A single command item in the palette. */
|
|
15
|
+
export interface CommandItem {
|
|
16
|
+
/** Unique identifier. */
|
|
17
|
+
id: string
|
|
18
|
+
/** Display label. */
|
|
19
|
+
label: string
|
|
20
|
+
/** Optional description shown below the label. */
|
|
21
|
+
description?: string
|
|
22
|
+
/** Optional icon component. */
|
|
23
|
+
icon?: LucideIcon
|
|
24
|
+
/** Keyboard shortcut display string (e.g. "Cmd+K"). */
|
|
25
|
+
shortcut?: string
|
|
26
|
+
/** Group name for sectioning items. */
|
|
27
|
+
group?: string
|
|
28
|
+
/** Callback when item is selected. */
|
|
29
|
+
onSelect: () => void
|
|
30
|
+
/** Additional search terms that match this item. */
|
|
31
|
+
keywords?: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Props for the CommandBar component. */
|
|
35
|
+
export interface CommandBarProps {
|
|
36
|
+
/** Array of command items to display and search. */
|
|
37
|
+
items: CommandItem[]
|
|
38
|
+
/** Placeholder text for the search input. */
|
|
39
|
+
placeholder?: string
|
|
40
|
+
/** Hotkey letter (combined with Cmd/Ctrl). Default "k". */
|
|
41
|
+
hotkey?: string
|
|
42
|
+
/** Async search function for remote results. */
|
|
43
|
+
onSearch?: (query: string) => Promise<CommandItem[]>
|
|
44
|
+
/** localStorage key for persisting recent selections. */
|
|
45
|
+
recentKey?: string
|
|
46
|
+
/** Maximum number of recent items to store. Default 5. */
|
|
47
|
+
maxRecent?: number
|
|
48
|
+
/** Additional class name for the dialog. */
|
|
49
|
+
className?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Fuzzy scoring
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function fuzzyScore(query: string, target: string): number {
|
|
57
|
+
const q = query.toLowerCase()
|
|
58
|
+
const t = target.toLowerCase()
|
|
59
|
+
if (t === q) return 100
|
|
60
|
+
if (t.startsWith(q)) return 80
|
|
61
|
+
if (t.includes(q)) return 60
|
|
62
|
+
// Fuzzy subsequence match
|
|
63
|
+
let qi = 0
|
|
64
|
+
let score = 0
|
|
65
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
66
|
+
if (t[ti] === q[qi]) {
|
|
67
|
+
score += 10
|
|
68
|
+
qi++
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return qi === q.length ? score : 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scoreItem(query: string, item: CommandItem): number {
|
|
75
|
+
if (!query) return 0
|
|
76
|
+
let best = fuzzyScore(query, item.label)
|
|
77
|
+
if (item.description) best = Math.max(best, fuzzyScore(query, item.description) * 0.8)
|
|
78
|
+
if (item.keywords) {
|
|
79
|
+
for (const kw of item.keywords) {
|
|
80
|
+
best = Math.max(best, fuzzyScore(query, kw) * 0.9)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return best
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// CommandBar
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @description A universal command palette activated by Cmd+K (Mac) or Ctrl+K (Win).
|
|
92
|
+
* Features fuzzy search, grouped items, recent selections (localStorage),
|
|
93
|
+
* async search support, keyboard navigation, and Framer Motion animations.
|
|
94
|
+
* Fully configurable and not hardcoded to any app.
|
|
95
|
+
*/
|
|
96
|
+
export function CommandBar({
|
|
97
|
+
items,
|
|
98
|
+
placeholder = 'Type a command\u2026',
|
|
99
|
+
hotkey = 'k',
|
|
100
|
+
onSearch,
|
|
101
|
+
recentKey = 'ui-kit-command-recent',
|
|
102
|
+
maxRecent = 5,
|
|
103
|
+
className,
|
|
104
|
+
}: CommandBarProps): React.JSX.Element {
|
|
105
|
+
const prefersReducedMotion = useReducedMotion()
|
|
106
|
+
const [open, setOpen] = useState(false)
|
|
107
|
+
const [query, setQuery] = useState('')
|
|
108
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
109
|
+
const [asyncResults, setAsyncResults] = useState<CommandItem[]>([])
|
|
110
|
+
const [isSearching, setIsSearching] = useState(false)
|
|
111
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
112
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
113
|
+
|
|
114
|
+
// Recent items from localStorage
|
|
115
|
+
const [recentIds, setRecentIds] = useState<string[]>(() => {
|
|
116
|
+
if (typeof window === 'undefined') return []
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(localStorage.getItem(recentKey) ?? '[]') as string[]
|
|
119
|
+
} catch {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const saveRecent = useCallback(
|
|
125
|
+
(id: string) => {
|
|
126
|
+
const updated = [id, ...recentIds.filter(r => r !== id)].slice(0, maxRecent)
|
|
127
|
+
setRecentIds(updated)
|
|
128
|
+
try {
|
|
129
|
+
localStorage.setItem(recentKey, JSON.stringify(updated))
|
|
130
|
+
} catch {
|
|
131
|
+
// localStorage might be full
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[recentIds, recentKey, maxRecent],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Hotkey listener
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const handler = (e: KeyboardEvent) => {
|
|
140
|
+
if ((e.metaKey || e.ctrlKey) && e.key === hotkey) {
|
|
141
|
+
e.preventDefault()
|
|
142
|
+
setOpen(o => !o)
|
|
143
|
+
}
|
|
144
|
+
if (e.key === 'Escape' && open) {
|
|
145
|
+
setOpen(false)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
document.addEventListener('keydown', handler)
|
|
149
|
+
return () => document.removeEventListener('keydown', handler)
|
|
150
|
+
}, [hotkey, open])
|
|
151
|
+
|
|
152
|
+
// Focus input when opened
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (open) {
|
|
155
|
+
setQuery('')
|
|
156
|
+
setActiveIndex(0)
|
|
157
|
+
setAsyncResults([])
|
|
158
|
+
requestAnimationFrame(() => inputRef.current?.focus())
|
|
159
|
+
}
|
|
160
|
+
}, [open])
|
|
161
|
+
|
|
162
|
+
// Prevent body scroll when open
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (open) {
|
|
165
|
+
const prev = document.body.style.overflow
|
|
166
|
+
document.body.style.overflow = 'hidden'
|
|
167
|
+
return () => { document.body.style.overflow = prev }
|
|
168
|
+
}
|
|
169
|
+
}, [open])
|
|
170
|
+
|
|
171
|
+
// Async search debounce
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!onSearch || !query) {
|
|
174
|
+
setAsyncResults([])
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
setIsSearching(true)
|
|
178
|
+
const timer = setTimeout(async () => {
|
|
179
|
+
try {
|
|
180
|
+
const results = await onSearch(query)
|
|
181
|
+
setAsyncResults(results)
|
|
182
|
+
} catch {
|
|
183
|
+
setAsyncResults([])
|
|
184
|
+
} finally {
|
|
185
|
+
setIsSearching(false)
|
|
186
|
+
}
|
|
187
|
+
}, 200)
|
|
188
|
+
return () => clearTimeout(timer)
|
|
189
|
+
}, [query, onSearch])
|
|
190
|
+
|
|
191
|
+
// Build display list
|
|
192
|
+
const displayItems = useMemo(() => {
|
|
193
|
+
const allItems = [...items, ...asyncResults]
|
|
194
|
+
|
|
195
|
+
if (!query) {
|
|
196
|
+
// Show recent items first, then all items
|
|
197
|
+
const recentItems = recentIds
|
|
198
|
+
.map(id => allItems.find(i => i.id === id))
|
|
199
|
+
.filter((i): i is CommandItem => i !== undefined)
|
|
200
|
+
const rest = allItems.filter(i => !recentIds.includes(i.id))
|
|
201
|
+
return [
|
|
202
|
+
...recentItems.map(i => ({ ...i, group: 'Recent' })),
|
|
203
|
+
...rest,
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Score and sort
|
|
208
|
+
return allItems
|
|
209
|
+
.map(item => ({ item, score: scoreItem(query, item) }))
|
|
210
|
+
.filter(({ score }) => score > 0)
|
|
211
|
+
.sort((a, b) => b.score - a.score)
|
|
212
|
+
.map(({ item }) => item)
|
|
213
|
+
}, [items, asyncResults, query, recentIds])
|
|
214
|
+
|
|
215
|
+
// Group items
|
|
216
|
+
const groups = useMemo(() => {
|
|
217
|
+
const grouped = new Map<string, CommandItem[]>()
|
|
218
|
+
for (const item of displayItems) {
|
|
219
|
+
const group = item.group ?? ''
|
|
220
|
+
const arr = grouped.get(group)
|
|
221
|
+
if (arr) arr.push(item)
|
|
222
|
+
else grouped.set(group, [item])
|
|
223
|
+
}
|
|
224
|
+
return grouped
|
|
225
|
+
}, [displayItems])
|
|
226
|
+
|
|
227
|
+
// Flatten for keyboard nav
|
|
228
|
+
const flatItems = displayItems
|
|
229
|
+
|
|
230
|
+
// Keep activeIndex in bounds
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
setActiveIndex(0)
|
|
233
|
+
}, [query])
|
|
234
|
+
|
|
235
|
+
const handleSelect = useCallback(
|
|
236
|
+
(item: CommandItem) => {
|
|
237
|
+
saveRecent(item.id)
|
|
238
|
+
setOpen(false)
|
|
239
|
+
item.onSelect()
|
|
240
|
+
},
|
|
241
|
+
[saveRecent],
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
// Keyboard navigation
|
|
245
|
+
const handleKeyDown = useCallback(
|
|
246
|
+
(e: React.KeyboardEvent) => {
|
|
247
|
+
if (e.key === 'ArrowDown') {
|
|
248
|
+
e.preventDefault()
|
|
249
|
+
setActiveIndex(i => Math.min(i + 1, flatItems.length - 1))
|
|
250
|
+
} else if (e.key === 'ArrowUp') {
|
|
251
|
+
e.preventDefault()
|
|
252
|
+
setActiveIndex(i => Math.max(i - 1, 0))
|
|
253
|
+
} else if (e.key === 'Enter') {
|
|
254
|
+
e.preventDefault()
|
|
255
|
+
const item = flatItems[activeIndex]
|
|
256
|
+
if (item) handleSelect(item)
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
[flatItems, activeIndex, handleSelect],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Scroll active item into view
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!listRef.current) return
|
|
265
|
+
const active = listRef.current.querySelector('[data-active="true"]')
|
|
266
|
+
active?.scrollIntoView({ block: 'nearest' })
|
|
267
|
+
}, [activeIndex])
|
|
268
|
+
|
|
269
|
+
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone/.test(navigator.userAgent ?? '')
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<AnimatePresence>
|
|
273
|
+
{open && (
|
|
274
|
+
<div className="fixed inset-0 z-50">
|
|
275
|
+
{/* Backdrop */}
|
|
276
|
+
<motion.div
|
|
277
|
+
initial={{ opacity: 0 }}
|
|
278
|
+
animate={{ opacity: 1 }}
|
|
279
|
+
exit={{ opacity: 0 }}
|
|
280
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15 }}
|
|
281
|
+
className="absolute inset-0 bg-[hsl(var(--bg-base)/0.6)] backdrop-blur-sm"
|
|
282
|
+
onClick={() => setOpen(false)}
|
|
283
|
+
/>
|
|
284
|
+
|
|
285
|
+
{/* Dialog */}
|
|
286
|
+
<motion.div
|
|
287
|
+
initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.96, y: -20 }}
|
|
288
|
+
animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1, y: 0 }}
|
|
289
|
+
exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.96, y: -20 }}
|
|
290
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
291
|
+
className={cn(
|
|
292
|
+
'absolute left-1/2 top-[15%] -translate-x-1/2',
|
|
293
|
+
'w-full max-w-lg rounded-2xl overflow-hidden',
|
|
294
|
+
'border border-[hsl(var(--border-default))]',
|
|
295
|
+
'bg-[hsl(var(--bg-elevated))] shadow-2xl',
|
|
296
|
+
'flex flex-col max-h-[70vh]',
|
|
297
|
+
className,
|
|
298
|
+
)}
|
|
299
|
+
onKeyDown={handleKeyDown}
|
|
300
|
+
>
|
|
301
|
+
{/* Search input */}
|
|
302
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-[hsl(var(--border-subtle)/0.5)]">
|
|
303
|
+
<Search className="h-5 w-5 text-[hsl(var(--text-tertiary))] shrink-0" />
|
|
304
|
+
<input
|
|
305
|
+
ref={inputRef}
|
|
306
|
+
type="text"
|
|
307
|
+
value={query}
|
|
308
|
+
onChange={e => setQuery(e.target.value)}
|
|
309
|
+
placeholder={placeholder}
|
|
310
|
+
className="flex-1 bg-transparent text-[hsl(var(--text-primary))] text-sm placeholder:text-[hsl(var(--text-tertiary))] outline-none"
|
|
311
|
+
/>
|
|
312
|
+
<kbd className="hidden sm:inline-flex items-center gap-1 rounded-md border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--text-tertiary))] font-mono">
|
|
313
|
+
Esc
|
|
314
|
+
</kbd>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{/* Results list */}
|
|
318
|
+
<div ref={listRef} className="flex-1 overflow-y-auto py-2">
|
|
319
|
+
{flatItems.length === 0 && !isSearching && (
|
|
320
|
+
<div className="px-4 py-8 text-center text-sm text-[hsl(var(--text-tertiary))]">
|
|
321
|
+
{query ? 'No results found.' : 'No commands available.'}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{isSearching && flatItems.length === 0 && (
|
|
326
|
+
<div className="px-4 py-8 flex items-center justify-center gap-2 text-sm text-[hsl(var(--text-tertiary))]">
|
|
327
|
+
<div className="h-4 w-4 rounded-full border-2 border-[hsl(var(--brand-primary))] border-t-transparent animate-spin" />
|
|
328
|
+
Searching...
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
{[...groups.entries()].map(([groupName, groupItems]) => {
|
|
333
|
+
return (
|
|
334
|
+
<div key={groupName || '__ungrouped'}>
|
|
335
|
+
{groupName && (
|
|
336
|
+
<div className="px-4 pt-2 pb-1">
|
|
337
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-[hsl(var(--text-tertiary))]">
|
|
338
|
+
{groupName}
|
|
339
|
+
</span>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
{groupItems.map(item => {
|
|
343
|
+
const globalIdx = flatItems.indexOf(item)
|
|
344
|
+
const isActive = globalIdx === activeIndex
|
|
345
|
+
const Icon = item.icon
|
|
346
|
+
const isRecent = item.group === 'Recent'
|
|
347
|
+
return (
|
|
348
|
+
<button
|
|
349
|
+
key={item.id}
|
|
350
|
+
data-active={isActive}
|
|
351
|
+
onClick={() => handleSelect(item)}
|
|
352
|
+
onMouseEnter={() => setActiveIndex(globalIdx)}
|
|
353
|
+
className={cn(
|
|
354
|
+
'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
|
355
|
+
isActive
|
|
356
|
+
? 'bg-[hsl(var(--brand-primary)/0.1)]'
|
|
357
|
+
: 'hover:bg-[hsl(var(--bg-surface)/0.5)]',
|
|
358
|
+
)}
|
|
359
|
+
>
|
|
360
|
+
{Icon ? (
|
|
361
|
+
<Icon className={cn(
|
|
362
|
+
'h-4 w-4 shrink-0',
|
|
363
|
+
isActive ? 'text-[hsl(var(--brand-primary))]' : 'text-[hsl(var(--text-tertiary))]',
|
|
364
|
+
)} />
|
|
365
|
+
) : isRecent ? (
|
|
366
|
+
<Clock className={cn(
|
|
367
|
+
'h-4 w-4 shrink-0',
|
|
368
|
+
isActive ? 'text-[hsl(var(--brand-primary))]' : 'text-[hsl(var(--text-tertiary))]',
|
|
369
|
+
)} />
|
|
370
|
+
) : (
|
|
371
|
+
<div className="h-4 w-4 shrink-0" />
|
|
372
|
+
)}
|
|
373
|
+
|
|
374
|
+
<div className="flex-1 min-w-0">
|
|
375
|
+
<div className={cn(
|
|
376
|
+
'text-sm font-medium truncate',
|
|
377
|
+
isActive ? 'text-[hsl(var(--text-primary))]' : 'text-[hsl(var(--text-primary))]',
|
|
378
|
+
)}>
|
|
379
|
+
{item.label}
|
|
380
|
+
</div>
|
|
381
|
+
{item.description && (
|
|
382
|
+
<div className="text-[11px] text-[hsl(var(--text-tertiary))] truncate mt-0.5">
|
|
383
|
+
{item.description}
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{item.shortcut && (
|
|
389
|
+
<kbd className="flex items-center gap-0.5 rounded-md border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--text-tertiary))] font-mono shrink-0">
|
|
390
|
+
{item.shortcut}
|
|
391
|
+
</kbd>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{isActive && (
|
|
395
|
+
<CornerDownLeft className="h-3.5 w-3.5 text-[hsl(var(--text-tertiary))] shrink-0" />
|
|
396
|
+
)}
|
|
397
|
+
</button>
|
|
398
|
+
)
|
|
399
|
+
})}
|
|
400
|
+
</div>
|
|
401
|
+
)
|
|
402
|
+
})}
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
{/* Footer */}
|
|
406
|
+
<div className="flex items-center gap-4 px-4 py-2 border-t border-[hsl(var(--border-subtle)/0.5)] text-[10px] text-[hsl(var(--text-tertiary))]">
|
|
407
|
+
<span className="inline-flex items-center gap-1">
|
|
408
|
+
<kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">↑↓</kbd>
|
|
409
|
+
Navigate
|
|
410
|
+
</span>
|
|
411
|
+
<span className="inline-flex items-center gap-1">
|
|
412
|
+
<kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">↵</kbd>
|
|
413
|
+
Select
|
|
414
|
+
</span>
|
|
415
|
+
<span className="inline-flex items-center gap-1">
|
|
416
|
+
<kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">Esc</kbd>
|
|
417
|
+
Close
|
|
418
|
+
</span>
|
|
419
|
+
<span className="ml-auto inline-flex items-center gap-1">
|
|
420
|
+
<kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono">
|
|
421
|
+
{isMac ? '\u2318' : 'Ctrl+'}
|
|
422
|
+
</kbd>
|
|
423
|
+
<kbd className="rounded border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] px-1 py-0.5 font-mono uppercase">
|
|
424
|
+
{hotkey}
|
|
425
|
+
</kbd>
|
|
426
|
+
Toggle
|
|
427
|
+
</span>
|
|
428
|
+
</div>
|
|
429
|
+
</motion.div>
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</AnimatePresence>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
|
|
8
|
+
export interface ConfidenceBarProps {
|
|
9
|
+
/** Confidence value between 0 and 1 (probability). */
|
|
10
|
+
value: number
|
|
11
|
+
/** Optional label displayed before the bar. */
|
|
12
|
+
label?: string
|
|
13
|
+
/** Show percentage text beside the bar. */
|
|
14
|
+
showPercentage?: boolean
|
|
15
|
+
/** Thresholds for color zones. */
|
|
16
|
+
thresholds?: { low: number; medium: number }
|
|
17
|
+
/** Size preset. */
|
|
18
|
+
size?: 'sm' | 'md'
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SIZE_CLASSES = {
|
|
23
|
+
sm: 'h-1.5',
|
|
24
|
+
md: 'h-2.5',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
function getBarColor(value: number, thresholds: { low: number; medium: number }): string {
|
|
28
|
+
if (value < thresholds.low) return 'bg-[hsl(var(--status-critical))]'
|
|
29
|
+
if (value < thresholds.medium) return 'bg-[hsl(var(--status-warning))]'
|
|
30
|
+
return 'bg-[hsl(var(--status-ok))]'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getTextColor(value: number, thresholds: { low: number; medium: number }): string {
|
|
34
|
+
if (value < thresholds.low) return 'text-[hsl(var(--status-critical))]'
|
|
35
|
+
if (value < thresholds.medium) return 'text-[hsl(var(--status-warning))]'
|
|
36
|
+
return 'text-[hsl(var(--status-ok))]'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @description A horizontal confidence/probability bar colored by threshold zones:
|
|
41
|
+
* red (<low), yellow (low-medium), green (>medium). Animates fill on mount.
|
|
42
|
+
*/
|
|
43
|
+
export function ConfidenceBar({
|
|
44
|
+
value,
|
|
45
|
+
label,
|
|
46
|
+
showPercentage = true,
|
|
47
|
+
thresholds = { low: 0.3, medium: 0.7 },
|
|
48
|
+
size = 'md',
|
|
49
|
+
className,
|
|
50
|
+
}: ConfidenceBarProps): React.JSX.Element {
|
|
51
|
+
const reduced = useReducedMotion()
|
|
52
|
+
const [hovered, setHovered] = useState(false)
|
|
53
|
+
const clamped = Math.min(1, Math.max(0, value))
|
|
54
|
+
const pct = clamped * 100
|
|
55
|
+
const barColor = getBarColor(clamped, thresholds)
|
|
56
|
+
const textColor = getTextColor(clamped, thresholds)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={cn('w-full', className)}
|
|
61
|
+
onMouseEnter={() => setHovered(true)}
|
|
62
|
+
onMouseLeave={() => setHovered(false)}
|
|
63
|
+
>
|
|
64
|
+
{/* Label + percentage row */}
|
|
65
|
+
{(label || showPercentage) && (
|
|
66
|
+
<div className="flex items-center justify-between mb-1">
|
|
67
|
+
{label && (
|
|
68
|
+
<span className="text-xs font-medium text-[hsl(var(--text-secondary))]">
|
|
69
|
+
{label}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
{showPercentage && (
|
|
73
|
+
<span className={cn('text-xs font-medium tabular-nums', textColor)}>
|
|
74
|
+
{pct.toFixed(1)}%
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Track */}
|
|
81
|
+
<div
|
|
82
|
+
className={cn(
|
|
83
|
+
'relative w-full overflow-hidden rounded-full bg-[hsl(var(--bg-overlay))]',
|
|
84
|
+
SIZE_CLASSES[size],
|
|
85
|
+
)}
|
|
86
|
+
role="meter"
|
|
87
|
+
aria-valuenow={pct}
|
|
88
|
+
aria-valuemin={0}
|
|
89
|
+
aria-valuemax={100}
|
|
90
|
+
aria-label={label ?? `Confidence: ${pct.toFixed(1)}%`}
|
|
91
|
+
>
|
|
92
|
+
{reduced ? (
|
|
93
|
+
<div
|
|
94
|
+
className={cn('h-full rounded-full', barColor)}
|
|
95
|
+
style={{ width: `${pct}%` }}
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<motion.div
|
|
99
|
+
className={cn('h-full rounded-full', barColor)}
|
|
100
|
+
initial={{ width: 0 }}
|
|
101
|
+
animate={{ width: `${pct}%` }}
|
|
102
|
+
transition={{ type: 'spring', stiffness: 80, damping: 20 }}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Tooltip on hover showing exact value */}
|
|
108
|
+
{hovered && (
|
|
109
|
+
<div className="mt-1 text-[10px] text-[hsl(var(--text-tertiary))] tabular-nums">
|
|
110
|
+
{clamped.toFixed(4)} ({pct.toFixed(2)}%)
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
|
4
5
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
5
6
|
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
@@ -55,7 +56,7 @@ export function ConfirmDialog({
|
|
|
55
56
|
variant = 'danger',
|
|
56
57
|
loading = false,
|
|
57
58
|
onConfirm,
|
|
58
|
-
}: ConfirmDialogProps) {
|
|
59
|
+
}: ConfirmDialogProps): React.JSX.Element {
|
|
59
60
|
const styles = variantStyles[variant]
|
|
60
61
|
|
|
61
62
|
return (
|