@annondeveloper/ui-kit 0.1.0 → 0.2.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 (65) hide show
  1. package/dist/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
  2. package/dist/chunk-2DWZVHZS.js.map +1 -0
  3. package/dist/form.d.ts +6 -6
  4. package/dist/form.js +1 -1
  5. package/dist/form.js.map +1 -1
  6. package/dist/index.d.ts +508 -52
  7. package/dist/index.js +2927 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
  10. package/package.json +1 -1
  11. package/src/components/animated-counter.tsx +2 -1
  12. package/src/components/avatar.tsx +2 -1
  13. package/src/components/badge.tsx +3 -2
  14. package/src/components/button.tsx +3 -2
  15. package/src/components/card.tsx +13 -12
  16. package/src/components/checkbox.tsx +3 -2
  17. package/src/components/color-input.tsx +414 -0
  18. package/src/components/command-bar.tsx +434 -0
  19. package/src/components/confidence-bar.tsx +115 -0
  20. package/src/components/confirm-dialog.tsx +2 -1
  21. package/src/components/copy-block.tsx +229 -0
  22. package/src/components/data-table.tsx +2 -1
  23. package/src/components/diff-viewer.tsx +319 -0
  24. package/src/components/dropdown-menu.tsx +2 -1
  25. package/src/components/empty-state.tsx +2 -1
  26. package/src/components/filter-pill.tsx +2 -1
  27. package/src/components/form-input.tsx +5 -4
  28. package/src/components/heatmap-calendar.tsx +213 -0
  29. package/src/components/infinite-scroll.tsx +243 -0
  30. package/src/components/kanban-column.tsx +198 -0
  31. package/src/components/live-feed.tsx +220 -0
  32. package/src/components/log-viewer.tsx +2 -1
  33. package/src/components/metric-card.tsx +2 -1
  34. package/src/components/notification-stack.tsx +226 -0
  35. package/src/components/pipeline-stage.tsx +2 -1
  36. package/src/components/popover.tsx +2 -1
  37. package/src/components/port-status-grid.tsx +2 -1
  38. package/src/components/progress.tsx +2 -1
  39. package/src/components/radio-group.tsx +2 -1
  40. package/src/components/realtime-value.tsx +283 -0
  41. package/src/components/select.tsx +2 -1
  42. package/src/components/severity-timeline.tsx +2 -1
  43. package/src/components/sheet.tsx +2 -1
  44. package/src/components/skeleton.tsx +4 -3
  45. package/src/components/slider.tsx +2 -1
  46. package/src/components/smart-table.tsx +383 -0
  47. package/src/components/sortable-list.tsx +268 -0
  48. package/src/components/sparkline.tsx +2 -1
  49. package/src/components/status-badge.tsx +2 -1
  50. package/src/components/status-pulse.tsx +2 -1
  51. package/src/components/step-wizard.tsx +372 -0
  52. package/src/components/streaming-text.tsx +163 -0
  53. package/src/components/success-checkmark.tsx +2 -1
  54. package/src/components/tabs.tsx +2 -1
  55. package/src/components/threshold-gauge.tsx +2 -1
  56. package/src/components/time-range-selector.tsx +2 -1
  57. package/src/components/toast.tsx +2 -1
  58. package/src/components/toggle-switch.tsx +2 -1
  59. package/src/components/tooltip.tsx +2 -1
  60. package/src/components/truncated-text.tsx +2 -1
  61. package/src/components/typing-indicator.tsx +123 -0
  62. package/src/components/uptime-tracker.tsx +2 -1
  63. package/src/components/utilization-bar.tsx +2 -1
  64. package/src/utils.ts +1 -1
  65. package/dist/chunk-5OKSXPWK.js.map +0 -1
