@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,67 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useState } from 'react'
3
+ import { SuccessCheckmark } from './success-checkmark'
4
+ import { Button } from './button'
5
+
6
+ const meta: Meta<typeof SuccessCheckmark> = {
7
+ title: 'Components/SuccessCheckmark',
8
+ component: SuccessCheckmark,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ size: { control: { type: 'range', min: 16, max: 120, step: 4 } },
12
+ },
13
+ }
14
+ export default meta
15
+ type Story = StoryObj<typeof SuccessCheckmark>
16
+
17
+ export const Default: Story = {
18
+ args: { size: 20 },
19
+ }
20
+
21
+ export const Large: Story = {
22
+ args: { size: 64 },
23
+ }
24
+
25
+ export const ExtraLarge: Story = {
26
+ args: { size: 96 },
27
+ }
28
+
29
+ export const WithMessage: Story = {
30
+ render: () => (
31
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
32
+ <SuccessCheckmark size={48} />
33
+ <span style={{ fontSize: '1rem', fontWeight: 600, color: 'hsl(210 40% 95%)' }}>
34
+ Device added successfully
35
+ </span>
36
+ <span style={{ fontSize: '0.75rem', color: 'hsl(210 20% 70%)' }}>
37
+ SNMP collection will begin within 60 seconds.
38
+ </span>
39
+ </div>
40
+ ),
41
+ }
42
+
43
+ export const ReplayAnimation: Story = {
44
+ render: () => {
45
+ const [key, setKey] = useState(0)
46
+ return (
47
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
48
+ <SuccessCheckmark key={key} size={48} />
49
+ <Button size="sm" variant="secondary" onClick={() => setKey(k => k + 1)}>
50
+ Replay Animation
51
+ </Button>
52
+ </div>
53
+ )
54
+ },
55
+ }
56
+
57
+ export const Sizes: Story = {
58
+ render: () => (
59
+ <div style={{ display: 'flex', gap: '1.5rem', alignItems: 'center' }}>
60
+ <SuccessCheckmark size={16} />
61
+ <SuccessCheckmark size={20} />
62
+ <SuccessCheckmark size={32} />
63
+ <SuccessCheckmark size={48} />
64
+ <SuccessCheckmark size={64} />
65
+ </div>
66
+ ),
67
+ }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { motion, useReducedMotion } from 'framer-motion'
4
+
5
+ export interface SuccessCheckmarkProps {
6
+ /** Size of the SVG in pixels. */
7
+ size?: number
8
+ className?: string
9
+ }
10
+
11
+ /**
12
+ * @description An animated success checkmark SVG with circle and path draw animations.
13
+ * Uses Framer Motion spring physics. Respects prefers-reduced-motion.
14
+ */
15
+ export function SuccessCheckmark({ size = 20, className }: SuccessCheckmarkProps) {
16
+ const reduced = useReducedMotion()
17
+
18
+ return (
19
+ <motion.svg
20
+ width={size}
21
+ height={size}
22
+ viewBox="0 0 20 20"
23
+ fill="none"
24
+ className={className}
25
+ initial={reduced ? false : { scale: 0, opacity: 0 }}
26
+ animate={{ scale: 1, opacity: 1 }}
27
+ transition={{ type: 'spring', stiffness: 300, damping: 20 }}
28
+ >
29
+ <motion.circle
30
+ cx="10"
31
+ cy="10"
32
+ r="9"
33
+ stroke="hsl(var(--status-ok))"
34
+ strokeWidth="1.5"
35
+ fill="none"
36
+ initial={reduced ? false : { pathLength: 0 }}
37
+ animate={{ pathLength: 1 }}
38
+ transition={{ duration: 0.4, ease: 'easeOut' }}
39
+ />
40
+ <motion.path
41
+ d="M6 10l2.5 2.5L14 7.5"
42
+ stroke="hsl(var(--status-ok))"
43
+ strokeWidth="1.5"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ fill="none"
47
+ initial={reduced ? false : { pathLength: 0 }}
48
+ animate={{ pathLength: 1 }}
49
+ transition={{ duration: 0.3, delay: 0.2, ease: 'easeOut' }}
50
+ />
51
+ </motion.svg>
52
+ )
53
+ }
@@ -0,0 +1,177 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, type KeyboardEvent } from 'react'
4
+ import { motion, useReducedMotion } from 'framer-motion'
5
+ import type { LucideIcon } from 'lucide-react'
6
+ import { cn } from '../utils'
7
+
8
+ /** A single tab definition. */
9
+ export interface Tab {
10
+ /** Unique value for this tab. */
11
+ value: string
12
+ /** Display label. */
13
+ label: string
14
+ /** Optional icon component. */
15
+ icon?: LucideIcon
16
+ /** Whether this tab is disabled. */
17
+ disabled?: boolean
18
+ }
19
+
20
+ export interface TabsProps {
21
+ /** Array of tab definitions. */
22
+ tabs: Tab[]
23
+ /** Currently selected tab value. */
24
+ value: string
25
+ /** Callback when a tab is selected. */
26
+ onChange: (value: string) => void
27
+ /** Visual variant. */
28
+ variant?: 'underline' | 'pills' | 'enclosed'
29
+ /** Size preset. */
30
+ size?: 'sm' | 'md'
31
+ /** Additional class name for the root element. */
32
+ className?: string
33
+ }
34
+
35
+ const sizeClasses = {
36
+ sm: 'text-xs px-3 py-1.5 gap-1.5',
37
+ md: 'text-sm px-4 py-2 gap-2',
38
+ } as const
39
+
40
+ /**
41
+ * @description Accessible tabbed interface with three visual variants.
42
+ * Uses Framer Motion layoutId for animated indicator sliding between tabs.
43
+ * Supports keyboard navigation (arrow keys) and ARIA roles.
44
+ */
45
+ export function Tabs({
46
+ tabs,
47
+ value,
48
+ onChange,
49
+ variant = 'underline',
50
+ size = 'md',
51
+ className,
52
+ }: TabsProps) {
53
+ const prefersReducedMotion = useReducedMotion()
54
+ const tabListRef = useRef<HTMLDivElement>(null)
55
+
56
+ const enabledTabs = tabs.filter((t) => !t.disabled)
57
+
58
+ const handleKeyDown = useCallback(
59
+ (e: KeyboardEvent<HTMLDivElement>) => {
60
+ const currentIdx = enabledTabs.findIndex((t) => t.value === value)
61
+ if (currentIdx === -1) return
62
+
63
+ let nextIdx: number | null = null
64
+
65
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
66
+ e.preventDefault()
67
+ nextIdx = (currentIdx + 1) % enabledTabs.length
68
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
69
+ e.preventDefault()
70
+ nextIdx = (currentIdx - 1 + enabledTabs.length) % enabledTabs.length
71
+ } else if (e.key === 'Home') {
72
+ e.preventDefault()
73
+ nextIdx = 0
74
+ } else if (e.key === 'End') {
75
+ e.preventDefault()
76
+ nextIdx = enabledTabs.length - 1
77
+ }
78
+
79
+ if (nextIdx !== null) {
80
+ onChange(enabledTabs[nextIdx].value)
81
+ const buttons = tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])')
82
+ buttons?.[nextIdx]?.focus()
83
+ }
84
+ },
85
+ [enabledTabs, value, onChange],
86
+ )
87
+
88
+ const listCls = cn(
89
+ 'inline-flex items-center',
90
+ variant === 'underline' && 'border-b border-[hsl(var(--border-subtle))] gap-0',
91
+ variant === 'pills' && 'gap-1 p-1 rounded-xl bg-[hsl(var(--bg-surface))]',
92
+ variant === 'enclosed' && 'gap-0 border-b border-[hsl(var(--border-subtle))]',
93
+ className,
94
+ )
95
+
96
+ const layoutId = `tabs-indicator-${variant}`
97
+
98
+ return (
99
+ <div
100
+ ref={tabListRef}
101
+ role="tablist"
102
+ aria-orientation="horizontal"
103
+ onKeyDown={handleKeyDown}
104
+ className={listCls}
105
+ >
106
+ {tabs.map((tab) => {
107
+ const isActive = tab.value === value
108
+ const Icon = tab.icon
109
+
110
+ const buttonCls = cn(
111
+ 'relative inline-flex items-center justify-center font-medium transition-colors duration-150',
112
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--brand-primary))] focus-visible:ring-offset-1 focus-visible:ring-offset-[hsl(var(--bg-base))]',
113
+ 'disabled:pointer-events-none disabled:opacity-40',
114
+ 'cursor-pointer select-none whitespace-nowrap',
115
+ sizeClasses[size],
116
+ // variant-specific active/inactive
117
+ variant === 'underline' && [
118
+ isActive
119
+ ? 'text-[hsl(var(--text-primary))]'
120
+ : 'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-secondary))]',
121
+ ],
122
+ variant === 'pills' && [
123
+ isActive
124
+ ? 'text-[hsl(var(--text-primary))]'
125
+ : 'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-secondary))] hover:bg-[hsl(var(--bg-elevated)/0.5)]',
126
+ 'rounded-lg',
127
+ ],
128
+ variant === 'enclosed' && [
129
+ isActive
130
+ ? 'text-[hsl(var(--text-primary))] bg-[hsl(var(--bg-elevated))]'
131
+ : 'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-secondary))]',
132
+ 'rounded-t-lg',
133
+ ],
134
+ )
135
+
136
+ return (
137
+ <button
138
+ key={tab.value}
139
+ role="tab"
140
+ aria-selected={isActive}
141
+ aria-controls={`tabpanel-${tab.value}`}
142
+ tabIndex={isActive ? 0 : -1}
143
+ disabled={tab.disabled}
144
+ onClick={() => onChange(tab.value)}
145
+ className={buttonCls}
146
+ >
147
+ {Icon && <Icon className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} />}
148
+ {tab.label}
149
+
150
+ {/* Animated indicator */}
151
+ {isActive && variant === 'underline' && (
152
+ <motion.div
153
+ layoutId={layoutId}
154
+ className="absolute bottom-0 left-0 right-0 h-0.5 bg-[hsl(var(--brand-primary))] rounded-full"
155
+ transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 500, damping: 35 }}
156
+ />
157
+ )}
158
+ {isActive && variant === 'pills' && (
159
+ <motion.div
160
+ layoutId={layoutId}
161
+ className="absolute inset-0 rounded-lg bg-[hsl(var(--bg-elevated))] -z-10"
162
+ transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 500, damping: 35 }}
163
+ />
164
+ )}
165
+ {isActive && variant === 'enclosed' && (
166
+ <motion.div
167
+ layoutId={layoutId}
168
+ className="absolute bottom-0 left-0 right-0 h-0.5 bg-[hsl(var(--brand-primary))] rounded-full"
169
+ transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 500, damping: 35 }}
170
+ />
171
+ )}
172
+ </button>
173
+ )
174
+ })}
175
+ </div>
176
+ )
177
+ }
@@ -0,0 +1,149 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useReducedMotion } from 'framer-motion'
5
+ import { cn, clamp } from '../utils'
6
+
7
+ export interface ThresholdGaugeProps {
8
+ /** Gauge value from 0 to 100. */
9
+ value: number
10
+ /** Label displayed below the gauge. */
11
+ label?: string
12
+ /** Warning and critical thresholds. */
13
+ thresholds?: { warning: number; critical: number }
14
+ /** Gauge diameter in pixels. */
15
+ size?: number
16
+ /** Show the numeric value in the center. */
17
+ showValue?: boolean
18
+ /** Custom formatter for the center value. */
19
+ format?: (n: number) => string
20
+ className?: string
21
+ }
22
+
23
+ const ARC_START = 135 // degrees, bottom-left
24
+ const ARC_SWEEP = 270 // degrees, total arc span
25
+
26
+ function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
27
+ const rad = ((angleDeg - 90) * Math.PI) / 180
28
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
29
+ }
30
+
31
+ function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string {
32
+ const start = polarToCartesian(cx, cy, r, endAngle)
33
+ const end = polarToCartesian(cx, cy, r, startAngle)
34
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0
35
+ return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y}`
36
+ }
37
+
38
+ function getArcColor(pct: number, warning: number, critical: number): string {
39
+ if (pct >= critical) return 'hsl(var(--util-high))'
40
+ if (pct >= warning) return 'hsl(var(--util-medium))'
41
+ return 'hsl(var(--util-low))'
42
+ }
43
+
44
+ /**
45
+ * @description A semicircular SVG gauge with color-coded threshold zones
46
+ * (green/yellow/red). Animates the arc on mount. Displays value in center.
47
+ */
48
+ export function ThresholdGauge({
49
+ value: rawValue,
50
+ label,
51
+ thresholds,
52
+ size = 120,
53
+ showValue = true,
54
+ format,
55
+ className,
56
+ }: ThresholdGaugeProps) {
57
+ const reduced = useReducedMotion()
58
+ const value = clamp(rawValue, 0, 100)
59
+ const warning = thresholds?.warning ?? 70
60
+ const critical = thresholds?.critical ?? 90
61
+
62
+ const [animatedValue, setAnimatedValue] = useState(reduced ? value : 0)
63
+
64
+ useEffect(() => {
65
+ if (reduced) {
66
+ setAnimatedValue(value)
67
+ return
68
+ }
69
+ const start = performance.now()
70
+ const from = 0
71
+ const dur = 600
72
+ let raf: number
73
+
74
+ function tick(now: number) {
75
+ const progress = Math.min((now - start) / dur, 1)
76
+ const eased = 1 - Math.pow(1 - progress, 3) // ease-out cubic
77
+ setAnimatedValue(from + (value - from) * eased)
78
+ if (progress < 1) raf = requestAnimationFrame(tick)
79
+ }
80
+ raf = requestAnimationFrame(tick)
81
+ return () => cancelAnimationFrame(raf)
82
+ }, [value, reduced])
83
+
84
+ const cx = size / 2
85
+ const cy = size / 2
86
+ const r = (size - 16) / 2
87
+ const strokeWidth = Math.max(6, size * 0.08)
88
+
89
+ // Background zone arcs
90
+ const warnAngle = ARC_START + (warning / 100) * ARC_SWEEP
91
+ const critAngle = ARC_START + (critical / 100) * ARC_SWEEP
92
+ const endAngle = ARC_START + ARC_SWEEP
93
+
94
+ const greenArc = describeArc(cx, cy, r, ARC_START, warnAngle)
95
+ const yellowArc = describeArc(cx, cy, r, warnAngle, critAngle)
96
+ const redArc = describeArc(cx, cy, r, critAngle, endAngle)
97
+
98
+ // Value arc
99
+ const valueAngle = ARC_START + (animatedValue / 100) * ARC_SWEEP
100
+ const valueArc = animatedValue > 0.5
101
+ ? describeArc(cx, cy, r, ARC_START, valueAngle)
102
+ : ''
103
+
104
+ const displayText = format ? format(value) : `${Math.round(value)}%`
105
+
106
+ return (
107
+ <div className={cn('inline-flex flex-col items-center', className)}>
108
+ <svg width={size} height={size * 0.75} viewBox={`0 0 ${size} ${size * 0.75}`} aria-hidden="true">
109
+ {/* Background zones */}
110
+ <path d={greenArc} fill="none" stroke="hsl(var(--util-low))" strokeWidth={strokeWidth} strokeLinecap="round" opacity={0.2} />
111
+ <path d={yellowArc} fill="none" stroke="hsl(var(--util-medium))" strokeWidth={strokeWidth} strokeLinecap="round" opacity={0.2} />
112
+ <path d={redArc} fill="none" stroke="hsl(var(--util-high))" strokeWidth={strokeWidth} strokeLinecap="round" opacity={0.2} />
113
+
114
+ {/* Value arc */}
115
+ {valueArc && (
116
+ <path
117
+ d={valueArc}
118
+ fill="none"
119
+ stroke={getArcColor(animatedValue, warning, critical)}
120
+ strokeWidth={strokeWidth}
121
+ strokeLinecap="round"
122
+ />
123
+ )}
124
+
125
+ {/* Center value */}
126
+ {showValue && (
127
+ <text
128
+ x={cx}
129
+ y={cy - 2}
130
+ textAnchor="middle"
131
+ dominantBaseline="central"
132
+ fill="hsl(var(--text-primary))"
133
+ fontSize={size * 0.2}
134
+ fontWeight={600}
135
+ fontFamily="inherit"
136
+ className="tabular-nums"
137
+ >
138
+ {displayText}
139
+ </text>
140
+ )}
141
+ </svg>
142
+ {label && (
143
+ <span className="text-[0.75rem] font-medium text-[hsl(var(--text-secondary))] mt-1">
144
+ {label}
145
+ </span>
146
+ )}
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import { motion, useReducedMotion } from 'framer-motion'
4
+ import { cn } from '../utils'
5
+
6
+ export interface TimeRange {
7
+ /** Display label (e.g. "1h", "24h"). */
8
+ label: string
9
+ /** Unique value identifier. */
10
+ value: string
11
+ /** Duration in seconds represented by this range. */
12
+ seconds: number
13
+ }
14
+
15
+ export interface TimeRangeSelectorProps {
16
+ /** Currently selected range value. */
17
+ value: string
18
+ /** Callback when a range is selected. */
19
+ onChange: (value: string, range: TimeRange) => void
20
+ /** Custom range options. Defaults to 1h, 6h, 24h, 7d, 30d. */
21
+ ranges?: TimeRange[]
22
+ className?: string
23
+ }
24
+
25
+ const defaultRanges: TimeRange[] = [
26
+ { label: '1h', value: '1h', seconds: 3600 },
27
+ { label: '6h', value: '6h', seconds: 21600 },
28
+ { label: '24h', value: '24h', seconds: 86400 },
29
+ { label: '7d', value: '7d', seconds: 604800 },
30
+ { label: '30d', value: '30d', seconds: 2592000 },
31
+ ]
32
+
33
+ /**
34
+ * @description A compact horizontal pill-group time range selector for dashboards.
35
+ * Active selection is highlighted with a sliding indicator using the brand color.
36
+ * Common in monitoring dashboards for controlling chart time windows.
37
+ */
38
+ export function TimeRangeSelector({
39
+ value,
40
+ onChange,
41
+ ranges = defaultRanges,
42
+ className,
43
+ }: TimeRangeSelectorProps) {
44
+ const reduced = useReducedMotion()
45
+
46
+ return (
47
+ <div
48
+ className={cn(
49
+ 'inline-flex items-center rounded-lg bg-[hsl(var(--bg-elevated))] p-0.5',
50
+ 'border border-[hsl(var(--border-subtle))]',
51
+ className,
52
+ )}
53
+ role="radiogroup"
54
+ aria-label="Time range"
55
+ >
56
+ {ranges.map(range => {
57
+ const isActive = range.value === value
58
+ return (
59
+ <button
60
+ key={range.value}
61
+ type="button"
62
+ role="radio"
63
+ aria-checked={isActive}
64
+ onClick={() => onChange(range.value, range)}
65
+ className={cn(
66
+ 'relative px-3 py-1 text-[0.75rem] font-medium rounded-md cursor-pointer',
67
+ 'transition-colors duration-150',
68
+ isActive
69
+ ? 'text-[hsl(var(--text-on-brand))]'
70
+ : 'text-[hsl(var(--text-secondary))] hover:text-[hsl(var(--text-primary))]',
71
+ )}
72
+ >
73
+ {isActive && (
74
+ <motion.span
75
+ layoutId="time-range-active"
76
+ className="absolute inset-0 rounded-md bg-[hsl(var(--brand-primary))]"
77
+ transition={reduced ? { duration: 0 } : { type: 'spring', stiffness: 400, damping: 30 }}
78
+ />
79
+ )}
80
+ <span className="relative z-10">{range.label}</span>
81
+ </button>
82
+ )
83
+ })}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,70 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { Toaster, toast } from './toast'
3
+ import { Button } from './button'
4
+
5
+ const meta: Meta<typeof Toaster> = {
6
+ title: 'Components/Toast',
7
+ component: Toaster,
8
+ tags: ['autodocs'],
9
+ decorators: [
10
+ (Story) => (
11
+ <div>
12
+ <Story />
13
+ <Toaster />
14
+ </div>
15
+ ),
16
+ ],
17
+ }
18
+ export default meta
19
+ type Story = StoryObj<typeof Toaster>
20
+
21
+ export const Success: Story = {
22
+ render: () => (
23
+ <Button onClick={() => toast.success('Entity saved successfully')}>
24
+ Show Success Toast
25
+ </Button>
26
+ ),
27
+ }
28
+
29
+ export const Error: Story = {
30
+ render: () => (
31
+ <Button variant="danger" onClick={() => toast.error('Failed to connect to device')}>
32
+ Show Error Toast
33
+ </Button>
34
+ ),
35
+ }
36
+
37
+ export const Info: Story = {
38
+ render: () => (
39
+ <Button variant="secondary" onClick={() => toast.info('Discovery scan started')}>
40
+ Show Info Toast
41
+ </Button>
42
+ ),
43
+ }
44
+
45
+ export const Warning: Story = {
46
+ render: () => (
47
+ <Button variant="outline" onClick={() => toast.warning('Collector response time is slow')}>
48
+ Show Warning Toast
49
+ </Button>
50
+ ),
51
+ }
52
+
53
+ export const WithDescription: Story = {
54
+ render: () => (
55
+ <Button onClick={() => toast.success('Credential saved', { description: 'SNMPv3 credential "datacenter-ro" is now available for discovery.' })}>
56
+ Toast with Description
57
+ </Button>
58
+ ),
59
+ }
60
+
61
+ export const AllTypes: Story = {
62
+ render: () => (
63
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
64
+ <Button size="sm" onClick={() => toast.success('Success message')}>Success</Button>
65
+ <Button size="sm" variant="danger" onClick={() => toast.error('Error message')}>Error</Button>
66
+ <Button size="sm" variant="outline" onClick={() => toast.warning('Warning message')}>Warning</Button>
67
+ <Button size="sm" variant="secondary" onClick={() => toast.info('Info message')}>Info</Button>
68
+ </div>
69
+ ),
70
+ }
@@ -0,0 +1,48 @@
1
+ 'use client'
2
+
3
+ import { Toaster as SonnerToaster } from 'sonner'
4
+
5
+ export interface ToasterProps {
6
+ /** Theme mode. Controls Sonner's internal theming. */
7
+ theme?: 'dark' | 'light'
8
+ /** Toast position on screen. */
9
+ position?: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center'
10
+ /** Auto-dismiss duration in milliseconds. */
11
+ duration?: number
12
+ }
13
+
14
+ /**
15
+ * @description A pre-themed Sonner toast container. Accepts theme as a prop
16
+ * instead of reading from a hook, making it portable across applications.
17
+ * Import this Toaster once in your app layout, then use `toast()` from sonner anywhere.
18
+ */
19
+ export function Toaster({ theme = 'dark', position = 'bottom-right', duration = 4000 }: ToasterProps) {
20
+ return (
21
+ <SonnerToaster
22
+ theme={theme}
23
+ position={position}
24
+ richColors
25
+ duration={duration}
26
+ gap={8}
27
+ toastOptions={{
28
+ style: {
29
+ background: 'hsl(var(--bg-elevated))',
30
+ color: 'hsl(var(--text-primary))',
31
+ border: '1px solid hsl(var(--border-default))',
32
+ borderRadius: '0.75rem',
33
+ boxShadow: '0 8px 32px hsl(0 0% 0% / 0.25)',
34
+ fontSize: '0.875rem',
35
+ },
36
+ classNames: {
37
+ success: 'toast-success',
38
+ error: 'toast-error',
39
+ warning: 'toast-warning',
40
+ info: 'toast-info',
41
+ },
42
+ }}
43
+ />
44
+ )
45
+ }
46
+
47
+ // Re-export toast function for convenient imports
48
+ export { toast } from 'sonner'