@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/chunk-5OKSXPWK.js +270 -0
  4. package/dist/chunk-5OKSXPWK.js.map +1 -0
  5. package/dist/cli/index.js +430 -0
  6. package/dist/form.d.ts +65 -0
  7. package/dist/form.js +148 -0
  8. package/dist/form.js.map +1 -0
  9. package/dist/index.d.ts +942 -0
  10. package/dist/index.js +2812 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/select-nnBJUO8U.d.ts +26 -0
  13. package/package.json +114 -0
  14. package/src/components/animated-counter.stories.tsx +68 -0
  15. package/src/components/animated-counter.tsx +85 -0
  16. package/src/components/avatar.tsx +106 -0
  17. package/src/components/badge.stories.tsx +70 -0
  18. package/src/components/badge.tsx +97 -0
  19. package/src/components/button.stories.tsx +101 -0
  20. package/src/components/button.tsx +67 -0
  21. package/src/components/card.tsx +128 -0
  22. package/src/components/checkbox.stories.tsx +64 -0
  23. package/src/components/checkbox.tsx +58 -0
  24. package/src/components/confirm-dialog.stories.tsx +96 -0
  25. package/src/components/confirm-dialog.tsx +145 -0
  26. package/src/components/data-table.stories.tsx +125 -0
  27. package/src/components/data-table.tsx +791 -0
  28. package/src/components/dropdown-menu.tsx +111 -0
  29. package/src/components/empty-state.stories.tsx +42 -0
  30. package/src/components/empty-state.tsx +43 -0
  31. package/src/components/filter-pill.stories.tsx +71 -0
  32. package/src/components/filter-pill.tsx +45 -0
  33. package/src/components/form-input.stories.tsx +91 -0
  34. package/src/components/form-input.tsx +77 -0
  35. package/src/components/log-viewer.tsx +212 -0
  36. package/src/components/metric-card.tsx +141 -0
  37. package/src/components/pipeline-stage.tsx +134 -0
  38. package/src/components/popover.tsx +72 -0
  39. package/src/components/port-status-grid.tsx +102 -0
  40. package/src/components/progress.tsx +128 -0
  41. package/src/components/radio-group.tsx +162 -0
  42. package/src/components/select.stories.tsx +52 -0
  43. package/src/components/select.tsx +92 -0
  44. package/src/components/severity-timeline.tsx +125 -0
  45. package/src/components/sheet.tsx +164 -0
  46. package/src/components/skeleton.stories.tsx +64 -0
  47. package/src/components/skeleton.tsx +62 -0
  48. package/src/components/slider.tsx +208 -0
  49. package/src/components/sparkline.tsx +104 -0
  50. package/src/components/status-badge.stories.tsx +84 -0
  51. package/src/components/status-badge.tsx +71 -0
  52. package/src/components/status-pulse.stories.tsx +56 -0
  53. package/src/components/status-pulse.tsx +78 -0
  54. package/src/components/success-checkmark.stories.tsx +67 -0
  55. package/src/components/success-checkmark.tsx +53 -0
  56. package/src/components/tabs.tsx +177 -0
  57. package/src/components/threshold-gauge.tsx +149 -0
  58. package/src/components/time-range-selector.tsx +86 -0
  59. package/src/components/toast.stories.tsx +70 -0
  60. package/src/components/toast.tsx +48 -0
  61. package/src/components/toggle-switch.stories.tsx +66 -0
  62. package/src/components/toggle-switch.tsx +51 -0
  63. package/src/components/tooltip.tsx +62 -0
  64. package/src/components/truncated-text.stories.tsx +56 -0
  65. package/src/components/truncated-text.tsx +80 -0
  66. package/src/components/uptime-tracker.tsx +138 -0
  67. package/src/components/utilization-bar.tsx +103 -0
  68. package/src/theme.css +178 -0
  69. 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
+ }