@annondeveloper/ui-kit 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@annondeveloper/ui-kit",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "A React component library with dark/light theming, built on Radix UI, Tailwind CSS v4, and Framer Motion.",
6
6
  "main": "dist/index.js",
@@ -49,7 +49,7 @@
49
49
  "react-dom": "^19.0.0",
50
50
  "react-hook-form": "^7.0.0",
51
51
  "sonner": "^2.0.0",
52
- "tailwind-merge": "^2.0.0"
52
+ "tailwind-merge": "^2.0.0 || ^3.0.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "react-hook-form": {
@@ -58,39 +58,37 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@axe-core/react": "^4.11.1",
61
- "@radix-ui/react-alert-dialog": "^1.0.0",
62
- "@radix-ui/react-dropdown-menu": "^2.0.0",
63
- "@radix-ui/react-popover": "^1.0.0",
64
- "@radix-ui/react-select": "^2.0.0",
65
- "@radix-ui/react-tooltip": "^1.0.0",
66
- "@storybook/addon-a11y": "^8.6.18",
67
- "@storybook/addon-essentials": "^8.6.14",
68
- "@storybook/blocks": "^8.6.14",
69
- "@storybook/react": "^8.6.18",
70
- "@storybook/react-vite": "^8.6.18",
71
- "@tanstack/react-table": "^8.0.0",
61
+ "@radix-ui/react-alert-dialog": "^1.1.15",
62
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
63
+ "@radix-ui/react-popover": "^1.1.15",
64
+ "@radix-ui/react-select": "^2.2.6",
65
+ "@radix-ui/react-tooltip": "^1.2.8",
66
+ "@storybook/addon-a11y": "^10.3.0",
67
+ "@storybook/react": "^10.3.0",
68
+ "@storybook/react-vite": "^10.3.0",
69
+ "@tanstack/react-table": "^8.21.3",
72
70
  "@testing-library/dom": "^10.4.1",
73
71
  "@testing-library/jest-dom": "^6.9.1",
74
72
  "@testing-library/react": "^16.3.2",
75
73
  "@testing-library/user-event": "^14.6.1",
76
74
  "@types/jest-axe": "^3.5.9",
77
- "@types/react": "^19.0.0",
78
- "@types/react-dom": "^19.0.0",
79
- "@vitejs/plugin-react": "^6.0.1",
75
+ "@types/react": "^19.2.14",
76
+ "@types/react-dom": "^19.2.3",
77
+ "@vitejs/plugin-react": "^6.0.0",
80
78
  "axe-core": "^4.11.1",
81
- "clsx": "^2.0.0",
82
- "framer-motion": "^12.0.0",
79
+ "clsx": "^2.1.1",
80
+ "framer-motion": "^12.38.0",
83
81
  "jest-axe": "^10.0.0",
84
82
  "jsdom": "^29.0.0",
85
- "lucide-react": ">=0.400.0",
86
- "react": "^19.0.0",
87
- "react-dom": "^19.0.0",
83
+ "lucide-react": ">=0.577.0",
84
+ "react": "^19.2.4",
85
+ "react-dom": "^19.2.4",
88
86
  "react-hook-form": "^7.71.2",
89
- "sonner": "^2.0.0",
90
- "storybook": "^8.6.18",
91
- "tailwind-merge": "^2.0.0",
92
- "tsup": "^8.4.0",
93
- "typescript": "^5.8.0",
87
+ "sonner": "^2.0.7",
88
+ "storybook": "^10.3.0",
89
+ "tailwind-merge": "^3.5.0",
90
+ "tsup": "^8.5.1",
91
+ "typescript": "^5.9.0",
94
92
  "vite": "^8.0.0",
95
93
  "vitest": "^4.1.0"
96
94
  },
