@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,791 @@
1
+ 'use client'
2
+
3
+ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
4
+ import {
5
+ useReactTable, getCoreRowModel, getSortedRowModel,
6
+ getFilteredRowModel, getPaginationRowModel,
7
+ flexRender, type ColumnDef, type SortingState,
8
+ type ColumnFiltersState, type VisibilityState,
9
+ type FilterFn, type Table,
10
+ } from '@tanstack/react-table'
11
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
12
+ import {
13
+ Search, ChevronUp, ChevronDown, ChevronsUpDown,
14
+ Download, Filter, X, List, AlignJustify, LayoutList,
15
+ Columns3, Eye, EyeOff, GripVertical,
16
+ } from 'lucide-react'
17
+ import type { LucideIcon } from 'lucide-react'
18
+ import { TruncatedText } from './truncated-text'
19
+ import { EmptyState } from './empty-state'
20
+ import { Skeleton } from './skeleton'
21
+ import { cn } from '../utils'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types & constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export type Density = 'compact' | 'comfortable' | 'spacious'
28
+
29
+ const DENSITY_CLASSES: Record<Density, string> = {
30
+ compact: 'py-1.5 px-3',
31
+ comfortable: 'py-3 px-4',
32
+ spacious: 'py-4 px-5',
33
+ }
34
+
35
+ const DENSITY_ICONS: { key: Density; icon: LucideIcon; label: string }[] = [
36
+ { key: 'compact', icon: List, label: 'Compact' },
37
+ { key: 'comfortable', icon: AlignJustify, label: 'Comfortable' },
38
+ { key: 'spacious', icon: LayoutList, label: 'Spacious' },
39
+ ]
40
+
41
+ const PAGE_SIZES = [10, 25, 50, 100]
42
+ const STORAGE_KEY = 'ui-kit-table-density'
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Global filter
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const globalFilterFn: FilterFn<unknown> = (row, _columnId, filterValue) => {
49
+ const search = String(filterValue).toLowerCase()
50
+ return row.getAllCells().some(cell =>
51
+ String(cell.getValue() ?? '').toLowerCase().includes(search)
52
+ )
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // CSV export
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function exportToCsv<T>(table: Table<T>, filename: string) {
60
+ const headers = table.getAllColumns().filter(c => c.getIsVisible()).map(c => c.id)
61
+ const rows = table.getFilteredRowModel().rows.map(row =>
62
+ headers.map(h => {
63
+ const val = row.getValue(h)
64
+ const str = String(val ?? '')
65
+ return str.includes(',') || str.includes('"') ? `"${str.replace(/"/g, '""')}"` : str
66
+ }).join(',')
67
+ )
68
+ const csv = [headers.join(','), ...rows].join('\n')
69
+ const blob = new Blob([csv], { type: 'text/csv' })
70
+ const url = URL.createObjectURL(blob)
71
+ const a = document.createElement('a')
72
+ a.href = url
73
+ a.download = `${filename}.csv`
74
+ a.click()
75
+ URL.revokeObjectURL(url)
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Column filter popover (internal)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function ColumnFilterPopover<T>({ column, table }: {
83
+ column: ReturnType<Table<T>['getHeaderGroups']>[0]['headers'][0]['column']
84
+ table: Table<T>
85
+ }) {
86
+ const [open, setOpen] = useState(false)
87
+ const ref = useRef<HTMLDivElement>(null)
88
+ const currentFilter = column.getFilterValue()
89
+ const isActive = currentFilter !== undefined && currentFilter !== '' &&
90
+ !(Array.isArray(currentFilter) && currentFilter.length === 0)
91
+
92
+ useEffect(() => {
93
+ if (!open) return
94
+ const handler = (e: MouseEvent) => {
95
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
96
+ }
97
+ document.addEventListener('mousedown', handler)
98
+ return () => document.removeEventListener('mousedown', handler)
99
+ }, [open])
100
+
101
+ const { kind, uniqueValues } = useMemo(() => {
102
+ const rows = table.getPreFilteredRowModel().rows.slice(0, 100)
103
+ const vals = rows.map(r => r.getValue(column.id)).filter(v => v != null)
104
+ const nums = vals.filter(v => typeof v === 'number' || (typeof v === 'string' && v !== '' && !isNaN(Number(v))))
105
+ if (nums.length > vals.length * 0.7 && vals.length > 0) return { kind: 'number' as const, uniqueValues: [] }
106
+ const uniques = [...new Set(vals.map(v => String(v)))]
107
+ if (uniques.length > 0 && uniques.length < 20) return { kind: 'enum' as const, uniqueValues: uniques.sort() }
108
+ return { kind: 'text' as const, uniqueValues: [] }
109
+ }, [table, column.id])
110
+
111
+ return (
112
+ <div ref={ref} className="relative inline-flex">
113
+ <button
114
+ onClick={(e) => { e.stopPropagation(); setOpen(o => !o) }}
115
+ className="relative p-0.5 rounded hover:bg-[hsl(var(--bg-elevated)/0.5)] transition-colors"
116
+ aria-label={`Filter ${column.id}`}
117
+ >
118
+ <Filter className="h-3 w-3 text-[hsl(var(--text-tertiary))]" />
119
+ {isActive && (
120
+ <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--brand-primary))]" />
121
+ )}
122
+ </button>
123
+
124
+ {open && (
125
+ <div
126
+ className="absolute top-full left-0 z-50 mt-1 min-w-[200px]
127
+ bg-[hsl(var(--bg-elevated))] border border-[hsl(var(--border-default))] rounded-lg shadow-lg p-3"
128
+ onClick={e => e.stopPropagation()}
129
+ >
130
+ <div className="flex items-center justify-between mb-2">
131
+ <span className="text-[11px] font-medium text-[hsl(var(--text-secondary))] uppercase tracking-wider">
132
+ Filter
133
+ </span>
134
+ {isActive && (
135
+ <button
136
+ onClick={() => { column.setFilterValue(undefined); setOpen(false) }}
137
+ className="text-[11px] text-[hsl(var(--brand-primary))] hover:underline"
138
+ >
139
+ Clear
140
+ </button>
141
+ )}
142
+ </div>
143
+
144
+ {kind === 'text' && (
145
+ <input
146
+ type="text"
147
+ value={(currentFilter as string) ?? ''}
148
+ onChange={e => column.setFilterValue(e.target.value || undefined)}
149
+ placeholder="Search\u2026"
150
+ className="w-full rounded-md border border-[hsl(var(--border-subtle))]
151
+ bg-[hsl(var(--bg-surface))] px-2.5 py-1.5 text-small text-[hsl(var(--text-primary))]
152
+ placeholder:text-[hsl(var(--text-tertiary))] outline-none
153
+ focus:border-[hsl(var(--brand-primary))] transition-colors"
154
+ autoFocus
155
+ />
156
+ )}
157
+
158
+ {kind === 'number' && (
159
+ <div className="flex gap-2">
160
+ <input
161
+ type="number"
162
+ value={(currentFilter as [number?, number?])?.[0] ?? ''}
163
+ onChange={e => {
164
+ const val = e.target.value === '' ? undefined : Number(e.target.value)
165
+ column.setFilterValue((old: [number?, number?]) => [val, old?.[1]])
166
+ }}
167
+ placeholder="Min"
168
+ className="w-full rounded-md border border-[hsl(var(--border-subtle))]
169
+ bg-[hsl(var(--bg-surface))] px-2.5 py-1.5 text-small text-[hsl(var(--text-primary))]
170
+ placeholder:text-[hsl(var(--text-tertiary))] outline-none
171
+ focus:border-[hsl(var(--brand-primary))] transition-colors"
172
+ />
173
+ <input
174
+ type="number"
175
+ value={(currentFilter as [number?, number?])?.[1] ?? ''}
176
+ onChange={e => {
177
+ const val = e.target.value === '' ? undefined : Number(e.target.value)
178
+ column.setFilterValue((old: [number?, number?]) => [old?.[0], val])
179
+ }}
180
+ placeholder="Max"
181
+ className="w-full rounded-md border border-[hsl(var(--border-subtle))]
182
+ bg-[hsl(var(--bg-surface))] px-2.5 py-1.5 text-small text-[hsl(var(--text-primary))]
183
+ placeholder:text-[hsl(var(--text-tertiary))] outline-none
184
+ focus:border-[hsl(var(--brand-primary))] transition-colors"
185
+ />
186
+ </div>
187
+ )}
188
+
189
+ {kind === 'enum' && (
190
+ <div className="max-h-[200px] overflow-y-auto space-y-1">
191
+ {uniqueValues.map(val => {
192
+ const selected = Array.isArray(currentFilter) ? (currentFilter as string[]).includes(val) : false
193
+ return (
194
+ <label key={val} className="flex items-center gap-2 rounded px-1.5 py-1
195
+ hover:bg-[hsl(var(--bg-surface)/0.5)] cursor-pointer transition-colors">
196
+ <input
197
+ type="checkbox"
198
+ checked={selected}
199
+ onChange={() => {
200
+ const prev = Array.isArray(currentFilter) ? (currentFilter as string[]) : []
201
+ const next = selected ? prev.filter(v => v !== val) : [...prev, val]
202
+ column.setFilterValue(next.length > 0 ? next : undefined)
203
+ }}
204
+ className="rounded border-[hsl(var(--border-default))] accent-[hsl(var(--brand-primary))]"
205
+ />
206
+ <span className="text-small text-[hsl(var(--text-primary))] truncate">{val}</span>
207
+ </label>
208
+ )
209
+ })}
210
+ </div>
211
+ )}
212
+ </div>
213
+ )}
214
+ </div>
215
+ )
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Column picker
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function ColumnPicker<T>({ table, onClose }: { table: Table<T>; onClose: () => void }) {
223
+ const ref = useRef<HTMLDivElement>(null)
224
+ const [dragIdx, setDragIdx] = useState<number | null>(null)
225
+
226
+ useEffect(() => {
227
+ const handler = (e: MouseEvent) => {
228
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose()
229
+ }
230
+ document.addEventListener('mousedown', handler)
231
+ return () => document.removeEventListener('mousedown', handler)
232
+ }, [onClose])
233
+
234
+ const allColumns = table.getAllLeafColumns()
235
+
236
+ const handleDragStart = (idx: number) => setDragIdx(idx)
237
+ const handleDragOver = (e: React.DragEvent, idx: number) => {
238
+ e.preventDefault()
239
+ if (dragIdx === null || dragIdx === idx) return
240
+ const order = table.getState().columnOrder.length > 0
241
+ ? [...table.getState().columnOrder]
242
+ : allColumns.map(c => c.id)
243
+ const [moved] = order.splice(dragIdx, 1)
244
+ if (moved !== undefined) order.splice(idx, 0, moved)
245
+ table.setColumnOrder(order)
246
+ setDragIdx(idx)
247
+ }
248
+
249
+ return (
250
+ <motion.div
251
+ ref={ref}
252
+ initial={{ opacity: 0, y: -4 }}
253
+ animate={{ opacity: 1, y: 0 }}
254
+ exit={{ opacity: 0, y: -4 }}
255
+ transition={{ duration: 0.12 }}
256
+ className={cn(
257
+ 'absolute right-0 top-full z-50 mt-1 w-56 rounded-xl overflow-hidden',
258
+ 'border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-elevated))] shadow-xl',
259
+ )}
260
+ >
261
+ <div className="px-3 py-2 border-b border-[hsl(var(--border-subtle)/0.5)]">
262
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-[hsl(var(--text-tertiary))]">
263
+ Columns
264
+ </span>
265
+ </div>
266
+ <div className="max-h-72 overflow-y-auto p-1">
267
+ {allColumns.map((col, idx) => {
268
+ if (!col.getCanHide()) return null
269
+ const visible = col.getIsVisible()
270
+ const header = typeof col.columnDef.header === 'string' ? col.columnDef.header : col.id
271
+ return (
272
+ <div
273
+ key={col.id}
274
+ draggable
275
+ onDragStart={() => handleDragStart(idx)}
276
+ onDragOver={(e) => handleDragOver(e, idx)}
277
+ onDragEnd={() => setDragIdx(null)}
278
+ className={cn(
279
+ 'flex items-center gap-2 rounded-lg px-2 py-1.5 text-small cursor-grab',
280
+ 'hover:bg-[hsl(var(--bg-surface)/0.7)] transition-colors',
281
+ dragIdx === idx && 'bg-[hsl(var(--brand-primary)/0.1)]',
282
+ )}
283
+ >
284
+ <GripVertical className="h-3 w-3 text-[hsl(var(--text-disabled))] shrink-0" />
285
+ <button
286
+ onClick={() => col.toggleVisibility()}
287
+ className="flex items-center gap-2 flex-1 text-left"
288
+ >
289
+ {visible
290
+ ? <Eye className="h-3.5 w-3.5 text-[hsl(var(--brand-primary))]" />
291
+ : <EyeOff className="h-3.5 w-3.5 text-[hsl(var(--text-disabled))]" />}
292
+ <span className={cn(
293
+ 'truncate',
294
+ visible ? 'text-[hsl(var(--text-primary))]' : 'text-[hsl(var(--text-disabled))]',
295
+ )}>
296
+ {header}
297
+ </span>
298
+ </button>
299
+ </div>
300
+ )
301
+ })}
302
+ </div>
303
+ <div className="px-3 py-2 border-t border-[hsl(var(--border-subtle)/0.5)]">
304
+ <button
305
+ onClick={() => {
306
+ table.toggleAllColumnsVisible(true)
307
+ table.setColumnOrder([])
308
+ }}
309
+ className="text-[10px] text-[hsl(var(--brand-primary))] hover:text-[hsl(var(--text-primary))] transition-colors"
310
+ >
311
+ Reset to defaults
312
+ </button>
313
+ </div>
314
+ </motion.div>
315
+ )
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // DataTable props
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export interface DataTableProps<T> {
323
+ /** Column definitions from @tanstack/react-table. */
324
+ columns: ColumnDef<T, unknown>[]
325
+ /** Row data array. */
326
+ data: T[]
327
+ /** Show loading skeleton. */
328
+ isLoading?: boolean
329
+ /** Custom empty state configuration. */
330
+ emptyState?: { icon: LucideIcon; title: string; description: string }
331
+ /** Placeholder text for the search input. */
332
+ searchPlaceholder?: string
333
+ /** Callback when a row is clicked. */
334
+ onRowClick?: (row: T) => void
335
+ /** Default sorting configuration. */
336
+ defaultSort?: { id: string; desc: boolean }
337
+ /** Default number of rows per page. */
338
+ defaultPageSize?: number
339
+ /** Filename for CSV export (enables export button when set). */
340
+ exportFilename?: string
341
+ /** Make the first column sticky on horizontal scroll. */
342
+ stickyFirstColumn?: boolean
343
+ /** Override the density setting. */
344
+ density?: Density
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // DataTable component
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * @description A full-featured data table with search, column filters, sorting, pagination,
353
+ * density control, column visibility/reorder, CSV export, and Framer Motion row animations.
354
+ * Built on @tanstack/react-table v8.
355
+ */
356
+ export function DataTable<T>({
357
+ columns,
358
+ data,
359
+ isLoading = false,
360
+ emptyState: emptyStateProps,
361
+ searchPlaceholder = 'Search...',
362
+ onRowClick,
363
+ defaultSort,
364
+ defaultPageSize = 25,
365
+ exportFilename,
366
+ stickyFirstColumn = false,
367
+ density: densityProp,
368
+ }: DataTableProps<T>) {
369
+ const prefersReducedMotion = useReducedMotion()
370
+
371
+ const [density, setDensity] = useState<Density>(() => {
372
+ if (densityProp) return densityProp
373
+ if (typeof window !== 'undefined') {
374
+ const stored = localStorage.getItem(STORAGE_KEY)
375
+ if (stored && stored in DENSITY_CLASSES) return stored as Density
376
+ }
377
+ return 'comfortable'
378
+ })
379
+
380
+ const handleDensity = useCallback((d: Density) => {
381
+ setDensity(d)
382
+ localStorage.setItem(STORAGE_KEY, d)
383
+ }, [])
384
+
385
+ const [sorting, setSorting] = useState<SortingState>(
386
+ defaultSort ? [{ id: defaultSort.id, desc: defaultSort.desc }] : []
387
+ )
388
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
389
+ const [globalFilter, setGlobalFilter] = useState('')
390
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
391
+ const [columnOrder, setColumnOrder] = useState<string[]>([])
392
+ const [columnPickerOpen, setColumnPickerOpen] = useState(false)
393
+
394
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
395
+ const table = useReactTable({
396
+ data: data as any[],
397
+ columns: columns as any,
398
+ state: { sorting, columnFilters, globalFilter, columnVisibility, columnOrder },
399
+ onSortingChange: setSorting,
400
+ onColumnFiltersChange: setColumnFilters,
401
+ onGlobalFilterChange: setGlobalFilter,
402
+ onColumnVisibilityChange: setColumnVisibility,
403
+ onColumnOrderChange: setColumnOrder,
404
+ globalFilterFn,
405
+ getCoreRowModel: getCoreRowModel(),
406
+ getSortedRowModel: getSortedRowModel(),
407
+ getFilteredRowModel: getFilteredRowModel(),
408
+ getPaginationRowModel: getPaginationRowModel(),
409
+ initialState: { pagination: { pageSize: defaultPageSize } },
410
+ })
411
+
412
+ const activeFilterCount = columnFilters.length
413
+ const { pageIndex, pageSize } = table.getState().pagination
414
+ const totalRows = table.getFilteredRowModel().rows.length
415
+ const startRow = totalRows === 0 ? 0 : pageIndex * pageSize + 1
416
+ const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
417
+ const pageCount = table.getPageCount()
418
+
419
+ const rowVariants = useMemo(() => ({
420
+ hidden: { opacity: 0, y: 4 },
421
+ visible: (i: number) => ({
422
+ opacity: 1,
423
+ y: 0,
424
+ transition: {
425
+ delay: Math.min(i, 20) * 0.02,
426
+ duration: 0.15,
427
+ },
428
+ }),
429
+ }), [])
430
+
431
+ // Loading skeleton
432
+ if (isLoading) {
433
+ return (
434
+ <div className="rounded-xl border border-[hsl(var(--border-subtle)/0.5)]
435
+ bg-[hsl(var(--bg-surface)/0.6)] backdrop-blur-xl overflow-hidden">
436
+ <div className="flex items-center gap-3 p-4 border-b border-[hsl(var(--border-subtle)/0.3)]">
437
+ <Skeleton className="h-9 w-64 rounded-lg" />
438
+ <div className="flex-1" />
439
+ <Skeleton className="h-8 w-24 rounded-lg" />
440
+ </div>
441
+ <div className="divide-y divide-[hsl(var(--border-subtle)/0.3)]">
442
+ {Array.from({ length: 8 }).map((_, i) => (
443
+ <div key={i} className={cn('flex gap-4', DENSITY_CLASSES.comfortable)}>
444
+ <Skeleton className="h-4 w-32" />
445
+ <Skeleton className="h-4 w-48" />
446
+ <Skeleton className="h-4 w-24" />
447
+ <Skeleton className="h-4 flex-1" />
448
+ </div>
449
+ ))}
450
+ </div>
451
+ </div>
452
+ )
453
+ }
454
+
455
+ return (
456
+ <div className="rounded-xl border border-[hsl(var(--border-subtle)/0.5)]
457
+ bg-[hsl(var(--bg-surface)/0.6)] backdrop-blur-xl overflow-hidden">
458
+
459
+ {/* Toolbar */}
460
+ <div className="flex flex-wrap items-center gap-3 px-4 py-3
461
+ border-b border-[hsl(var(--border-subtle)/0.3)]">
462
+
463
+ {/* Search */}
464
+ <div className="relative flex-1 min-w-[200px] max-w-sm">
465
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
466
+ text-[hsl(var(--text-tertiary))]" />
467
+ <input
468
+ type="text"
469
+ value={globalFilter}
470
+ onChange={e => setGlobalFilter(e.target.value)}
471
+ placeholder={searchPlaceholder}
472
+ className="w-full rounded-lg border border-[hsl(var(--border-subtle))]
473
+ bg-[hsl(var(--bg-surface))] pl-9 pr-3 py-2 text-small
474
+ text-[hsl(var(--text-primary))] placeholder:text-[hsl(var(--text-tertiary))]
475
+ outline-none focus:border-[hsl(var(--brand-primary))] transition-colors"
476
+ />
477
+ {globalFilter && (
478
+ <button
479
+ onClick={() => setGlobalFilter('')}
480
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded
481
+ hover:bg-[hsl(var(--bg-elevated)/0.5)] transition-colors"
482
+ >
483
+ <X className="h-3.5 w-3.5 text-[hsl(var(--text-tertiary))]" />
484
+ </button>
485
+ )}
486
+ </div>
487
+
488
+ {/* Active filter count */}
489
+ {activeFilterCount > 0 && (
490
+ <span className="inline-flex items-center gap-1 rounded-full
491
+ bg-[hsl(var(--brand-primary)/0.15)] px-2.5 py-1 text-[11px] font-medium
492
+ text-[hsl(var(--brand-primary))]">
493
+ <Filter className="h-3 w-3" />
494
+ {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''}
495
+ </span>
496
+ )}
497
+
498
+ <div className="flex-1" />
499
+
500
+ {/* Density toggle */}
501
+ <div className="flex items-center rounded-lg border border-[hsl(var(--border-subtle))]
502
+ bg-[hsl(var(--bg-surface))] p-0.5">
503
+ {DENSITY_ICONS.map(({ key, icon: Icon, label }) => (
504
+ <button
505
+ key={key}
506
+ onClick={() => handleDensity(key)}
507
+ title={label}
508
+ className={cn(
509
+ 'p-1.5 rounded-md transition-colors',
510
+ density === key
511
+ ? 'bg-[hsl(var(--brand-primary)/0.2)] text-[hsl(var(--brand-primary))]'
512
+ : 'text-[hsl(var(--text-tertiary))] hover:text-[hsl(var(--text-secondary))]',
513
+ )}
514
+ >
515
+ <Icon className="h-3.5 w-3.5" />
516
+ </button>
517
+ ))}
518
+ </div>
519
+
520
+ {/* Column visibility picker */}
521
+ <div className="relative">
522
+ <button
523
+ onClick={() => setColumnPickerOpen(o => !o)}
524
+ className={cn(
525
+ 'flex items-center gap-1.5 rounded-lg border border-[hsl(var(--border-subtle))]',
526
+ 'bg-[hsl(var(--bg-surface))] px-3 py-1.5 text-small text-[hsl(var(--text-secondary))]',
527
+ 'hover:bg-[hsl(var(--bg-elevated)/0.5)] hover:text-[hsl(var(--text-primary))] transition-colors',
528
+ )}
529
+ >
530
+ <Columns3 className="h-3.5 w-3.5" />
531
+ Columns
532
+ </button>
533
+ <AnimatePresence>
534
+ {columnPickerOpen && (
535
+ <ColumnPicker table={table} onClose={() => setColumnPickerOpen(false)} />
536
+ )}
537
+ </AnimatePresence>
538
+ </div>
539
+
540
+ {/* CSV export */}
541
+ {exportFilename && (
542
+ <button
543
+ onClick={() => exportToCsv(table, exportFilename)}
544
+ className="flex items-center gap-1.5 rounded-lg border border-[hsl(var(--border-subtle))]
545
+ bg-[hsl(var(--bg-surface))] px-3 py-1.5 text-small text-[hsl(var(--text-secondary))]
546
+ hover:bg-[hsl(var(--bg-elevated)/0.5)] hover:text-[hsl(var(--text-primary))]
547
+ transition-colors"
548
+ >
549
+ <Download className="h-3.5 w-3.5" />
550
+ Export
551
+ </button>
552
+ )}
553
+
554
+ {/* Row count */}
555
+ <span className="text-[11px] text-[hsl(var(--text-tertiary))] tabular-nums">
556
+ {totalRows} row{totalRows !== 1 ? 's' : ''}
557
+ </span>
558
+ </div>
559
+
560
+ {/* Table */}
561
+ <div className={cn('overflow-x-auto', stickyFirstColumn && 'relative')}>
562
+ <table className="w-full border-collapse text-small">
563
+ <thead>
564
+ {table.getHeaderGroups().map(headerGroup => (
565
+ <tr key={headerGroup.id} className="bg-[hsl(var(--bg-elevated)/0.3)]">
566
+ {headerGroup.headers.map((header, colIdx) => {
567
+ const canSort = header.column.getCanSort()
568
+ const sorted = header.column.getIsSorted()
569
+ return (
570
+ <th
571
+ key={header.id}
572
+ className={cn(
573
+ DENSITY_CLASSES[density],
574
+ 'text-left text-[11px] font-semibold uppercase tracking-wider',
575
+ 'text-[hsl(var(--text-tertiary))] select-none whitespace-nowrap',
576
+ 'border-b border-[hsl(var(--border-subtle)/0.3)]',
577
+ stickyFirstColumn && colIdx === 0 &&
578
+ 'sticky left-0 z-10 bg-[hsl(var(--bg-elevated)/0.8)] backdrop-blur-sm',
579
+ )}
580
+ >
581
+ <div className="flex items-center gap-1.5">
582
+ {canSort ? (
583
+ <button
584
+ onClick={header.column.getToggleSortingHandler()}
585
+ className="flex items-center gap-1 hover:text-[hsl(var(--text-secondary))] transition-colors"
586
+ >
587
+ {header.isPlaceholder
588
+ ? null
589
+ : flexRender(header.column.columnDef.header, header.getContext())}
590
+ {sorted === 'asc' ? (
591
+ <ChevronUp className="h-3.5 w-3.5 text-[hsl(var(--brand-primary))]" />
592
+ ) : sorted === 'desc' ? (
593
+ <ChevronDown className="h-3.5 w-3.5 text-[hsl(var(--brand-primary))]" />
594
+ ) : (
595
+ <ChevronsUpDown className="h-3 w-3 opacity-40" />
596
+ )}
597
+ </button>
598
+ ) : (
599
+ header.isPlaceholder
600
+ ? null
601
+ : flexRender(header.column.columnDef.header, header.getContext())
602
+ )}
603
+ {header.column.getCanFilter() && (
604
+ <ColumnFilterPopover column={header.column} table={table} />
605
+ )}
606
+ </div>
607
+ </th>
608
+ )
609
+ })}
610
+ </tr>
611
+ ))}
612
+ </thead>
613
+ <tbody>
614
+ <AnimatePresence mode="popLayout">
615
+ {table.getRowModel().rows.length === 0 ? (
616
+ <tr>
617
+ <td colSpan={columns.length} className="p-0">
618
+ {emptyStateProps ? (
619
+ <EmptyState
620
+ icon={emptyStateProps.icon}
621
+ title={emptyStateProps.title}
622
+ description={emptyStateProps.description}
623
+ className="border-0 rounded-none"
624
+ />
625
+ ) : (
626
+ <EmptyState
627
+ icon={Search}
628
+ title="No results"
629
+ description="No rows match your search or filter criteria."
630
+ className="border-0 rounded-none"
631
+ />
632
+ )}
633
+ </td>
634
+ </tr>
635
+ ) : (
636
+ table.getRowModel().rows.map((row, i) => (
637
+ <motion.tr
638
+ key={row.id}
639
+ custom={i}
640
+ variants={prefersReducedMotion ? undefined : rowVariants}
641
+ initial={prefersReducedMotion ? undefined : 'hidden'}
642
+ animate={prefersReducedMotion ? undefined : 'visible'}
643
+ exit={prefersReducedMotion ? undefined : { opacity: 0 }}
644
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
645
+ className={cn(
646
+ 'border-b border-[hsl(var(--border-subtle)/0.3)]',
647
+ 'transition-colors',
648
+ onRowClick && 'cursor-pointer hover:bg-[hsl(var(--bg-elevated)/0.5)]',
649
+ )}
650
+ >
651
+ {row.getVisibleCells().map((cell, colIdx) => (
652
+ <td
653
+ key={cell.id}
654
+ className={cn(
655
+ DENSITY_CLASSES[density],
656
+ 'text-[hsl(var(--text-primary))]',
657
+ stickyFirstColumn && colIdx === 0 &&
658
+ 'sticky left-0 z-10 bg-[hsl(var(--bg-surface)/0.9)] backdrop-blur-sm',
659
+ )}
660
+ >
661
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
662
+ </td>
663
+ ))}
664
+ </motion.tr>
665
+ ))
666
+ )}
667
+ </AnimatePresence>
668
+ </tbody>
669
+ </table>
670
+ </div>
671
+
672
+ {/* Pagination footer */}
673
+ {totalRows > 0 && (
674
+ <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3
675
+ border-t border-[hsl(var(--border-subtle)/0.3)]">
676
+ <span className="text-[12px] text-[hsl(var(--text-tertiary))] tabular-nums">
677
+ Showing {startRow}&ndash;{endRow} of {totalRows}
678
+ </span>
679
+
680
+ <div className="flex items-center gap-2">
681
+ <select
682
+ value={pageSize}
683
+ onChange={e => table.setPageSize(Number(e.target.value))}
684
+ className="rounded-md border border-[hsl(var(--border-subtle))]
685
+ bg-[hsl(var(--bg-surface))] px-2 py-1 text-[12px]
686
+ text-[hsl(var(--text-secondary))] outline-none
687
+ focus:border-[hsl(var(--brand-primary))] transition-colors"
688
+ >
689
+ {PAGE_SIZES.map(size => (
690
+ <option key={size} value={size}>{size} / page</option>
691
+ ))}
692
+ </select>
693
+
694
+ <div className="flex items-center gap-1">
695
+ <PaginationButton
696
+ onClick={() => table.setPageIndex(0)}
697
+ disabled={!table.getCanPreviousPage()}
698
+ >
699
+ First
700
+ </PaginationButton>
701
+ <PaginationButton
702
+ onClick={() => table.previousPage()}
703
+ disabled={!table.getCanPreviousPage()}
704
+ >
705
+ Prev
706
+ </PaginationButton>
707
+
708
+ {generatePageNumbers(pageIndex, pageCount).map((p, idx) =>
709
+ p === -1 ? (
710
+ <span key={`ellipsis-${idx}`} className="px-1 text-[hsl(var(--text-tertiary))]">
711
+ ...
712
+ </span>
713
+ ) : (
714
+ <PaginationButton
715
+ key={p}
716
+ onClick={() => table.setPageIndex(p)}
717
+ active={p === pageIndex}
718
+ >
719
+ {p + 1}
720
+ </PaginationButton>
721
+ )
722
+ )}
723
+
724
+ <PaginationButton
725
+ onClick={() => table.nextPage()}
726
+ disabled={!table.getCanNextPage()}
727
+ >
728
+ Next
729
+ </PaginationButton>
730
+ <PaginationButton
731
+ onClick={() => table.setPageIndex(pageCount - 1)}
732
+ disabled={!table.getCanNextPage()}
733
+ >
734
+ Last
735
+ </PaginationButton>
736
+ </div>
737
+ </div>
738
+ </div>
739
+ )}
740
+ </div>
741
+ )
742
+ }
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // Pagination button
746
+ // ---------------------------------------------------------------------------
747
+
748
+ function PaginationButton({ children, onClick, disabled, active }: {
749
+ children: React.ReactNode
750
+ onClick: () => void
751
+ disabled?: boolean
752
+ active?: boolean
753
+ }) {
754
+ return (
755
+ <button
756
+ onClick={onClick}
757
+ disabled={disabled}
758
+ className={cn(
759
+ 'rounded-md px-2 py-1 text-[12px] font-medium transition-colors tabular-nums',
760
+ active
761
+ ? 'bg-[hsl(var(--brand-primary)/0.2)] text-[hsl(var(--brand-primary))]'
762
+ : 'text-[hsl(var(--text-secondary))] hover:bg-[hsl(var(--bg-elevated)/0.5)] hover:text-[hsl(var(--text-primary))]',
763
+ disabled && 'opacity-40 pointer-events-none',
764
+ )}
765
+ >
766
+ {children}
767
+ </button>
768
+ )
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // Page number generation
773
+ // ---------------------------------------------------------------------------
774
+
775
+ function generatePageNumbers(current: number, total: number): number[] {
776
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i)
777
+ const pages: number[] = []
778
+ const addPage = (p: number) => { if (!pages.includes(p)) pages.push(p) }
779
+ addPage(0)
780
+ for (let i = Math.max(1, current - 1); i <= Math.min(total - 2, current + 1); i++) addPage(i)
781
+ addPage(total - 1)
782
+ const result: number[] = []
783
+ for (let i = 0; i < pages.length; i++) {
784
+ if (i > 0 && pages[i]! - pages[i - 1]! > 1) result.push(-1)
785
+ result.push(pages[i]!)
786
+ }
787
+ return result
788
+ }
789
+
790
+ // Re-export TruncatedText for convenience (used often with DataTable)
791
+ export { TruncatedText }