@@ -0,0 +1,383 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { useState, useMemo, useCallback } from 'react'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
7
+ import { Sparkles, X, TrendingUp, AlertTriangle, Hash, Regex } from 'lucide-react'
8
+ import type { LucideIcon } from 'lucide-react'
9
+ import { DataTable, type DataTableProps } from './data-table'
10
+ import { cn } from '../utils'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Describes an auto-detected filter suggestion for a data column. */
17
+ export interface FilterSuggestion {
18
+ /** Column accessor key. */
19
+ column: string
20
+ /** Type of insight: outlier, top-n, pattern, or threshold. */
21
+ type: 'outlier' | 'top-n' | 'pattern' | 'threshold'
22
+ /** Human-readable label for the suggestion pill. */
23
+ label: string
24
+ /** Apply this filter to the data. */
25
+ filter: () => void
26
+ }
27
+
28
+ /** Props for the SmartTable component. */
29
+ export interface SmartTableProps<T> extends DataTableProps<T> {
30
+ /** Callback fired when a filter suggestion is generated or clicked. */
31
+ onFilterSuggestion?: (suggestion: FilterSuggestion) => void
32
+ /** Maximum number of suggestions to show. */
33
+ maxSuggestions?: number
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Analysis helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function mean(nums: number[]): number {
41
+ if (nums.length === 0) return 0
42
+ return nums.reduce((a, b) => a + b, 0) / nums.length
43
+ }
44
+
45
+ function stdDev(nums: number[]): number {
46
+ if (nums.length < 2) return 0
47
+ const m = mean(nums)
48
+ const variance = nums.reduce((sum, n) => sum + (n - m) ** 2, 0) / nums.length
49
+ return Math.sqrt(variance)
50
+ }
51
+
52
+ function analyzeColumn<T>(
53
+ columnId: string,
54
+ columnHeader: string,
55
+ data: T[],
56
+ getValue: (row: T) => unknown,
57
+ ): FilterSuggestion[] {
58
+ const suggestions: FilterSuggestion[] = []
59
+ const values = data.map(getValue).filter(v => v != null)
60
+ if (values.length === 0) return suggestions
61
+
62
+ // Check if numeric column
63
+ const numericValues = values
64
+ .map(v => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' && !isNaN(Number(v)) ? Number(v) : null))
65
+ .filter((v): v is number => v !== null)
66
+
67
+ if (numericValues.length > values.length * 0.7) {
68
+ // Outlier detection (>2 std dev from mean)
69
+ const m = mean(numericValues)
70
+ const sd = stdDev(numericValues)
71
+ if (sd > 0) {
72
+ const outliers = numericValues.filter(v => Math.abs(v - m) > 2 * sd)
73
+ if (outliers.length > 0 && outliers.length < numericValues.length * 0.3) {
74
+ suggestions.push({
75
+ column: columnId,
76
+ type: 'outlier',
77
+ label: `${outliers.length} outlier${outliers.length > 1 ? 's' : ''} in ${columnHeader}`,
78
+ filter: () => {},
79
+ })
80
+ }
81
+ }
82
+
83
+ // Threshold suggestion — find if >80% of values are above or below median
84
+ const sorted = [...numericValues].sort((a, b) => a - b)
85
+ const median = sorted[Math.floor(sorted.length / 2)] ?? 0
86
+ const aboveMedian = numericValues.filter(v => v > median)
87
+ if (aboveMedian.length > 0 && aboveMedian.length < numericValues.length * 0.2) {
88
+ suggestions.push({
89
+ column: columnId,
90
+ type: 'threshold',
91
+ label: `Top ${aboveMedian.length} high values in ${columnHeader}`,
92
+ filter: () => {},
93
+ })
94
+ }
95
+ } else {
96
+ // String column analysis
97
+ const strValues = values.map(String)
98
+ const freq = new Map<string, number>()
99
+ for (const v of strValues) {
100
+ freq.set(v, (freq.get(v) ?? 0) + 1)
101
+ }
102
+
103
+ // Pattern detection: if one value is >90% dominant
104
+ for (const [val, count] of freq) {
105
+ if (count / strValues.length >= 0.9 && freq.size > 1) {
106
+ const otherCount = strValues.length - count
107
+ suggestions.push({
108
+ column: columnId,
109
+ type: 'pattern',
110
+ label: `Show non-"${val.length > 20 ? val.slice(0, 20) + '\u2026' : val}" (${otherCount})`,
111
+ filter: () => {},
112
+ })
113
+ }
114
+ }
115
+
116
+ // Top-N: if there are more than 5 unique values, suggest top 5
117
+ if (freq.size > 5) {
118
+ const topEntries = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
119
+ const topTotal = topEntries.reduce((s, e) => s + e[1], 0)
120
+ suggestions.push({
121
+ column: columnId,
122
+ type: 'top-n',
123
+ label: `Top 5 ${columnHeader} (${Math.round((topTotal / strValues.length) * 100)}%)`,
124
+ filter: () => {},
125
+ })
126
+ }
127
+
128
+ // Minority value detection: values that appear in <12% of rows
129
+ const minorityValues = [...freq.entries()].filter(
130
+ ([, count]) => count / strValues.length < 0.12 && count > 0,
131
+ )
132
+ if (minorityValues.length > 0 && minorityValues.length < freq.size) {
133
+ const totalMinority = minorityValues.reduce((s, [, c]) => s + c, 0)
134
+ suggestions.push({
135
+ column: columnId,
136
+ type: 'pattern',
137
+ label: `Rare ${columnHeader} values (${totalMinority} rows)`,
138
+ filter: () => {},
139
+ })
140
+ }
141
+ }
142
+
143
+ return suggestions
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Suggestion icon mapping
148
+ // ---------------------------------------------------------------------------
149
+
150
+ const SUGGESTION_ICONS: Record<FilterSuggestion['type'], LucideIcon> = {
151
+ outlier: AlertTriangle,
152
+ 'top-n': TrendingUp,
153
+ pattern: Regex,
154
+ threshold: Hash,
155
+ }
156
+
157
+ const SUGGESTION_COLORS: Record<FilterSuggestion['type'], string> = {
158
+ outlier: 'bg-[hsl(var(--status-warning)/0.15)] text-[hsl(var(--status-warning))] border-[hsl(var(--status-warning)/0.3)]',
159
+ 'top-n': 'bg-[hsl(var(--brand-primary)/0.15)] text-[hsl(var(--brand-primary))] border-[hsl(var(--brand-primary)/0.3)]',
160
+ pattern: 'bg-[hsl(var(--brand-secondary)/0.15)] text-[hsl(var(--brand-secondary))] border-[hsl(var(--brand-secondary)/0.3)]',
161
+ threshold: 'bg-[hsl(var(--status-critical)/0.15)] text-[hsl(var(--status-critical))] border-[hsl(var(--status-critical)/0.3)]',
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // SmartTable
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * @description An enhanced DataTable that analyzes column data on mount and auto-generates
170
+ * smart filter suggestions such as outlier detection, top-N values, dominant patterns,
171
+ * and threshold-based highlights. Click a suggestion to apply it as a filter.
172
+ * Wraps the existing DataTable via composition.
173
+ */
174
+ export function SmartTable<T>({
175
+ columns,
176
+ data,
177
+ onFilterSuggestion,
178
+ maxSuggestions = 6,
179
+ ...tableProps
180
+ }: SmartTableProps<T>): React.JSX.Element {
181
+ const prefersReducedMotion = useReducedMotion()
182
+ const [dismissed, setDismissed] = useState<Set<string>>(new Set())
183
+ const [appliedFilter, setAppliedFilter] = useState<string | null>(null)
184
+ const [filteredData, setFilteredData] = useState<T[] | null>(null)
185
+
186
+ // Generate suggestions by analyzing each column
187
+ const suggestions = useMemo(() => {
188
+ if (data.length < 3) return []
189
+
190
+ const allSuggestions: FilterSuggestion[] = []
191
+
192
+ for (const colDef of columns) {
193
+ const col = colDef as ColumnDef<T, unknown> & { accessorKey?: string; accessorFn?: (row: T) => unknown; header?: string }
194
+ const columnId = col.accessorKey ?? (col as { id?: string }).id ?? ''
195
+ const columnHeader = typeof col.header === 'string' ? col.header : columnId
196
+
197
+ if (!columnId) continue
198
+
199
+ const getValue = col.accessorFn
200
+ ? col.accessorFn
201
+ : (row: T) => (row as Record<string, unknown>)[columnId]
202
+
203
+ const columnSuggestions = analyzeColumn(columnId, columnHeader, data, getValue)
204
+
205
+ // Wire up actual filter functions
206
+ for (const s of columnSuggestions) {
207
+ s.filter = () => {
208
+ const vals = data.map(getValue).filter(v => v != null)
209
+ let filtered: T[]
210
+
211
+ switch (s.type) {
212
+ case 'outlier': {
213
+ const nums = vals
214
+ .map(v => (typeof v === 'number' ? v : Number(v)))
215
+ .filter(v => !isNaN(v))
216
+ const m = mean(nums)
217
+ const sd = stdDev(nums)
218
+ filtered = data.filter(row => {
219
+ const v = getValue(row)
220
+ const n = typeof v === 'number' ? v : Number(v)
221
+ return !isNaN(n) && Math.abs(n - m) > 2 * sd
222
+ })
223
+ break
224
+ }
225
+ case 'top-n': {
226
+ const freq = new Map<string, number>()
227
+ for (const v of vals) freq.set(String(v), (freq.get(String(v)) ?? 0) + 1)
228
+ const topKeys = new Set([...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(e => e[0]))
229
+ filtered = data.filter(row => topKeys.has(String(getValue(row))))
230
+ break
231
+ }
232
+ case 'threshold': {
233
+ const nums = vals
234
+ .map(v => (typeof v === 'number' ? v : Number(v)))
235
+ .filter(v => !isNaN(v))
236
+ const sorted = [...nums].sort((a, b) => a - b)
237
+ const median = sorted[Math.floor(sorted.length / 2)] ?? 0
238
+ filtered = data.filter(row => {
239
+ const v = getValue(row)
240
+ const n = typeof v === 'number' ? v : Number(v)
241
+ return !isNaN(n) && n > median
242
+ })
243
+ break
244
+ }
245
+ case 'pattern': {
246
+ const freq = new Map<string, number>()
247
+ for (const v of vals) freq.set(String(v), (freq.get(String(v)) ?? 0) + 1)
248
+ // Find dominant value
249
+ let dominant = ''
250
+ let maxCount = 0
251
+ for (const [k, c] of freq) {
252
+ if (c > maxCount) { dominant = k; maxCount = c }
253
+ }
254
+ if (maxCount / vals.length >= 0.9) {
255
+ // Show non-dominant
256
+ filtered = data.filter(row => String(getValue(row)) !== dominant)
257
+ } else {
258
+ // Rare values
259
+ const rareKeys = new Set(
260
+ [...freq.entries()].filter(([, c]) => c / vals.length < 0.12).map(e => e[0]),
261
+ )
262
+ filtered = data.filter(row => rareKeys.has(String(getValue(row))))
263
+ }
264
+ break
265
+ }
266
+ default:
267
+ filtered = data
268
+ }
269
+
270
+ setFilteredData(filtered)
271
+ setAppliedFilter(s.label)
272
+ onFilterSuggestion?.(s)
273
+ }
274
+ }
275
+
276
+ allSuggestions.push(...columnSuggestions)
277
+ }
278
+
279
+ return allSuggestions.slice(0, maxSuggestions)
280
+ }, [data, columns, maxSuggestions, onFilterSuggestion])
281
+
282
+ const visibleSuggestions = suggestions.filter(s => !dismissed.has(s.label))
283
+
284
+ const handleDismiss = useCallback((label: string) => {
285
+ setDismissed(prev => new Set(prev).add(label))
286
+ }, [])
287
+
288
+ const handleClearFilter = useCallback(() => {
289
+ setFilteredData(null)
290
+ setAppliedFilter(null)
291
+ }, [])
292
+
293
+ const displayData = filteredData ?? data
294
+
295
+ return (
296
+ <div>
297
+ {/* Suggestion pills bar */}
298
+ <AnimatePresence>
299
+ {visibleSuggestions.length > 0 && !appliedFilter && (
300
+ <motion.div
301
+ initial={prefersReducedMotion ? undefined : { opacity: 0, y: -8 }}
302
+ animate={prefersReducedMotion ? undefined : { opacity: 1, y: 0 }}
303
+ exit={prefersReducedMotion ? undefined : { opacity: 0, y: -8 }}
304
+ transition={{ duration: 0.2 }}
305
+ className="mb-3 flex flex-wrap items-center gap-2"
306
+ >
307
+ <span className="flex items-center gap-1.5 text-[11px] font-medium text-[hsl(var(--text-tertiary))] uppercase tracking-wider">
308
+ <Sparkles className="h-3.5 w-3.5 text-[hsl(var(--brand-primary))]" />
309
+ Suggested Filters
310
+ </span>
311
+
312
+ {visibleSuggestions.map(suggestion => {
313
+ const Icon = SUGGESTION_ICONS[suggestion.type]
314
+ return (
315
+ <motion.button
316
+ key={suggestion.label}
317
+ layout={!prefersReducedMotion}
318
+ initial={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.9 }}
319
+ animate={prefersReducedMotion ? undefined : { opacity: 1, scale: 1 }}
320
+ exit={prefersReducedMotion ? undefined : { opacity: 0, scale: 0.9 }}
321
+ transition={{ duration: 0.15 }}
322
+ onClick={suggestion.filter}
323
+ className={cn(
324
+ 'group inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[12px] font-medium',
325
+ 'transition-all hover:shadow-sm cursor-pointer',
326
+ SUGGESTION_COLORS[suggestion.type],
327
+ )}
328
+ >
329
+ <Icon className="h-3 w-3" />
330
+ {suggestion.label}
331
+ <span
332
+ role="button"
333
+ tabIndex={0}
334
+ onClick={(e) => { e.stopPropagation(); handleDismiss(suggestion.label) }}
335
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleDismiss(suggestion.label) } }}
336
+ className="ml-0.5 opacity-0 group-hover:opacity-100 transition-opacity rounded-full p-0.5 hover:bg-[hsl(var(--bg-overlay)/0.3)]"
337
+ >
338
+ <X className="h-2.5 w-2.5" />
339
+ </span>
340
+ </motion.button>
341
+ )
342
+ })}
343
+ </motion.div>
344
+ )}
345
+ </AnimatePresence>
346
+
347
+ {/* Active filter indicator */}
348
+ <AnimatePresence>
349
+ {appliedFilter && (
350
+ <motion.div
351
+ initial={prefersReducedMotion ? undefined : { opacity: 0, y: -4 }}
352
+ animate={prefersReducedMotion ? undefined : { opacity: 1, y: 0 }}
353
+ exit={prefersReducedMotion ? undefined : { opacity: 0, y: -4 }}
354
+ transition={{ duration: 0.15 }}
355
+ className="mb-3 flex items-center gap-2"
356
+ >
357
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-[hsl(var(--brand-primary)/0.15)] border border-[hsl(var(--brand-primary)/0.3)] px-3 py-1 text-[12px] font-medium text-[hsl(var(--brand-primary))]">
358
+ <Sparkles className="h-3 w-3" />
359
+ {appliedFilter}
360
+ <span className="ml-1 tabular-nums text-[11px] opacity-70">
361
+ ({displayData.length} row{displayData.length !== 1 ? 's' : ''})
362
+ </span>
363
+ </span>
364
+ <button
365
+ onClick={handleClearFilter}
366
+ className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium text-[hsl(var(--text-secondary))] hover:text-[hsl(var(--text-primary))] hover:bg-[hsl(var(--bg-elevated)/0.5)] transition-colors"
367
+ >
368
+ <X className="h-3 w-3" />
369
+ Clear
370
+ </button>
371
+ </motion.div>
372
+ )}
373
+ </AnimatePresence>
374
+
375
+ {/* Wrapped DataTable */}
376
+ <DataTable<T>
377
+ columns={columns}
378
+ data={displayData}
379
+ {...tableProps}
380
+ />
381
+ </div>
382
+ )
383
+ }
@@ -0,0 +1,268 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { useState, useCallback, useRef } from 'react'
5
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
6
+ import { GripVertical } from 'lucide-react'
7
+ import { cn } from '../utils'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** A sortable item must have a string id. */
14
+ export interface SortableItem {
15
+ id: string
16
+ [key: string]: unknown
17
+ }
18
+
19
+ /** Props passed to the drag handle in the render function. */
20
+ export interface DragHandleProps {
21
+ /** Attach to the drag handle element. */
22
+ onPointerDown: (e: React.PointerEvent) => void
23
+ /** Whether this item is currently being dragged. */
24
+ isDragging: boolean
25
+ /** Keyboard handler for accessible reordering. */
26
+ onKeyDown: (e: React.KeyboardEvent) => void
27
+ /** The drag handle should be focusable. */
28
+ tabIndex: number
29
+ /** ARIA role for the drag handle. */
30
+ role: string
31
+ /** ARIA description for keyboard reordering. */
32
+ 'aria-roledescription': string
33
+ }
34
+
35
+ /** Props for the SortableList component. */
36
+ export interface SortableListProps<T extends SortableItem> {
37
+ /** Array of items to display. Each must have a unique `id`. */
38
+ items: T[]
39
+ /** Callback when items are reordered. Receives the new array. */
40
+ onReorder: (items: T[]) => void
41
+ /** Render function for each item. Receives the item, its index, and drag handle props. */
42
+ renderItem: (item: T, index: number, dragHandleProps: DragHandleProps) => React.JSX.Element
43
+ /** Layout direction. Default "vertical". */
44
+ direction?: 'vertical' | 'horizontal'
45
+ /** Additional class name for the list container. */
46
+ className?: string
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // SortableList
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * @description A drag-and-drop reorderable list with smooth Framer Motion layout animations.
55
+ * Pure React implementation using pointer events (no external DnD library).
56
+ * Supports keyboard reordering (Space to pick up, arrows to move, Enter/Space to drop).
57
+ * Touch-friendly and accessible.
58
+ */
59
+ export function SortableList<T extends SortableItem>({
60
+ items,
61
+ onReorder,
62
+ renderItem,
63
+ direction = 'vertical',
64
+ className,
65
+ }: SortableListProps<T>): React.JSX.Element {
66
+ const prefersReducedMotion = useReducedMotion()
67
+ const [dragIdx, setDragIdx] = useState<number | null>(null)
68
+ const [overIdx, setOverIdx] = useState<number | null>(null)
69
+ const [kbPickedIdx, setKbPickedIdx] = useState<number | null>(null)
70
+ const containerRef = useRef<HTMLDivElement>(null)
71
+ const startPos = useRef({ x: 0, y: 0 })
72
+ const dragItemId = useRef<string | null>(null)
73
+
74
+ const handlePointerDown = useCallback(
75
+ (index: number) => (e: React.PointerEvent) => {
76
+ e.preventDefault()
77
+ // Only respond to primary button / touch
78
+ if (e.button !== 0) return
79
+
80
+ setDragIdx(index)
81
+ setOverIdx(index)
82
+ dragItemId.current = items[index]?.id ?? null
83
+ startPos.current = { x: e.clientX, y: e.clientY }
84
+
85
+ const handlePointerMove = (ev: PointerEvent) => {
86
+ if (!containerRef.current) return
87
+ const container = containerRef.current
88
+ const children = Array.from(container.children) as HTMLElement[]
89
+
90
+ // Find which item we're over
91
+ for (let i = 0; i < children.length; i++) {
92
+ const rect = children[i]!.getBoundingClientRect()
93
+ const midX = rect.left + rect.width / 2
94
+ const midY = rect.top + rect.height / 2
95
+ const isOver = direction === 'vertical'
96
+ ? ev.clientY < midY + rect.height / 2 && ev.clientY > midY - rect.height / 2
97
+ : ev.clientX < midX + rect.width / 2 && ev.clientX > midX - rect.width / 2
98
+
99
+ if (isOver) {
100
+ setOverIdx(i)
101
+ break
102
+ }
103
+ }
104
+ }
105
+
106
+ const handlePointerUp = () => {
107
+ document.removeEventListener('pointermove', handlePointerMove)
108
+ document.removeEventListener('pointerup', handlePointerUp)
109
+
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
+ })
123
+ }
124
+
125
+ document.addEventListener('pointermove', handlePointerMove)
126
+ document.addEventListener('pointerup', handlePointerUp)
127
+ },
128
+ [items, onReorder, direction],
129
+ )
130
+
131
+ // Keyboard reorder
132
+ const handleKeyDown = useCallback(
133
+ (index: number) => (e: React.KeyboardEvent) => {
134
+ if (e.key === ' ' || e.key === 'Enter') {
135
+ e.preventDefault()
136
+ if (kbPickedIdx === null) {
137
+ // Pick up
138
+ setKbPickedIdx(index)
139
+ } else {
140
+ // Drop
141
+ if (kbPickedIdx !== index) {
142
+ const newItems = [...items]
143
+ const [moved] = newItems.splice(kbPickedIdx, 1)
144
+ if (moved) newItems.splice(index, 0, moved)
145
+ onReorder(newItems)
146
+ }
147
+ setKbPickedIdx(null)
148
+ }
149
+ } else if (e.key === 'Escape') {
150
+ setKbPickedIdx(null)
151
+ } else if (kbPickedIdx !== null) {
152
+ const isUp = direction === 'vertical' ? e.key === 'ArrowUp' : e.key === 'ArrowLeft'
153
+ const isDown = direction === 'vertical' ? e.key === 'ArrowDown' : e.key === 'ArrowRight'
154
+
155
+ if (isUp && kbPickedIdx > 0) {
156
+ e.preventDefault()
157
+ const newItems = [...items]
158
+ const [moved] = newItems.splice(kbPickedIdx, 1)
159
+ const newIdx = kbPickedIdx - 1
160
+ if (moved) newItems.splice(newIdx, 0, moved)
161
+ onReorder(newItems)
162
+ setKbPickedIdx(newIdx)
163
+ } else if (isDown && kbPickedIdx < items.length - 1) {
164
+ e.preventDefault()
165
+ const newItems = [...items]
166
+ const [moved] = newItems.splice(kbPickedIdx, 1)
167
+ const newIdx = kbPickedIdx + 1
168
+ if (moved) newItems.splice(newIdx, 0, moved)
169
+ onReorder(newItems)
170
+ setKbPickedIdx(newIdx)
171
+ }
172
+ }
173
+ },
174
+ [items, onReorder, kbPickedIdx, direction],
175
+ )
176
+
177
+ // Compute visual order during drag
178
+ const getVisualItems = useCallback(() => {
179
+ if (dragIdx === null || overIdx === null || dragIdx === overIdx) return items
180
+ const visual = [...items]
181
+ const [moved] = visual.splice(dragIdx, 1)
182
+ if (moved) visual.splice(overIdx, 0, moved)
183
+ return visual
184
+ }, [items, dragIdx, overIdx])
185
+
186
+ const visualItems = dragIdx !== null ? getVisualItems() : items
187
+
188
+ return (
189
+ <div
190
+ ref={containerRef}
191
+ className={cn(
192
+ 'flex',
193
+ direction === 'vertical' ? 'flex-col' : 'flex-row flex-wrap',
194
+ className,
195
+ )}
196
+ role="listbox"
197
+ aria-label="Sortable list"
198
+ >
199
+ <AnimatePresence>
200
+ {visualItems.map((item, index) => {
201
+ const isDragging = dragIdx !== null && item.id === dragItemId.current
202
+ const isKbPicked = kbPickedIdx !== null && items[kbPickedIdx]?.id === item.id
203
+
204
+ const dragHandleProps: DragHandleProps = {
205
+ onPointerDown: handlePointerDown(items.findIndex(i => i.id === item.id)),
206
+ isDragging: isDragging || isKbPicked,
207
+ onKeyDown: handleKeyDown(items.findIndex(i => i.id === item.id)),
208
+ tabIndex: 0,
209
+ role: 'option',
210
+ 'aria-roledescription': 'sortable item',
211
+ }
212
+
213
+ return (
214
+ <motion.div
215
+ key={item.id}
216
+ layout={!prefersReducedMotion}
217
+ transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 500, damping: 35, mass: 0.5 }}
218
+ className={cn(
219
+ 'relative',
220
+ isDragging && 'z-10 opacity-80',
221
+ isKbPicked && 'ring-2 ring-[hsl(var(--brand-primary))] rounded-lg',
222
+ )}
223
+ role="option"
224
+ aria-selected={isKbPicked}
225
+ >
226
+ {/* Drop indicator line */}
227
+ {dragIdx !== null && overIdx === index && !isDragging && (
228
+ <div
229
+ className={cn(
230
+ 'absolute z-20 bg-[hsl(var(--brand-primary))] rounded-full',
231
+ direction === 'vertical'
232
+ ? 'left-0 right-0 -top-px h-0.5'
233
+ : 'top-0 bottom-0 -left-px w-0.5',
234
+ )}
235
+ />
236
+ )}
237
+ {renderItem(item, index, dragHandleProps)}
238
+ </motion.div>
239
+ )
240
+ })}
241
+ </AnimatePresence>
242
+ </div>
243
+ )
244
+ }
245
+
246
+ /**
247
+ * @description Default drag handle component. Renders a GripVertical icon
248
+ * with proper pointer/keyboard event handlers from DragHandleProps.
249
+ */
250
+ export function DragHandle(props: DragHandleProps): React.JSX.Element {
251
+ return (
252
+ <span
253
+ onPointerDown={props.onPointerDown}
254
+ onKeyDown={props.onKeyDown}
255
+ tabIndex={props.tabIndex}
256
+ role={props.role}
257
+ aria-roledescription={props['aria-roledescription']}
258
+ className={cn(
259
+ 'inline-flex items-center justify-center p-1 rounded cursor-grab touch-none select-none',
260
+ 'text-[hsl(var(--text-disabled))] hover:text-[hsl(var(--text-secondary))] transition-colors',
261
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--brand-primary))]',
262
+ props.isDragging && 'cursor-grabbing text-[hsl(var(--brand-primary))]',
263
+ )}
264
+ >
265
+ <GripVertical className="h-4 w-4" />
266
+ </span>
267
+ )
268
+ }
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { cn } from '../utils'
4
5
 
5
6
  export interface SparklineProps {
@@ -45,7 +46,7 @@ export function Sparkline({
45
46
  fillOpacity = 0.1,
46
47
  showDots = false,
47
48
  className,
48
- }: SparklineProps) {
49
+ }: SparklineProps): React.JSX.Element | null {
49
50
  if (data.length < 2) return null
50
51
 
51
52
  const pad = showDots ? 3 : 1
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type React from 'react'
3
4
  import { cn } from '../utils'
4
5
 
5
6
  /** Configuration for a single status entry in a StatusBadge. */
@@ -49,7 +50,7 @@ export interface StatusBadgeProps {
49
50
  */
50
51
  export function StatusBadge({
51
52
  status, label, size = 'md', pulse = false, statusMap, className,
52
- }: StatusBadgeProps) {
53
+ }: StatusBadgeProps): React.JSX.Element {
53
54
  const map = statusMap ?? defaultStatusMap
54
55
  const fallback = map['unknown'] ?? defaultStatusMap['unknown']!
55
56
  const config = map[status] ?? fallback