@@ -33,11 +33,15 @@ export function AnimatedCounter({
33
33
  const prevRef = useRef(value)
34
34
  const rafRef = useRef<number | null>(null)
35
35
  const [displayed, setDisplayed] = useState(value)
36
+ const decimalPlacesRef = useRef(
37
+ Number.isInteger(value) ? 0 : (value.toString().split('.')[1]?.length ?? 1)
38
+ )
36
39
 
37
40
  useEffect(() => {
38
41
  const from = prevRef.current
39
42
  const to = value
40
43
  prevRef.current = value
44
+ decimalPlacesRef.current = Number.isInteger(to) ? 0 : (to.toString().split('.')[1]?.length ?? 1)
41
45
 
42
46
  if (reduced || from === to) {
43
47
  setDisplayed(to)
@@ -72,11 +76,9 @@ export function AnimatedCounter({
72
76
 
73
77
  const formatted = format
74
78
  ? format(displayed)
75
- : Number.isInteger(value)
79
+ : decimalPlacesRef.current === 0
76
80
  ? Math.round(displayed).toString()
77
- : displayed.toFixed(
78
- value.toString().split('.')[1]?.length ?? 1
79
- )
81
+ : displayed.toFixed(decimalPlacesRef.current)
80
82
 
81
83
  return (
82
84
  <span className={cn('tabular-nums', className)}>
@@ -88,6 +88,12 @@ function formatColor(hex: string, fmt: 'hex' | 'rgb' | 'hsl'): string {
88
88
  return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
89
89
  }
90
90
 
91
+ function isSafeColor(c: string): boolean {
92
+ return /^#[0-9a-f]{3,8}$/i.test(c) ||
93
+ /^rgba?\(\s*[\d.]+/.test(c) ||
94
+ /^hsla?\(\s*[\d.]+/.test(c)
95
+ }
96
+
91
97
  const RECENT_COLORS_KEY = 'ui-kit-recent-colors'
92
98
  const MAX_RECENT = 8
93
99
 
@@ -110,18 +116,24 @@ export function ColorInput({
110
116
  className,
111
117
  }: ColorInputProps): React.JSX.Element {
112
118
  const prefersReducedMotion = useReducedMotion()
119
+ const isValidHex = /^#[0-9a-f]{3,8}$/i.test(value)
120
+ const safeValue = isValidHex ? value : '#000000'
113
121
  const [open, setOpen] = useState(false)
114
122
  const [copied, setCopied] = useState(false)
115
123
  const [textInput, setTextInput] = useState('')
116
124
  const [alpha, setAlpha] = useState(1)
117
125
  const panelRef = useRef<HTMLDivElement>(null)
118
126
  const satAreaRef = useRef<HTMLDivElement>(null)
127
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
128
+
129
+ useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
119
130
 
120
131
  // Recent colors
121
132
  const [recentColors, setRecentColors] = useState<string[]>(() => {
122
133
  if (typeof window === 'undefined') return []
123
134
  try {
124
- return JSON.parse(localStorage.getItem(RECENT_COLORS_KEY) ?? '[]') as string[]
135
+ const raw = JSON.parse(localStorage.getItem(RECENT_COLORS_KEY) ?? '[]')
136
+ return Array.isArray(raw) ? raw.filter((x: unknown) => typeof x === 'string' && x.length < 256).slice(0, MAX_RECENT) : []
125
137
  } catch { return [] }
126
138
  })
127
139
 
@@ -134,13 +146,13 @@ export function ColorInput({
134
146
  }, [])
135
147
 
136
148
  // HSL from current value
137
- const { r, g, b } = useMemo(() => hexToRgb(value), [value])
149
+ const { r, g, b } = useMemo(() => hexToRgb(safeValue), [safeValue])
138
150
  const hsl = useMemo(() => rgbToHsl(r, g, b), [r, g, b])
139
151
 
140
152
  // Sync text input
141
153
  useEffect(() => {
142
- setTextInput(formatColor(value, format))
143
- }, [value, format])
154
+ setTextInput(formatColor(safeValue, format))
155
+ }, [safeValue, format])
144
156
 
145
157
  // Close on click outside
146
158
  useEffect(() => {
@@ -220,16 +232,17 @@ export function ColorInput({
220
232
  return
221
233
  }
222
234
  // Revert
223
- setTextInput(formatColor(value, format))
224
- }, [textInput, value, format, onChange])
235
+ setTextInput(formatColor(safeValue, format))
236
+ }, [textInput, safeValue, format, onChange])
225
237
 
226
238
  const handleCopy = useCallback(async () => {
227
239
  try {
228
- await navigator.clipboard.writeText(formatColor(value, format))
240
+ await navigator.clipboard.writeText(formatColor(safeValue, format))
229
241
  setCopied(true)
230
- setTimeout(() => setCopied(false), 1500)
242
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
243
+ copyTimerRef.current = setTimeout(() => setCopied(false), 1500)
231
244
  } catch { /* noop */ }
232
- }, [value, format])
245
+ }, [safeValue, format])
233
246
 
234
247
  // Position for sat/brightness marker
235
248
  const markerX = hsl.s * 100
@@ -256,7 +269,7 @@ export function ColorInput({
256
269
  >
257
270
  <span
258
271
  className="h-5 w-5 rounded-md border border-[hsl(var(--border-subtle))]"
259
- style={{ backgroundColor: value }}
272
+ style={{ backgroundColor: isSafeColor(safeValue) ? safeValue : undefined }}
260
273
  />
261
274
  <span className="font-mono text-xs text-[hsl(var(--text-primary))]">
262
275
  {formatColor(value, format)}
@@ -294,7 +307,7 @@ export function ColorInput({
294
307
  style={{
295
308
  left: `${markerX}%`,
296
309
  top: `${Math.max(0, Math.min(100, markerY))}%`,
297
- backgroundColor: value,
310
+ backgroundColor: isSafeColor(safeValue) ? safeValue : undefined,
298
311
  }}
299
312
  />
300
313
  </div>
@@ -325,7 +338,7 @@ export function ColorInput({
325
338
  onChange={e => setAlpha(Number(e.target.value) / 100)}
326
339
  className="w-full h-3 rounded-full appearance-none cursor-pointer"
327
340
  style={{
328
- background: `linear-gradient(to right, transparent, ${value})`,
341
+ background: `linear-gradient(to right, transparent, ${isSafeColor(safeValue) ? safeValue : '#000'})`,
329
342
  }}
330
343
  />
331
344
  </div>
@@ -376,7 +389,7 @@ export function ColorInput({
376
389
  ? 'border-[hsl(var(--brand-primary))] ring-2 ring-[hsl(var(--brand-primary)/0.3)] scale-110'
377
390
  : 'border-[hsl(var(--border-subtle))] hover:scale-110',
378
391
  )}
379
- style={{ backgroundColor: color }}
392
+ style={{ backgroundColor: isSafeColor(color) ? color : undefined }}
380
393
  title={color}
381
394
  />
382
395
  ))}
@@ -399,7 +412,7 @@ export function ColorInput({
399
412
  'h-6 w-6 rounded-md border border-[hsl(var(--border-subtle))]',
400
413
  'hover:scale-110 transition-transform',
401
414
  )}
402
- style={{ backgroundColor: color }}
415
+ style={{ backgroundColor: isSafeColor(color) ? color : undefined }}
403
416
  title={color}
404
417
  />
405
418
  ))}
@@ -115,7 +115,8 @@ export function CommandBar({
115
115
  const [recentIds, setRecentIds] = useState<string[]>(() => {
116
116
  if (typeof window === 'undefined') return []
117
117
  try {
118
- return JSON.parse(localStorage.getItem(recentKey) ?? '[]') as string[]
118
+ const raw = JSON.parse(localStorage.getItem(recentKey) ?? '[]')
119
+ return Array.isArray(raw) ? raw.filter((x: unknown) => typeof x === 'string' && x.length < 256).slice(0, maxRecent) : []
119
120
  } catch {
120
121
  return []
121
122
  }
@@ -48,6 +48,9 @@ export function CopyBlock({
48
48
  const [isCollapsed, setIsCollapsed] = useState(true)
49
49
  const [needsCollapse, setNeedsCollapse] = useState(false)
50
50
  const contentRef = useRef<HTMLPreElement>(null)
51
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
52
+
53
+ useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
51
54
 
52
55
  // Check if content exceeds maxHeight
53
56
  useEffect(() => {
@@ -61,19 +64,11 @@ export function CopyBlock({
61
64
  try {
62
65
  await navigator.clipboard.writeText(content)
63
66
  setCopied(true)
64
- setTimeout(() => setCopied(false), 2000)
67
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
68
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
65
69
  } catch {
66
- // Fallback for older browsers
67
- const textarea = document.createElement('textarea')
68
- textarea.value = content
69
- textarea.style.position = 'fixed'
70
- textarea.style.opacity = '0'
71
- document.body.appendChild(textarea)
72
- textarea.select()
73
- document.execCommand('copy')
74
- document.body.removeChild(textarea)
75
- setCopied(true)
76
- setTimeout(() => setCopied(false), 2000)
70
+ // Clipboard API not available
71
+ console.warn('Clipboard API not available')
77
72
  }
78
73
  }, [content])
79
74
 
@@ -19,6 +19,7 @@ import type { LucideIcon } from 'lucide-react'
19
19
  import { TruncatedText } from './truncated-text'
20
20
  import { EmptyState } from './empty-state'
21
21
  import { Skeleton } from './skeleton'
22
+ import { Select } from './select'
22
23
  import { cn } from '../utils'
23
24
 
24
25
  // ---------------------------------------------------------------------------
@@ -679,18 +680,12 @@ export function DataTable<T>({
679
680
  </span>
680
681
 
681
682
  <div className="flex items-center gap-2">
682
- <select
683
- value={pageSize}
684
- onChange={e => table.setPageSize(Number(e.target.value))}
685
- className="rounded-md border border-[hsl(var(--border-subtle))]
686
- bg-[hsl(var(--bg-surface))] px-2 py-1 text-[12px]
687
- text-[hsl(var(--text-secondary))] outline-none
688
- focus:border-[hsl(var(--brand-primary))] transition-colors"
689
- >
690
- {PAGE_SIZES.map(size => (
691
- <option key={size} value={size}>{size} / page</option>
692
- ))}
693
- </select>
683
+ <Select
684
+ value={String(pageSize)}
685
+ onValueChange={v => table.setPageSize(Number(v))}
686
+ options={PAGE_SIZES.map(size => ({ value: String(size), label: `${size} / page` }))}
687
+ className="w-[110px] text-[12px]"
688
+ />
694
689
 
695
690
  <div className="flex items-center gap-1">
696
691
  <PaginationButton
@@ -27,8 +27,29 @@ interface DiffLine {
27
27
  newLineNo?: number
28
28
  }
29
29
 
30
+ const MAX_LINES = 2000
31
+
30
32
  /** Simple line-by-line diff using longest common subsequence. */
31
33
  function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
34
+ // Fall back to simple line-by-line comparison for large inputs
35
+ if (oldLines.length > MAX_LINES || newLines.length > MAX_LINES) {
36
+ const result: DiffLine[] = oldLines.map((l, i) => ({
37
+ type: (l === (newLines[i] ?? '') ? 'unchanged' : 'removed') as LineType,
38
+ content: l,
39
+ oldLineNo: i + 1,
40
+ newLineNo: i + 1,
41
+ }))
42
+ for (let i = oldLines.length; i < newLines.length; i++) {
43
+ result.push({
44
+ type: 'added' as LineType,
45
+ content: newLines[i],
46
+ oldLineNo: undefined,
47
+ newLineNo: i + 1,
48
+ })
49
+ }
50
+ return result
51
+ }
52
+
32
53
  // Build LCS table
33
54
  const m = oldLines.length
34
55
  const n = newLines.length
@@ -47,8 +47,13 @@ function toDateKey(d: Date): string {
47
47
  }
48
48
 
49
49
  function parseDate(s: string): Date {
50
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
51
+ return new Date()
52
+ }
50
53
  const [y, m, d] = s.split('-').map(Number)
51
- return new Date(y, m - 1, d)
54
+ const date = new Date(y, m - 1, d)
55
+ if (isNaN(date.getTime())) return new Date()
56
+ return date
52
57
  }
53
58
 
54
59
  /**
@@ -28,6 +28,8 @@ export interface InfiniteScrollProps<T> {
28
28
  itemHeight?: number
29
29
  /** Content to display when items array is empty. */
30
30
  emptyState?: React.ReactNode
31
+ /** Function to derive a stable key for each item. Falls back to index. */
32
+ getItemKey?: (item: T, index: number) => string | number
31
33
  /** Additional class name for the scroll container. */
32
34
  className?: string
33
35
  }
@@ -49,6 +51,7 @@ export function InfiniteScroll<T>({
49
51
  isLoading = false,
50
52
  threshold = 200,
51
53
  itemHeight,
54
+ getItemKey,
52
55
  emptyState,
53
56
  className,
54
57
  }: InfiniteScrollProps<T>): React.JSX.Element {
@@ -57,6 +60,8 @@ export function InfiniteScroll<T>({
57
60
  const sentinelRef = useRef<HTMLDivElement>(null)
58
61
  const [showScrollTop, setShowScrollTop] = useState(false)
59
62
  const loadingRef = useRef(false)
63
+ const loadMoreRef = useRef(loadMore)
64
+ useEffect(() => { loadMoreRef.current = loadMore }, [loadMore])
60
65
 
61
66
  // IntersectionObserver for infinite load trigger
62
67
  useEffect(() => {
@@ -68,7 +73,7 @@ export function InfiniteScroll<T>({
68
73
  const entry = entries[0]
69
74
  if (entry?.isIntersecting && hasMore && !isLoading && !loadingRef.current) {
70
75
  loadingRef.current = true
71
- const result = loadMore()
76
+ const result = loadMoreRef.current()
72
77
  if (result && typeof result.then === 'function') {
73
78
  result.then(() => { loadingRef.current = false }).catch(() => { loadingRef.current = false })
74
79
  } else {
@@ -84,7 +89,7 @@ export function InfiniteScroll<T>({
84
89
 
85
90
  observer.observe(sentinel)
86
91
  return () => observer.disconnect()
87
- }, [hasMore, isLoading, loadMore, threshold])
92
+ }, [hasMore, isLoading, threshold])
88
93
 
89
94
  // Update loadingRef when isLoading changes
90
95
  useEffect(() => {
@@ -179,7 +184,7 @@ export function InfiniteScroll<T>({
179
184
  ) : (
180
185
  /* Non-virtualized rendering */
181
186
  items.map((item, index) => (
182
- <div key={index}>
187
+ <div key={getItemKey ? getItemKey(item, index) : index}>
183
188
  {renderItem(item, index)}
184
189
  </div>
185
190
  ))
@@ -143,6 +143,8 @@ export function LiveFeed({
143
143
  ref={scrollRef}
144
144
  onScroll={handleScroll}
145
145
  className="flex-1 overflow-y-auto"
146
+ aria-live="polite"
147
+ aria-atomic="false"
146
148
  >
147
149
  {visibleItems.length === 0 ? (
148
150
  <div className="flex items-center justify-center py-12 text-sm text-[hsl(var(--text-tertiary))]">
@@ -151,6 +151,8 @@ export function LogViewer({
151
151
  onScroll={checkAtBottom}
152
152
  className="overflow-y-auto"
153
153
  style={{ maxHeight }}
154
+ aria-live="polite"
155
+ aria-atomic="false"
154
156
  >
155
157
  {filtered.map((entry, i) => (
156
158
  <div
@@ -53,6 +53,13 @@ const TYPE_ICON_COLOR: Record<Notification['type'], string> = {
53
53
  error: 'text-[hsl(var(--status-critical))]',
54
54
  }
55
55
 
56
+ const TYPE_PROGRESS_BG: Record<Notification['type'], string> = {
57
+ info: 'bg-[hsl(var(--brand-secondary))]',
58
+ success: 'bg-[hsl(var(--status-ok))]',
59
+ warning: 'bg-[hsl(var(--status-warning))]',
60
+ error: 'bg-[hsl(var(--status-critical))]',
61
+ }
62
+
56
63
  const POSITION_CLASSES: Record<NonNullable<NotificationStackProps['position']>, string> = {
57
64
  'top-right': 'top-4 right-4',
58
65
  'top-left': 'top-4 left-4',
@@ -216,7 +223,7 @@ function NotificationCard({
216
223
  {duration > 0 && (
217
224
  <div className="h-0.5 bg-[hsl(var(--bg-overlay))]">
218
225
  <div
219
- className={cn('h-full transition-[width] duration-100', TYPE_COLOR[type].replace('border-l-', 'bg-'))}
226
+ className={cn('h-full transition-[width] duration-100', TYPE_PROGRESS_BG[type])}
220
227
  style={{ width: `${progress}%` }}
221
228
  />
222
229
  </div>
@@ -70,6 +70,8 @@ export function SortableList<T extends SortableItem>({
70
70
  const containerRef = useRef<HTMLDivElement>(null)
71
71
  const startPos = useRef({ x: 0, y: 0 })
72
72
  const dragItemId = useRef<string | null>(null)
73
+ const dragIdxRef = useRef<number | null>(null)
74
+ const overIdxRef = useRef<number | null>(null)
73
75
 
74
76
  const handlePointerDown = useCallback(
75
77
  (index: number) => (e: React.PointerEvent) => {
@@ -79,6 +81,8 @@ export function SortableList<T extends SortableItem>({
79
81
 
80
82
  setDragIdx(index)
81
83
  setOverIdx(index)
84
+ dragIdxRef.current = index
85
+ overIdxRef.current = index
82
86
  dragItemId.current = items[index]?.id ?? null
83
87
  startPos.current = { x: e.clientX, y: e.clientY }
84
88
 
@@ -97,6 +101,7 @@ export function SortableList<T extends SortableItem>({
97
101
  : ev.clientX < midX + rect.width / 2 && ev.clientX > midX - rect.width / 2
98
102
 
99
103
  if (isOver) {
104
+ overIdxRef.current = i
100
105
  setOverIdx(i)
101
106
  break
102
107
  }
@@ -107,19 +112,18 @@ export function SortableList<T extends SortableItem>({
107
112
  document.removeEventListener('pointermove', handlePointerMove)
108
113
  document.removeEventListener('pointerup', handlePointerUp)
109
114
 
110
- setDragIdx(prev => {
111
- setOverIdx(over => {
112
- if (prev !== null && over !== null && prev !== over) {
113
- const newItems = [...items]
114
- const [moved] = newItems.splice(prev, 1)
115
- if (moved) newItems.splice(over, 0, moved)
116
- // Use setTimeout to avoid state update during render
117
- setTimeout(() => onReorder(newItems), 0)
118
- }
119
- return null
120
- })
121
- return null
122
- })
115
+ const prev = dragIdxRef.current
116
+ const over = overIdxRef.current
117
+ if (prev !== null && over !== null && prev !== over) {
118
+ const newItems = [...items]
119
+ const [moved] = newItems.splice(prev, 1)
120
+ if (moved) newItems.splice(over, 0, moved)
121
+ onReorder(newItems)
122
+ }
123
+ dragIdxRef.current = null
124
+ overIdxRef.current = null
125
+ setDragIdx(null)
126
+ setOverIdx(null)
123
127
  }
124
128
 
125
129
  document.addEventListener('pointermove', handlePointerMove)
@@ -97,6 +97,8 @@ export function StepWizard({
97
97
  const [direction, setDirection] = useState(1) // 1=forward, -1=backward
98
98
  const [isComplete, setIsComplete] = useState(false)
99
99
  const contentRef = useRef<HTMLDivElement>(null)
100
+ const wizardRef = useRef<HTMLDivElement>(null)
101
+ const validatingRef = useRef(false)
100
102
 
101
103
  // Save state to sessionStorage
102
104
  useEffect(() => {
@@ -118,20 +120,25 @@ export function StepWizard({
118
120
  }, [currentStep, onStepChange])
119
121
 
120
122
  const handleNext = useCallback(async () => {
123
+ if (validatingRef.current) return
121
124
  const step = steps[currentStep]
122
125
  if (step?.validate) {
126
+ validatingRef.current = true
123
127
  setIsValidating(true)
124
128
  try {
125
129
  const valid = await step.validate()
126
130
  if (!valid) {
127
131
  setIsValidating(false)
132
+ validatingRef.current = false
128
133
  return
129
134
  }
130
135
  } catch {
131
136
  setIsValidating(false)
137
+ validatingRef.current = false
132
138
  return
133
139
  }
134
140
  setIsValidating(false)
141
+ validatingRef.current = false
135
142
  }
136
143
 
137
144
  setCompleted(prev => new Set(prev).add(currentStep))
@@ -165,6 +172,7 @@ export function StepWizard({
165
172
  // Keyboard navigation
166
173
  useEffect(() => {
167
174
  const handler = (e: KeyboardEvent) => {
175
+ if (!wizardRef.current?.contains(e.target as Node)) return
168
176
  if (e.key === 'Enter' && !e.shiftKey && !(e.target instanceof HTMLTextAreaElement)) {
169
177
  handleNext()
170
178
  }
@@ -188,7 +196,7 @@ export function StepWizard({
188
196
  const isHorizontal = orientation === 'horizontal'
189
197
 
190
198
  return (
191
- <div className={cn('flex flex-col', className)}>
199
+ <div ref={wizardRef} className={cn('flex flex-col', className)}>
192
200
  {/* Step indicator */}
193
201
  <div className={cn(
194
202
  'mb-6',
@@ -73,6 +73,9 @@ export function StreamingText({
73
73
  const containerRef = useRef<HTMLDivElement>(null)
74
74
  const prevStreamingRef = useRef(isStreaming)
75
75
  const [copied, setCopied] = useState(false)
76
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
77
+
78
+ useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
76
79
 
77
80
  useEffect(() => {
78
81
  if (prevStreamingRef.current && !isStreaming) {
@@ -91,7 +94,8 @@ export function StreamingText({
91
94
  const handleCopy = useCallback(() => {
92
95
  void navigator.clipboard.writeText(text).then(() => {
93
96
  setCopied(true)
94
- setTimeout(() => setCopied(false), 2000)
97
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
98
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
95
99
  })
96
100
  }, [text])
97
101
 
@@ -114,6 +118,7 @@ export function StreamingText({
114
118
  animation: `streaming-cursor-blink ${speed}ms step-end infinite`,
115
119
  }
116
120
  }
121
+ aria-hidden="true"
117
122
  />
118
123
  )}
119
124
  <AnimatePresence>
@@ -150,14 +155,6 @@ export function StreamingText({
150
155
  )}
151
156
  </AnimatePresence>
152
157
 
153
- {showCursor && isStreaming && !reduced && (
154
- <style>{`
155
- @keyframes streaming-cursor-blink {
156
- 0%, 100% { opacity: 1; }
157
- 50% { opacity: 0; }
158
- }
159
- `}</style>
160
- )}
161
158
  </div>
162
159
  )
163
160
  }
package/src/theme.css CHANGED
@@ -168,6 +168,12 @@ html.light {
168
168
  color: hsl(var(--text-primary));
169
169
  }
170
170
 
171
+ /* ── Streaming cursor ────────────────────────────────────────────────────── */
172
+ @keyframes streaming-cursor-blink {
173
+ 0%, 100% { opacity: 1; }
174
+ 50% { opacity: 0; }
175
+ }
176
+
171
177
  /* ── Reduced motion ──────────────────────────────────────────────────────── */
172
178
  @media (prefers-reduced-motion: reduce) {
173
179
  .animate-pulse-ring,