@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.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/chunk-5OKSXPWK.js +270 -0
- package/dist/chunk-5OKSXPWK.js.map +1 -0
- package/dist/cli/index.js +430 -0
- package/dist/form.d.ts +65 -0
- package/dist/form.js +148 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +942 -0
- package/dist/index.js +2812 -0
- package/dist/index.js.map +1 -0
- package/dist/select-nnBJUO8U.d.ts +26 -0
- package/package.json +114 -0
- package/src/components/animated-counter.stories.tsx +68 -0
- package/src/components/animated-counter.tsx +85 -0
- package/src/components/avatar.tsx +106 -0
- package/src/components/badge.stories.tsx +70 -0
- package/src/components/badge.tsx +97 -0
- package/src/components/button.stories.tsx +101 -0
- package/src/components/button.tsx +67 -0
- package/src/components/card.tsx +128 -0
- package/src/components/checkbox.stories.tsx +64 -0
- package/src/components/checkbox.tsx +58 -0
- package/src/components/confirm-dialog.stories.tsx +96 -0
- package/src/components/confirm-dialog.tsx +145 -0
- package/src/components/data-table.stories.tsx +125 -0
- package/src/components/data-table.tsx +791 -0
- package/src/components/dropdown-menu.tsx +111 -0
- package/src/components/empty-state.stories.tsx +42 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/filter-pill.stories.tsx +71 -0
- package/src/components/filter-pill.tsx +45 -0
- package/src/components/form-input.stories.tsx +91 -0
- package/src/components/form-input.tsx +77 -0
- package/src/components/log-viewer.tsx +212 -0
- package/src/components/metric-card.tsx +141 -0
- package/src/components/pipeline-stage.tsx +134 -0
- package/src/components/popover.tsx +72 -0
- package/src/components/port-status-grid.tsx +102 -0
- package/src/components/progress.tsx +128 -0
- package/src/components/radio-group.tsx +162 -0
- package/src/components/select.stories.tsx +52 -0
- package/src/components/select.tsx +92 -0
- package/src/components/severity-timeline.tsx +125 -0
- package/src/components/sheet.tsx +164 -0
- package/src/components/skeleton.stories.tsx +64 -0
- package/src/components/skeleton.tsx +62 -0
- package/src/components/slider.tsx +208 -0
- package/src/components/sparkline.tsx +104 -0
- package/src/components/status-badge.stories.tsx +84 -0
- package/src/components/status-badge.tsx +71 -0
- package/src/components/status-pulse.stories.tsx +56 -0
- package/src/components/status-pulse.tsx +78 -0
- package/src/components/success-checkmark.stories.tsx +67 -0
- package/src/components/success-checkmark.tsx +53 -0
- package/src/components/tabs.tsx +177 -0
- package/src/components/threshold-gauge.tsx +149 -0
- package/src/components/time-range-selector.tsx +86 -0
- package/src/components/toast.stories.tsx +70 -0
- package/src/components/toast.tsx +48 -0
- package/src/components/toggle-switch.stories.tsx +66 -0
- package/src/components/toggle-switch.tsx +51 -0
- package/src/components/tooltip.tsx +62 -0
- package/src/components/truncated-text.stories.tsx +56 -0
- package/src/components/truncated-text.tsx +80 -0
- package/src/components/uptime-tracker.tsx +138 -0
- package/src/components/utilization-bar.tsx +103 -0
- package/src/theme.css +178 -0
- 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}–{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 }
|