@annondeveloper/ui-kit 0.1.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/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/chunk-5OKSXPWK.js +270 -0
- package/dist/chunk-5OKSXPWK.js.map +1 -0
- package/dist/cli/index.js +430 -0
- package/dist/form.d.ts +65 -0
- package/dist/form.js +148 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +942 -0
- package/dist/index.js +2812 -0
- package/dist/index.js.map +1 -0
- package/dist/select-nnBJUO8U.d.ts +26 -0
- package/package.json +114 -0
- package/src/components/animated-counter.stories.tsx +68 -0
- package/src/components/animated-counter.tsx +85 -0
- package/src/components/avatar.tsx +106 -0
- package/src/components/badge.stories.tsx +70 -0
- package/src/components/badge.tsx +97 -0
- package/src/components/button.stories.tsx +101 -0
- package/src/components/button.tsx +67 -0
- package/src/components/card.tsx +128 -0
- package/src/components/checkbox.stories.tsx +64 -0
- package/src/components/checkbox.tsx +58 -0
- package/src/components/confirm-dialog.stories.tsx +96 -0
- package/src/components/confirm-dialog.tsx +145 -0
- package/src/components/data-table.stories.tsx +125 -0
- package/src/components/data-table.tsx +791 -0
- package/src/components/dropdown-menu.tsx +111 -0
- package/src/components/empty-state.stories.tsx +42 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/filter-pill.stories.tsx +71 -0
- package/src/components/filter-pill.tsx +45 -0
- package/src/components/form-input.stories.tsx +91 -0
- package/src/components/form-input.tsx +77 -0
- package/src/components/log-viewer.tsx +212 -0
- package/src/components/metric-card.tsx +141 -0
- package/src/components/pipeline-stage.tsx +134 -0
- package/src/components/popover.tsx +72 -0
- package/src/components/port-status-grid.tsx +102 -0
- package/src/components/progress.tsx +128 -0
- package/src/components/radio-group.tsx +162 -0
- package/src/components/select.stories.tsx +52 -0
- package/src/components/select.tsx +92 -0
- package/src/components/severity-timeline.tsx +125 -0
- package/src/components/sheet.tsx +164 -0
- package/src/components/skeleton.stories.tsx +64 -0
- package/src/components/skeleton.tsx +62 -0
- package/src/components/slider.tsx +208 -0
- package/src/components/sparkline.tsx +104 -0
- package/src/components/status-badge.stories.tsx +84 -0
- package/src/components/status-badge.tsx +71 -0
- package/src/components/status-pulse.stories.tsx +56 -0
- package/src/components/status-pulse.tsx +78 -0
- package/src/components/success-checkmark.stories.tsx +67 -0
- package/src/components/success-checkmark.tsx +53 -0
- package/src/components/tabs.tsx +177 -0
- package/src/components/threshold-gauge.tsx +149 -0
- package/src/components/time-range-selector.tsx +86 -0
- package/src/components/toast.stories.tsx +70 -0
- package/src/components/toast.tsx +48 -0
- package/src/components/toggle-switch.stories.tsx +66 -0
- package/src/components/toggle-switch.tsx +51 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/truncated-text.stories.tsx +56 -0
- package/src/components/truncated-text.tsx +80 -0
- package/src/components/uptime-tracker.tsx +138 -0
- package/src/components/utilization-bar.tsx +103 -0
- package/src/theme.css +178 -0
- package/src/utils.ts +123 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @description A shimmer skeleton loader block. Requires the `skeleton-shimmer` CSS class
|
|
7
|
+
* from theme.css to animate.
|
|
8
|
+
*/
|
|
9
|
+
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className={cn('skeleton-shimmer rounded-md', className)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SkeletonTextProps {
|
|
19
|
+
/** Number of text lines to render. */
|
|
20
|
+
lines?: number
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @description A multi-line skeleton text placeholder. The last line renders shorter
|
|
26
|
+
* for a natural paragraph appearance.
|
|
27
|
+
*/
|
|
28
|
+
export function SkeletonText({ lines = 1, className }: SkeletonTextProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn('space-y-2', className)}>
|
|
31
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
32
|
+
<Skeleton
|
|
33
|
+
key={i}
|
|
34
|
+
className={cn('h-3', i === lines - 1 && lines > 1 ? 'w-4/5' : 'w-full')}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @description A card-shaped skeleton placeholder with header area and content bars.
|
|
43
|
+
*/
|
|
44
|
+
export function SkeletonCard({ className }: { className?: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('rounded-lg border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] p-4 space-y-3', className)}>
|
|
47
|
+
<div className="flex items-center gap-3">
|
|
48
|
+
<Skeleton className="size-8 rounded-md" />
|
|
49
|
+
<div className="flex-1 space-y-1.5">
|
|
50
|
+
<Skeleton className="h-3.5 w-32" />
|
|
51
|
+
<Skeleton className="h-2.5 w-20" />
|
|
52
|
+
</div>
|
|
53
|
+
<Skeleton className="h-5 w-14 rounded-full" />
|
|
54
|
+
</div>
|
|
55
|
+
<div className="flex gap-3">
|
|
56
|
+
<Skeleton className="h-2.5 flex-1" />
|
|
57
|
+
<Skeleton className="h-2.5 flex-1" />
|
|
58
|
+
<Skeleton className="h-2.5 flex-1" />
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState, type KeyboardEvent, type MouseEvent, type TouchEvent } from 'react'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
export interface SliderProps {
|
|
7
|
+
/** Current value. */
|
|
8
|
+
value: number
|
|
9
|
+
/** Callback when value changes. */
|
|
10
|
+
onChange: (value: number) => void
|
|
11
|
+
/** Minimum value. */
|
|
12
|
+
min?: number
|
|
13
|
+
/** Maximum value. */
|
|
14
|
+
max?: number
|
|
15
|
+
/** Step increment. */
|
|
16
|
+
step?: number
|
|
17
|
+
/** Optional label displayed above the slider. */
|
|
18
|
+
label?: string
|
|
19
|
+
/** Show the current value. */
|
|
20
|
+
showValue?: boolean
|
|
21
|
+
/** Additional class name for the root element. */
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @description A custom-styled range slider with keyboard accessibility.
|
|
27
|
+
* Features a styled track, filled portion, and draggable thumb.
|
|
28
|
+
* Shows current value on hover/drag via a tooltip above the thumb.
|
|
29
|
+
*/
|
|
30
|
+
export function Slider({
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
min = 0,
|
|
34
|
+
max = 100,
|
|
35
|
+
step = 1,
|
|
36
|
+
label,
|
|
37
|
+
showValue = false,
|
|
38
|
+
className,
|
|
39
|
+
}: SliderProps) {
|
|
40
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
41
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
42
|
+
const [isHovering, setIsHovering] = useState(false)
|
|
43
|
+
|
|
44
|
+
const pct = max === min ? 0 : ((value - min) / (max - min)) * 100
|
|
45
|
+
|
|
46
|
+
const clampToStep = useCallback(
|
|
47
|
+
(raw: number) => {
|
|
48
|
+
const clamped = Math.min(max, Math.max(min, raw))
|
|
49
|
+
const stepped = Math.round((clamped - min) / step) * step + min
|
|
50
|
+
// Avoid floating point issues
|
|
51
|
+
return Math.min(max, Math.max(min, parseFloat(stepped.toPrecision(10))))
|
|
52
|
+
},
|
|
53
|
+
[min, max, step],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const getValueFromPosition = useCallback(
|
|
57
|
+
(clientX: number) => {
|
|
58
|
+
const track = trackRef.current
|
|
59
|
+
if (!track) return value
|
|
60
|
+
const rect = track.getBoundingClientRect()
|
|
61
|
+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
|
62
|
+
return clampToStep(min + ratio * (max - min))
|
|
63
|
+
},
|
|
64
|
+
[min, max, value, clampToStep],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const handleMouseDown = useCallback(
|
|
68
|
+
(e: MouseEvent) => {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
setIsDragging(true)
|
|
71
|
+
onChange(getValueFromPosition(e.clientX))
|
|
72
|
+
|
|
73
|
+
const handleMouseMove = (ev: globalThis.MouseEvent) => {
|
|
74
|
+
onChange(getValueFromPosition(ev.clientX))
|
|
75
|
+
}
|
|
76
|
+
const handleMouseUp = () => {
|
|
77
|
+
setIsDragging(false)
|
|
78
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
79
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
80
|
+
}
|
|
81
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
82
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
83
|
+
},
|
|
84
|
+
[getValueFromPosition, onChange],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const handleTouchStart = useCallback(
|
|
88
|
+
(e: TouchEvent) => {
|
|
89
|
+
setIsDragging(true)
|
|
90
|
+
if (e.touches[0]) {
|
|
91
|
+
onChange(getValueFromPosition(e.touches[0].clientX))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleTouchMove = (ev: globalThis.TouchEvent) => {
|
|
95
|
+
if (ev.touches[0]) {
|
|
96
|
+
onChange(getValueFromPosition(ev.touches[0].clientX))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const handleTouchEnd = () => {
|
|
100
|
+
setIsDragging(false)
|
|
101
|
+
document.removeEventListener('touchmove', handleTouchMove)
|
|
102
|
+
document.removeEventListener('touchend', handleTouchEnd)
|
|
103
|
+
}
|
|
104
|
+
document.addEventListener('touchmove', handleTouchMove)
|
|
105
|
+
document.addEventListener('touchend', handleTouchEnd)
|
|
106
|
+
},
|
|
107
|
+
[getValueFromPosition, onChange],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = useCallback(
|
|
111
|
+
(e: KeyboardEvent) => {
|
|
112
|
+
let newValue = value
|
|
113
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
newValue = clampToStep(value + step)
|
|
116
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
newValue = clampToStep(value - step)
|
|
119
|
+
} else if (e.key === 'Home') {
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
newValue = min
|
|
122
|
+
} else if (e.key === 'End') {
|
|
123
|
+
e.preventDefault()
|
|
124
|
+
newValue = max
|
|
125
|
+
}
|
|
126
|
+
if (newValue !== value) onChange(newValue)
|
|
127
|
+
},
|
|
128
|
+
[value, min, max, step, clampToStep, onChange],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const showTooltip = isDragging || isHovering
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className={cn('w-full', className)}>
|
|
135
|
+
{/* Label + value row */}
|
|
136
|
+
{(label || showValue) && (
|
|
137
|
+
<div className="flex items-center justify-between mb-2">
|
|
138
|
+
{label && (
|
|
139
|
+
<span className="text-xs font-medium text-[hsl(var(--text-secondary))]">{label}</span>
|
|
140
|
+
)}
|
|
141
|
+
{showValue && (
|
|
142
|
+
<span className="text-xs font-medium tabular-nums text-[hsl(var(--text-secondary))]">
|
|
143
|
+
{value}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* Track */}
|
|
150
|
+
<div
|
|
151
|
+
ref={trackRef}
|
|
152
|
+
role="slider"
|
|
153
|
+
aria-valuenow={value}
|
|
154
|
+
aria-valuemin={min}
|
|
155
|
+
aria-valuemax={max}
|
|
156
|
+
aria-label={label}
|
|
157
|
+
tabIndex={0}
|
|
158
|
+
onMouseDown={handleMouseDown}
|
|
159
|
+
onTouchStart={handleTouchStart}
|
|
160
|
+
onKeyDown={handleKeyDown}
|
|
161
|
+
onMouseEnter={() => setIsHovering(true)}
|
|
162
|
+
onMouseLeave={() => setIsHovering(false)}
|
|
163
|
+
className={cn(
|
|
164
|
+
'relative h-6 w-full cursor-pointer select-none',
|
|
165
|
+
'focus-visible:outline-none',
|
|
166
|
+
'group',
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{/* Track background */}
|
|
170
|
+
<div className="absolute top-1/2 -translate-y-1/2 w-full h-2 rounded-full bg-[hsl(var(--bg-overlay))]">
|
|
171
|
+
{/* Filled portion */}
|
|
172
|
+
<div
|
|
173
|
+
className="absolute inset-y-0 left-0 rounded-full bg-[hsl(var(--brand-primary))]"
|
|
174
|
+
style={{ width: `${pct}%` }}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Thumb */}
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2',
|
|
182
|
+
'h-5 w-5 rounded-full',
|
|
183
|
+
'bg-[hsl(var(--text-on-brand))] border-2 border-[hsl(var(--brand-primary))]',
|
|
184
|
+
'shadow-md transition-transform duration-100',
|
|
185
|
+
isDragging && 'scale-110',
|
|
186
|
+
'group-focus-visible:ring-2 group-focus-visible:ring-[hsl(var(--brand-primary))] group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-[hsl(var(--bg-base))]',
|
|
187
|
+
)}
|
|
188
|
+
style={{ left: `${pct}%` }}
|
|
189
|
+
>
|
|
190
|
+
{/* Value tooltip */}
|
|
191
|
+
{showTooltip && (
|
|
192
|
+
<div
|
|
193
|
+
className={cn(
|
|
194
|
+
'absolute -top-8 left-1/2 -translate-x-1/2',
|
|
195
|
+
'px-2 py-0.5 rounded-md text-xs font-medium tabular-nums',
|
|
196
|
+
'bg-[hsl(var(--bg-elevated))] text-[hsl(var(--text-primary))]',
|
|
197
|
+
'border border-[hsl(var(--border-subtle))] shadow-sm',
|
|
198
|
+
'pointer-events-none whitespace-nowrap',
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
{value}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
|
|
5
|
+
export interface SparklineProps {
|
|
6
|
+
/** Array of numeric values to plot. */
|
|
7
|
+
data: number[]
|
|
8
|
+
/** SVG width in pixels. */
|
|
9
|
+
width?: number
|
|
10
|
+
/** SVG height in pixels. */
|
|
11
|
+
height?: number
|
|
12
|
+
/** Line color — must use hsl(var(--token)) format. */
|
|
13
|
+
color?: string
|
|
14
|
+
/** Opacity for the gradient fill below the line (0 to disable). */
|
|
15
|
+
fillOpacity?: number
|
|
16
|
+
/** Show dots on first and last data points. */
|
|
17
|
+
showDots?: boolean
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildPoints(data: number[], w: number, h: number, pad: number): string {
|
|
22
|
+
if (data.length < 2) return ''
|
|
23
|
+
const min = Math.min(...data)
|
|
24
|
+
const max = Math.max(...data)
|
|
25
|
+
const range = max - min || 1
|
|
26
|
+
const stepX = (w - pad * 2) / (data.length - 1)
|
|
27
|
+
return data
|
|
28
|
+
.map((v, i) => {
|
|
29
|
+
const x = pad + i * stepX
|
|
30
|
+
const y = pad + (1 - (v - min) / range) * (h - pad * 2)
|
|
31
|
+
return `${x},${y}`
|
|
32
|
+
})
|
|
33
|
+
.join(' ')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @description A tiny inline SVG sparkline chart for embedding in tables, cards,
|
|
38
|
+
* and metric tiles. Pure SVG with no external dependencies.
|
|
39
|
+
*/
|
|
40
|
+
export function Sparkline({
|
|
41
|
+
data,
|
|
42
|
+
width = 80,
|
|
43
|
+
height = 24,
|
|
44
|
+
color = 'hsl(var(--brand-primary))',
|
|
45
|
+
fillOpacity = 0.1,
|
|
46
|
+
showDots = false,
|
|
47
|
+
className,
|
|
48
|
+
}: SparklineProps) {
|
|
49
|
+
if (data.length < 2) return null
|
|
50
|
+
|
|
51
|
+
const pad = showDots ? 3 : 1
|
|
52
|
+
const points = buildPoints(data, width, height, pad)
|
|
53
|
+
const gradId = `sp-grad-${Math.random().toString(36).slice(2, 8)}`
|
|
54
|
+
|
|
55
|
+
const min = Math.min(...data)
|
|
56
|
+
const max = Math.max(...data)
|
|
57
|
+
const range = max - min || 1
|
|
58
|
+
const stepX = (width - pad * 2) / (data.length - 1)
|
|
59
|
+
|
|
60
|
+
const firstX = pad
|
|
61
|
+
const firstY = pad + (1 - (data[0] - min) / range) * (height - pad * 2)
|
|
62
|
+
const lastX = pad + (data.length - 1) * stepX
|
|
63
|
+
const lastY = pad + (1 - (data[data.length - 1] - min) / range) * (height - pad * 2)
|
|
64
|
+
|
|
65
|
+
const fillPath = fillOpacity > 0
|
|
66
|
+
? `M ${firstX},${height} L ${points.split(' ').join(' L ')} L ${lastX},${height} Z`
|
|
67
|
+
: undefined
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<svg
|
|
71
|
+
width={width}
|
|
72
|
+
height={height}
|
|
73
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
74
|
+
className={cn('shrink-0', className)}
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
>
|
|
77
|
+
{fillOpacity > 0 && (
|
|
78
|
+
<defs>
|
|
79
|
+
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
|
80
|
+
<stop offset="0%" stopColor={color} stopOpacity={fillOpacity} />
|
|
81
|
+
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
82
|
+
</linearGradient>
|
|
83
|
+
</defs>
|
|
84
|
+
)}
|
|
85
|
+
{fillPath && (
|
|
86
|
+
<path d={fillPath} fill={`url(#${gradId})`} />
|
|
87
|
+
)}
|
|
88
|
+
<polyline
|
|
89
|
+
points={points}
|
|
90
|
+
fill="none"
|
|
91
|
+
stroke={color}
|
|
92
|
+
strokeWidth={1.5}
|
|
93
|
+
strokeLinecap="round"
|
|
94
|
+
strokeLinejoin="round"
|
|
95
|
+
/>
|
|
96
|
+
{showDots && (
|
|
97
|
+
<>
|
|
98
|
+
<circle cx={firstX} cy={firstY} r={2} fill={color} />
|
|
99
|
+
<circle cx={lastX} cy={lastY} r={2} fill={color} />
|
|
100
|
+
</>
|
|
101
|
+
)}
|
|
102
|
+
</svg>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { StatusBadge } from './status-badge'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof StatusBadge> = {
|
|
5
|
+
title: 'Components/StatusBadge',
|
|
6
|
+
component: StatusBadge,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
status: {
|
|
10
|
+
control: 'select',
|
|
11
|
+
options: ['ok', 'active', 'warning', 'critical', 'unknown', 'maintenance', 'stale', 'inactive', 'decommissioned', 'pending'],
|
|
12
|
+
},
|
|
13
|
+
size: { control: 'select', options: ['sm', 'md'] },
|
|
14
|
+
pulse: { control: 'boolean' },
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof StatusBadge>
|
|
19
|
+
|
|
20
|
+
export const Default: Story = {
|
|
21
|
+
args: { status: 'ok' },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Critical: Story = {
|
|
25
|
+
args: { status: 'critical' },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const Warning: Story = {
|
|
29
|
+
args: { status: 'warning' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const WithPulse: Story = {
|
|
33
|
+
args: { status: 'critical', pulse: true },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const SmallSize: Story = {
|
|
37
|
+
args: { status: 'active', size: 'sm' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const CustomLabel: Story = {
|
|
41
|
+
args: { status: 'ok', label: 'Healthy' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const AllStatuses: Story = {
|
|
45
|
+
render: () => (
|
|
46
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
47
|
+
{['ok', 'active', 'warning', 'critical', 'unknown', 'maintenance', 'stale', 'inactive', 'decommissioned', 'pending'].map(s => (
|
|
48
|
+
<StatusBadge key={s} status={s} />
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const CustomStatusMap: Story = {
|
|
55
|
+
render: () => {
|
|
56
|
+
const customMap = {
|
|
57
|
+
running: {
|
|
58
|
+
label: 'Running',
|
|
59
|
+
dot: 'bg-[hsl(142,71%,45%)]',
|
|
60
|
+
text: 'text-[hsl(142,71%,45%)]',
|
|
61
|
+
bg: 'bg-[hsl(142,71%,45%)]/10',
|
|
62
|
+
},
|
|
63
|
+
stopped: {
|
|
64
|
+
label: 'Stopped',
|
|
65
|
+
dot: 'bg-[hsl(0,84%,60%)]',
|
|
66
|
+
text: 'text-[hsl(0,84%,60%)]',
|
|
67
|
+
bg: 'bg-[hsl(0,84%,60%)]/10',
|
|
68
|
+
},
|
|
69
|
+
migrating: {
|
|
70
|
+
label: 'Migrating',
|
|
71
|
+
dot: 'bg-[hsl(258,80%,65%)]',
|
|
72
|
+
text: 'text-[hsl(258,80%,65%)]',
|
|
73
|
+
bg: 'bg-[hsl(258,80%,65%)]/10',
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
78
|
+
<StatusBadge status="running" statusMap={customMap} />
|
|
79
|
+
<StatusBadge status="stopped" statusMap={customMap} />
|
|
80
|
+
<StatusBadge status="migrating" statusMap={customMap} />
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
|
|
5
|
+
/** Configuration for a single status entry in a StatusBadge. */
|
|
6
|
+
export interface StatusConfig {
|
|
7
|
+
/** Display label. */
|
|
8
|
+
label: string
|
|
9
|
+
/** CSS class for the status dot. */
|
|
10
|
+
dot: string
|
|
11
|
+
/** CSS class for the text color. */
|
|
12
|
+
text: string
|
|
13
|
+
/** CSS class for the background color. */
|
|
14
|
+
bg: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A sensible default status map covering common statuses. */
|
|
18
|
+
export const defaultStatusMap: Record<string, StatusConfig> = {
|
|
19
|
+
ok: { label: 'OK', dot: 'bg-[hsl(var(--status-ok))]', text: 'text-[hsl(var(--status-ok))]', bg: 'bg-[hsl(var(--status-ok))]/10' },
|
|
20
|
+
active: { label: 'Active', dot: 'bg-[hsl(var(--status-ok))]', text: 'text-[hsl(var(--status-ok))]', bg: 'bg-[hsl(var(--status-ok))]/10' },
|
|
21
|
+
warning: { label: 'Warning', dot: 'bg-[hsl(var(--status-warning))]', text: 'text-[hsl(var(--status-warning))]', bg: 'bg-[hsl(var(--status-warning))]/10' },
|
|
22
|
+
critical: { label: 'Critical', dot: 'bg-[hsl(var(--status-critical))]', text: 'text-[hsl(var(--status-critical))]', bg: 'bg-[hsl(var(--status-critical))]/10' },
|
|
23
|
+
unknown: { label: 'Unknown', dot: 'bg-[hsl(var(--status-unknown))]', text: 'text-[hsl(var(--status-unknown))]', bg: 'bg-[hsl(var(--status-unknown))]/10' },
|
|
24
|
+
maintenance: { label: 'Maintenance', dot: 'bg-[hsl(var(--status-maintenance))]', text: 'text-[hsl(var(--status-maintenance))]', bg: 'bg-[hsl(var(--status-maintenance))]/10' },
|
|
25
|
+
stale: { label: 'Stale', dot: 'bg-[hsl(var(--status-warning))]', text: 'text-[hsl(var(--status-warning))]', bg: 'bg-[hsl(var(--status-warning))]/10' },
|
|
26
|
+
inactive: { label: 'Inactive', dot: 'bg-[hsl(var(--text-tertiary))]', text: 'text-[hsl(var(--text-tertiary))]', bg: 'bg-[hsl(var(--text-tertiary))]/10' },
|
|
27
|
+
decommissioned: { label: 'Decommissioned', dot: 'bg-[hsl(var(--text-disabled))]', text: 'text-[hsl(var(--text-disabled))]', bg: 'bg-[hsl(var(--text-disabled))]/10' },
|
|
28
|
+
pending: { label: 'Pending', dot: 'bg-[hsl(var(--status-warning))]', text: 'text-[hsl(var(--status-warning))]', bg: 'bg-[hsl(var(--status-warning))]/10' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StatusBadgeProps {
|
|
32
|
+
/** Status key to look up in the status map. */
|
|
33
|
+
status: string
|
|
34
|
+
/** Override display label. */
|
|
35
|
+
label?: string
|
|
36
|
+
/** Size variant. */
|
|
37
|
+
size?: 'sm' | 'md'
|
|
38
|
+
/** Show a CSS pulse animation on the dot. */
|
|
39
|
+
pulse?: boolean
|
|
40
|
+
/** Custom status map. Falls back to defaultStatusMap. */
|
|
41
|
+
statusMap?: Record<string, StatusConfig>
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @description A status badge with colored dot indicator and label.
|
|
47
|
+
* Accepts a configurable statusMap to define custom status values and their appearance.
|
|
48
|
+
* Falls back to a built-in default map if none is provided.
|
|
49
|
+
*/
|
|
50
|
+
export function StatusBadge({
|
|
51
|
+
status, label, size = 'md', pulse = false, statusMap, className,
|
|
52
|
+
}: StatusBadgeProps) {
|
|
53
|
+
const map = statusMap ?? defaultStatusMap
|
|
54
|
+
const fallback = map['unknown'] ?? defaultStatusMap['unknown']!
|
|
55
|
+
const config = map[status] ?? fallback
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
className={cn(
|
|
60
|
+
'inline-flex items-center gap-1.5 rounded-full font-medium',
|
|
61
|
+
size === 'sm' ? 'px-1.5 py-0.5 text-[0.6875rem]' : 'px-2 py-1 text-[0.75rem]',
|
|
62
|
+
config.text,
|
|
63
|
+
config.bg,
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span className={cn('rounded-full shrink-0', size === 'sm' ? 'size-1.5' : 'size-2', config.dot, pulse && 'animate-pulse')} />
|
|
68
|
+
{label ?? config.label}
|
|
69
|
+
</span>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { StatusPulse } from './status-pulse'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof StatusPulse> = {
|
|
5
|
+
title: 'Components/StatusPulse',
|
|
6
|
+
component: StatusPulse,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
status: { control: 'select', options: ['online', 'degraded', 'offline', 'unknown'] },
|
|
10
|
+
label: { control: 'boolean' },
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof StatusPulse>
|
|
15
|
+
|
|
16
|
+
export const Online: Story = {
|
|
17
|
+
args: { status: 'online' },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const Degraded: Story = {
|
|
21
|
+
args: { status: 'degraded' },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Offline: Story = {
|
|
25
|
+
args: { status: 'offline' },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const Unknown: Story = {
|
|
29
|
+
args: { status: 'unknown' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const WithoutLabel: Story = {
|
|
33
|
+
args: { status: 'online', label: false },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const AllStates: Story = {
|
|
37
|
+
render: () => (
|
|
38
|
+
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'center' }}>
|
|
39
|
+
<StatusPulse status="online" />
|
|
40
|
+
<StatusPulse status="degraded" />
|
|
41
|
+
<StatusPulse status="offline" />
|
|
42
|
+
<StatusPulse status="unknown" />
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DotOnly: Story = {
|
|
48
|
+
render: () => (
|
|
49
|
+
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
|
50
|
+
<StatusPulse status="online" label={false} />
|
|
51
|
+
<StatusPulse status="degraded" label={false} />
|
|
52
|
+
<StatusPulse status="offline" label={false} />
|
|
53
|
+
<StatusPulse status="unknown" label={false} />
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
/** Configuration for a single status in StatusPulse. */
|
|
7
|
+
export interface PulseConfig {
|
|
8
|
+
/** CSS class for the solid dot. */
|
|
9
|
+
dot: string
|
|
10
|
+
/** CSS class for the pulse ring. */
|
|
11
|
+
ring: string
|
|
12
|
+
/** Whether to show the pulse ring animation. */
|
|
13
|
+
pulse: boolean
|
|
14
|
+
/** Use faster animation (e.g. for degraded states). */
|
|
15
|
+
fast: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Default pulse configuration map for common statuses. */
|
|
19
|
+
export const defaultPulseConfigMap: Record<string, PulseConfig> = {
|
|
20
|
+
online: { dot: 'bg-[hsl(var(--status-ok))]', ring: 'bg-[hsl(var(--status-ok))]', pulse: true, fast: false },
|
|
21
|
+
degraded: { dot: 'bg-[hsl(var(--status-warning))]', ring: 'bg-[hsl(var(--status-warning))]', pulse: true, fast: true },
|
|
22
|
+
offline: { dot: 'bg-[hsl(var(--status-critical))]', ring: 'bg-[hsl(var(--status-critical))]', pulse: false, fast: false },
|
|
23
|
+
unknown: { dot: 'bg-[hsl(var(--text-tertiary))]', ring: 'bg-[hsl(var(--text-tertiary))]', pulse: false, fast: false },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StatusPulseProps {
|
|
27
|
+
/** Status key to look up in the config map. */
|
|
28
|
+
status: string
|
|
29
|
+
/** Whether to show the status label text. */
|
|
30
|
+
label?: boolean
|
|
31
|
+
/** Custom pulse configuration map. Falls back to defaultPulseConfigMap. */
|
|
32
|
+
configMap?: Record<string, PulseConfig>
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @description An animated status indicator dot with optional pulse ring and label.
|
|
38
|
+
* Accepts a configurable map to define custom statuses. Respects prefers-reduced-motion.
|
|
39
|
+
*/
|
|
40
|
+
export function StatusPulse({ status, label = true, configMap, className }: StatusPulseProps) {
|
|
41
|
+
const reduced = useReducedMotion()
|
|
42
|
+
const map = configMap ?? defaultPulseConfigMap
|
|
43
|
+
const cfg = map[status] ?? map['unknown'] ?? defaultPulseConfigMap['unknown']!
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('flex items-center gap-2', className)}>
|
|
47
|
+
<div className="relative flex size-2.5 shrink-0">
|
|
48
|
+
{/* Pulse ring -- CSS animation only */}
|
|
49
|
+
{cfg.pulse && !reduced && (
|
|
50
|
+
<span
|
|
51
|
+
className={cn(
|
|
52
|
+
'absolute inline-flex size-full rounded-full opacity-75 animate-pulse-ring',
|
|
53
|
+
cfg.ring,
|
|
54
|
+
cfg.fast && '[animation-duration:1s]',
|
|
55
|
+
)}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
{/* Solid dot */}
|
|
59
|
+
<motion.span
|
|
60
|
+
className={cn('relative inline-flex size-2.5 rounded-full', cfg.dot)}
|
|
61
|
+
animate={{ scale: 1 }}
|
|
62
|
+
whileHover={{ scale: reduced ? 1 : 1.3 }}
|
|
63
|
+
transition={{ type: 'spring', stiffness: 400, damping: 15 }}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
{label && (
|
|
67
|
+
<motion.span
|
|
68
|
+
key={status}
|
|
69
|
+
initial={reduced ? {} : { opacity: 0 }}
|
|
70
|
+
animate={{ opacity: 1 }}
|
|
71
|
+
className="text-xs font-medium capitalize text-[hsl(var(--text-secondary))]"
|
|
72
|
+
>
|
|
73
|
+
{status}
|
|
74
|
+
</motion.span>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|