@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,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'
|