@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,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
+ }