@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.
- package/dist/{chunk-5OKSXPWK.js → chunk-2DWZVHZS.js} +2 -2
- package/dist/chunk-2DWZVHZS.js.map +1 -0
- package/dist/form.d.ts +6 -6
- package/dist/form.js +1 -1
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +508 -52
- package/dist/index.js +2927 -4
- package/dist/index.js.map +1 -1
- package/dist/{select-nnBJUO8U.d.ts → select-B2wXqqSM.d.ts} +2 -2
- package/package.json +1 -1
- package/src/components/animated-counter.tsx +2 -1
- package/src/components/avatar.tsx +2 -1
- package/src/components/badge.tsx +3 -2
- package/src/components/button.tsx +3 -2
- package/src/components/card.tsx +13 -12
- package/src/components/checkbox.tsx +3 -2
- package/src/components/color-input.tsx +414 -0
- package/src/components/command-bar.tsx +434 -0
- package/src/components/confidence-bar.tsx +115 -0
- package/src/components/confirm-dialog.tsx +2 -1
- package/src/components/copy-block.tsx +229 -0
- package/src/components/data-table.tsx +2 -1
- package/src/components/diff-viewer.tsx +319 -0
- package/src/components/dropdown-menu.tsx +2 -1
- package/src/components/empty-state.tsx +2 -1
- package/src/components/filter-pill.tsx +2 -1
- package/src/components/form-input.tsx +5 -4
- package/src/components/heatmap-calendar.tsx +213 -0
- package/src/components/infinite-scroll.tsx +243 -0
- package/src/components/kanban-column.tsx +198 -0
- package/src/components/live-feed.tsx +220 -0
- package/src/components/log-viewer.tsx +2 -1
- package/src/components/metric-card.tsx +2 -1
- package/src/components/notification-stack.tsx +226 -0
- package/src/components/pipeline-stage.tsx +2 -1
- package/src/components/popover.tsx +2 -1
- package/src/components/port-status-grid.tsx +2 -1
- package/src/components/progress.tsx +2 -1
- package/src/components/radio-group.tsx +2 -1
- package/src/components/realtime-value.tsx +283 -0
- package/src/components/select.tsx +2 -1
- package/src/components/severity-timeline.tsx +2 -1
- package/src/components/sheet.tsx +2 -1
- package/src/components/skeleton.tsx +4 -3
- package/src/components/slider.tsx +2 -1
- package/src/components/smart-table.tsx +383 -0
- package/src/components/sortable-list.tsx +268 -0
- package/src/components/sparkline.tsx +2 -1
- package/src/components/status-badge.tsx +2 -1
- package/src/components/status-pulse.tsx +2 -1
- package/src/components/step-wizard.tsx +372 -0
- package/src/components/streaming-text.tsx +163 -0
- package/src/components/success-checkmark.tsx +2 -1
- package/src/components/tabs.tsx +2 -1
- package/src/components/threshold-gauge.tsx +2 -1
- package/src/components/time-range-selector.tsx +2 -1
- package/src/components/toast.tsx +2 -1
- package/src/components/toggle-switch.tsx +2 -1
- package/src/components/tooltip.tsx +2 -1
- package/src/components/truncated-text.tsx +2 -1
- package/src/components/typing-indicator.tsx +123 -0
- package/src/components/uptime-tracker.tsx +2 -1
- package/src/components/utilization-bar.tsx +2 -1
- package/src/utils.ts +1 -1
- 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
|