@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
5
|
import { cn } from '../utils'
|
|
5
6
|
|
|
@@ -37,7 +38,7 @@ export interface StatusPulseProps {
|
|
|
37
38
|
* @description An animated status indicator dot with optional pulse ring and label.
|
|
38
39
|
* Accepts a configurable map to define custom statuses. Respects prefers-reduced-motion.
|
|
39
40
|
*/
|
|
40
|
-
export function StatusPulse({ status, label = true, configMap, className }: StatusPulseProps) {
|
|
41
|
+
export function StatusPulse({ status, label = true, configMap, className }: StatusPulseProps): React.JSX.Element {
|
|
41
42
|
const reduced = useReducedMotion()
|
|
42
43
|
const map = configMap ?? defaultPulseConfigMap
|
|
43
44
|
const cfg = map[status] ?? map['unknown'] ?? defaultPulseConfigMap['unknown']!
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
5
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import { Check, ChevronRight, ChevronLeft } from 'lucide-react'
|
|
7
|
+
import type { LucideIcon } from 'lucide-react'
|
|
8
|
+
import { cn } from '../utils'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Definition of a single wizard step. */
|
|
15
|
+
export interface WizardStep {
|
|
16
|
+
/** Unique step identifier. */
|
|
17
|
+
id: string
|
|
18
|
+
/** Step title displayed in the step indicator. */
|
|
19
|
+
title: string
|
|
20
|
+
/** Optional description below the title. */
|
|
21
|
+
description?: string
|
|
22
|
+
/** Optional icon for the step indicator. */
|
|
23
|
+
icon?: LucideIcon
|
|
24
|
+
/** Step content to render. */
|
|
25
|
+
content: React.ReactNode
|
|
26
|
+
/** Validation function called before advancing. Return true to allow, false to block. */
|
|
27
|
+
validate?: () => boolean | Promise<boolean>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Props for the StepWizard component. */
|
|
31
|
+
export interface StepWizardProps {
|
|
32
|
+
/** Array of steps in order. */
|
|
33
|
+
steps: WizardStep[]
|
|
34
|
+
/** Called when the final step is completed. */
|
|
35
|
+
onComplete: () => void
|
|
36
|
+
/** Called whenever the active step changes. */
|
|
37
|
+
onStepChange?: (step: number) => void
|
|
38
|
+
/** Step indicator layout. Default "horizontal". */
|
|
39
|
+
orientation?: 'horizontal' | 'vertical'
|
|
40
|
+
/** Allow skipping forward to any uncompleted step. Default false. */
|
|
41
|
+
allowSkip?: boolean
|
|
42
|
+
/** Show a summary/completion state after the last step. Default false. */
|
|
43
|
+
showSummary?: boolean
|
|
44
|
+
/** Additional class name for the root container. */
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SESSION_KEY_PREFIX = 'ui-kit-wizard-'
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// StepWizard
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @description A multi-step form wizard with animated slide transitions, per-step validation,
|
|
56
|
+
* step indicator bar (horizontal or vertical), keyboard navigation, progress percentage,
|
|
57
|
+
* and auto-save to sessionStorage for resilience to page refresh.
|
|
58
|
+
*/
|
|
59
|
+
export function StepWizard({
|
|
60
|
+
steps,
|
|
61
|
+
onComplete,
|
|
62
|
+
onStepChange,
|
|
63
|
+
orientation = 'horizontal',
|
|
64
|
+
allowSkip = false,
|
|
65
|
+
showSummary = false,
|
|
66
|
+
className,
|
|
67
|
+
}: StepWizardProps): React.JSX.Element {
|
|
68
|
+
const prefersReducedMotion = useReducedMotion()
|
|
69
|
+
const sessionKey = useMemo(() => SESSION_KEY_PREFIX + steps.map(s => s.id).join('-'), [steps])
|
|
70
|
+
|
|
71
|
+
// Restore state from sessionStorage
|
|
72
|
+
const [currentStep, setCurrentStep] = useState(() => {
|
|
73
|
+
if (typeof window === 'undefined') return 0
|
|
74
|
+
try {
|
|
75
|
+
const saved = sessionStorage.getItem(sessionKey)
|
|
76
|
+
if (saved) {
|
|
77
|
+
const parsed = JSON.parse(saved) as { step: number; completed: number[] }
|
|
78
|
+
return Math.min(parsed.step, steps.length - 1)
|
|
79
|
+
}
|
|
80
|
+
} catch { /* noop */ }
|
|
81
|
+
return 0
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const [completed, setCompleted] = useState<Set<number>>(() => {
|
|
85
|
+
if (typeof window === 'undefined') return new Set()
|
|
86
|
+
try {
|
|
87
|
+
const saved = sessionStorage.getItem(sessionKey)
|
|
88
|
+
if (saved) {
|
|
89
|
+
const parsed = JSON.parse(saved) as { step: number; completed: number[] }
|
|
90
|
+
return new Set(parsed.completed)
|
|
91
|
+
}
|
|
92
|
+
} catch { /* noop */ }
|
|
93
|
+
return new Set()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const [isValidating, setIsValidating] = useState(false)
|
|
97
|
+
const [direction, setDirection] = useState(1) // 1=forward, -1=backward
|
|
98
|
+
const [isComplete, setIsComplete] = useState(false)
|
|
99
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
100
|
+
|
|
101
|
+
// Save state to sessionStorage
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
try {
|
|
104
|
+
sessionStorage.setItem(sessionKey, JSON.stringify({
|
|
105
|
+
step: currentStep,
|
|
106
|
+
completed: [...completed],
|
|
107
|
+
}))
|
|
108
|
+
} catch { /* noop */ }
|
|
109
|
+
}, [currentStep, completed, sessionKey])
|
|
110
|
+
|
|
111
|
+
const totalSteps = steps.length
|
|
112
|
+
const progress = totalSteps === 0 ? 100 : Math.round(((completed.size) / totalSteps) * 100)
|
|
113
|
+
|
|
114
|
+
const goToStep = useCallback((idx: number) => {
|
|
115
|
+
setDirection(idx > currentStep ? 1 : -1)
|
|
116
|
+
setCurrentStep(idx)
|
|
117
|
+
onStepChange?.(idx)
|
|
118
|
+
}, [currentStep, onStepChange])
|
|
119
|
+
|
|
120
|
+
const handleNext = useCallback(async () => {
|
|
121
|
+
const step = steps[currentStep]
|
|
122
|
+
if (step?.validate) {
|
|
123
|
+
setIsValidating(true)
|
|
124
|
+
try {
|
|
125
|
+
const valid = await step.validate()
|
|
126
|
+
if (!valid) {
|
|
127
|
+
setIsValidating(false)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
setIsValidating(false)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
setIsValidating(false)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setCompleted(prev => new Set(prev).add(currentStep))
|
|
138
|
+
|
|
139
|
+
if (currentStep < totalSteps - 1) {
|
|
140
|
+
goToStep(currentStep + 1)
|
|
141
|
+
} else {
|
|
142
|
+
// Final step completed
|
|
143
|
+
if (showSummary) {
|
|
144
|
+
setIsComplete(true)
|
|
145
|
+
}
|
|
146
|
+
onComplete()
|
|
147
|
+
// Clear session storage
|
|
148
|
+
try { sessionStorage.removeItem(sessionKey) } catch { /* noop */ }
|
|
149
|
+
}
|
|
150
|
+
}, [currentStep, steps, totalSteps, goToStep, onComplete, showSummary, sessionKey])
|
|
151
|
+
|
|
152
|
+
const handleBack = useCallback(() => {
|
|
153
|
+
if (currentStep > 0) {
|
|
154
|
+
goToStep(currentStep - 1)
|
|
155
|
+
}
|
|
156
|
+
}, [currentStep, goToStep])
|
|
157
|
+
|
|
158
|
+
const handleStepClick = useCallback((idx: number) => {
|
|
159
|
+
// Can click on completed steps or if allowSkip
|
|
160
|
+
if (completed.has(idx) || allowSkip || idx < currentStep) {
|
|
161
|
+
goToStep(idx)
|
|
162
|
+
}
|
|
163
|
+
}, [completed, allowSkip, currentStep, goToStep])
|
|
164
|
+
|
|
165
|
+
// Keyboard navigation
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const handler = (e: KeyboardEvent) => {
|
|
168
|
+
if (e.key === 'Enter' && !e.shiftKey && !(e.target instanceof HTMLTextAreaElement)) {
|
|
169
|
+
handleNext()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
document.addEventListener('keydown', handler)
|
|
173
|
+
return () => document.removeEventListener('keydown', handler)
|
|
174
|
+
}, [handleNext])
|
|
175
|
+
|
|
176
|
+
const slideVariants = {
|
|
177
|
+
enter: (dir: number) => ({
|
|
178
|
+
x: prefersReducedMotion ? 0 : dir > 0 ? 40 : -40,
|
|
179
|
+
opacity: prefersReducedMotion ? 1 : 0,
|
|
180
|
+
}),
|
|
181
|
+
center: { x: 0, opacity: 1 },
|
|
182
|
+
exit: (dir: number) => ({
|
|
183
|
+
x: prefersReducedMotion ? 0 : dir > 0 ? -40 : 40,
|
|
184
|
+
opacity: prefersReducedMotion ? 1 : 0,
|
|
185
|
+
}),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isHorizontal = orientation === 'horizontal'
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className={cn('flex flex-col', className)}>
|
|
192
|
+
{/* Step indicator */}
|
|
193
|
+
<div className={cn(
|
|
194
|
+
'mb-6',
|
|
195
|
+
isHorizontal ? 'flex items-center' : 'flex flex-col gap-1',
|
|
196
|
+
)}>
|
|
197
|
+
{steps.map((step, idx) => {
|
|
198
|
+
const isActive = idx === currentStep
|
|
199
|
+
const isDone = completed.has(idx) || isComplete
|
|
200
|
+
const isClickable = isDone || allowSkip || idx < currentStep
|
|
201
|
+
const Icon = step.icon
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
key={step.id}
|
|
206
|
+
className={cn(
|
|
207
|
+
isHorizontal ? 'flex items-center flex-1' : 'flex items-center gap-3',
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
{/* Step circle + label */}
|
|
211
|
+
<button
|
|
212
|
+
onClick={() => handleStepClick(idx)}
|
|
213
|
+
disabled={!isClickable}
|
|
214
|
+
className={cn(
|
|
215
|
+
'flex items-center gap-2 group',
|
|
216
|
+
isClickable ? 'cursor-pointer' : 'cursor-default',
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
<div
|
|
220
|
+
className={cn(
|
|
221
|
+
'flex items-center justify-center rounded-full transition-all',
|
|
222
|
+
'h-8 w-8 text-xs font-semibold shrink-0',
|
|
223
|
+
isDone
|
|
224
|
+
? 'bg-[hsl(var(--status-ok))] text-[hsl(var(--text-on-brand))]'
|
|
225
|
+
: isActive
|
|
226
|
+
? 'bg-[hsl(var(--brand-primary))] text-[hsl(var(--text-on-brand))] ring-4 ring-[hsl(var(--brand-primary)/0.2)]'
|
|
227
|
+
: 'bg-[hsl(var(--bg-overlay))] text-[hsl(var(--text-tertiary))]',
|
|
228
|
+
isClickable && !isActive && !isDone && 'group-hover:bg-[hsl(var(--bg-elevated))]',
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
{isDone ? (
|
|
232
|
+
<Check className="h-4 w-4" />
|
|
233
|
+
) : Icon ? (
|
|
234
|
+
<Icon className="h-4 w-4" />
|
|
235
|
+
) : (
|
|
236
|
+
idx + 1
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className={cn(
|
|
241
|
+
isHorizontal ? 'hidden sm:block' : 'block',
|
|
242
|
+
)}>
|
|
243
|
+
<div className={cn(
|
|
244
|
+
'text-xs font-medium leading-tight',
|
|
245
|
+
isActive ? 'text-[hsl(var(--text-primary))]' : 'text-[hsl(var(--text-secondary))]',
|
|
246
|
+
)}>
|
|
247
|
+
{step.title}
|
|
248
|
+
</div>
|
|
249
|
+
{step.description && !isHorizontal && (
|
|
250
|
+
<div className="text-[10px] text-[hsl(var(--text-tertiary))]">
|
|
251
|
+
{step.description}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</button>
|
|
256
|
+
|
|
257
|
+
{/* Connector line */}
|
|
258
|
+
{isHorizontal && idx < totalSteps - 1 && (
|
|
259
|
+
<div className={cn(
|
|
260
|
+
'flex-1 h-0.5 mx-2 rounded-full transition-colors',
|
|
261
|
+
completed.has(idx) ? 'bg-[hsl(var(--status-ok))]' : 'bg-[hsl(var(--border-subtle))]',
|
|
262
|
+
)} />
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
})}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Progress bar */}
|
|
270
|
+
<div className="w-full h-1 rounded-full bg-[hsl(var(--bg-overlay))] mb-4 overflow-hidden">
|
|
271
|
+
<motion.div
|
|
272
|
+
className="h-full rounded-full bg-[hsl(var(--brand-primary))]"
|
|
273
|
+
initial={{ width: 0 }}
|
|
274
|
+
animate={{ width: `${progress}%` }}
|
|
275
|
+
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 300, damping: 30 }}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* Step content */}
|
|
280
|
+
<div ref={contentRef} className="relative min-h-[200px]">
|
|
281
|
+
<AnimatePresence mode="wait" custom={direction}>
|
|
282
|
+
{isComplete && showSummary ? (
|
|
283
|
+
<motion.div
|
|
284
|
+
key="summary"
|
|
285
|
+
custom={1}
|
|
286
|
+
variants={slideVariants}
|
|
287
|
+
initial="enter"
|
|
288
|
+
animate="center"
|
|
289
|
+
exit="exit"
|
|
290
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.25 }}
|
|
291
|
+
className="flex flex-col items-center justify-center py-12 text-center"
|
|
292
|
+
>
|
|
293
|
+
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-[hsl(var(--status-ok)/0.15)] mb-4">
|
|
294
|
+
<Check className="h-8 w-8 text-[hsl(var(--status-ok))]" />
|
|
295
|
+
</div>
|
|
296
|
+
<h3 className="text-lg font-semibold text-[hsl(var(--text-primary))] mb-1">
|
|
297
|
+
All steps completed
|
|
298
|
+
</h3>
|
|
299
|
+
<p className="text-sm text-[hsl(var(--text-secondary))]">
|
|
300
|
+
All {totalSteps} steps have been successfully completed.
|
|
301
|
+
</p>
|
|
302
|
+
</motion.div>
|
|
303
|
+
) : (
|
|
304
|
+
<motion.div
|
|
305
|
+
key={currentStep}
|
|
306
|
+
custom={direction}
|
|
307
|
+
variants={slideVariants}
|
|
308
|
+
initial="enter"
|
|
309
|
+
animate="center"
|
|
310
|
+
exit="exit"
|
|
311
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.25 }}
|
|
312
|
+
>
|
|
313
|
+
{steps[currentStep]?.content}
|
|
314
|
+
</motion.div>
|
|
315
|
+
)}
|
|
316
|
+
</AnimatePresence>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Navigation buttons */}
|
|
320
|
+
{!isComplete && (
|
|
321
|
+
<div className="flex items-center justify-between mt-6 pt-4 border-t border-[hsl(var(--border-subtle)/0.5)]">
|
|
322
|
+
<button
|
|
323
|
+
onClick={handleBack}
|
|
324
|
+
disabled={currentStep === 0}
|
|
325
|
+
className={cn(
|
|
326
|
+
'inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
|
327
|
+
'border border-[hsl(var(--border-default))]',
|
|
328
|
+
'text-[hsl(var(--text-primary))]',
|
|
329
|
+
'hover:bg-[hsl(var(--bg-overlay))]',
|
|
330
|
+
'disabled:opacity-40 disabled:pointer-events-none',
|
|
331
|
+
)}
|
|
332
|
+
>
|
|
333
|
+
<ChevronLeft className="h-4 w-4" />
|
|
334
|
+
Back
|
|
335
|
+
</button>
|
|
336
|
+
|
|
337
|
+
<span className="text-[11px] text-[hsl(var(--text-tertiary))] tabular-nums">
|
|
338
|
+
Step {currentStep + 1} of {totalSteps}
|
|
339
|
+
</span>
|
|
340
|
+
|
|
341
|
+
<button
|
|
342
|
+
onClick={handleNext}
|
|
343
|
+
disabled={isValidating}
|
|
344
|
+
className={cn(
|
|
345
|
+
'inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
|
346
|
+
'bg-[hsl(var(--brand-primary))] text-[hsl(var(--text-on-brand))]',
|
|
347
|
+
'hover:bg-[hsl(var(--brand-primary))]/90',
|
|
348
|
+
'disabled:opacity-70 disabled:cursor-not-allowed',
|
|
349
|
+
)}
|
|
350
|
+
>
|
|
351
|
+
{isValidating ? (
|
|
352
|
+
<>
|
|
353
|
+
<div className="h-4 w-4 rounded-full border-2 border-[hsl(var(--text-on-brand))] border-t-transparent animate-spin" />
|
|
354
|
+
Validating...
|
|
355
|
+
</>
|
|
356
|
+
) : currentStep === totalSteps - 1 ? (
|
|
357
|
+
<>
|
|
358
|
+
Complete
|
|
359
|
+
<Check className="h-4 w-4" />
|
|
360
|
+
</>
|
|
361
|
+
) : (
|
|
362
|
+
<>
|
|
363
|
+
Next
|
|
364
|
+
<ChevronRight className="h-4 w-4" />
|
|
365
|
+
</>
|
|
366
|
+
)}
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
import { Copy, Check } from 'lucide-react'
|
|
8
|
+
|
|
9
|
+
export interface StreamingTextProps {
|
|
10
|
+
/** The text content — grows as tokens arrive. */
|
|
11
|
+
text: string
|
|
12
|
+
/** Whether tokens are still arriving. */
|
|
13
|
+
isStreaming: boolean
|
|
14
|
+
/** Cursor blink speed in ms. */
|
|
15
|
+
speed?: number
|
|
16
|
+
/** Show a blinking cursor at the end while streaming. */
|
|
17
|
+
showCursor?: boolean
|
|
18
|
+
/** Called when isStreaming transitions from true to false. */
|
|
19
|
+
onComplete?: () => void
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Simple inline formatter: **bold** and `code` spans. */
|
|
24
|
+
function formatSegments(text: string): React.ReactNode[] {
|
|
25
|
+
const segments: React.ReactNode[] = []
|
|
26
|
+
const regex = /(\*\*(.+?)\*\*|`([^`]+?)`)/g
|
|
27
|
+
let lastIndex = 0
|
|
28
|
+
let match: RegExpExecArray | null
|
|
29
|
+
|
|
30
|
+
while ((match = regex.exec(text)) !== null) {
|
|
31
|
+
if (match.index > lastIndex) {
|
|
32
|
+
segments.push(text.slice(lastIndex, match.index))
|
|
33
|
+
}
|
|
34
|
+
if (match[2]) {
|
|
35
|
+
segments.push(
|
|
36
|
+
<strong key={match.index} className="font-semibold">
|
|
37
|
+
{match[2]}
|
|
38
|
+
</strong>
|
|
39
|
+
)
|
|
40
|
+
} else if (match[3]) {
|
|
41
|
+
segments.push(
|
|
42
|
+
<code
|
|
43
|
+
key={match.index}
|
|
44
|
+
className="rounded px-1 py-0.5 text-[0.875em] bg-[hsl(var(--bg-overlay))] text-[hsl(var(--brand-primary))] font-mono"
|
|
45
|
+
>
|
|
46
|
+
{match[3]}
|
|
47
|
+
</code>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
lastIndex = match.index + match[0].length
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (lastIndex < text.length) {
|
|
54
|
+
segments.push(text.slice(lastIndex))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return segments
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @description Displays AI/LLM streaming text responses with a blinking cursor,
|
|
62
|
+
* markdown-like formatting (bold, code), copy button on completion, and auto-scroll.
|
|
63
|
+
*/
|
|
64
|
+
export function StreamingText({
|
|
65
|
+
text,
|
|
66
|
+
isStreaming,
|
|
67
|
+
speed = 500,
|
|
68
|
+
showCursor = true,
|
|
69
|
+
onComplete,
|
|
70
|
+
className,
|
|
71
|
+
}: StreamingTextProps): React.JSX.Element {
|
|
72
|
+
const reduced = useReducedMotion()
|
|
73
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
74
|
+
const prevStreamingRef = useRef(isStreaming)
|
|
75
|
+
const [copied, setCopied] = useState(false)
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (prevStreamingRef.current && !isStreaming) {
|
|
79
|
+
onComplete?.()
|
|
80
|
+
}
|
|
81
|
+
prevStreamingRef.current = isStreaming
|
|
82
|
+
}, [isStreaming, onComplete])
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (isStreaming && containerRef.current) {
|
|
86
|
+
const el = containerRef.current
|
|
87
|
+
el.scrollTop = el.scrollHeight
|
|
88
|
+
}
|
|
89
|
+
}, [text, isStreaming])
|
|
90
|
+
|
|
91
|
+
const handleCopy = useCallback(() => {
|
|
92
|
+
void navigator.clipboard.writeText(text).then(() => {
|
|
93
|
+
setCopied(true)
|
|
94
|
+
setTimeout(() => setCopied(false), 2000)
|
|
95
|
+
})
|
|
96
|
+
}, [text])
|
|
97
|
+
|
|
98
|
+
const formatted = formatSegments(text)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={cn('relative', className)}>
|
|
102
|
+
<div
|
|
103
|
+
ref={containerRef}
|
|
104
|
+
className="overflow-y-auto text-[hsl(var(--text-primary))] leading-relaxed whitespace-pre-wrap break-words"
|
|
105
|
+
>
|
|
106
|
+
{formatted}
|
|
107
|
+
{showCursor && isStreaming && (
|
|
108
|
+
<span
|
|
109
|
+
className="inline-block w-[2px] h-[1.1em] align-text-bottom ml-0.5 bg-[hsl(var(--brand-primary))]"
|
|
110
|
+
style={
|
|
111
|
+
reduced
|
|
112
|
+
? { opacity: 1 }
|
|
113
|
+
: {
|
|
114
|
+
animation: `streaming-cursor-blink ${speed}ms step-end infinite`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
<AnimatePresence>
|
|
120
|
+
{showCursor && !isStreaming && text.length > 0 && (
|
|
121
|
+
<motion.span
|
|
122
|
+
className="inline-block w-[2px] h-[1.1em] align-text-bottom ml-0.5 bg-[hsl(var(--brand-primary))]"
|
|
123
|
+
initial={{ opacity: 1 }}
|
|
124
|
+
exit={{ opacity: 0 }}
|
|
125
|
+
transition={{ duration: reduced ? 0 : 0.4 }}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
</AnimatePresence>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<AnimatePresence>
|
|
132
|
+
{!isStreaming && text.length > 0 && (
|
|
133
|
+
<motion.button
|
|
134
|
+
type="button"
|
|
135
|
+
initial={{ opacity: 0, scale: 0.8 }}
|
|
136
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
137
|
+
exit={{ opacity: 0, scale: 0.8 }}
|
|
138
|
+
transition={{ duration: reduced ? 0 : 0.2 }}
|
|
139
|
+
onClick={handleCopy}
|
|
140
|
+
className={cn(
|
|
141
|
+
'absolute top-0 right-0 p-1.5 rounded-lg',
|
|
142
|
+
'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-primary))]',
|
|
143
|
+
'bg-[hsl(var(--bg-surface))]/80 hover:bg-[hsl(var(--bg-elevated))]',
|
|
144
|
+
'transition-colors duration-150 cursor-pointer',
|
|
145
|
+
)}
|
|
146
|
+
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
|
147
|
+
>
|
|
148
|
+
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
|
149
|
+
</motion.button>
|
|
150
|
+
)}
|
|
151
|
+
</AnimatePresence>
|
|
152
|
+
|
|
153
|
+
{showCursor && isStreaming && !reduced && (
|
|
154
|
+
<style>{`
|
|
155
|
+
@keyframes streaming-cursor-blink {
|
|
156
|
+
0%, 100% { opacity: 1; }
|
|
157
|
+
50% { opacity: 0; }
|
|
158
|
+
}
|
|
159
|
+
`}</style>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
5
|
|
|
5
6
|
export interface SuccessCheckmarkProps {
|
|
@@ -12,7 +13,7 @@ export interface SuccessCheckmarkProps {
|
|
|
12
13
|
* @description An animated success checkmark SVG with circle and path draw animations.
|
|
13
14
|
* Uses Framer Motion spring physics. Respects prefers-reduced-motion.
|
|
14
15
|
*/
|
|
15
|
-
export function SuccessCheckmark({ size = 20, className }: SuccessCheckmarkProps) {
|
|
16
|
+
export function SuccessCheckmark({ size = 20, className }: SuccessCheckmarkProps): React.JSX.Element {
|
|
16
17
|
const reduced = useReducedMotion()
|
|
17
18
|
|
|
18
19
|
return (
|
package/src/components/tabs.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { useCallback, useRef, type KeyboardEvent } from 'react'
|
|
4
5
|
import { motion, useReducedMotion } from 'framer-motion'
|
|
5
6
|
import type { LucideIcon } from 'lucide-react'
|
|
@@ -49,7 +50,7 @@ export function Tabs({
|
|
|
49
50
|
variant = 'underline',
|
|
50
51
|
size = 'md',
|
|
51
52
|
className,
|
|
52
|
-
}: TabsProps) {
|
|
53
|
+
}: TabsProps): React.JSX.Element {
|
|
53
54
|
const prefersReducedMotion = useReducedMotion()
|
|
54
55
|
const tabListRef = useRef<HTMLDivElement>(null)
|
|
55
56
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { useEffect, useState } from 'react'
|
|
4
5
|
import { useReducedMotion } from 'framer-motion'
|
|
5
6
|
import { cn, clamp } from '../utils'
|
|
@@ -53,7 +54,7 @@ export function ThresholdGauge({
|
|
|
53
54
|
showValue = true,
|
|
54
55
|
format,
|
|
55
56
|
className,
|
|
56
|
-
}: ThresholdGaugeProps) {
|
|
57
|
+
}: ThresholdGaugeProps): React.JSX.Element {
|
|
57
58
|
const reduced = useReducedMotion()
|
|
58
59
|
const value = clamp(rawValue, 0, 100)
|
|
59
60
|
const warning = thresholds?.warning ?? 70
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
5
|
import { cn } from '../utils'
|
|
5
6
|
|
|
@@ -40,7 +41,7 @@ export function TimeRangeSelector({
|
|
|
40
41
|
onChange,
|
|
41
42
|
ranges = defaultRanges,
|
|
42
43
|
className,
|
|
43
|
-
}: TimeRangeSelectorProps) {
|
|
44
|
+
}: TimeRangeSelectorProps): React.JSX.Element {
|
|
44
45
|
const reduced = useReducedMotion()
|
|
45
46
|
|
|
46
47
|
return (
|
package/src/components/toast.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { Toaster as SonnerToaster } from 'sonner'
|
|
4
5
|
|
|
5
6
|
export interface ToasterProps {
|
|
@@ -16,7 +17,7 @@ export interface ToasterProps {
|
|
|
16
17
|
* instead of reading from a hook, making it portable across applications.
|
|
17
18
|
* Import this Toaster once in your app layout, then use `toast()` from sonner anywhere.
|
|
18
19
|
*/
|
|
19
|
-
export function Toaster({ theme = 'dark', position = 'bottom-right', duration = 4000 }: ToasterProps) {
|
|
20
|
+
export function Toaster({ theme = 'dark', position = 'bottom-right', duration = 4000 }: ToasterProps): React.JSX.Element {
|
|
20
21
|
return (
|
|
21
22
|
<SonnerToaster
|
|
22
23
|
theme={theme}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { ToggleLeft, ToggleRight } from 'lucide-react'
|
|
4
5
|
import { cn } from '../utils'
|
|
5
6
|
|
|
@@ -23,7 +24,7 @@ export interface ToggleSwitchProps {
|
|
|
23
24
|
*/
|
|
24
25
|
export function ToggleSwitch({
|
|
25
26
|
enabled, onChange, size = 'md', disabled, label, className,
|
|
26
|
-
}: ToggleSwitchProps) {
|
|
27
|
+
}: ToggleSwitchProps): React.JSX.Element {
|
|
27
28
|
const iconSize = size === 'sm' ? 'size-5' : 'size-6'
|
|
28
29
|
|
|
29
30
|
return (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { type ReactNode } from 'react'
|
|
4
5
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
|
5
6
|
import { cn } from '../utils'
|
|
@@ -28,7 +29,7 @@ export function Tooltip({
|
|
|
28
29
|
side = 'top',
|
|
29
30
|
delay = 200,
|
|
30
31
|
className,
|
|
31
|
-
}: TooltipProps) {
|
|
32
|
+
}: TooltipProps): React.JSX.Element {
|
|
32
33
|
return (
|
|
33
34
|
<TooltipPrimitive.Provider delayDuration={delay}>
|
|
34
35
|
<TooltipPrimitive.Root>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type React from 'react'
|
|
3
4
|
import { useRef, useState, useEffect } from 'react'
|
|
4
5
|
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
5
6
|
import { Copy, Check } from 'lucide-react'
|
|
@@ -16,7 +17,7 @@ export interface TruncatedTextProps {
|
|
|
16
17
|
* @description A text element that truncates with ellipsis and shows a tooltip with the
|
|
17
18
|
* full text on hover when truncated. Includes a copy-to-clipboard button in the tooltip.
|
|
18
19
|
*/
|
|
19
|
-
export function TruncatedText({ text, maxWidth = '100%', className = '' }: TruncatedTextProps) {
|
|
20
|
+
export function TruncatedText({ text, maxWidth = '100%', className = '' }: TruncatedTextProps): React.JSX.Element {
|
|
20
21
|
const ref = useRef<HTMLSpanElement>(null)
|
|
21
22
|
const [isTruncated, setIsTruncated] = useState(false)
|
|
22
23
|
const [copied, setCopied] = useState(false)
|