@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,141 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ElementType } from 'react'
|
|
4
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { TrendingUp, TrendingDown } from 'lucide-react'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
import { AnimatedCounter } from './animated-counter'
|
|
8
|
+
import { Sparkline } from './sparkline'
|
|
9
|
+
|
|
10
|
+
export interface MetricCardProps {
|
|
11
|
+
/** Metric display label. */
|
|
12
|
+
label: string
|
|
13
|
+
/** Current numeric value. */
|
|
14
|
+
value: number
|
|
15
|
+
/** Custom formatter for the displayed value (e.g. fmtBytes, fmtBps). */
|
|
16
|
+
format?: (n: number) => string
|
|
17
|
+
/** Previous value for trend calculation. */
|
|
18
|
+
previousValue?: number
|
|
19
|
+
/** Interpret trend direction for coloring. */
|
|
20
|
+
trendDirection?: 'up-good' | 'up-bad' | 'down-good' | 'down-bad'
|
|
21
|
+
/** Lucide icon component to display. */
|
|
22
|
+
icon?: ElementType
|
|
23
|
+
/** Status determines left border accent color. */
|
|
24
|
+
status?: 'ok' | 'warning' | 'critical'
|
|
25
|
+
/** Recent values to render an inline sparkline. */
|
|
26
|
+
sparklineData?: number[]
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const statusBorder: Record<string, string> = {
|
|
31
|
+
ok: 'border-l-[hsl(var(--status-ok))]',
|
|
32
|
+
warning: 'border-l-[hsl(var(--status-warning))]',
|
|
33
|
+
critical: 'border-l-[hsl(var(--status-critical))]',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const trendColors: Record<string, string> = {
|
|
37
|
+
good: 'text-[hsl(var(--status-ok))]',
|
|
38
|
+
bad: 'text-[hsl(var(--status-critical))]',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @description A dashboard stat tile showing a metric value with animated counter,
|
|
43
|
+
* trend indicator, optional sparkline, and status-colored left border.
|
|
44
|
+
* Designed for monitoring dashboards and overview panels.
|
|
45
|
+
*/
|
|
46
|
+
export function MetricCard({
|
|
47
|
+
label,
|
|
48
|
+
value,
|
|
49
|
+
format,
|
|
50
|
+
previousValue,
|
|
51
|
+
trendDirection,
|
|
52
|
+
icon: Icon,
|
|
53
|
+
status,
|
|
54
|
+
sparklineData,
|
|
55
|
+
className,
|
|
56
|
+
}: MetricCardProps) {
|
|
57
|
+
const reduced = useReducedMotion()
|
|
58
|
+
|
|
59
|
+
// Trend calculation
|
|
60
|
+
let trendPct: number | null = null
|
|
61
|
+
let trendUp = false
|
|
62
|
+
let trendColorKey: 'good' | 'bad' | null = null
|
|
63
|
+
|
|
64
|
+
if (previousValue != null && previousValue !== 0) {
|
|
65
|
+
trendPct = ((value - previousValue) / Math.abs(previousValue)) * 100
|
|
66
|
+
trendUp = trendPct >= 0
|
|
67
|
+
|
|
68
|
+
if (trendDirection) {
|
|
69
|
+
const isUp = trendPct >= 0
|
|
70
|
+
if (trendDirection === 'up-good') trendColorKey = isUp ? 'good' : 'bad'
|
|
71
|
+
else if (trendDirection === 'up-bad') trendColorKey = isUp ? 'bad' : 'good'
|
|
72
|
+
else if (trendDirection === 'down-good') trendColorKey = isUp ? 'bad' : 'good'
|
|
73
|
+
else if (trendDirection === 'down-bad') trendColorKey = isUp ? 'good' : 'bad'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<motion.div
|
|
79
|
+
initial={reduced ? false : { opacity: 0, y: 8 }}
|
|
80
|
+
animate={{ opacity: 1, y: 0 }}
|
|
81
|
+
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
82
|
+
className={cn(
|
|
83
|
+
'relative rounded-2xl border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))]',
|
|
84
|
+
'p-5 shadow-sm border-l-[3px]',
|
|
85
|
+
status ? statusBorder[status] : 'border-l-[hsl(var(--border-subtle))]',
|
|
86
|
+
className,
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<div className="flex items-start justify-between gap-3">
|
|
90
|
+
<div className="min-w-0 flex-1">
|
|
91
|
+
<div className="flex items-center gap-2 mb-1">
|
|
92
|
+
{Icon && (
|
|
93
|
+
<Icon className="size-4 shrink-0 text-[hsl(var(--text-tertiary))]" />
|
|
94
|
+
)}
|
|
95
|
+
<span className="text-[0.75rem] font-medium text-[hsl(var(--text-secondary))] truncate">
|
|
96
|
+
{label}
|
|
97
|
+
</span>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="flex items-baseline gap-2">
|
|
101
|
+
<span className="text-2xl font-semibold text-[hsl(var(--text-primary))] tabular-nums">
|
|
102
|
+
<AnimatedCounter value={value} format={format} />
|
|
103
|
+
</span>
|
|
104
|
+
|
|
105
|
+
{trendPct != null && (
|
|
106
|
+
<span
|
|
107
|
+
className={cn(
|
|
108
|
+
'inline-flex items-center gap-0.5 text-[0.6875rem] font-medium tabular-nums',
|
|
109
|
+
trendColorKey ? trendColors[trendColorKey] : 'text-[hsl(var(--text-tertiary))]',
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{trendUp ? (
|
|
113
|
+
<TrendingUp className="size-3" />
|
|
114
|
+
) : (
|
|
115
|
+
<TrendingDown className="size-3" />
|
|
116
|
+
)}
|
|
117
|
+
{Math.abs(trendPct).toFixed(1)}%
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{sparklineData && sparklineData.length >= 2 && (
|
|
124
|
+
<Sparkline
|
|
125
|
+
data={sparklineData}
|
|
126
|
+
width={72}
|
|
127
|
+
height={28}
|
|
128
|
+
color={
|
|
129
|
+
status === 'critical'
|
|
130
|
+
? 'hsl(var(--status-critical))'
|
|
131
|
+
: status === 'warning'
|
|
132
|
+
? 'hsl(var(--status-warning))'
|
|
133
|
+
: 'hsl(var(--brand-primary))'
|
|
134
|
+
}
|
|
135
|
+
fillOpacity={0.15}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</motion.div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ElementType } from 'react'
|
|
4
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { ChevronRight } from 'lucide-react'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
|
|
8
|
+
export interface StageInfo {
|
|
9
|
+
/** Stage display name. */
|
|
10
|
+
name: string
|
|
11
|
+
/** Current stage status. */
|
|
12
|
+
status: 'active' | 'idle' | 'error' | 'disabled'
|
|
13
|
+
/** Optional metric to display inside the stage box. */
|
|
14
|
+
metric?: { label: string; value: string }
|
|
15
|
+
/** Lucide icon component for the stage. */
|
|
16
|
+
icon?: ElementType
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PipelineStageProps {
|
|
20
|
+
/** Ordered array of processing stages. */
|
|
21
|
+
stages: StageInfo[]
|
|
22
|
+
/** Callback when a stage is clicked. */
|
|
23
|
+
onStageClick?: (stage: StageInfo, index: number) => void
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const statusDot: Record<string, string> = {
|
|
28
|
+
active: 'bg-[hsl(var(--status-ok))]',
|
|
29
|
+
idle: 'bg-[hsl(var(--text-tertiary))]',
|
|
30
|
+
error: 'bg-[hsl(var(--status-critical))]',
|
|
31
|
+
disabled: 'bg-[hsl(var(--text-disabled))]',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const statusBorder: Record<string, string> = {
|
|
35
|
+
active: 'border-[hsl(var(--status-ok))]/30',
|
|
36
|
+
idle: 'border-[hsl(var(--border-default))]',
|
|
37
|
+
error: 'border-[hsl(var(--status-critical))]/30',
|
|
38
|
+
disabled: 'border-[hsl(var(--border-subtle))]',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const statusBg: Record<string, string> = {
|
|
42
|
+
active: 'bg-[hsl(var(--bg-surface))]',
|
|
43
|
+
idle: 'bg-[hsl(var(--bg-surface))]',
|
|
44
|
+
error: 'bg-[hsl(var(--status-critical))]/5',
|
|
45
|
+
disabled: 'bg-[hsl(var(--bg-elevated))]',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @description A horizontal data pipeline visualization showing processing stages
|
|
50
|
+
* connected by animated chevron arrows. Each stage displays name, status dot,
|
|
51
|
+
* optional icon, and optional metric. Designed for data pipeline monitoring views.
|
|
52
|
+
*/
|
|
53
|
+
export function PipelineStage({
|
|
54
|
+
stages,
|
|
55
|
+
onStageClick,
|
|
56
|
+
className,
|
|
57
|
+
}: PipelineStageProps) {
|
|
58
|
+
const reduced = useReducedMotion()
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn('flex items-center gap-0 overflow-x-auto', className)}>
|
|
62
|
+
{stages.map((stage, i) => {
|
|
63
|
+
const Icon = stage.icon
|
|
64
|
+
const isDisabled = stage.status === 'disabled'
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div key={`${stage.name}-${i}`} className="flex items-center shrink-0">
|
|
68
|
+
{/* Arrow connector */}
|
|
69
|
+
{i > 0 && (
|
|
70
|
+
<motion.div
|
|
71
|
+
initial={reduced ? false : { opacity: 0, x: -4 }}
|
|
72
|
+
animate={{ opacity: 1, x: 0 }}
|
|
73
|
+
transition={{ duration: 0.15, delay: reduced ? 0 : i * 0.05 }}
|
|
74
|
+
className="px-1"
|
|
75
|
+
>
|
|
76
|
+
<ChevronRight className="size-4 text-[hsl(var(--text-tertiary))]" />
|
|
77
|
+
</motion.div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Stage box */}
|
|
81
|
+
<motion.button
|
|
82
|
+
type="button"
|
|
83
|
+
initial={reduced ? false : { opacity: 0, y: 6 }}
|
|
84
|
+
animate={{ opacity: 1, y: 0 }}
|
|
85
|
+
transition={{ duration: 0.2, delay: reduced ? 0 : i * 0.06 }}
|
|
86
|
+
onClick={() => onStageClick?.(stage, i)}
|
|
87
|
+
disabled={isDisabled && !onStageClick}
|
|
88
|
+
className={cn(
|
|
89
|
+
'flex flex-col items-start gap-1.5 px-4 py-3 rounded-xl border',
|
|
90
|
+
'transition-all min-w-[120px]',
|
|
91
|
+
statusBorder[stage.status],
|
|
92
|
+
statusBg[stage.status],
|
|
93
|
+
onStageClick && !isDisabled && 'cursor-pointer hover:bg-[hsl(var(--bg-elevated))]',
|
|
94
|
+
isDisabled && 'opacity-50',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{/* Header row: icon + name + status dot */}
|
|
98
|
+
<div className="flex items-center gap-2 w-full">
|
|
99
|
+
{Icon && (
|
|
100
|
+
<Icon className={cn('size-3.5 shrink-0', isDisabled ? 'text-[hsl(var(--text-disabled))]' : 'text-[hsl(var(--text-secondary))]')} />
|
|
101
|
+
)}
|
|
102
|
+
<span className={cn(
|
|
103
|
+
'text-[0.8125rem] font-medium truncate',
|
|
104
|
+
isDisabled ? 'text-[hsl(var(--text-disabled))]' : 'text-[hsl(var(--text-primary))]',
|
|
105
|
+
)}>
|
|
106
|
+
{stage.name}
|
|
107
|
+
</span>
|
|
108
|
+
<span
|
|
109
|
+
className={cn(
|
|
110
|
+
'size-2 rounded-full shrink-0 ml-auto',
|
|
111
|
+
statusDot[stage.status],
|
|
112
|
+
stage.status === 'active' && 'animate-pulse',
|
|
113
|
+
)}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Metric */}
|
|
118
|
+
{stage.metric && (
|
|
119
|
+
<div className="flex items-baseline gap-1">
|
|
120
|
+
<span className="text-[0.875rem] font-semibold text-[hsl(var(--text-primary))] tabular-nums">
|
|
121
|
+
{stage.metric.value}
|
|
122
|
+
</span>
|
|
123
|
+
<span className="text-[0.625rem] text-[hsl(var(--text-tertiary))]">
|
|
124
|
+
{stage.metric.label}
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</motion.button>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react'
|
|
4
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
|
5
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
|
|
8
|
+
export interface PopoverProps {
|
|
9
|
+
/** Trigger element that opens the popover. */
|
|
10
|
+
trigger: ReactNode
|
|
11
|
+
/** Popover content. */
|
|
12
|
+
children: ReactNode
|
|
13
|
+
/** Side of the trigger to display the popover. */
|
|
14
|
+
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
15
|
+
/** Alignment of the popover relative to the trigger. */
|
|
16
|
+
align?: 'start' | 'center' | 'end'
|
|
17
|
+
/** Additional class name for the content container. */
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const contentVariants = {
|
|
22
|
+
hidden: { opacity: 0, scale: 0.96, y: -2 },
|
|
23
|
+
visible: { opacity: 1, scale: 1, y: 0 },
|
|
24
|
+
exit: { opacity: 0, scale: 0.96, y: -2 },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @description A popover wrapper built on Radix Popover with Framer Motion entry animation.
|
|
29
|
+
* Closes on outside click. Includes an arrow pointer.
|
|
30
|
+
*/
|
|
31
|
+
export function Popover({ trigger, children, side = 'bottom', align = 'center', className }: PopoverProps) {
|
|
32
|
+
const prefersReducedMotion = useReducedMotion()
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<PopoverPrimitive.Root>
|
|
36
|
+
<PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
|
|
37
|
+
|
|
38
|
+
<AnimatePresence>
|
|
39
|
+
<PopoverPrimitive.Portal>
|
|
40
|
+
<PopoverPrimitive.Content
|
|
41
|
+
side={side}
|
|
42
|
+
align={align}
|
|
43
|
+
sideOffset={8}
|
|
44
|
+
asChild
|
|
45
|
+
>
|
|
46
|
+
<motion.div
|
|
47
|
+
initial="hidden"
|
|
48
|
+
animate="visible"
|
|
49
|
+
exit="exit"
|
|
50
|
+
variants={contentVariants}
|
|
51
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
|
52
|
+
className={cn(
|
|
53
|
+
'z-50 rounded-xl p-4',
|
|
54
|
+
'border border-[hsl(var(--border-default))]',
|
|
55
|
+
'bg-[hsl(var(--bg-elevated))] shadow-xl',
|
|
56
|
+
'focus:outline-none',
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
<PopoverPrimitive.Arrow
|
|
62
|
+
className="fill-[hsl(var(--bg-elevated))]"
|
|
63
|
+
width={12}
|
|
64
|
+
height={6}
|
|
65
|
+
/>
|
|
66
|
+
</motion.div>
|
|
67
|
+
</PopoverPrimitive.Content>
|
|
68
|
+
</PopoverPrimitive.Portal>
|
|
69
|
+
</AnimatePresence>
|
|
70
|
+
</PopoverPrimitive.Root>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
export interface PortStatus {
|
|
7
|
+
/** Unique port identifier. */
|
|
8
|
+
id: string
|
|
9
|
+
/** Display label (e.g. "Gi1/0/1"). */
|
|
10
|
+
label: string
|
|
11
|
+
/** Port operational status. */
|
|
12
|
+
status: 'up' | 'down' | 'disabled' | 'error'
|
|
13
|
+
/** Link speed display string (e.g. "10G", "1G"). */
|
|
14
|
+
speed?: string
|
|
15
|
+
/** Port utilization 0-100 percentage. */
|
|
16
|
+
utilization?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PortStatusGridProps {
|
|
20
|
+
/** Array of port status objects. */
|
|
21
|
+
ports: PortStatus[]
|
|
22
|
+
/** Number of columns. Defaults to auto-fit. */
|
|
23
|
+
columns?: number
|
|
24
|
+
/** Dot/rectangle size. */
|
|
25
|
+
size?: 'sm' | 'md'
|
|
26
|
+
/** Callback when a port is clicked. */
|
|
27
|
+
onPortClick?: (port: PortStatus) => void
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const statusColor: Record<string, string> = {
|
|
32
|
+
up: 'bg-[hsl(var(--status-ok))]',
|
|
33
|
+
down: 'bg-[hsl(var(--status-critical))]',
|
|
34
|
+
disabled: 'bg-[hsl(var(--text-disabled))]',
|
|
35
|
+
error: 'bg-[hsl(var(--status-warning))]',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const statusHover: Record<string, string> = {
|
|
39
|
+
up: 'hover:ring-[hsl(var(--status-ok))]/40',
|
|
40
|
+
down: 'hover:ring-[hsl(var(--status-critical))]/40',
|
|
41
|
+
disabled: 'hover:ring-[hsl(var(--text-disabled))]/40',
|
|
42
|
+
error: 'hover:ring-[hsl(var(--status-warning))]/40',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sizeClasses = {
|
|
46
|
+
sm: 'size-3 rounded-[2px]',
|
|
47
|
+
md: 'size-5 rounded-[3px]',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @description A grid of small colored indicators representing network ports or
|
|
52
|
+
* interfaces. Each port is colored by operational status with hover tooltips showing
|
|
53
|
+
* label, speed, and utilization. Designed for switch/router faceplate visualizations.
|
|
54
|
+
*/
|
|
55
|
+
export function PortStatusGrid({
|
|
56
|
+
ports,
|
|
57
|
+
columns,
|
|
58
|
+
size = 'sm',
|
|
59
|
+
onPortClick,
|
|
60
|
+
className,
|
|
61
|
+
}: PortStatusGridProps) {
|
|
62
|
+
const reduced = useReducedMotion()
|
|
63
|
+
|
|
64
|
+
const gridStyle = columns
|
|
65
|
+
? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }
|
|
66
|
+
: { gridTemplateColumns: `repeat(auto-fill, minmax(${size === 'sm' ? '12px' : '20px'}, 1fr))` }
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className={cn('grid gap-1', className)}
|
|
71
|
+
style={gridStyle}
|
|
72
|
+
role="grid"
|
|
73
|
+
aria-label="Port status grid"
|
|
74
|
+
>
|
|
75
|
+
{ports.map((port, i) => {
|
|
76
|
+
const tooltipParts = [port.label]
|
|
77
|
+
if (port.speed) tooltipParts.push(port.speed)
|
|
78
|
+
if (port.utilization != null) tooltipParts.push(`${port.utilization.toFixed(0)}%`)
|
|
79
|
+
tooltipParts.push(port.status)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<motion.button
|
|
83
|
+
key={port.id}
|
|
84
|
+
type="button"
|
|
85
|
+
initial={reduced ? false : { opacity: 0, scale: 0.5 }}
|
|
86
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
87
|
+
transition={{ duration: 0.1, delay: reduced ? 0 : Math.min(i * 0.008, 0.4) }}
|
|
88
|
+
onClick={() => onPortClick?.(port)}
|
|
89
|
+
title={tooltipParts.join(' \u00b7 ')}
|
|
90
|
+
aria-label={`${port.label}: ${port.status}`}
|
|
91
|
+
className={cn(
|
|
92
|
+
'transition-all cursor-pointer ring-0 hover:ring-2',
|
|
93
|
+
sizeClasses[size],
|
|
94
|
+
statusColor[port.status] ?? statusColor.disabled,
|
|
95
|
+
statusHover[port.status] ?? statusHover.disabled,
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
4
|
+
import { cn } from '../utils'
|
|
5
|
+
|
|
6
|
+
export interface ProgressProps {
|
|
7
|
+
/** Current progress value (0-100 by default). */
|
|
8
|
+
value: number
|
|
9
|
+
/** Maximum value. */
|
|
10
|
+
max?: number
|
|
11
|
+
/** Optional label displayed above the bar. */
|
|
12
|
+
label?: string
|
|
13
|
+
/** Show the current percentage value. */
|
|
14
|
+
showValue?: boolean
|
|
15
|
+
/** Color variant. */
|
|
16
|
+
variant?: 'default' | 'success' | 'warning' | 'danger'
|
|
17
|
+
/** Height preset. */
|
|
18
|
+
size?: 'sm' | 'md' | 'lg'
|
|
19
|
+
/** Animate the fill width on value changes. */
|
|
20
|
+
animated?: boolean
|
|
21
|
+
/** Show an indeterminate shimmer animation (ignores value). */
|
|
22
|
+
indeterminate?: boolean
|
|
23
|
+
/** Additional class name for the root element. */
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const variantColors: Record<NonNullable<ProgressProps['variant']>, string> = {
|
|
28
|
+
default: 'bg-[hsl(var(--brand-primary))]',
|
|
29
|
+
success: 'bg-[hsl(var(--status-ok))]',
|
|
30
|
+
warning: 'bg-[hsl(var(--status-warning))]',
|
|
31
|
+
danger: 'bg-[hsl(var(--status-critical))]',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sizeClasses: Record<NonNullable<ProgressProps['size']>, string> = {
|
|
35
|
+
sm: 'h-1.5',
|
|
36
|
+
md: 'h-2.5',
|
|
37
|
+
lg: 'h-4',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @description A progress bar with optional label, animated fill, and indeterminate mode.
|
|
42
|
+
* Supports multiple color variants and size presets.
|
|
43
|
+
*/
|
|
44
|
+
export function Progress({
|
|
45
|
+
value,
|
|
46
|
+
max = 100,
|
|
47
|
+
label,
|
|
48
|
+
showValue = false,
|
|
49
|
+
variant = 'default',
|
|
50
|
+
size = 'md',
|
|
51
|
+
animated = true,
|
|
52
|
+
indeterminate = false,
|
|
53
|
+
className,
|
|
54
|
+
}: ProgressProps) {
|
|
55
|
+
const prefersReducedMotion = useReducedMotion()
|
|
56
|
+
const pct = Math.min(100, Math.max(0, (value / max) * 100))
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('w-full', className)}>
|
|
60
|
+
{/* Label + value row */}
|
|
61
|
+
{(label || showValue) && (
|
|
62
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
63
|
+
{label && (
|
|
64
|
+
<span className="text-xs font-medium text-[hsl(var(--text-secondary))]">{label}</span>
|
|
65
|
+
)}
|
|
66
|
+
{showValue && !indeterminate && (
|
|
67
|
+
<span className="text-xs font-medium tabular-nums text-[hsl(var(--text-secondary))]">
|
|
68
|
+
{Math.round(pct)}%
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{/* Track */}
|
|
75
|
+
<div
|
|
76
|
+
role="progressbar"
|
|
77
|
+
aria-valuenow={indeterminate ? undefined : pct}
|
|
78
|
+
aria-valuemin={0}
|
|
79
|
+
aria-valuemax={100}
|
|
80
|
+
aria-label={label}
|
|
81
|
+
className={cn(
|
|
82
|
+
'w-full overflow-hidden rounded-full bg-[hsl(var(--bg-overlay))]',
|
|
83
|
+
sizeClasses[size],
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{indeterminate ? (
|
|
87
|
+
<div
|
|
88
|
+
className={cn(
|
|
89
|
+
'h-full w-1/3 rounded-full',
|
|
90
|
+
variantColors[variant],
|
|
91
|
+
prefersReducedMotion ? '' : 'animate-shimmer',
|
|
92
|
+
)}
|
|
93
|
+
style={
|
|
94
|
+
prefersReducedMotion
|
|
95
|
+
? { width: '33%' }
|
|
96
|
+
: {
|
|
97
|
+
animation: 'indeterminate-slide 1.5s ease-in-out infinite',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
) : animated && !prefersReducedMotion ? (
|
|
102
|
+
<motion.div
|
|
103
|
+
className={cn('h-full rounded-full', variantColors[variant])}
|
|
104
|
+
initial={{ width: 0 }}
|
|
105
|
+
animate={{ width: `${pct}%` }}
|
|
106
|
+
transition={{ type: 'spring', stiffness: 100, damping: 20 }}
|
|
107
|
+
/>
|
|
108
|
+
) : (
|
|
109
|
+
<div
|
|
110
|
+
className={cn('h-full rounded-full transition-[width] duration-300', variantColors[variant])}
|
|
111
|
+
style={{ width: `${pct}%` }}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Indeterminate keyframes injected as inline style */}
|
|
117
|
+
{indeterminate && !prefersReducedMotion && (
|
|
118
|
+
<style>{`
|
|
119
|
+
@keyframes indeterminate-slide {
|
|
120
|
+
0% { transform: translateX(-100%); }
|
|
121
|
+
50% { transform: translateX(200%); }
|
|
122
|
+
100% { transform: translateX(-100%); }
|
|
123
|
+
}
|
|
124
|
+
`}</style>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|