@requence/table 0.0.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 +108 -0
- package/dist/VirtualTable.d.ts +94 -0
- package/dist/VirtualTable.d.ts.map +1 -0
- package/dist/VirtualTable.js +411 -0
- package/dist/VirtualTable.js.map +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +9 -0
- package/dist/useTableCache.d.ts +71 -0
- package/dist/useTableCache.d.ts.map +1 -0
- package/dist/useTableCache.js +232 -0
- package/dist/useTableCache.js.map +10 -0
- package/dist/useTableColumnWidths.d.ts +20 -0
- package/dist/useTableColumnWidths.d.ts.map +1 -0
- package/dist/useTableColumnWidths.js +45 -0
- package/dist/useTableColumnWidths.js.map +10 -0
- package/package.json +64 -0
- package/src/VirtualTable.tsx +673 -0
- package/src/index.ts +26 -0
- package/src/useTableCache.ts +424 -0
- package/src/useTableColumnWidths.ts +67 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
Children,
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
isValidElement,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import { flushSync } from 'react-dom'
|
|
14
|
+
import { twMerge } from 'tailwind-merge'
|
|
15
|
+
|
|
16
|
+
/* ── Types ──────────────────────────────────────────────────────── */
|
|
17
|
+
|
|
18
|
+
export interface VirtualTableProps {
|
|
19
|
+
/** Total number of rows in the dataset */
|
|
20
|
+
totalCount: number
|
|
21
|
+
/** Fixed height of each row in pixels */
|
|
22
|
+
rowHeight: number
|
|
23
|
+
/** Number of extra rows rendered above/below viewport (default: 5) */
|
|
24
|
+
overscan?: number
|
|
25
|
+
/** Called when the visible row range changes (for triggering page fetches) */
|
|
26
|
+
onRangeChange?: (range: { start: number; end: number }) => void
|
|
27
|
+
/** Additional className for the outer container */
|
|
28
|
+
className?: string
|
|
29
|
+
/** Accessible label for the table */
|
|
30
|
+
'aria-label'?: string
|
|
31
|
+
/** Additional inline styles for the outer container */
|
|
32
|
+
style?: CSSProperties
|
|
33
|
+
children: ReactNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface VirtualTableHeaderProps {
|
|
37
|
+
className?: string
|
|
38
|
+
children: ReactNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface VirtualTableColumnProps {
|
|
42
|
+
/** Column width. Number for pixels, string for CSS grid values (e.g. '1fr'). Defaults to '1fr'. */
|
|
43
|
+
width?: number | string
|
|
44
|
+
/** Optional className for the header cell */
|
|
45
|
+
className?: string
|
|
46
|
+
/** Whether this column can be resized by dragging. Default: false */
|
|
47
|
+
resizable?: boolean
|
|
48
|
+
/** Minimum width in pixels during resize. Default: 50 */
|
|
49
|
+
minWidth?: number
|
|
50
|
+
/** Maximum width in pixels during resize */
|
|
51
|
+
maxWidth?: number
|
|
52
|
+
/** Mark this column as transparent — the row background will not extend behind it. */
|
|
53
|
+
transparent?: boolean
|
|
54
|
+
/** Called when a resize drag starts */
|
|
55
|
+
onResizeStart?: () => void
|
|
56
|
+
/** Called when a resize drag ends with the final pixel width, original width, and equivalent fr value */
|
|
57
|
+
onResizeEnd?: (width: number, startWidth: number, frValue: number) => void
|
|
58
|
+
children?: ReactNode
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VirtualTableBodyProps {
|
|
62
|
+
children: (index: number) => ReactNode | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface VirtualTableRowProps extends ComponentProps<'div'> {}
|
|
66
|
+
|
|
67
|
+
export interface VirtualTableSkeletonRowProps extends ComponentProps<'div'> {}
|
|
68
|
+
|
|
69
|
+
export interface VirtualTableCellProps extends ComponentProps<'div'> {
|
|
70
|
+
/** Show cell content only on row hover */
|
|
71
|
+
showOnHover?: boolean
|
|
72
|
+
/** Number of columns this cell spans */
|
|
73
|
+
colSpan?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface VirtualTableEmptyProps {
|
|
77
|
+
className?: string
|
|
78
|
+
children: ReactNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VirtualTableFooterProps {
|
|
82
|
+
className?: string
|
|
83
|
+
children: (range: { start: number; end: number }) => ReactNode
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── Internal types ─────────────────────────────────────────────── */
|
|
87
|
+
|
|
88
|
+
interface ColumnDef {
|
|
89
|
+
width?: number | string
|
|
90
|
+
header?: ReactNode
|
|
91
|
+
className?: string
|
|
92
|
+
resizable?: boolean
|
|
93
|
+
minWidth?: number
|
|
94
|
+
maxWidth?: number
|
|
95
|
+
transparent?: boolean
|
|
96
|
+
onResizeStart?: () => void
|
|
97
|
+
onResizeEnd?: (width: number, startWidth: number, frValue: number) => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Slots {
|
|
101
|
+
header: { className?: string; columns: ColumnDef[] } | null
|
|
102
|
+
body: VirtualTableBodyProps | null
|
|
103
|
+
skeletonRow: VirtualTableSkeletonRowProps | null
|
|
104
|
+
empty: VirtualTableEmptyProps | null
|
|
105
|
+
footer: VirtualTableFooterProps | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ── Slot system ───────────────────────────────────────────────── */
|
|
109
|
+
|
|
110
|
+
interface SlotComponent<P> {
|
|
111
|
+
(props: P): ReactNode
|
|
112
|
+
slot: string
|
|
113
|
+
slotDefaults: Partial<P>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function asSlot<P>(slot: string, defaults?: Partial<P>): SlotComponent<P> {
|
|
117
|
+
const Component = (() => null) as unknown as SlotComponent<P>
|
|
118
|
+
Component.slot = slot
|
|
119
|
+
Component.slotDefaults = defaults ?? ({} as Partial<P>)
|
|
120
|
+
return Component
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createTableHeader(
|
|
124
|
+
defaults?: Partial<VirtualTableHeaderProps>,
|
|
125
|
+
): SlotComponent<VirtualTableHeaderProps> {
|
|
126
|
+
return asSlot('header', defaults)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createTableColumn(
|
|
130
|
+
defaults?: Partial<VirtualTableColumnProps>,
|
|
131
|
+
): SlotComponent<VirtualTableColumnProps> {
|
|
132
|
+
return asSlot('column', defaults)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createTableBody(
|
|
136
|
+
defaults?: Partial<VirtualTableBodyProps>,
|
|
137
|
+
): SlotComponent<VirtualTableBodyProps> {
|
|
138
|
+
return asSlot('body', defaults)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createTableSkeletonRow(
|
|
142
|
+
defaults?: Partial<VirtualTableSkeletonRowProps>,
|
|
143
|
+
): SlotComponent<VirtualTableSkeletonRowProps> {
|
|
144
|
+
return asSlot('skeletonRow', defaults)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createTableEmpty(
|
|
148
|
+
defaults?: Partial<VirtualTableEmptyProps>,
|
|
149
|
+
): SlotComponent<VirtualTableEmptyProps> {
|
|
150
|
+
return asSlot('empty', defaults)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createTableFooter(
|
|
154
|
+
defaults?: Partial<VirtualTableFooterProps>,
|
|
155
|
+
): SlotComponent<VirtualTableFooterProps> {
|
|
156
|
+
return asSlot('footer', defaults)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createTableRow(
|
|
160
|
+
defaults?: Partial<VirtualTableRowProps>,
|
|
161
|
+
): SlotComponent<VirtualTableRowProps> {
|
|
162
|
+
return asSlot('row', defaults)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ── Constants ──────────────────────────────────────────────────── */
|
|
166
|
+
|
|
167
|
+
const GRID_VAR = '--vtable-grid-cols'
|
|
168
|
+
const GRID_VAR_REF = `var(${GRID_VAR})`
|
|
169
|
+
|
|
170
|
+
/* ── Helpers ────────────────────────────────────────────────────── */
|
|
171
|
+
|
|
172
|
+
function buildGridTemplate(columns: ColumnDef[]): string {
|
|
173
|
+
return columns
|
|
174
|
+
.map((col) => {
|
|
175
|
+
if (typeof col.width === 'number') {
|
|
176
|
+
return `${col.width}px`
|
|
177
|
+
}
|
|
178
|
+
const min = col.minWidth ? `${col.minWidth}px` : '0'
|
|
179
|
+
if (typeof col.width === 'string') {
|
|
180
|
+
return `minmax(${min}, ${col.width})`
|
|
181
|
+
}
|
|
182
|
+
return `minmax(${min}, 1fr)`
|
|
183
|
+
})
|
|
184
|
+
.join(' ')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ── Slot Components (render nothing — used as config markers) ── */
|
|
188
|
+
|
|
189
|
+
const VirtualTableHeader = asSlot<VirtualTableHeaderProps>('header')
|
|
190
|
+
const VirtualTableColumn = asSlot<VirtualTableColumnProps>('column')
|
|
191
|
+
const VirtualTableBody = asSlot<VirtualTableBodyProps>('body')
|
|
192
|
+
const VirtualTableRow = asSlot<VirtualTableRowProps>('row')
|
|
193
|
+
const VirtualTableSkeletonRow =
|
|
194
|
+
asSlot<VirtualTableSkeletonRowProps>('skeletonRow')
|
|
195
|
+
const VirtualTableEmpty = asSlot<VirtualTableEmptyProps>('empty')
|
|
196
|
+
const VirtualTableFooter = asSlot<VirtualTableFooterProps>('footer')
|
|
197
|
+
|
|
198
|
+
function slotIs(child: React.ReactElement, slot: string): boolean {
|
|
199
|
+
return (child.type as any)?.slot === slot
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractSlots(children: ReactNode): Slots {
|
|
203
|
+
let header: Slots['header'] = null
|
|
204
|
+
let body: Slots['body'] = null
|
|
205
|
+
let skeletonRow: Slots['skeletonRow'] = null
|
|
206
|
+
let empty: Slots['empty'] = null
|
|
207
|
+
let footer: Slots['footer'] = null
|
|
208
|
+
|
|
209
|
+
Children.forEach(children, (child) => {
|
|
210
|
+
if (!isValidElement(child)) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (slotIs(child, 'header')) {
|
|
215
|
+
const defaults = (child.type as any).slotDefaults ?? {}
|
|
216
|
+
const props = child.props as VirtualTableHeaderProps
|
|
217
|
+
const columns: ColumnDef[] = []
|
|
218
|
+
Children.forEach(props.children, (col) => {
|
|
219
|
+
if (isValidElement(col) && slotIs(col, 'column')) {
|
|
220
|
+
const d = (col.type as any).slotDefaults ?? {}
|
|
221
|
+
const p = col.props as VirtualTableColumnProps
|
|
222
|
+
columns.push({
|
|
223
|
+
width: p.width ?? d.width,
|
|
224
|
+
header: p.children,
|
|
225
|
+
className: twMerge(d.className as string, p.className),
|
|
226
|
+
resizable: p.resizable ?? d.resizable,
|
|
227
|
+
minWidth: p.minWidth ?? d.minWidth,
|
|
228
|
+
maxWidth: p.maxWidth ?? d.maxWidth,
|
|
229
|
+
transparent: p.transparent ?? d.transparent,
|
|
230
|
+
onResizeStart: p.onResizeStart ?? d.onResizeStart,
|
|
231
|
+
onResizeEnd: p.onResizeEnd ?? d.onResizeEnd,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
header = {
|
|
236
|
+
className: twMerge(defaults.className as string, props.className),
|
|
237
|
+
columns,
|
|
238
|
+
}
|
|
239
|
+
} else if (slotIs(child, 'body')) {
|
|
240
|
+
body = child.props as VirtualTableBodyProps
|
|
241
|
+
} else if (slotIs(child, 'skeletonRow')) {
|
|
242
|
+
const defaults = (child.type as any).slotDefaults ?? {}
|
|
243
|
+
const props = child.props as VirtualTableSkeletonRowProps
|
|
244
|
+
skeletonRow = {
|
|
245
|
+
...defaults,
|
|
246
|
+
...props,
|
|
247
|
+
className: twMerge(defaults.className as string, props.className),
|
|
248
|
+
}
|
|
249
|
+
} else if (slotIs(child, 'empty')) {
|
|
250
|
+
const defaults = (child.type as any).slotDefaults ?? {}
|
|
251
|
+
const props = child.props as VirtualTableEmptyProps
|
|
252
|
+
empty = {
|
|
253
|
+
...defaults,
|
|
254
|
+
...props,
|
|
255
|
+
className: twMerge(defaults.className as string, props.className),
|
|
256
|
+
}
|
|
257
|
+
} else if (slotIs(child, 'footer')) {
|
|
258
|
+
const defaults = (child.type as any).slotDefaults ?? {}
|
|
259
|
+
const props = child.props as VirtualTableFooterProps
|
|
260
|
+
footer = {
|
|
261
|
+
...defaults,
|
|
262
|
+
...props,
|
|
263
|
+
className: twMerge(defaults.className as string, props.className),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return { header, body, skeletonRow, empty, footer }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── Resize Handle ─────────────────────────────────────────────── */
|
|
272
|
+
|
|
273
|
+
interface ResizeHandleProps {
|
|
274
|
+
onMouseDown: (e: React.MouseEvent) => void
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ResizeHandle({ onMouseDown }: ResizeHandleProps) {
|
|
278
|
+
return (
|
|
279
|
+
<div
|
|
280
|
+
role="separator"
|
|
281
|
+
aria-orientation="vertical"
|
|
282
|
+
className="resizer absolute right-0 top-0 z-10 h-full w-1.5 cursor-col-resize"
|
|
283
|
+
onMouseDown={onMouseDown}
|
|
284
|
+
/>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ── Cell ───────────────────────────────────────────────────────── */
|
|
289
|
+
|
|
290
|
+
function VirtualTableCell({
|
|
291
|
+
className,
|
|
292
|
+
style,
|
|
293
|
+
showOnHover,
|
|
294
|
+
colSpan,
|
|
295
|
+
...rest
|
|
296
|
+
}: VirtualTableCellProps) {
|
|
297
|
+
return (
|
|
298
|
+
<div
|
|
299
|
+
role="cell"
|
|
300
|
+
className={twMerge(
|
|
301
|
+
'overflow-hidden text-ellipsis whitespace-nowrap',
|
|
302
|
+
showOnHover &&
|
|
303
|
+
'not-group-hover/row:*:delay-200 *:opacity-10 *:transition-opacity *:duration-300 *:ease-in-out group-hover/row:*:opacity-100',
|
|
304
|
+
className,
|
|
305
|
+
)}
|
|
306
|
+
style={colSpan ? { gridColumn: `span ${colSpan}`, ...style } : style}
|
|
307
|
+
{...rest}
|
|
308
|
+
/>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ── Data Row ──────────────────────────────────────────────────── */
|
|
313
|
+
|
|
314
|
+
interface DataRowProps {
|
|
315
|
+
index: number
|
|
316
|
+
rowHeight: number
|
|
317
|
+
rowProps: ComponentProps<'div'>
|
|
318
|
+
children: ReactNode
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function DataRow({ index, rowHeight, rowProps, children }: DataRowProps) {
|
|
322
|
+
const { className, style, ...restProps } = rowProps
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
role="row"
|
|
326
|
+
aria-rowindex={index + 1}
|
|
327
|
+
className={twMerge('group/row absolute w-full', className)}
|
|
328
|
+
style={{
|
|
329
|
+
height: rowHeight,
|
|
330
|
+
transform: `translateY(${index * rowHeight}px)`,
|
|
331
|
+
display: 'grid',
|
|
332
|
+
gridTemplateColumns: GRID_VAR_REF,
|
|
333
|
+
alignItems: 'center',
|
|
334
|
+
willChange: 'transform',
|
|
335
|
+
contain: 'layout style paint',
|
|
336
|
+
...style,
|
|
337
|
+
}}
|
|
338
|
+
{...restProps}
|
|
339
|
+
>
|
|
340
|
+
{children}
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ── VirtualTable ──────────────────────────────────────────────── */
|
|
346
|
+
|
|
347
|
+
function VirtualTableRoot({
|
|
348
|
+
totalCount,
|
|
349
|
+
rowHeight,
|
|
350
|
+
overscan = 5,
|
|
351
|
+
onRangeChange,
|
|
352
|
+
className,
|
|
353
|
+
style: styleProp,
|
|
354
|
+
'aria-label': ariaLabel,
|
|
355
|
+
children,
|
|
356
|
+
}: VirtualTableProps) {
|
|
357
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
358
|
+
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 })
|
|
359
|
+
const prevRangeRef = useRef({ start: 0, end: 0 })
|
|
360
|
+
|
|
361
|
+
const { header, body, skeletonRow, empty, footer } = extractSlots(children)
|
|
362
|
+
const columns = header?.columns ?? []
|
|
363
|
+
const gridTemplate = buildGridTemplate(columns)
|
|
364
|
+
const renderRow = body?.children ?? (() => null)
|
|
365
|
+
|
|
366
|
+
// ── Scroll handler (synchronous for flicker-free scrolling) ────
|
|
367
|
+
const calculateRange = useCallback(() => {
|
|
368
|
+
const el = scrollRef.current
|
|
369
|
+
if (!el || totalCount === 0) {
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const scrollTop = el.scrollTop
|
|
374
|
+
const viewportHeight = el.clientHeight
|
|
375
|
+
|
|
376
|
+
const rawStart = Math.floor(scrollTop / rowHeight)
|
|
377
|
+
const rawEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight)
|
|
378
|
+
|
|
379
|
+
const start = Math.max(0, rawStart - overscan)
|
|
380
|
+
const end = Math.min(totalCount, rawEnd + overscan)
|
|
381
|
+
|
|
382
|
+
const prev = prevRangeRef.current
|
|
383
|
+
if (prev.start !== start || prev.end !== end) {
|
|
384
|
+
prevRangeRef.current = { start, end }
|
|
385
|
+
flushSync(() => setVisibleRange({ start, end }))
|
|
386
|
+
}
|
|
387
|
+
}, [totalCount, rowHeight, overscan])
|
|
388
|
+
|
|
389
|
+
const handleScroll = useCallback(() => {
|
|
390
|
+
calculateRange()
|
|
391
|
+
}, [calculateRange])
|
|
392
|
+
|
|
393
|
+
// Recalculate on totalCount/rowHeight changes
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
calculateRange()
|
|
396
|
+
}, [calculateRange])
|
|
397
|
+
|
|
398
|
+
// Notify consumer when visible range changes
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
onRangeChange?.(visibleRange)
|
|
401
|
+
}, [visibleRange, onRangeChange])
|
|
402
|
+
|
|
403
|
+
// ── Column resize ──────────────────────────────────────────────
|
|
404
|
+
const handleResizeMouseDown = useCallback(
|
|
405
|
+
(columnIndex: number, e: React.MouseEvent) => {
|
|
406
|
+
e.preventDefault()
|
|
407
|
+
|
|
408
|
+
const container = scrollRef.current
|
|
409
|
+
if (!container) {
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const col = columns[columnIndex]
|
|
414
|
+
const headerCells = container.querySelectorAll('[role="columnheader"]')
|
|
415
|
+
|
|
416
|
+
// Resolve current pixel width (handles fr columns)
|
|
417
|
+
const startWidth =
|
|
418
|
+
headerCells[columnIndex]?.getBoundingClientRect().width ??
|
|
419
|
+
(typeof col.width === 'number' ? col.width : 100)
|
|
420
|
+
const startX = e.clientX
|
|
421
|
+
|
|
422
|
+
const minW = col.minWidth ?? 50
|
|
423
|
+
const maxW = col.maxWidth ?? Infinity
|
|
424
|
+
|
|
425
|
+
// Prevent the resized column from pushing the table beyond the container
|
|
426
|
+
const otherColumnsMinWidth = columns.reduce((sum, c, i) => {
|
|
427
|
+
if (i === columnIndex) {
|
|
428
|
+
return sum
|
|
429
|
+
}
|
|
430
|
+
if (typeof c.width === 'number') {
|
|
431
|
+
return sum + c.width
|
|
432
|
+
}
|
|
433
|
+
return sum + (c.minWidth ?? 0)
|
|
434
|
+
}, 0)
|
|
435
|
+
const maxAllowedWidth = container.clientWidth - otherColumnsMinWidth
|
|
436
|
+
|
|
437
|
+
let currentWidth = startWidth
|
|
438
|
+
|
|
439
|
+
col.onResizeStart?.()
|
|
440
|
+
|
|
441
|
+
const prevCursor = document.body.style.cursor
|
|
442
|
+
document.body.style.cursor = 'col-resize'
|
|
443
|
+
|
|
444
|
+
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
445
|
+
const delta = moveEvent.clientX - startX
|
|
446
|
+
currentWidth = Math.min(
|
|
447
|
+
maxW,
|
|
448
|
+
maxAllowedWidth,
|
|
449
|
+
Math.max(minW, startWidth + delta),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
// Only override the dragged column — preserve original values for others
|
|
453
|
+
const template = columns
|
|
454
|
+
.map((c, i) => {
|
|
455
|
+
if (i === columnIndex) {
|
|
456
|
+
return `${currentWidth}px`
|
|
457
|
+
}
|
|
458
|
+
return buildGridTemplate([c])
|
|
459
|
+
})
|
|
460
|
+
.join(' ')
|
|
461
|
+
container.style.setProperty(GRID_VAR, template)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const handleMouseUp = () => {
|
|
465
|
+
document.body.style.cursor = prevCursor
|
|
466
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
467
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
468
|
+
|
|
469
|
+
// Compute equivalent fr value from other fr columns' pixel widths
|
|
470
|
+
const allCells = container.querySelectorAll('[role="columnheader"]')
|
|
471
|
+
let otherFrPx = 0
|
|
472
|
+
let otherFrUnits = 0
|
|
473
|
+
columns.forEach((c, i) => {
|
|
474
|
+
if (i === columnIndex) {
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (typeof c.width !== 'number') {
|
|
478
|
+
otherFrPx += allCells[i]?.getBoundingClientRect().width ?? 0
|
|
479
|
+
otherFrUnits +=
|
|
480
|
+
typeof c.width === 'string' ? parseFloat(c.width) || 1 : 1
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
const frValue =
|
|
484
|
+
otherFrPx > 0 ? (currentWidth / otherFrPx) * otherFrUnits : 1
|
|
485
|
+
|
|
486
|
+
col.onResizeEnd?.(currentWidth, startWidth, frValue)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
490
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
491
|
+
},
|
|
492
|
+
[columns],
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
// ── Empty state ────────────────────────────────────────────────
|
|
496
|
+
if (totalCount === 0 && empty) {
|
|
497
|
+
return (
|
|
498
|
+
<div
|
|
499
|
+
role="table"
|
|
500
|
+
aria-label={ariaLabel}
|
|
501
|
+
className={twMerge('flex flex-col overflow-hidden', className)}
|
|
502
|
+
style={{ [GRID_VAR]: gridTemplate, ...styleProp } as CSSProperties}
|
|
503
|
+
>
|
|
504
|
+
{/* Header */}
|
|
505
|
+
<div
|
|
506
|
+
role="rowgroup"
|
|
507
|
+
className={twMerge('sticky top-0 z-10', header?.className)}
|
|
508
|
+
>
|
|
509
|
+
<div
|
|
510
|
+
role="row"
|
|
511
|
+
style={{
|
|
512
|
+
display: 'grid',
|
|
513
|
+
gridTemplateColumns: GRID_VAR_REF,
|
|
514
|
+
alignItems: 'center',
|
|
515
|
+
height: rowHeight,
|
|
516
|
+
}}
|
|
517
|
+
>
|
|
518
|
+
{columns.map((col, i) => (
|
|
519
|
+
<div
|
|
520
|
+
key={i}
|
|
521
|
+
role="columnheader"
|
|
522
|
+
className={twMerge(
|
|
523
|
+
'whitespace-nowrap',
|
|
524
|
+
col.resizable && 'relative',
|
|
525
|
+
col.className,
|
|
526
|
+
)}
|
|
527
|
+
>
|
|
528
|
+
{col.header}
|
|
529
|
+
{col.resizable && (
|
|
530
|
+
<ResizeHandle
|
|
531
|
+
onMouseDown={(e) => handleResizeMouseDown(i, e)}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
))}
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
{/* Empty */}
|
|
540
|
+
<div
|
|
541
|
+
className={twMerge(
|
|
542
|
+
'flex items-center justify-center',
|
|
543
|
+
empty.className,
|
|
544
|
+
)}
|
|
545
|
+
>
|
|
546
|
+
{empty.children}
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── Build visible rows ─────────────────────────────────────────
|
|
553
|
+
const rows: ReactNode[] = []
|
|
554
|
+
for (let i = visibleRange.start; i < visibleRange.end; i++) {
|
|
555
|
+
const content = renderRow(i)
|
|
556
|
+
if (content === null) {
|
|
557
|
+
if (skeletonRow) {
|
|
558
|
+
const { children: skeletonContent, ...skeletonProps } = skeletonRow
|
|
559
|
+
rows.push(
|
|
560
|
+
<DataRow
|
|
561
|
+
key={`row-${i}`}
|
|
562
|
+
index={i}
|
|
563
|
+
rowHeight={rowHeight}
|
|
564
|
+
rowProps={skeletonProps}
|
|
565
|
+
>
|
|
566
|
+
{skeletonContent}
|
|
567
|
+
</DataRow>,
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
const rowElement = content as ReactElement
|
|
572
|
+
const rowDefaults = (rowElement.type as any)?.slotDefaults ?? {}
|
|
573
|
+
const rawProps = rowElement.props as VirtualTableRowProps
|
|
574
|
+
const { children: cellContent, ...userRowProps } = rawProps
|
|
575
|
+
const rowProps = {
|
|
576
|
+
...rowDefaults,
|
|
577
|
+
...userRowProps,
|
|
578
|
+
className: twMerge(
|
|
579
|
+
rowDefaults.className as string,
|
|
580
|
+
userRowProps.className,
|
|
581
|
+
),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
rows.push(
|
|
585
|
+
<DataRow
|
|
586
|
+
key={`row-${i}`}
|
|
587
|
+
index={i}
|
|
588
|
+
rowHeight={rowHeight}
|
|
589
|
+
rowProps={rowProps}
|
|
590
|
+
>
|
|
591
|
+
{cellContent}
|
|
592
|
+
</DataRow>,
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const totalHeight = totalCount * rowHeight
|
|
598
|
+
|
|
599
|
+
return (
|
|
600
|
+
<>
|
|
601
|
+
<div
|
|
602
|
+
role="table"
|
|
603
|
+
aria-label={ariaLabel}
|
|
604
|
+
aria-rowcount={totalCount}
|
|
605
|
+
ref={scrollRef}
|
|
606
|
+
onScroll={handleScroll}
|
|
607
|
+
className={twMerge('relative overflow-auto', className)}
|
|
608
|
+
style={{ [GRID_VAR]: gridTemplate, ...styleProp } as CSSProperties}
|
|
609
|
+
>
|
|
610
|
+
{/* Header */}
|
|
611
|
+
<div
|
|
612
|
+
role="rowgroup"
|
|
613
|
+
className={twMerge('sticky top-0 z-10', header?.className)}
|
|
614
|
+
>
|
|
615
|
+
<div
|
|
616
|
+
role="row"
|
|
617
|
+
style={{
|
|
618
|
+
display: 'grid',
|
|
619
|
+
gridTemplateColumns: GRID_VAR_REF,
|
|
620
|
+
alignItems: 'center',
|
|
621
|
+
}}
|
|
622
|
+
>
|
|
623
|
+
{columns.map((col, i) => (
|
|
624
|
+
<div
|
|
625
|
+
key={i}
|
|
626
|
+
role="columnheader"
|
|
627
|
+
className={twMerge(
|
|
628
|
+
'whitespace-nowrap',
|
|
629
|
+
col.resizable && 'relative',
|
|
630
|
+
col.className,
|
|
631
|
+
)}
|
|
632
|
+
>
|
|
633
|
+
{col.header}
|
|
634
|
+
{col.resizable && (
|
|
635
|
+
<ResizeHandle
|
|
636
|
+
onMouseDown={(e) => handleResizeMouseDown(i, e)}
|
|
637
|
+
/>
|
|
638
|
+
)}
|
|
639
|
+
</div>
|
|
640
|
+
))}
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
|
|
644
|
+
{/* Body sentinel + visible rows */}
|
|
645
|
+
<div
|
|
646
|
+
role="rowgroup"
|
|
647
|
+
className="relative"
|
|
648
|
+
style={{ height: totalHeight }}
|
|
649
|
+
>
|
|
650
|
+
{rows}
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
{/* Footer */}
|
|
655
|
+
{footer && totalCount > 0 && (
|
|
656
|
+
<div className={footer.className}>{footer.children(visibleRange)}</div>
|
|
657
|
+
)}
|
|
658
|
+
</>
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* ── Compound export ───────────────────────────────────────────── */
|
|
663
|
+
|
|
664
|
+
export const VirtualTable = Object.assign(VirtualTableRoot, {
|
|
665
|
+
Header: VirtualTableHeader,
|
|
666
|
+
Column: VirtualTableColumn,
|
|
667
|
+
Body: VirtualTableBody,
|
|
668
|
+
SkeletonRow: VirtualTableSkeletonRow,
|
|
669
|
+
Row: VirtualTableRow,
|
|
670
|
+
Cell: VirtualTableCell,
|
|
671
|
+
Empty: VirtualTableEmpty,
|
|
672
|
+
Footer: VirtualTableFooter,
|
|
673
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { VirtualTable } from './VirtualTable.tsx'
|
|
2
|
+
export {
|
|
3
|
+
createTableHeader,
|
|
4
|
+
createTableColumn,
|
|
5
|
+
createTableBody,
|
|
6
|
+
createTableSkeletonRow,
|
|
7
|
+
createTableEmpty,
|
|
8
|
+
createTableFooter,
|
|
9
|
+
createTableRow,
|
|
10
|
+
} from './VirtualTable.tsx'
|
|
11
|
+
export type {
|
|
12
|
+
VirtualTableProps,
|
|
13
|
+
VirtualTableHeaderProps,
|
|
14
|
+
VirtualTableColumnProps,
|
|
15
|
+
VirtualTableBodyProps,
|
|
16
|
+
VirtualTableSkeletonRowProps,
|
|
17
|
+
VirtualTableRowProps,
|
|
18
|
+
VirtualTableCellProps,
|
|
19
|
+
VirtualTableEmptyProps,
|
|
20
|
+
VirtualTableFooterProps,
|
|
21
|
+
} from './VirtualTable.tsx'
|
|
22
|
+
|
|
23
|
+
export { useTableCache } from './useTableCache.ts'
|
|
24
|
+
export type { UseTableCacheOptions, TableCache } from './useTableCache.ts'
|
|
25
|
+
|
|
26
|
+
export { useTableColumnWidths } from './useTableColumnWidths.ts'
|