@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,162 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, type KeyboardEvent } from 'react'
|
|
4
|
+
import { motion, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
/** A single radio option definition. */
|
|
8
|
+
export interface RadioOption {
|
|
9
|
+
/** Unique value. */
|
|
10
|
+
value: string
|
|
11
|
+
/** Display label. */
|
|
12
|
+
label: string
|
|
13
|
+
/** Optional description text below the label. */
|
|
14
|
+
description?: string
|
|
15
|
+
/** Whether this option is disabled. */
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RadioGroupProps {
|
|
20
|
+
/** Array of radio option definitions. */
|
|
21
|
+
options: RadioOption[]
|
|
22
|
+
/** Currently selected value. */
|
|
23
|
+
value: string
|
|
24
|
+
/** Callback when selection changes. */
|
|
25
|
+
onChange: (value: string) => void
|
|
26
|
+
/** Layout direction. */
|
|
27
|
+
orientation?: 'horizontal' | 'vertical'
|
|
28
|
+
/** Additional class name for the root element. */
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @description A custom-styled radio button group with Framer Motion selection indicator.
|
|
34
|
+
* Supports keyboard navigation (arrow keys, Home, End) and ARIA roles.
|
|
35
|
+
*/
|
|
36
|
+
export function RadioGroup({
|
|
37
|
+
options,
|
|
38
|
+
value,
|
|
39
|
+
onChange,
|
|
40
|
+
orientation = 'vertical',
|
|
41
|
+
className,
|
|
42
|
+
}: RadioGroupProps) {
|
|
43
|
+
const prefersReducedMotion = useReducedMotion()
|
|
44
|
+
const groupRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
|
|
46
|
+
const enabledOptions = options.filter((o) => !o.disabled)
|
|
47
|
+
|
|
48
|
+
const handleKeyDown = useCallback(
|
|
49
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
50
|
+
const currentIdx = enabledOptions.findIndex((o) => o.value === value)
|
|
51
|
+
if (currentIdx === -1) return
|
|
52
|
+
|
|
53
|
+
const isVertical = orientation === 'vertical'
|
|
54
|
+
const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight'
|
|
55
|
+
const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft'
|
|
56
|
+
|
|
57
|
+
let nextIdx: number | null = null
|
|
58
|
+
|
|
59
|
+
if (e.key === nextKey) {
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
nextIdx = (currentIdx + 1) % enabledOptions.length
|
|
62
|
+
} else if (e.key === prevKey) {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
nextIdx = (currentIdx - 1 + enabledOptions.length) % enabledOptions.length
|
|
65
|
+
} else if (e.key === 'Home') {
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
nextIdx = 0
|
|
68
|
+
} else if (e.key === 'End') {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
nextIdx = enabledOptions.length - 1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (nextIdx !== null) {
|
|
74
|
+
onChange(enabledOptions[nextIdx].value)
|
|
75
|
+
const radios = groupRef.current?.querySelectorAll<HTMLDivElement>('[role="radio"]:not([aria-disabled="true"])')
|
|
76
|
+
radios?.[nextIdx]?.focus()
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[enabledOptions, value, onChange, orientation],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={groupRef}
|
|
85
|
+
role="radiogroup"
|
|
86
|
+
aria-orientation={orientation}
|
|
87
|
+
onKeyDown={handleKeyDown}
|
|
88
|
+
className={cn(
|
|
89
|
+
'flex',
|
|
90
|
+
orientation === 'vertical' ? 'flex-col gap-2' : 'flex-row flex-wrap gap-4',
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{options.map((option) => {
|
|
95
|
+
const isSelected = option.value === value
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
key={option.value}
|
|
100
|
+
role="radio"
|
|
101
|
+
aria-checked={isSelected}
|
|
102
|
+
aria-disabled={option.disabled}
|
|
103
|
+
tabIndex={isSelected ? 0 : -1}
|
|
104
|
+
onClick={() => {
|
|
105
|
+
if (!option.disabled) onChange(option.value)
|
|
106
|
+
}}
|
|
107
|
+
className={cn(
|
|
108
|
+
'flex items-start gap-3 cursor-pointer select-none group',
|
|
109
|
+
'focus-visible:outline-none',
|
|
110
|
+
option.disabled && 'opacity-40 pointer-events-none',
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{/* Radio circle */}
|
|
114
|
+
<div
|
|
115
|
+
className={cn(
|
|
116
|
+
'relative mt-0.5 h-[18px] w-[18px] shrink-0 rounded-full border-2 transition-colors duration-150',
|
|
117
|
+
'flex items-center justify-center',
|
|
118
|
+
isSelected
|
|
119
|
+
? 'border-[hsl(var(--brand-primary))]'
|
|
120
|
+
: 'border-[hsl(var(--border-strong))] group-hover:border-[hsl(var(--brand-primary))]',
|
|
121
|
+
'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))]',
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{isSelected && (
|
|
125
|
+
<motion.div
|
|
126
|
+
layoutId="radio-indicator"
|
|
127
|
+
initial={prefersReducedMotion ? false : { scale: 0 }}
|
|
128
|
+
animate={{ scale: 1 }}
|
|
129
|
+
transition={
|
|
130
|
+
prefersReducedMotion
|
|
131
|
+
? { duration: 0 }
|
|
132
|
+
: { type: 'spring', stiffness: 500, damping: 30 }
|
|
133
|
+
}
|
|
134
|
+
className="h-2.5 w-2.5 rounded-full bg-[hsl(var(--brand-primary))]"
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Label + description */}
|
|
140
|
+
<div className="min-w-0">
|
|
141
|
+
<span
|
|
142
|
+
className={cn(
|
|
143
|
+
'text-sm font-medium',
|
|
144
|
+
isSelected
|
|
145
|
+
? 'text-[hsl(var(--text-primary))]'
|
|
146
|
+
: 'text-[hsl(var(--text-secondary))]',
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{option.label}
|
|
150
|
+
</span>
|
|
151
|
+
{option.description && (
|
|
152
|
+
<p className="mt-0.5 text-xs text-[hsl(var(--text-tertiary))]">
|
|
153
|
+
{option.description}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Select } from './select'
|
|
4
|
+
|
|
5
|
+
const sampleOptions = [
|
|
6
|
+
{ value: 'us-east', label: 'US East' },
|
|
7
|
+
{ value: 'us-west', label: 'US West' },
|
|
8
|
+
{ value: 'eu-central', label: 'EU Central' },
|
|
9
|
+
{ value: 'ap-south', label: 'AP South' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const meta: Meta<typeof Select> = {
|
|
13
|
+
title: 'Components/Select',
|
|
14
|
+
component: Select,
|
|
15
|
+
tags: ['autodocs'],
|
|
16
|
+
argTypes: {
|
|
17
|
+
disabled: { control: 'boolean' },
|
|
18
|
+
placeholder: { control: 'text' },
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj<typeof Select>
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: (args) => {
|
|
26
|
+
const [value, setValue] = useState('us-east')
|
|
27
|
+
return <Select {...args} value={value} onValueChange={setValue} options={sampleOptions} />
|
|
28
|
+
},
|
|
29
|
+
args: {},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const WithPlaceholder: Story = {
|
|
33
|
+
render: (args) => {
|
|
34
|
+
const [value, setValue] = useState('')
|
|
35
|
+
return (
|
|
36
|
+
<Select
|
|
37
|
+
{...args}
|
|
38
|
+
value={value}
|
|
39
|
+
onValueChange={setValue}
|
|
40
|
+
options={sampleOptions}
|
|
41
|
+
placeholder="Select a region..."
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Disabled: Story = {
|
|
48
|
+
render: () => {
|
|
49
|
+
const [value, setValue] = useState('eu-central')
|
|
50
|
+
return <Select value={value} onValueChange={setValue} options={sampleOptions} disabled />
|
|
51
|
+
},
|
|
52
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as RadixSelect from '@radix-ui/react-select'
|
|
4
|
+
import { ChevronDown, Check } from 'lucide-react'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
export interface SelectOption {
|
|
8
|
+
value: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SelectProps {
|
|
13
|
+
/** Currently selected value. */
|
|
14
|
+
value: string
|
|
15
|
+
/** Callback when selection changes. */
|
|
16
|
+
onValueChange: (v: string) => void
|
|
17
|
+
/** Available options. */
|
|
18
|
+
options: SelectOption[]
|
|
19
|
+
/** Placeholder text when no value is selected. */
|
|
20
|
+
placeholder?: string
|
|
21
|
+
className?: string
|
|
22
|
+
/** Disable the select. */
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @description A themed select dropdown built on Radix UI Select.
|
|
28
|
+
* Supports dark/light mode via CSS custom property tokens.
|
|
29
|
+
*/
|
|
30
|
+
export function Select({
|
|
31
|
+
value, onValueChange, options, placeholder, className, disabled,
|
|
32
|
+
}: SelectProps) {
|
|
33
|
+
return (
|
|
34
|
+
<RadixSelect.Root value={value} onValueChange={onValueChange} disabled={disabled}>
|
|
35
|
+
<RadixSelect.Trigger
|
|
36
|
+
suppressHydrationWarning
|
|
37
|
+
className={cn(
|
|
38
|
+
'flex w-full items-center justify-between gap-2 rounded-lg',
|
|
39
|
+
'border border-[hsl(var(--border-default))] bg-[hsl(var(--bg-base))]',
|
|
40
|
+
'px-3 py-2 text-sm text-[hsl(var(--text-primary))]',
|
|
41
|
+
'hover:border-[hsl(var(--border-strong))] focus:outline-none',
|
|
42
|
+
'focus:ring-2 focus:ring-[hsl(var(--brand-primary))]',
|
|
43
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
44
|
+
'data-[placeholder]:text-[hsl(var(--text-tertiary))]',
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<RadixSelect.Value placeholder={placeholder} />
|
|
49
|
+
<RadixSelect.Icon>
|
|
50
|
+
<ChevronDown className="size-4 text-[hsl(var(--text-tertiary))] shrink-0" />
|
|
51
|
+
</RadixSelect.Icon>
|
|
52
|
+
</RadixSelect.Trigger>
|
|
53
|
+
|
|
54
|
+
<RadixSelect.Portal>
|
|
55
|
+
<RadixSelect.Content
|
|
56
|
+
position="popper"
|
|
57
|
+
sideOffset={4}
|
|
58
|
+
className={cn(
|
|
59
|
+
'z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden',
|
|
60
|
+
'rounded-xl border border-[hsl(var(--border-default))]',
|
|
61
|
+
'bg-[hsl(var(--bg-elevated))] shadow-xl',
|
|
62
|
+
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
|
63
|
+
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<RadixSelect.Viewport className="p-1">
|
|
67
|
+
{options.map((opt) => (
|
|
68
|
+
<RadixSelect.Item
|
|
69
|
+
key={opt.value}
|
|
70
|
+
value={opt.value}
|
|
71
|
+
className={cn(
|
|
72
|
+
'relative flex cursor-pointer select-none items-center',
|
|
73
|
+
'rounded-lg py-2 pl-8 pr-3 text-sm',
|
|
74
|
+
'text-[hsl(var(--text-primary))]',
|
|
75
|
+
'outline-none',
|
|
76
|
+
'hover:bg-[hsl(var(--bg-overlay))]',
|
|
77
|
+
'focus:bg-[hsl(var(--brand-primary))]/10 focus:text-[hsl(var(--brand-primary))]',
|
|
78
|
+
'data-[state=checked]:text-[hsl(var(--brand-primary))]',
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<RadixSelect.ItemIndicator className="absolute left-2 flex items-center">
|
|
82
|
+
<Check className="size-4" />
|
|
83
|
+
</RadixSelect.ItemIndicator>
|
|
84
|
+
<RadixSelect.ItemText>{opt.label}</RadixSelect.ItemText>
|
|
85
|
+
</RadixSelect.Item>
|
|
86
|
+
))}
|
|
87
|
+
</RadixSelect.Viewport>
|
|
88
|
+
</RadixSelect.Content>
|
|
89
|
+
</RadixSelect.Portal>
|
|
90
|
+
</RadixSelect.Root>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react'
|
|
4
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { cn } from '../utils'
|
|
6
|
+
|
|
7
|
+
export interface TimelineEvent {
|
|
8
|
+
/** ISO 8601 timestamp. */
|
|
9
|
+
time: string
|
|
10
|
+
/** Short event label. */
|
|
11
|
+
label: string
|
|
12
|
+
/** Severity determines dot color. */
|
|
13
|
+
severity: 'critical' | 'warning' | 'info' | 'ok'
|
|
14
|
+
/** Optional detail text shown on click/hover. */
|
|
15
|
+
detail?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SeverityTimelineProps {
|
|
19
|
+
/** Events to display (most recent should be first). */
|
|
20
|
+
events: TimelineEvent[]
|
|
21
|
+
/** Maximum number of visible events. */
|
|
22
|
+
maxVisible?: number
|
|
23
|
+
/** Callback when an event dot/label is clicked. */
|
|
24
|
+
onEventClick?: (event: TimelineEvent) => void
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const severityDot: Record<string, string> = {
|
|
29
|
+
critical: 'bg-[hsl(var(--status-critical))]',
|
|
30
|
+
warning: 'bg-[hsl(var(--status-warning))]',
|
|
31
|
+
info: 'bg-[hsl(var(--brand-primary))]',
|
|
32
|
+
ok: 'bg-[hsl(var(--status-ok))]',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const severityRing: Record<string, string> = {
|
|
36
|
+
critical: 'ring-[hsl(var(--status-critical))]/30',
|
|
37
|
+
warning: 'ring-[hsl(var(--status-warning))]/30',
|
|
38
|
+
info: 'ring-[hsl(var(--brand-primary))]/30',
|
|
39
|
+
ok: 'ring-[hsl(var(--status-ok))]/30',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatTime(iso: string): string {
|
|
43
|
+
try {
|
|
44
|
+
const d = new Date(iso)
|
|
45
|
+
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
46
|
+
} catch {
|
|
47
|
+
return iso
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @description A horizontal scrollable timeline showing events with severity-colored
|
|
53
|
+
* dots. Most recent events appear on the left. Click events for detail callbacks.
|
|
54
|
+
* Designed for alert timelines and incident history strips.
|
|
55
|
+
*/
|
|
56
|
+
export function SeverityTimeline({
|
|
57
|
+
events,
|
|
58
|
+
maxVisible = 20,
|
|
59
|
+
onEventClick,
|
|
60
|
+
className,
|
|
61
|
+
}: SeverityTimelineProps) {
|
|
62
|
+
const reduced = useReducedMotion()
|
|
63
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
64
|
+
const visible = events.slice(0, maxVisible)
|
|
65
|
+
|
|
66
|
+
if (visible.length === 0) return null
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={scrollRef}
|
|
71
|
+
className={cn(
|
|
72
|
+
'relative overflow-x-auto scrollbar-thin py-2',
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<div className="flex items-start gap-0 min-w-max">
|
|
77
|
+
<AnimatePresence initial={false}>
|
|
78
|
+
{visible.map((ev, i) => (
|
|
79
|
+
<motion.div
|
|
80
|
+
key={`${ev.time}-${i}`}
|
|
81
|
+
initial={reduced ? false : { opacity: 0, scale: 0.8 }}
|
|
82
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
83
|
+
exit={{ opacity: 0, scale: 0.8 }}
|
|
84
|
+
transition={{ duration: 0.15, delay: reduced ? 0 : i * 0.03 }}
|
|
85
|
+
className="flex flex-col items-center relative"
|
|
86
|
+
style={{ minWidth: 64 }}
|
|
87
|
+
>
|
|
88
|
+
{/* Connector line */}
|
|
89
|
+
{i < visible.length - 1 && (
|
|
90
|
+
<div
|
|
91
|
+
className="absolute top-[11px] left-1/2 h-px bg-[hsl(var(--border-default))]"
|
|
92
|
+
style={{ width: 64 }}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Dot */}
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => onEventClick?.(ev)}
|
|
100
|
+
className={cn(
|
|
101
|
+
'relative z-10 size-[10px] rounded-full ring-4 shrink-0',
|
|
102
|
+
'transition-transform hover:scale-125 cursor-pointer',
|
|
103
|
+
severityDot[ev.severity] ?? severityDot.info,
|
|
104
|
+
severityRing[ev.severity] ?? severityRing.info,
|
|
105
|
+
)}
|
|
106
|
+
title={ev.detail ?? ev.label}
|
|
107
|
+
aria-label={`${ev.label} — ${ev.severity}`}
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
{/* Label */}
|
|
111
|
+
<span className="mt-1.5 text-[0.6875rem] font-medium text-[hsl(var(--text-primary))] text-center max-w-[56px] truncate">
|
|
112
|
+
{ev.label}
|
|
113
|
+
</span>
|
|
114
|
+
|
|
115
|
+
{/* Timestamp */}
|
|
116
|
+
<span className="text-[0.625rem] text-[hsl(var(--text-tertiary))] tabular-nums">
|
|
117
|
+
{formatTime(ev.time)}
|
|
118
|
+
</span>
|
|
119
|
+
</motion.div>
|
|
120
|
+
))}
|
|
121
|
+
</AnimatePresence>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useCallback, useRef, type ReactNode } from 'react'
|
|
4
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { X } from 'lucide-react'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
|
|
8
|
+
export interface SheetProps {
|
|
9
|
+
/** Whether the sheet is open. */
|
|
10
|
+
open: boolean
|
|
11
|
+
/** Callback to close the sheet. */
|
|
12
|
+
onClose: () => void
|
|
13
|
+
/** Edge from which the sheet slides in. */
|
|
14
|
+
side?: 'right' | 'left' | 'top' | 'bottom'
|
|
15
|
+
/** Title displayed in the sheet header. */
|
|
16
|
+
title?: string
|
|
17
|
+
/** Description text below the title. */
|
|
18
|
+
description?: string
|
|
19
|
+
/** Width class for left/right sheets, height class for top/bottom. */
|
|
20
|
+
width?: string
|
|
21
|
+
/** Sheet content. */
|
|
22
|
+
children: ReactNode
|
|
23
|
+
/** Additional class name for the panel. */
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const slideVariants = {
|
|
28
|
+
right: { initial: { x: '100%' }, animate: { x: 0 }, exit: { x: '100%' } },
|
|
29
|
+
left: { initial: { x: '-100%' }, animate: { x: 0 }, exit: { x: '-100%' } },
|
|
30
|
+
top: { initial: { y: '-100%' }, animate: { y: 0 }, exit: { y: '-100%' } },
|
|
31
|
+
bottom: { initial: { y: '100%' }, animate: { y: 0 }, exit: { y: '100%' } },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const positionClasses = {
|
|
35
|
+
right: 'inset-y-0 right-0',
|
|
36
|
+
left: 'inset-y-0 left-0',
|
|
37
|
+
top: 'inset-x-0 top-0',
|
|
38
|
+
bottom: 'inset-x-0 bottom-0',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @description A slide-over panel (drawer) from any edge of the screen.
|
|
43
|
+
* Features backdrop overlay, spring animation, Escape to close, backdrop click to close,
|
|
44
|
+
* and focus trapping within the panel.
|
|
45
|
+
*/
|
|
46
|
+
export function Sheet({
|
|
47
|
+
open,
|
|
48
|
+
onClose,
|
|
49
|
+
side = 'right',
|
|
50
|
+
title,
|
|
51
|
+
description,
|
|
52
|
+
width = 'max-w-md',
|
|
53
|
+
children,
|
|
54
|
+
className,
|
|
55
|
+
}: SheetProps) {
|
|
56
|
+
const prefersReducedMotion = useReducedMotion()
|
|
57
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
58
|
+
|
|
59
|
+
// Close on Escape
|
|
60
|
+
const handleKeyDown = useCallback(
|
|
61
|
+
(e: KeyboardEvent) => {
|
|
62
|
+
if (e.key === 'Escape') onClose()
|
|
63
|
+
},
|
|
64
|
+
[onClose],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (open) {
|
|
69
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
70
|
+
// Prevent body scroll
|
|
71
|
+
const prevOverflow = document.body.style.overflow
|
|
72
|
+
document.body.style.overflow = 'hidden'
|
|
73
|
+
return () => {
|
|
74
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
75
|
+
document.body.style.overflow = prevOverflow
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, [open, handleKeyDown])
|
|
79
|
+
|
|
80
|
+
// Focus the panel when opened
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (open) {
|
|
83
|
+
requestAnimationFrame(() => {
|
|
84
|
+
panelRef.current?.focus()
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}, [open])
|
|
88
|
+
|
|
89
|
+
const isHorizontal = side === 'left' || side === 'right'
|
|
90
|
+
const variants = slideVariants[side]
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<AnimatePresence>
|
|
94
|
+
{open && (
|
|
95
|
+
<div className="fixed inset-0 z-50">
|
|
96
|
+
{/* Backdrop */}
|
|
97
|
+
<motion.div
|
|
98
|
+
initial={{ opacity: 0 }}
|
|
99
|
+
animate={{ opacity: 1 }}
|
|
100
|
+
exit={{ opacity: 0 }}
|
|
101
|
+
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2 }}
|
|
102
|
+
className="absolute inset-0 bg-[hsl(var(--bg-base)/0.6)] backdrop-blur-sm"
|
|
103
|
+
onClick={onClose}
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
{/* Panel */}
|
|
107
|
+
<motion.div
|
|
108
|
+
ref={panelRef}
|
|
109
|
+
tabIndex={-1}
|
|
110
|
+
role="dialog"
|
|
111
|
+
aria-modal="true"
|
|
112
|
+
aria-label={title}
|
|
113
|
+
initial={variants.initial}
|
|
114
|
+
animate={variants.animate}
|
|
115
|
+
exit={variants.exit}
|
|
116
|
+
transition={
|
|
117
|
+
prefersReducedMotion
|
|
118
|
+
? { duration: 0 }
|
|
119
|
+
: { type: 'spring', stiffness: 400, damping: 35 }
|
|
120
|
+
}
|
|
121
|
+
className={cn(
|
|
122
|
+
'absolute flex flex-col',
|
|
123
|
+
'bg-[hsl(var(--bg-surface))] border-[hsl(var(--border-default))]',
|
|
124
|
+
'shadow-2xl focus:outline-none',
|
|
125
|
+
positionClasses[side],
|
|
126
|
+
isHorizontal
|
|
127
|
+
? cn('w-full h-full', width, side === 'right' ? 'border-l' : 'border-r')
|
|
128
|
+
: cn('h-auto w-full', side === 'top' ? 'border-b' : 'border-t'),
|
|
129
|
+
className,
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
{/* Header */}
|
|
133
|
+
{(title || description) && (
|
|
134
|
+
<div className="flex items-start justify-between gap-4 px-6 pt-6 pb-4 border-b border-[hsl(var(--border-subtle))]">
|
|
135
|
+
<div className="min-w-0">
|
|
136
|
+
{title && (
|
|
137
|
+
<h2 className="text-base font-semibold text-[hsl(var(--text-primary))]">{title}</h2>
|
|
138
|
+
)}
|
|
139
|
+
{description && (
|
|
140
|
+
<p className="mt-1 text-sm text-[hsl(var(--text-secondary))]">{description}</p>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
<button
|
|
144
|
+
onClick={onClose}
|
|
145
|
+
className={cn(
|
|
146
|
+
'shrink-0 p-1.5 rounded-lg transition-colors',
|
|
147
|
+
'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-primary))]',
|
|
148
|
+
'hover:bg-[hsl(var(--bg-elevated))]',
|
|
149
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--brand-primary))]',
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
<X className="h-4 w-4" />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Content */}
|
|
158
|
+
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
|
159
|
+
</motion.div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</AnimatePresence>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { Skeleton, SkeletonText, SkeletonCard } from './skeleton'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Skeleton> = {
|
|
5
|
+
title: 'Components/Skeleton',
|
|
6
|
+
component: Skeleton,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
}
|
|
9
|
+
export default meta
|
|
10
|
+
|
|
11
|
+
type SkeletonStory = StoryObj<typeof Skeleton>
|
|
12
|
+
type SkeletonTextStory = StoryObj<typeof SkeletonText>
|
|
13
|
+
type SkeletonCardStory = StoryObj<typeof SkeletonCard>
|
|
14
|
+
|
|
15
|
+
export const Default: SkeletonStory = {
|
|
16
|
+
render: () => <Skeleton className="h-4 w-48" />,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const VariousSizes: SkeletonStory = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', width: '300px' }}>
|
|
22
|
+
<Skeleton className="h-3 w-24" />
|
|
23
|
+
<Skeleton className="h-4 w-48" />
|
|
24
|
+
<Skeleton className="h-6 w-64" />
|
|
25
|
+
<Skeleton className="h-8 w-full" />
|
|
26
|
+
<Skeleton className="h-12 w-12 rounded-full" />
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const TextSingleLine: SkeletonTextStory = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<div style={{ width: '300px' }}>
|
|
34
|
+
<SkeletonText lines={1} />
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TextMultiLine: SkeletonTextStory = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<div style={{ width: '300px' }}>
|
|
42
|
+
<SkeletonText lines={4} />
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Card: SkeletonCardStory = {
|
|
48
|
+
render: () => (
|
|
49
|
+
<div style={{ width: '320px' }}>
|
|
50
|
+
<SkeletonCard />
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const CardGrid: SkeletonCardStory = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 280px)', gap: '0.75rem' }}>
|
|
58
|
+
<SkeletonCard />
|
|
59
|
+
<SkeletonCard />
|
|
60
|
+
<SkeletonCard />
|
|
61
|
+
<SkeletonCard />
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
}
|