@rimelight/ui 0.0.51 → 0.0.53

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.
@@ -1,4 +1,5 @@
1
- import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
1
+ import { createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
2
+ import { isServer } from "solid-js/web"
2
3
  import type {
3
4
  TableProps,
4
5
  TableColumn,
@@ -9,9 +10,15 @@ import type {
9
10
  ColumnFiltersState,
10
11
  TableColumnPinningState,
11
12
  TableRowPinningState,
12
- TablePaginationState
13
+ TablePaginationState,
14
+ TableVirtualizeOptions,
15
+ TableColumnSizingState,
16
+ TableColumnOrderState,
17
+ TableEditingCellState,
18
+ TableExportOptions
13
19
  } from "./table"
14
20
  import { tableTheme } from "./table.theme"
21
+ import { dropdownMenuTheme } from "../dropdown-menu/dropdown-menu.theme"
15
22
  import RLSInput from "../input/RLSInput"
16
23
  import RLSDropdownMenu from "../dropdown-menu/RLSDropdownMenu"
17
24
  import RLSIcon from "../icon/RLSIcon"
@@ -40,6 +47,7 @@ function getNestedValue(obj: AnyData, key: string): any {
40
47
 
41
48
  export function RLSTable<T = any>(props: RLSTableProps<T>) {
42
49
  let tableElementRef: HTMLTableElement | null = null
50
+ let containerRef: HTMLDivElement | null = null
43
51
 
44
52
  // ─── Controlled / Uncontrolled State Store ──────────────────────────────────
45
53
  const [internalSorting, setInternalSorting] = createSignal<SortingState>(props.sorting ?? [])
@@ -67,8 +75,31 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
67
75
  const [internalPagination, setInternalPagination] = createSignal<
68
76
  TablePaginationState | undefined
69
77
  >(props.pagination)
78
+ const [internalColumnSizing, setInternalColumnSizing] = createSignal<TableColumnSizingState>(
79
+ props.columnSizing ?? {}
80
+ )
81
+ const [internalColumnOrder, setInternalColumnOrder] = createSignal<TableColumnOrderState>(
82
+ props.columnOrder ?? []
83
+ )
84
+ const [internalEditingCell, setInternalEditingCell] = createSignal<TableEditingCellState>(
85
+ props.editingCell ?? null
86
+ )
87
+
88
+ // Drag & drop indicator states
89
+ const [draggedColId, setDraggedColId] = createSignal<string | null>(null)
90
+ const [dropTargetCol, setDropTargetCol] = createSignal<{
91
+ id: string
92
+ position: "left" | "right"
93
+ } | null>(null)
94
+
95
+ const [draggedRowIndex, setDraggedRowIndex] = createSignal<number | null>(null)
96
+ const [dropTargetRow, setDropTargetRow] = createSignal<{
97
+ id: string
98
+ index: number
99
+ position: "top" | "bottom"
100
+ } | null>(null)
70
101
 
71
- // Reactive state readers that prefer controlled props if provided
102
+ // Reactive state readers
72
103
  const currentSorting = () => (props.sorting !== undefined ? props.sorting : internalSorting())
73
104
  const currentGlobalFilter = () =>
74
105
  props.globalFilter !== undefined ? props.globalFilter : internalGlobalFilter()
@@ -85,6 +116,12 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
85
116
  props.rowPinning !== undefined ? props.rowPinning : internalRowPinning()
86
117
  const currentPagination = () =>
87
118
  props.pagination !== undefined ? props.pagination : internalPagination()
119
+ const currentColumnSizing = () =>
120
+ props.columnSizing !== undefined ? props.columnSizing : internalColumnSizing()
121
+ const currentColumnOrder = () =>
122
+ props.columnOrder !== undefined ? props.columnOrder : internalColumnOrder()
123
+ const currentEditingCell = () =>
124
+ props.editingCell !== undefined ? props.editingCell : internalEditingCell()
88
125
 
89
126
  const updateSorting = (next: SortingState) => {
90
127
  setInternalSorting(next)
@@ -122,23 +159,210 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
122
159
  setInternalPagination(next)
123
160
  props.onPaginationChange?.(next)
124
161
  }
162
+ const updateColumnSizing = (next: TableColumnSizingState) => {
163
+ setInternalColumnSizing(next)
164
+ props.onColumnSizingChange?.(next)
165
+ }
166
+ const updateColumnOrder = (next: TableColumnOrderState) => {
167
+ setInternalColumnOrder(next)
168
+ props.onColumnOrderChange?.(next)
169
+ }
170
+ const updateEditingCell = (next: TableEditingCellState) => {
171
+ setInternalEditingCell(next)
172
+ props.onEditingCellChange?.(next)
173
+ }
125
174
 
126
- // ─── Column resolution ───────────────────────────────────────────────────────
127
- const resolvedColumns = createMemo<TableColumn<T>[]>(() => {
175
+ // ─── Column resolution & ordering ───────────────────────────────────────────
176
+ const rawResolvedColumns = createMemo<TableColumn<T>[]>(() => {
177
+ let cols: TableColumn<T>[] = []
128
178
  if (props.columns && props.columns.length > 0) {
129
- return props.columns
179
+ cols = [...props.columns]
180
+ } else {
181
+ const d = props.data
182
+ if (d && d.length > 0 && typeof d[0] === "object" && d[0] !== null) {
183
+ const firstRow = d[0] as AnyData
184
+ cols = Object.keys(firstRow).map((key) => ({
185
+ id: key,
186
+ accessorKey: key as any,
187
+ header: key.charAt(0).toUpperCase() + key.slice(1),
188
+ enableSorting: true
189
+ }))
190
+ }
191
+ }
192
+
193
+ // If row reordering is enabled and no dedicated reorder column exists, auto-prepend a grip column
194
+ if (
195
+ props.enableRowReordering &&
196
+ !cols.some((c) => c.id === "reorder" || c.id === "dragHandle")
197
+ ) {
198
+ const reorderCol: TableColumn<T> = {
199
+ id: "reorder",
200
+ size: 36,
201
+ minSize: 36,
202
+ maxSize: 36,
203
+ enableSorting: false,
204
+ enableHiding: false,
205
+ header: "",
206
+ cell: ({ row }) => (
207
+ <div class="flex items-center justify-center">
208
+ <span
209
+ class={classes().dragHandle}
210
+ data-slot="drag-handle"
211
+ draggable={true}
212
+ onDragStart={(e) => {
213
+ setDraggedRowIndex(row.index)
214
+ e.dataTransfer?.setData("text/plain", String(row.index))
215
+ if (e.dataTransfer) {
216
+ e.dataTransfer.effectAllowed = "move"
217
+ }
218
+ }}
219
+ onDragEnd={() => {
220
+ setDraggedRowIndex(null)
221
+ setDropTargetRow(null)
222
+ }}
223
+ >
224
+ <span class="i-lucide-grip-vertical size-4" />
225
+ </span>
226
+ </div>
227
+ )
228
+ }
229
+ return [reorderCol, ...cols]
230
+ }
231
+
232
+ // Auto-inject rowActions / actions column if defined on table props and not in columns
233
+ const actionsDef = props.actions || props.rowActions
234
+ if (actionsDef && !cols.some((c) => c.id === "actions")) {
235
+ cols.push({
236
+ id: "actions",
237
+ header: "",
238
+ size: 80,
239
+ minSize: 60,
240
+ enableSorting: false,
241
+ enableHiding: false,
242
+ meta: {
243
+ class: {
244
+ th: "text-right",
245
+ td: "text-right"
246
+ }
247
+ },
248
+ actions: actionsDef
249
+ })
250
+ }
251
+
252
+ // Normalize actions column defaults
253
+ for (const c of cols) {
254
+ if (c.id === "actions" || c.actions || c.items) {
255
+ if (c.enableSorting === undefined) c.enableSorting = false
256
+ if (c.header === undefined && c.id === "actions") c.header = ""
257
+ if (c.size === undefined && c.id === "actions") c.size = 60
258
+ if (c.minSize === undefined && c.id === "actions") c.minSize = 60
259
+ if (!c.meta?.class?.td && c.id === "actions") {
260
+ c.meta = {
261
+ ...c.meta,
262
+ class: {
263
+ ...c.meta?.class,
264
+ th: c.meta?.class?.th || "text-right",
265
+ td: c.meta?.class?.td || "text-right"
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ return cols
273
+ })
274
+
275
+ function getCellActions(row: TableRow<T>, col: TableColumn<T>) {
276
+ const actionsFn =
277
+ col.actions ??
278
+ col.items ??
279
+ (col.id === "actions" ? props.actions || props.rowActions : undefined)
280
+ if (actionsFn) {
281
+ return typeof actionsFn === "function" ? (actionsFn as any)(row) : actionsFn
282
+ }
283
+ return undefined
284
+ }
285
+
286
+ function renderCellContent(
287
+ row: TableRow<T>,
288
+ col: TableColumn<T>,
289
+ cellValue: () => any,
290
+ isEditing: () => boolean,
291
+ setEditing: (e: boolean) => void
292
+ ) {
293
+ const actions = getCellActions(row, col)
294
+ if (Array.isArray(actions) && actions.length > 0) {
295
+ const dmClasses = dropdownMenuTheme({ size: "xs", color: "neutral" })
296
+ return (
297
+ <div class="flex items-center justify-end">
298
+ <div class={dmClasses.root} data-slot="root" data-dropdown-menu>
299
+ <button
300
+ type="button"
301
+ class={`${dmClasses.trigger} aspect-square !px-1.5 !py-1.5 inline-flex items-center justify-center shrink-0`}
302
+ data-slot="trigger"
303
+ data-dropdown-trigger
304
+ >
305
+ <span class="i-lucide-ellipsis-vertical size-4" />
306
+ </button>
307
+ </div>
308
+ </div>
309
+ )
310
+ }
311
+ if (typeof col.cell === "function") {
312
+ const rendered = col.cell({
313
+ row,
314
+ getValue: cellValue,
315
+ renderValue: cellValue,
316
+ isEditing,
317
+ setEditing,
318
+ column: col,
319
+ table: tableApi
320
+ })
321
+ if (Array.isArray(rendered) && rendered.length > 0) {
322
+ const dmClasses = dropdownMenuTheme({ size: "xs", color: "neutral" })
323
+ return (
324
+ <div class="flex items-center justify-end">
325
+ <div class={dmClasses.root} data-slot="root" data-dropdown-menu>
326
+ <button
327
+ type="button"
328
+ class={`${dmClasses.trigger} aspect-square !px-1.5 !py-1.5 inline-flex items-center justify-center shrink-0`}
329
+ data-slot="trigger"
330
+ data-dropdown-trigger
331
+ >
332
+ <span class="i-lucide-ellipsis-vertical size-4" />
333
+ </button>
334
+ </div>
335
+ </div>
336
+ )
337
+ }
338
+ return rendered
339
+ }
340
+ return cellValue() !== undefined && cellValue() !== null ? String(cellValue()) : "—"
341
+ }
342
+
343
+ const resolvedColumns = createMemo<TableColumn<T>[]>(() => {
344
+ const raw = rawResolvedColumns()
345
+ const order = currentColumnOrder()
346
+ if (!order || order.length === 0) return raw
347
+
348
+ const ordered: TableColumn<T>[] = []
349
+ const rawMap = new Map<string, TableColumn<T>>()
350
+ for (const col of raw) {
351
+ const id = String(col.id || col.accessorKey || "")
352
+ rawMap.set(id, col)
353
+ }
354
+
355
+ for (const id of order) {
356
+ if (rawMap.has(id)) {
357
+ ordered.push(rawMap.get(id)!)
358
+ rawMap.delete(id)
359
+ }
130
360
  }
131
- const d = props.data
132
- if (d && d.length > 0 && typeof d[0] === "object" && d[0] !== null) {
133
- const firstRow = d[0] as AnyData
134
- return Object.keys(firstRow).map((key) => ({
135
- id: key,
136
- accessorKey: key as any,
137
- header: key.charAt(0).toUpperCase() + key.slice(1),
138
- enableSorting: true
139
- }))
361
+ // Append any columns not explicitly in order
362
+ for (const remaining of rawMap.values()) {
363
+ ordered.push(remaining)
140
364
  }
141
- return []
365
+ return ordered
142
366
  })
143
367
 
144
368
  const visibleColumns = createMemo(() => {
@@ -151,7 +375,75 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
151
375
  })
152
376
  })
153
377
 
378
+ // ─── Column Resizing Handler ────────────────────────────────────────────────
379
+ const [resizingColId, setResizingColId] = createSignal<string | null>(null)
380
+
381
+ const startResize = (
382
+ e: PointerEvent,
383
+ colId: string,
384
+ minSize?: number | string,
385
+ maxSize?: number | string
386
+ ) => {
387
+ e.preventDefault()
388
+ e.stopPropagation()
389
+ const startX = e.clientX
390
+ const thElement = (e.target as HTMLElement).closest("th")
391
+ const initialWidth = currentColumnSizing()[colId] ?? thElement?.offsetWidth ?? 150
392
+ const minW = typeof minSize === "number" ? minSize : 40
393
+ const maxW = typeof maxSize === "number" ? maxSize : 2000
394
+ setResizingColId(colId)
395
+
396
+ let liveWidth = initialWidth
397
+
398
+ const onPointerMove = (moveEv: PointerEvent) => {
399
+ const deltaX = moveEv.clientX - startX
400
+ liveWidth = Math.max(minW, Math.min(maxW, initialWidth + deltaX))
401
+ if (props.columnResizeMode !== "onEnd") {
402
+ updateColumnSizing({
403
+ ...currentColumnSizing(),
404
+ [colId]: liveWidth
405
+ })
406
+ }
407
+ }
408
+
409
+ const onPointerUp = () => {
410
+ if (props.columnResizeMode === "onEnd") {
411
+ updateColumnSizing({
412
+ ...currentColumnSizing(),
413
+ [colId]: liveWidth
414
+ })
415
+ }
416
+ setResizingColId(null)
417
+ window.removeEventListener("pointermove", onPointerMove)
418
+ window.removeEventListener("pointerup", onPointerUp)
419
+ }
420
+
421
+ window.addEventListener("pointermove", onPointerMove)
422
+ window.addEventListener("pointerup", onPointerUp)
423
+ }
424
+
154
425
  // ─── Row Hierarchy Building ──────────────────────────────────────────────────
426
+ const getAllRowDescendants = (row: TableRow<T>): TableRow<T>[] => {
427
+ if (!row.subRows || row.subRows.length === 0) return []
428
+ const res: TableRow<T>[] = []
429
+ for (const child of row.subRows) {
430
+ res.push(child)
431
+ res.push(...getAllRowDescendants(child))
432
+ }
433
+ return res
434
+ }
435
+
436
+ const getAllFlatRows = (rows: TableRow<T>[]): TableRow<T>[] => {
437
+ const flat: TableRow<T>[] = []
438
+ for (const r of rows) {
439
+ flat.push(r)
440
+ if (r.subRows && r.subRows.length > 0) {
441
+ flat.push(...getAllFlatRows(r.subRows))
442
+ }
443
+ }
444
+ return flat
445
+ }
446
+
155
447
  const buildRowTree = (rawItems: T[], parentDepth = 0): TableRow<T>[] => {
156
448
  return rawItems.map((item, idx) => {
157
449
  const rId = props.getRowId
@@ -175,11 +467,42 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
175
467
  original: item,
176
468
  index: idx,
177
469
  depth: parentDepth,
178
- getValue: (key: string) => getNestedValue(item as AnyData, key),
179
- getIsSelected: () => !!currentRowSelection()[rId],
470
+ getValue: (key: string) => {
471
+ const colDef = resolvedColumns().find((c) => (c.id || c.accessorKey) === key)
472
+ if (colDef && typeof colDef.accessorFn === "function") {
473
+ return colDef.accessorFn(item)
474
+ }
475
+ return getNestedValue(item as AnyData, key)
476
+ },
477
+ getIsSelected: () => {
478
+ const sel = currentRowSelection()
479
+ if (subRowsList && subRowsList.length > 0) {
480
+ const descendants = getAllRowDescendants(rowObj)
481
+ if (descendants.length > 0) {
482
+ return descendants.every((d) => !!sel[d.id])
483
+ }
484
+ }
485
+ return !!sel[rId]
486
+ },
487
+ getIsSomeSelected: () => {
488
+ if (!subRowsList || subRowsList.length === 0) return false
489
+ const sel = currentRowSelection()
490
+ const descendants = getAllRowDescendants(rowObj)
491
+ if (descendants.length === 0) return false
492
+ const count = descendants.filter((d) => !!sel[d.id]).length
493
+ return count > 0 && count < descendants.length
494
+ },
180
495
  toggleSelected: (val?: boolean) => {
181
- const nextVal = val !== undefined ? val : !currentRowSelection()[rId]
182
- updateRowSelection({ ...currentRowSelection(), [rId]: nextVal })
496
+ const isCurrentlySelected = rowObj.getIsSelected()
497
+ const nextVal = val !== undefined ? val : !isCurrentlySelected
498
+ const nextMap = { ...currentRowSelection(), [rId]: nextVal }
499
+ if (subRowsList) {
500
+ const descendants = getAllRowDescendants(rowObj)
501
+ for (const d of descendants) {
502
+ nextMap[d.id] = nextVal
503
+ }
504
+ }
505
+ updateRowSelection(nextMap)
183
506
  },
184
507
  getIsExpanded: () => !!currentExpanded()[rId],
185
508
  toggleExpanded: (val?: boolean) => {
@@ -188,13 +511,11 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
188
511
  },
189
512
  getToggleExpandedHandler: () => (e?: Event) => {
190
513
  e?.stopPropagation?.()
191
- const nextVal = !currentExpanded()[rId]
192
- updateExpanded({ ...currentExpanded(), [rId]: nextVal })
514
+ rowObj.toggleExpanded()
193
515
  },
194
516
  getToggleSelectedHandler: () => (e?: Event) => {
195
517
  e?.stopPropagation?.()
196
- const nextVal = !currentRowSelection()[rId]
197
- updateRowSelection({ ...currentRowSelection(), [rId]: nextVal })
518
+ rowObj.toggleSelected()
198
519
  },
199
520
  getIsPinned: () => {
200
521
  const pin = currentRowPinning()
@@ -211,7 +532,12 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
211
532
  updateRowPinning({ top: nextTop, bottom: nextBottom })
212
533
  },
213
534
  getIsGrouped: () => false,
214
- getCanExpand: () => !!(subRowsList && subRowsList.length > 0) || !!props.expandedContent,
535
+ getCanExpand: () => {
536
+ if (typeof props.getRowCanExpand === "function") {
537
+ return props.getRowCanExpand(rowObj)
538
+ }
539
+ return !!(subRowsList && subRowsList.length > 0)
540
+ },
215
541
  ...(subRowsList ? { subRows: subRowsList } : {})
216
542
  }
217
543
  return rowObj
@@ -227,10 +553,20 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
227
553
  if (typeof r.original !== "object" || !r.original) {
228
554
  return String(r.original).toLowerCase().includes(q)
229
555
  }
230
- return Object.values(r.original as AnyData).some((val) =>
231
- String(val ?? "")
232
- .toLowerCase()
233
- .includes(q)
556
+ return (
557
+ Object.values(r.original as AnyData).some((val) =>
558
+ String(val ?? "")
559
+ .toLowerCase()
560
+ .includes(q)
561
+ ) ||
562
+ resolvedColumns().some((col) => {
563
+ const v = col.accessorFn
564
+ ? col.accessorFn(r.original)
565
+ : r.getValue((col.id || col.accessorKey || "") as string)
566
+ return String(v ?? "")
567
+ .toLowerCase()
568
+ .includes(q)
569
+ })
234
570
  )
235
571
  })
236
572
  }
@@ -271,8 +607,16 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
271
607
  let cmp: number
272
608
  if (typeof valA === "number" && typeof valB === "number") {
273
609
  cmp = valA - valB
610
+ } else if (valA instanceof Date && valB instanceof Date) {
611
+ cmp = valA.getTime() - valB.getTime()
274
612
  } else {
275
- cmp = String(valA).localeCompare(String(valB))
613
+ const numA = Number(valA)
614
+ const numB = Number(valB)
615
+ if (!isNaN(numA) && !isNaN(numB)) {
616
+ cmp = numA - numB
617
+ } else {
618
+ cmp = String(valA).localeCompare(String(valB))
619
+ }
276
620
  }
277
621
  return desc ? -cmp : cmp
278
622
  }
@@ -280,44 +624,60 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
280
624
  })
281
625
  })
282
626
 
283
- // ─── Grouping pipeline ───────────────────────────────────────────────────────
284
- type GroupedItem = { type: "group"; group: TableGroup<T> } | { type: "row"; row: TableRow<T> }
627
+ // ─── Multi-level Grouping pipeline ──────────────────────────────────────────
628
+ type GroupedItem =
629
+ | { type: "group"; group: TableGroup<T>; depth: number }
630
+ | { type: "row"; row: TableRow<T> }
285
631
 
286
- const groupedItems = createMemo<GroupedItem[]>(() => {
287
- const sRows = sortedRows()
288
- const grp = props.grouping
289
- if (!grp || grp.length === 0) {
290
- return sRows.map((r) => ({ type: "row", row: r }))
632
+ function groupRowsRecursively(
633
+ rows: TableRow<T>[],
634
+ groupCols: string[],
635
+ depth = 0,
636
+ parentPath = ""
637
+ ): GroupedItem[] {
638
+ if (depth >= groupCols.length || rows.length === 0) {
639
+ return rows.map((r) => ({ type: "row", row: r }))
291
640
  }
292
641
 
293
- const groupColId = grp[0]!
642
+ const colId = groupCols[depth]!
294
643
  const groupMap = new Map<string, TableRow<T>[]>()
295
644
  const groupOrder: string[] = []
296
645
 
297
- for (const row of sRows) {
298
- const val = String(row.getValue(groupColId) ?? "")
646
+ for (const row of rows) {
647
+ const val = String(row.getValue(colId) ?? "")
299
648
  if (!groupMap.has(val)) {
300
649
  groupMap.set(val, [])
301
650
  groupOrder.push(val)
302
651
  }
652
+ row.groupingColumnId = colId
653
+ row.groupingValue = val
303
654
  groupMap.get(val)!.push(row)
304
655
  }
305
656
 
306
657
  const result: GroupedItem[] = []
307
658
  for (const val of groupOrder) {
308
- const rows = groupMap.get(val)!
659
+ const subRows = groupMap.get(val)!
660
+ const groupId = `group-${parentPath}${colId}-${val}`
309
661
  const group: TableGroup<T> = {
310
- id: `group-${groupColId}-${val}`,
311
- columnId: groupColId,
662
+ id: groupId,
663
+ columnId: colId,
312
664
  value: val,
313
- rows
314
- }
315
- result.push({ type: "group", group })
316
- for (const row of rows) {
317
- result.push({ type: "row", row })
665
+ depth,
666
+ rows: subRows
318
667
  }
668
+ result.push({ type: "group", group, depth })
669
+ result.push(...groupRowsRecursively(subRows, groupCols, depth + 1, `${groupId}/`))
319
670
  }
320
671
  return result
672
+ }
673
+
674
+ const groupedItems = createMemo<GroupedItem[]>(() => {
675
+ const sRows = sortedRows()
676
+ const grp = props.grouping
677
+ if (!grp || grp.length === 0) {
678
+ return sRows.map((r) => ({ type: "row", row: r }))
679
+ }
680
+ return groupRowsRecursively(sRows, grp, 0, "")
321
681
  })
322
682
 
323
683
  // ─── Row Pinning ─────────────────────────────────────────────────────────────
@@ -369,7 +729,75 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
369
729
  return pageRows
370
730
  })
371
731
 
732
+ // ─── Virtualization Windowing ────────────────────────────────────────────────
733
+ const [scrollTop, setScrollTop] = createSignal(0)
734
+ const isVirtual = () => !!props.virtualize
735
+ const virtualOpts = (): TableVirtualizeOptions =>
736
+ typeof props.virtualize === "object" ? props.virtualize : {}
737
+
738
+ const estimateRowHeight = () => {
739
+ const opt = virtualOpts().estimateSize
740
+ if (typeof opt === "number") return opt
741
+ return 48
742
+ }
743
+
744
+ const overscan = () => virtualOpts().overscan ?? 8
745
+
746
+ const virtualizedView = createMemo(() => {
747
+ const items = paginatedItems()
748
+ if (!isVirtual()) {
749
+ return {
750
+ items,
751
+ padTop: 0,
752
+ padBottom: 0,
753
+ startIndex: 0
754
+ }
755
+ }
756
+
757
+ const rowH = estimateRowHeight()
758
+ const scrollPos = scrollTop()
759
+ const containerHeight = containerRef?.clientHeight || 400
760
+ const totalCount = items.length
761
+
762
+ const rawStart = Math.floor(scrollPos / rowH)
763
+ const startIndex = Math.max(0, rawStart - overscan())
764
+ const visibleCount = Math.ceil(containerHeight / rowH) + overscan() * 2
765
+ const endIndex = Math.min(totalCount, startIndex + visibleCount)
766
+
767
+ const padTop = startIndex * rowH
768
+ const padBottom = Math.max(0, (totalCount - endIndex) * rowH)
769
+ const sliced = items.slice(startIndex, endIndex)
770
+
771
+ return {
772
+ items: sliced,
773
+ padTop,
774
+ padBottom,
775
+ startIndex
776
+ }
777
+ })
778
+
779
+ if (!isServer) {
780
+ onMount(() => {
781
+ if (isVirtual()) {
782
+ const scrollEl = virtualOpts().getScrollElement?.() || containerRef
783
+ if (scrollEl) {
784
+ const handleScroll = () => setScrollTop(scrollEl.scrollTop)
785
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true })
786
+ onCleanup(() => scrollEl.removeEventListener("scroll", handleScroll))
787
+ }
788
+ }
789
+ })
790
+ }
791
+
372
792
  // ─── Column offsets ──────────────────────────────────────────────────────────
793
+ const getColSizeNumber = (col: TableColumn<T>): number => {
794
+ const colId = String(col.id || col.accessorKey || "")
795
+ const sized = currentColumnSizing()[colId]
796
+ if (typeof sized === "number") return sized
797
+ if (typeof col.size === "number") return col.size
798
+ return 150
799
+ }
800
+
373
801
  const columnOffsets = createMemo(() => {
374
802
  const cols = resolvedColumns()
375
803
  const pin = currentColumnPinning()
@@ -381,7 +809,7 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
381
809
  for (const colId of pin.left) {
382
810
  left[colId] = offset
383
811
  const col = cols.find((c) => (c.id || c.accessorKey) === colId)
384
- offset += typeof col?.size === "number" ? col.size : 150
812
+ offset += col ? getColSizeNumber(col) : 150
385
813
  }
386
814
  }
387
815
 
@@ -390,7 +818,7 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
390
818
  for (const colId of [...pin.right].reverse()) {
391
819
  right[colId] = offset
392
820
  const col = cols.find((c) => (c.id || c.accessorKey) === colId)
393
- offset += typeof col?.size === "number" ? col.size : 150
821
+ offset += col ? getColSizeNumber(col) : 150
394
822
  }
395
823
  }
396
824
 
@@ -415,15 +843,78 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
415
843
  }
416
844
 
417
845
  // ─── Expose TableApi ─────────────────────────────────────────────────────────
846
+ const getPageRows = () => {
847
+ return paginatedItems()
848
+ .filter((i) => i.type === "row")
849
+ .map((i) => (i as { type: "row"; row: TableRow<T> }).row)
850
+ }
851
+
418
852
  const tableApi: TableApi<T> = {
419
853
  get tableRef() {
420
854
  return tableElementRef
421
855
  },
422
856
  getFilteredRows: () => filteredRows(),
857
+ getFlatRows: () => getAllFlatRows(filteredRows()),
423
858
  getSortedRows: () => sortedRows(),
424
- getSelectedRows: () => filteredRows().filter((r) => r.getIsSelected()),
859
+ getSelectedRows: () => getAllFlatRows(filteredRows()).filter((r) => r.getIsSelected()),
860
+ getFilteredSelectedRowModel: () => {
861
+ const flatSelected = getAllFlatRows(filteredRows()).filter((r) => r.getIsSelected())
862
+ return {
863
+ rows: filteredRows().filter((r) => r.getIsSelected()),
864
+ flatRows: flatSelected
865
+ }
866
+ },
867
+ getFilteredRowModel: () => ({
868
+ rows: filteredRows(),
869
+ flatRows: getAllFlatRows(filteredRows())
870
+ }),
425
871
  getAllColumns: () => resolvedColumns(),
426
872
  getColumn: (id: string) => resolvedColumns().find((c) => (c.id || c.accessorKey) === id),
873
+ getIsAllRowsSelected: () => {
874
+ const flat = getAllFlatRows(filteredRows())
875
+ if (flat.length === 0) return false
876
+ return flat.every((r) => r.getIsSelected())
877
+ },
878
+ getIsSomeRowsSelected: () => {
879
+ const flat = getAllFlatRows(filteredRows())
880
+ if (flat.length === 0) return false
881
+ const selCount = flat.filter((r) => r.getIsSelected()).length
882
+ return selCount > 0 && selCount < flat.length
883
+ },
884
+ toggleAllRowsSelected: (selected?: boolean) => {
885
+ const flat = getAllFlatRows(filteredRows())
886
+ const isAll = flat.length > 0 && flat.every((r) => r.getIsSelected())
887
+ const targetState = selected !== undefined ? selected : !isAll
888
+ const nextMap = { ...currentRowSelection() }
889
+ for (const r of flat) {
890
+ nextMap[r.id] = targetState
891
+ }
892
+ updateRowSelection(nextMap)
893
+ },
894
+ getIsAllPageRowsSelected: () => {
895
+ const rows = getPageRows()
896
+ const flat = getAllFlatRows(rows)
897
+ if (flat.length === 0) return false
898
+ return flat.every((r) => r.getIsSelected())
899
+ },
900
+ getIsSomePageRowsSelected: () => {
901
+ const rows = getPageRows()
902
+ const flat = getAllFlatRows(rows)
903
+ if (flat.length === 0) return false
904
+ const selCount = flat.filter((r) => r.getIsSelected()).length
905
+ return selCount > 0 && selCount < flat.length
906
+ },
907
+ toggleAllPageRowsSelected: (selected?: boolean) => {
908
+ const rows = getPageRows()
909
+ const flat = getAllFlatRows(rows)
910
+ const isAll = flat.length > 0 && flat.every((r) => r.getIsSelected())
911
+ const targetState = selected !== undefined ? selected : !isAll
912
+ const nextMap = { ...currentRowSelection() }
913
+ for (const r of flat) {
914
+ nextMap[r.id] = targetState
915
+ }
916
+ updateRowSelection(nextMap)
917
+ },
427
918
  setGlobalFilter: (val: string) => updateGlobalFilter(val),
428
919
  setColumnFilter: (id: string, val: any) => {
429
920
  const current = currentColumnFilters().filter((f) => f.id !== id)
@@ -439,16 +930,162 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
439
930
  setRowPinning: (pinning: TableRowPinningState) => updateRowPinning(pinning),
440
931
  setSorting: (st: SortingState) => updateSorting(st),
441
932
  setPagination: (pag: TablePaginationState) => updatePagination(pag),
933
+ setPageIndex: (pageIndex: number) => {
934
+ const cur = currentPagination() || { pageIndex: 0, pageSize: 10 }
935
+ updatePagination({ ...cur, pageIndex })
936
+ },
937
+ setPageSize: (pageSize: number) => {
938
+ const cur = currentPagination() || { pageIndex: 0, pageSize: 10 }
939
+ updatePagination({ ...cur, pageSize })
940
+ },
941
+ // Resizing & Ordering
942
+ setColumnSizing: (id: string, size: number) => {
943
+ updateColumnSizing({ ...currentColumnSizing(), [id]: size })
944
+ },
945
+ resetColumnSizing: () => updateColumnSizing({}),
946
+ setColumnOrder: (order: TableColumnOrderState) => updateColumnOrder(order),
947
+ resetColumnOrder: () => updateColumnOrder([]),
948
+ // Exporting
949
+ exportToCsv: (options?: TableExportOptions) => {
950
+ const targetRows = options?.selectedOnly ? tableApi.getSelectedRows() : tableApi.getFlatRows()
951
+ const colsToExport = resolvedColumns().filter((col) => {
952
+ const id = String(col.id || col.accessorKey || "")
953
+ if (id === "select" || id === "actions" || id === "reorder" || id === "dragHandle")
954
+ return false
955
+ if (options?.columns && options.columns.length > 0) {
956
+ return options.columns.includes(id)
957
+ }
958
+ return currentColumnVisibility()[id] !== false
959
+ })
960
+
961
+ const escapeCsv = (val: any): string => {
962
+ if (val === undefined || val === null) return ""
963
+ const str = String(val)
964
+ if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) {
965
+ return `"${str.replace(/"/g, '""')}"`
966
+ }
967
+ return str
968
+ }
969
+
970
+ const headers = colsToExport.map((c) => {
971
+ const label = typeof c.header === "string" ? c.header : String(c.id || c.accessorKey || "")
972
+ return escapeCsv(label)
973
+ })
974
+
975
+ const lines: string[] = [headers.join(",")]
976
+
977
+ for (const row of targetRows) {
978
+ const rowVals = colsToExport.map((col) => {
979
+ let val: any
980
+ if (typeof col.accessorFn === "function") {
981
+ val = col.accessorFn(row.original)
982
+ } else {
983
+ const key = String(col.accessorKey || col.id || "")
984
+ val = row.getValue(key)
985
+ }
986
+ return escapeCsv(val)
987
+ })
988
+ lines.push(rowVals.join(","))
989
+ }
990
+
991
+ const csvContent = lines.join("\r\n")
992
+
993
+ if (!isServer && options?.filename) {
994
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
995
+ const url = URL.createObjectURL(blob)
996
+ const link = document.createElement("a")
997
+ link.href = url
998
+ link.download = options.filename.endsWith(".csv")
999
+ ? options.filename
1000
+ : `${options.filename}.csv`
1001
+ link.click()
1002
+ URL.revokeObjectURL(url)
1003
+ }
1004
+
1005
+ return csvContent
1006
+ },
1007
+ exportToJson: (options?: TableExportOptions) => {
1008
+ const targetRows = options?.selectedOnly ? tableApi.getSelectedRows() : tableApi.getFlatRows()
1009
+ const colsToExport = resolvedColumns().filter((col) => {
1010
+ const id = String(col.id || col.accessorKey || "")
1011
+ if (id === "select" || id === "actions" || id === "reorder" || id === "dragHandle")
1012
+ return false
1013
+ if (options?.columns && options.columns.length > 0) {
1014
+ return options.columns.includes(id)
1015
+ }
1016
+ return currentColumnVisibility()[id] !== false
1017
+ })
1018
+
1019
+ const jsonArray = targetRows.map((row) => {
1020
+ const obj: Record<string, any> = {}
1021
+ for (const col of colsToExport) {
1022
+ const key = String(col.id || col.accessorKey || "")
1023
+ let val: any
1024
+ if (typeof col.accessorFn === "function") {
1025
+ val = col.accessorFn(row.original)
1026
+ } else {
1027
+ val = row.getValue(key)
1028
+ }
1029
+ obj[key] = val
1030
+ }
1031
+ return obj
1032
+ })
1033
+
1034
+ const jsonContent = JSON.stringify(jsonArray, null, 2)
1035
+
1036
+ if (!isServer && options?.filename) {
1037
+ const blob = new Blob([jsonContent], { type: "application/json;charset=utf-8;" })
1038
+ const url = URL.createObjectURL(blob)
1039
+ const link = document.createElement("a")
1040
+ link.href = url
1041
+ link.download = options.filename.endsWith(".json")
1042
+ ? options.filename
1043
+ : `${options.filename}.json`
1044
+ link.click()
1045
+ URL.revokeObjectURL(url)
1046
+ }
1047
+
1048
+ return jsonContent
1049
+ },
1050
+ // Faceted statistics
1051
+ getFacetedUniqueValues: (columnId: string) => {
1052
+ const map = new Map<any, number>()
1053
+ const rows = getAllFlatRows(filteredRows())
1054
+ for (const r of rows) {
1055
+ const val = r.getValue(columnId)
1056
+ map.set(val, (map.get(val) || 0) + 1)
1057
+ }
1058
+ return map
1059
+ },
1060
+ getFacetedMinMaxValues: (columnId: string) => {
1061
+ const rows = getAllFlatRows(filteredRows())
1062
+ let min: number | undefined
1063
+ let max: number | undefined
1064
+ for (const r of rows) {
1065
+ const val = r.getValue(columnId)
1066
+ if (typeof val === "number" && !isNaN(val)) {
1067
+ if (min === undefined || val < min) min = val
1068
+ if (max === undefined || val > max) max = val
1069
+ }
1070
+ }
1071
+ return min !== undefined && max !== undefined ? [min, max] : undefined
1072
+ },
1073
+ // Inline editing support
1074
+ setEditingCell: (cell: TableEditingCellState) => updateEditingCell(cell),
1075
+ getEditingCell: () => currentEditingCell(),
442
1076
  getState: () => ({
443
1077
  sorting: currentSorting(),
444
1078
  globalFilter: currentGlobalFilter(),
445
1079
  columnFilters: currentColumnFilters(),
446
1080
  columnVisibility: currentColumnVisibility(),
447
1081
  columnPinning: currentColumnPinning(),
1082
+ columnSizing: currentColumnSizing(),
1083
+ columnOrder: currentColumnOrder(),
448
1084
  rowSelection: currentRowSelection(),
449
1085
  expanded: currentExpanded(),
450
1086
  rowPinning: currentRowPinning(),
451
- pagination: currentPagination() || { pageIndex: 0, pageSize: 10 }
1087
+ pagination: currentPagination() || { pageIndex: 0, pageSize: 10 },
1088
+ editingCell: currentEditingCell()
452
1089
  })
453
1090
  }
454
1091
 
@@ -473,12 +1110,17 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
473
1110
  )
474
1111
 
475
1112
  const showToolbar = createMemo(
476
- () => props.searchable || props.showColumnsToggle || !!props.toolbar
1113
+ () =>
1114
+ props.searchable ||
1115
+ props.showColumnsToggle ||
1116
+ props.exportable ||
1117
+ !!props.toolbar ||
1118
+ !!props.slots?.["toolbar"]
477
1119
  )
478
1120
 
479
1121
  const columnDropdownItems = createMemo(() =>
480
1122
  resolvedColumns()
481
- .filter((col) => col.enableHiding !== false)
1123
+ .filter((col) => col.enableHiding !== false && col.id !== "reorder")
482
1124
  .map((col) => {
483
1125
  const colId = (col.id || col.accessorKey || "") as string
484
1126
  const rawLabel = typeof col.header === "string" ? col.header : colId
@@ -494,17 +1136,38 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
494
1136
  })
495
1137
  )
496
1138
 
1139
+ const exportDropdownItems = createMemo(() => [
1140
+ {
1141
+ id: "csv",
1142
+ label: "Export as CSV",
1143
+ icon: "i-lucide-file-spreadsheet",
1144
+ onSelect: () => {
1145
+ tableApi.exportToCsv({ filename: props.exportFilename ?? "export" })
1146
+ }
1147
+ },
1148
+ {
1149
+ id: "json",
1150
+ label: "Export as JSON",
1151
+ icon: "i-lucide-file-json",
1152
+ onSelect: () => {
1153
+ tableApi.exportToJson({ filename: props.exportFilename ?? "export" })
1154
+ }
1155
+ }
1156
+ ])
1157
+
497
1158
  const hasFooters = createMemo(() =>
498
- visibleColumns().some((col) => typeof col.footer === "function" || col.footer)
1159
+ visibleColumns().some(
1160
+ (col) =>
1161
+ typeof col.footer === "function" ||
1162
+ col.footer ||
1163
+ props.slots?.[`${String(col.id || col.accessorKey || "")}-footer`]
1164
+ )
499
1165
  )
500
1166
 
501
1167
  return (
502
1168
  <div class={classes().root} data-slot="root">
503
1169
  <Show when={showToolbar()}>
504
- <div
505
- class="flex items-center gap-3 px-3.5 py-3 border-b border-default bg-muted/20 overflow-x-auto"
506
- data-slot="toolbar"
507
- >
1170
+ <div class={classes().toolbar} data-slot="toolbar">
508
1171
  <Show when={props.searchable}>
509
1172
  <RLSInput
510
1173
  type="search"
@@ -516,10 +1179,23 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
516
1179
  />
517
1180
  </Show>
518
1181
 
519
- {props.toolbar?.()}
1182
+ {props.slots?.["toolbar"]
1183
+ ? props.slots["toolbar"]({ table: tableApi })
1184
+ : props.toolbar?.()}
1185
+
1186
+ <div class="ml-auto flex items-center gap-2 shrink-0">
1187
+ <Show when={props.exportable}>
1188
+ <RLSDropdownMenu
1189
+ label="Export"
1190
+ icon="i-lucide-download"
1191
+ items={exportDropdownItems()}
1192
+ content={{ align: "end" }}
1193
+ size="sm"
1194
+ color="neutral"
1195
+ />
1196
+ </Show>
520
1197
 
521
- <Show when={props.showColumnsToggle}>
522
- <div class="ml-auto shrink-0">
1198
+ <Show when={props.showColumnsToggle}>
523
1199
  <RLSDropdownMenu
524
1200
  label="Columns"
525
1201
  icon="i-lucide-columns"
@@ -528,16 +1204,47 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
528
1204
  size="sm"
529
1205
  color="neutral"
530
1206
  />
531
- </div>
532
- </Show>
1207
+ </Show>
1208
+ </div>
533
1209
  </div>
534
1210
  </Show>
535
1211
 
536
- <div class="overflow-x-auto w-full">
1212
+ <div
1213
+ ref={(el) => (containerRef = el)}
1214
+ class="overflow-x-auto w-full relative"
1215
+ style={isVirtual() ? "max-height: 400px; overflow-y: auto;" : undefined}
1216
+ >
537
1217
  <table ref={(el) => (tableElementRef = el)} class={classes().base} data-slot="base">
538
- <Show when={props.caption || props.captionContent}>
1218
+ <colgroup>
1219
+ <For each={visibleColumns()}>
1220
+ {(col) => {
1221
+ const colId = () => String(col.id || col.accessorKey || "")
1222
+ const sizeVal = () => currentColumnSizing()[colId()] ?? col.size
1223
+ const styles: string[] = []
1224
+ if (typeof sizeVal() === "number") styles.push(`width: ${sizeVal()}px;`)
1225
+ else if (sizeVal()) styles.push(`width: ${sizeVal()};`)
1226
+ if (col.minSize) {
1227
+ styles.push(
1228
+ `min-width: ${typeof col.minSize === "number" ? `${col.minSize}px` : col.minSize};`
1229
+ )
1230
+ }
1231
+ if (col.maxSize) {
1232
+ styles.push(
1233
+ `max-width: ${typeof col.maxSize === "number" ? `${col.maxSize}px` : col.maxSize};`
1234
+ )
1235
+ }
1236
+ return <col data-col-id={colId()} style={styles.join(" ") || undefined} />
1237
+ }}
1238
+ </For>
1239
+ </colgroup>
1240
+
1241
+ <Show when={props.caption || props.captionContent || props.slots?.["caption"]}>
539
1242
  <caption class={classes().caption} data-slot="caption">
540
- {props.captionContent ? props.captionContent() : props.caption}
1243
+ {props.slots?.["caption"]
1244
+ ? props.slots["caption"]({ table: tableApi })
1245
+ : props.captionContent
1246
+ ? props.captionContent()
1247
+ : props.caption}
541
1248
  </caption>
542
1249
  </Show>
543
1250
 
@@ -547,7 +1254,7 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
547
1254
  <tr class={classes().tr} data-slot="tr">
548
1255
  <For each={visibleColumns()}>
549
1256
  {(col) => {
550
- const colId = () => (col.id || col.accessorKey || "") as string
1257
+ const colId = () => String(col.id || col.accessorKey || "")
551
1258
  const pinned = () =>
552
1259
  colId() in columnOffsets().left || colId() in columnOffsets().right
553
1260
  const pinStyle = () => {
@@ -559,6 +1266,23 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
559
1266
  }
560
1267
  return ""
561
1268
  }
1269
+ const sizeStyle = () => {
1270
+ const sizeVal = currentColumnSizing()[colId()] ?? col.size
1271
+ const styles: string[] = []
1272
+ if (typeof sizeVal === "number") styles.push(`width: ${sizeVal}px;`)
1273
+ else if (sizeVal) styles.push(`width: ${sizeVal};`)
1274
+ if (col.minSize) {
1275
+ styles.push(
1276
+ `min-width: ${typeof col.minSize === "number" ? `${col.minSize}px` : col.minSize};`
1277
+ )
1278
+ }
1279
+ if (col.maxSize) {
1280
+ styles.push(
1281
+ `max-width: ${typeof col.maxSize === "number" ? `${col.maxSize}px` : col.maxSize};`
1282
+ )
1283
+ }
1284
+ return styles.join(" ")
1285
+ }
562
1286
  const metaThClass = () =>
563
1287
  typeof col.meta?.class?.th === "function"
564
1288
  ? col.meta.class.th(col)
@@ -569,18 +1293,40 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
569
1293
  : col.meta?.style?.th || ""
570
1294
  const canSort = () =>
571
1295
  col.enableSorting !== false &&
572
- !!(col.accessorKey || col.id) &&
1296
+ !!(col.accessorKey || col.id || col.accessorFn) &&
573
1297
  colId() !== "actions" &&
574
- colId() !== "select"
1298
+ colId() !== "select" &&
1299
+ colId() !== "reorder" &&
1300
+ colId() !== "dragHandle"
575
1301
 
576
1302
  const activeSort = () => currentSorting().find((s) => s.id === colId())
577
1303
  const sortDir = () =>
578
1304
  activeSort() ? (activeSort()!.desc ? "desc" : "asc") : "none"
579
1305
 
580
- const sizeStyle = () =>
581
- col.size
582
- ? `width: ${typeof col.size === "number" ? `${col.size}px` : col.size};`
583
- : ""
1306
+ const slotHeader = props.slots?.[`${colId()}-header`]
1307
+
1308
+ const isDropTarget = () =>
1309
+ dropTargetCol()?.id === colId() && draggedColId() !== colId()
1310
+ const dropPosition = () =>
1311
+ isDropTarget() ? dropTargetCol()?.position : undefined
1312
+
1313
+ const headerLabel = () => {
1314
+ if (typeof col.header === "function") {
1315
+ return col.header({ column: col, table: tableApi })
1316
+ }
1317
+ if (col.header !== undefined && col.header !== null) {
1318
+ return col.header
1319
+ }
1320
+ if (
1321
+ colId() === "reorder" ||
1322
+ colId() === "select" ||
1323
+ colId() === "actions" ||
1324
+ colId() === "dragHandle"
1325
+ ) {
1326
+ return ""
1327
+ }
1328
+ return colId().charAt(0).toUpperCase() + colId().slice(1)
1329
+ }
584
1330
 
585
1331
  return (
586
1332
  <th
@@ -589,36 +1335,115 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
589
1335
  style={`${sizeStyle()} ${pinStyle()} ${metaThStyle()}`}
590
1336
  data-col-id={colId()}
591
1337
  data-slot="th"
592
- >
593
- <Show
594
- when={canSort()}
595
- fallback={
596
- typeof col.header === "function"
597
- ? col.header({ column: col, table: tableApi })
598
- : col.header || colId()
1338
+ data-drop-position={dropPosition()}
1339
+ onDragOver={(e) => {
1340
+ if (!props.enableColumnReordering || !draggedColId()) return
1341
+ e.preventDefault()
1342
+ const th = e.currentTarget as HTMLElement
1343
+ const rect = th.getBoundingClientRect()
1344
+ const pos = e.clientX < rect.left + rect.width / 2 ? "left" : "right"
1345
+ const cur = dropTargetCol()
1346
+ if (!cur || cur.id !== colId() || cur.position !== pos) {
1347
+ setDropTargetCol({ id: colId(), position: pos })
599
1348
  }
600
- >
601
- <button
602
- type="button"
603
- class="inline-flex items-center gap-1.5 hover:text-highlighted focus:outline-none transition-colors cursor-pointer group -mx-1 px-1 py-0.5 rounded"
604
- onClick={(e) => toggleSort(colId(), e.shiftKey)}
605
- aria-label={`Sort by ${colId()}`}
1349
+ }}
1350
+ onDragLeave={() => {
1351
+ if (dropTargetCol()?.id === colId()) {
1352
+ setDropTargetCol(null)
1353
+ }
1354
+ }}
1355
+ onDrop={(e) => {
1356
+ if (!props.enableColumnReordering || !draggedColId()) return
1357
+ e.preventDefault()
1358
+ const sourceId = draggedColId()!
1359
+ const target = dropTargetCol()
1360
+ setDraggedColId(null)
1361
+ setDropTargetCol(null)
1362
+ if (!target || sourceId === target.id) return
1363
+
1364
+ const cols = resolvedColumns().map((c) =>
1365
+ String(c.id || c.accessorKey || "")
1366
+ )
1367
+ const fromIdx = cols.indexOf(sourceId)
1368
+ if (fromIdx === -1) return
1369
+ const newOrder = [...cols]
1370
+ newOrder.splice(fromIdx, 1)
1371
+ let toIdx = newOrder.indexOf(target.id)
1372
+ if (toIdx === -1) return
1373
+ if (target.position === "right") {
1374
+ toIdx += 1
1375
+ }
1376
+ newOrder.splice(toIdx, 0, sourceId)
1377
+ updateColumnOrder(newOrder)
1378
+ }}
1379
+ >
1380
+ <div class="flex items-center gap-1.5">
1381
+ {/* Column Drag Handle */}
1382
+ <Show
1383
+ when={
1384
+ props.enableColumnReordering &&
1385
+ colId() !== "reorder" &&
1386
+ colId() !== "select" &&
1387
+ colId() !== "actions" &&
1388
+ colId() !== "dragHandle"
1389
+ }
606
1390
  >
607
- <span>
608
- {typeof col.header === "function"
609
- ? col.header({ column: col, table: tableApi })
610
- : col.header || colId()}
611
- </span>
612
1391
  <span
613
- class={`size-3.5 transition-all shrink-0 ${
614
- sortDir() === "asc"
615
- ? "i-lucide-arrow-up-narrow-wide text-primary"
616
- : sortDir() === "desc"
617
- ? "i-lucide-arrow-down-wide-narrow text-primary"
618
- : "i-lucide-arrow-up-down opacity-40 group-hover:opacity-100"
619
- }`}
620
- />
621
- </button>
1392
+ class={classes().dragHandle}
1393
+ data-slot="drag-handle"
1394
+ draggable={true}
1395
+ onDragStart={(e) => {
1396
+ setDraggedColId(colId())
1397
+ e.dataTransfer?.setData("text/plain", colId())
1398
+ if (e.dataTransfer) {
1399
+ e.dataTransfer.effectAllowed = "move"
1400
+ }
1401
+ }}
1402
+ onDragEnd={() => {
1403
+ setDraggedColId(null)
1404
+ setDropTargetCol(null)
1405
+ }}
1406
+ >
1407
+ <span class="i-lucide-grip-vertical size-3.5 opacity-40 hover:opacity-100" />
1408
+ </span>
1409
+ </Show>
1410
+
1411
+ <div class="flex-1 min-w-0">
1412
+ <Show
1413
+ when={!slotHeader}
1414
+ fallback={slotHeader?.({ column: col, table: tableApi })}
1415
+ >
1416
+ <Show when={canSort()} fallback={<span>{headerLabel()}</span>}>
1417
+ <button
1418
+ type="button"
1419
+ class="inline-flex items-center gap-1.5 hover:text-highlighted focus:outline-none transition-colors cursor-pointer group -mx-1 px-1 py-0.5 rounded"
1420
+ onClick={(e) => toggleSort(colId(), e.shiftKey)}
1421
+ aria-label={`Sort by ${colId()}`}
1422
+ >
1423
+ <span>{headerLabel()}</span>
1424
+ <span
1425
+ class={`size-3.5 transition-all shrink-0 ${
1426
+ sortDir() === "asc"
1427
+ ? "i-lucide-arrow-up-narrow-wide text-primary"
1428
+ : sortDir() === "desc"
1429
+ ? "i-lucide-arrow-down-wide-narrow text-primary"
1430
+ : "i-lucide-arrow-up-down opacity-40 group-hover:opacity-100"
1431
+ }`}
1432
+ />
1433
+ </button>
1434
+ </Show>
1435
+ </Show>
1436
+ </div>
1437
+ </div>
1438
+
1439
+ {/* Column Resizer Handle */}
1440
+ <Show when={props.enableColumnResizing}>
1441
+ <div
1442
+ class={classes().resizer}
1443
+ data-slot="resizer"
1444
+ data-resizing={resizingColId() === colId() ? "true" : undefined}
1445
+ onPointerDown={(e) => startResize(e, colId(), col.minSize, col.maxSize)}
1446
+ />
622
1447
  </Show>
623
1448
  </th>
624
1449
  )
@@ -630,7 +1455,9 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
630
1455
 
631
1456
  {/* ── tbody ── */}
632
1457
  <tbody class={classes().tbody} data-slot="tbody">
633
- {props.bodyTop?.()}
1458
+ {props.slots?.["body-top"]
1459
+ ? props.slots["body-top"]({ table: tableApi })
1460
+ : props.bodyTop?.()}
634
1461
 
635
1462
  <Show
636
1463
  when={!props.loading}
@@ -642,7 +1469,7 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
642
1469
  data-slot="loading"
643
1470
  >
644
1471
  <Show
645
- when={props.loadingContent}
1472
+ when={props.slots?.["loading"] || props.loadingContent}
646
1473
  fallback={
647
1474
  <div class="flex items-center justify-center gap-2 text-muted py-4">
648
1475
  <RLSIcon
@@ -653,7 +1480,9 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
653
1480
  </div>
654
1481
  }
655
1482
  >
656
- {props.loadingContent?.()}
1483
+ {props.slots?.["loading"]
1484
+ ? props.slots["loading"]({ table: tableApi })
1485
+ : props.loadingContent?.()}
657
1486
  </Show>
658
1487
  </td>
659
1488
  </tr>
@@ -669,7 +1498,7 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
669
1498
  data-slot="empty"
670
1499
  >
671
1500
  <Show
672
- when={props.emptyContent}
1501
+ when={props.slots?.["empty"] || props.emptyContent}
673
1502
  fallback={
674
1503
  <div class="flex flex-col items-center justify-center py-6 gap-2 text-muted">
675
1504
  <RLSIcon name="i-lucide-inbox" class="size-8 opacity-40" />
@@ -677,28 +1506,46 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
677
1506
  </div>
678
1507
  }
679
1508
  >
680
- {props.emptyContent?.()}
1509
+ {props.slots?.["empty"]
1510
+ ? props.slots["empty"]({ table: tableApi })
1511
+ : props.emptyContent?.()}
681
1512
  </Show>
682
1513
  </td>
683
1514
  </tr>
684
1515
  }
685
1516
  >
686
- <For each={paginatedItems()}>
1517
+ {/* Virtual Top Spacer */}
1518
+ <Show when={isVirtual() && virtualizedView().padTop > 0}>
1519
+ <tr>
1520
+ <td
1521
+ colSpan={Math.max(1, visibleColumns().length)}
1522
+ style={`height: ${virtualizedView().padTop}px; padding: 0; border: 0;`}
1523
+ />
1524
+ </tr>
1525
+ </Show>
1526
+
1527
+ <For each={virtualizedView().items}>
687
1528
  {(item) => {
688
1529
  if (item.type === "group") {
689
- const { group } = item
1530
+ const { group, depth } = item
1531
+ const groupHeaderSlot = props.slots?.["group-header"]
1532
+
690
1533
  return (
691
- <tr class={classes().trGroup} data-slot="tr-group">
1534
+ <tr class={classes().trGroup} data-slot="tr-group" data-depth={depth}>
692
1535
  <td
693
1536
  colSpan={Math.max(1, visibleColumns().length)}
694
1537
  class={classes().tdGroup}
1538
+ style={{ "padding-left": `${depth * 1.5 + 1}rem` }}
695
1539
  data-slot="td-group"
696
1540
  >
697
1541
  <Show
698
- when={props.groupHeader}
1542
+ when={groupHeaderSlot || props.groupHeader}
699
1543
  fallback={
700
1544
  <span class="flex items-center gap-2">
701
1545
  <span class="i-lucide-layers size-3.5 text-primary opacity-70" />
1546
+ <span class="text-xs uppercase text-muted font-medium">
1547
+ {group.columnId}:
1548
+ </span>
702
1549
  <span class="text-highlighted">{String(group.value)}</span>
703
1550
  <span class="ml-auto text-muted font-normal normal-case">
704
1551
  {group.rows.length} {group.rows.length === 1 ? "row" : "rows"}
@@ -706,7 +1553,13 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
706
1553
  </span>
707
1554
  }
708
1555
  >
709
- {props.groupHeader?.(group, visibleColumns().length)}
1556
+ {groupHeaderSlot
1557
+ ? groupHeaderSlot({
1558
+ group,
1559
+ colSpan: visibleColumns().length,
1560
+ table: tableApi
1561
+ })
1562
+ : props.groupHeader?.(group, visibleColumns().length)}
710
1563
  </Show>
711
1564
  </td>
712
1565
  </tr>
@@ -723,6 +1576,13 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
723
1576
  ? props.meta.style.tr(row)
724
1577
  : props.meta?.style?.tr || ""
725
1578
 
1579
+ const isRowDropTarget = () =>
1580
+ dropTargetRow()?.id === row.id &&
1581
+ draggedRowIndex() !== null &&
1582
+ draggedRowIndex() !== row.index
1583
+ const rowDropPos = () =>
1584
+ isRowDropTarget() ? dropTargetRow()?.position : undefined
1585
+
726
1586
  return (
727
1587
  <>
728
1588
  <tr
@@ -733,6 +1593,51 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
733
1593
  data-selected={row.getIsSelected() ? "true" : undefined}
734
1594
  data-expanded={row.getIsExpanded() ? "true" : undefined}
735
1595
  data-pinned={row.getIsPinned() || undefined}
1596
+ data-drop-position={rowDropPos()}
1597
+ onDragOver={(e) => {
1598
+ if (!props.enableRowReordering || draggedRowIndex() === null) return
1599
+ e.preventDefault()
1600
+ const tr = e.currentTarget as HTMLElement
1601
+ const rect = tr.getBoundingClientRect()
1602
+ const pos = e.clientY < rect.top + rect.height / 2 ? "top" : "bottom"
1603
+ const cur = dropTargetRow()
1604
+ if (!cur || cur.id !== row.id || cur.position !== pos) {
1605
+ setDropTargetRow({ id: row.id, index: row.index, position: pos })
1606
+ }
1607
+ }}
1608
+ onDragLeave={() => {
1609
+ if (dropTargetRow()?.id === row.id) {
1610
+ setDropTargetRow(null)
1611
+ }
1612
+ }}
1613
+ onDrop={(e) => {
1614
+ if (
1615
+ !props.enableRowReordering ||
1616
+ draggedRowIndex() === null ||
1617
+ !props.data
1618
+ )
1619
+ return
1620
+ e.preventDefault()
1621
+ const fromIdx = draggedRowIndex()!
1622
+ const target = dropTargetRow()
1623
+ setDraggedRowIndex(null)
1624
+ setDropTargetRow(null)
1625
+ if (!target || fromIdx === target.index) return
1626
+
1627
+ const newData = [...props.data]
1628
+ const [movedItem] = newData.splice(fromIdx, 1)
1629
+ if (movedItem !== undefined) {
1630
+ let toIdx = target.index
1631
+ if (fromIdx < target.index && target.position === "top") {
1632
+ toIdx = target.index - 1
1633
+ } else if (fromIdx > target.index && target.position === "bottom") {
1634
+ toIdx = target.index + 1
1635
+ }
1636
+ toIdx = Math.max(0, Math.min(newData.length, toIdx))
1637
+ newData.splice(toIdx, 0, movedItem)
1638
+ props.onRowReorder?.(newData, fromIdx, toIdx)
1639
+ }
1640
+ }}
736
1641
  onClick={(e) => {
737
1642
  const target = e.target as HTMLElement
738
1643
  if (target.closest("a, button, input, select, textarea, [role=button]"))
@@ -745,10 +1650,14 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
745
1650
  >
746
1651
  <For each={visibleColumns()}>
747
1652
  {(col) => {
748
- const colId = () => (col.id || col.accessorKey || "") as string
749
- const accessorKey = () => (col.accessorKey || col.id || "") as string
750
- const cellValue = () =>
751
- accessorKey() ? row.getValue(accessorKey()) : undefined
1653
+ const colId = () => String(col.id || col.accessorKey || "")
1654
+ const cellValue = () => {
1655
+ if (typeof col.accessorFn === "function") {
1656
+ return col.accessorFn(row.original)
1657
+ }
1658
+ const key = (col.accessorKey || col.id || "") as string
1659
+ return key ? row.getValue(key) : undefined
1660
+ }
752
1661
  const pinned = () =>
753
1662
  colId() in columnOffsets().left || colId() in columnOffsets().right
754
1663
 
@@ -769,6 +1678,24 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
769
1678
  ? col.meta.rowspan.td(row)
770
1679
  : col.meta?.rowspan?.td
771
1680
 
1681
+ const sizeStyle = () => {
1682
+ const sizeVal = currentColumnSizing()[colId()] ?? col.size
1683
+ const styles: string[] = []
1684
+ if (typeof sizeVal === "number") styles.push(`width: ${sizeVal}px;`)
1685
+ else if (sizeVal) styles.push(`width: ${sizeVal};`)
1686
+ if (col.minSize) {
1687
+ styles.push(
1688
+ `min-width: ${typeof col.minSize === "number" ? `${col.minSize}px` : col.minSize};`
1689
+ )
1690
+ }
1691
+ if (col.maxSize) {
1692
+ styles.push(
1693
+ `max-width: ${typeof col.maxSize === "number" ? `${col.maxSize}px` : col.maxSize};`
1694
+ )
1695
+ }
1696
+ return styles.join(" ")
1697
+ }
1698
+
772
1699
  const pinStyle = () => {
773
1700
  if (colId() in columnOffsets().left) {
774
1701
  return `left: ${columnOffsets().left[colId()]}px;`
@@ -779,26 +1706,41 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
779
1706
  return ""
780
1707
  }
781
1708
 
1709
+ const isEditing = () =>
1710
+ currentEditingCell()?.rowId === row.id &&
1711
+ currentEditingCell()?.columnId === colId()
1712
+ const setEditing = (editing: boolean) => {
1713
+ tableApi.setEditingCell(
1714
+ editing ? { rowId: row.id, columnId: colId() } : null
1715
+ )
1716
+ }
1717
+
1718
+ const cellSlot = props.slots?.[`${colId()}-cell`]
1719
+
782
1720
  return (
783
1721
  <td
784
1722
  class={`${classes().td} ${pinned() ? "sticky bg-default z-10" : ""} ${metaTdClass()}`}
785
- style={`${pinStyle()} ${metaTdStyle()}`}
1723
+ style={`${sizeStyle()} ${pinStyle()} ${metaTdStyle()}`}
786
1724
  colSpan={colSpanVal()}
787
1725
  rowSpan={rowSpanVal()}
788
1726
  data-slot="td"
789
1727
  data-col-id={colId()}
790
1728
  >
791
- {typeof col.cell === "function"
792
- ? col.cell({
793
- row,
794
- getValue: cellValue,
795
- renderValue: cellValue,
796
- column: col,
797
- table: tableApi
798
- })
799
- : cellValue() !== undefined && cellValue() !== null
800
- ? String(cellValue())
801
- : "—"}
1729
+ <Show
1730
+ when={!cellSlot}
1731
+ fallback={cellSlot?.({
1732
+ row,
1733
+ column: col,
1734
+ cell: { value: cellValue() },
1735
+ getValue: cellValue,
1736
+ renderValue: cellValue,
1737
+ isEditing,
1738
+ setEditing,
1739
+ table: tableApi
1740
+ })}
1741
+ >
1742
+ {renderCellContent(row, col, cellValue, isEditing, setEditing)}
1743
+ </Show>
802
1744
  </td>
803
1745
  )
804
1746
  }}
@@ -806,14 +1748,22 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
806
1748
  </tr>
807
1749
 
808
1750
  {/* Expanded sub-row content */}
809
- <Show when={row.getIsExpanded() && props.expandedContent}>
1751
+ <Show
1752
+ when={
1753
+ row.getCanExpand() &&
1754
+ row.getIsExpanded() &&
1755
+ (props.slots?.["expanded"] || props.expandedContent)
1756
+ }
1757
+ >
810
1758
  <tr class={classes().trExpanded} data-slot="tr-expanded">
811
1759
  <td
812
1760
  colSpan={Math.max(1, visibleColumns().length)}
813
1761
  class={classes().tdExpanded}
814
1762
  data-slot="td-expanded"
815
1763
  >
816
- {props.expandedContent?.(row)}
1764
+ {props.slots?.["expanded"]
1765
+ ? props.slots["expanded"]({ row, table: tableApi })
1766
+ : props.expandedContent?.(row)}
817
1767
  </td>
818
1768
  </tr>
819
1769
  </Show>
@@ -821,49 +1771,168 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
821
1771
  {/* Tree / Sub-rows */}
822
1772
  <Show when={row.getIsExpanded() && row.subRows && row.subRows.length > 0}>
823
1773
  <For each={row.subRows}>
824
- {(subRow) => (
825
- <tr
826
- class={classes().tr}
827
- data-slot="tr"
828
- data-row-id={subRow.id}
829
- data-depth={subRow.depth}
830
- >
831
- <For each={visibleColumns()}>
832
- {(col) => {
833
- const colId = () => (col.id || col.accessorKey || "") as string
834
- const accessorKey = () =>
835
- (col.accessorKey || col.id || "") as string
836
- const cellValue = () =>
837
- accessorKey() ? subRow.getValue(accessorKey()) : undefined
838
- return (
839
- <td class={classes().td} data-slot="td" data-col-id={colId()}>
840
- {typeof col.cell === "function"
841
- ? col.cell({
1774
+ {(subRow) => {
1775
+ const subMetaClass = () =>
1776
+ typeof props.meta?.class?.tr === "function"
1777
+ ? props.meta.class.tr(subRow)
1778
+ : props.meta?.class?.tr || ""
1779
+ const subMetaStyle = () =>
1780
+ typeof props.meta?.style?.tr === "function"
1781
+ ? props.meta.style.tr(subRow)
1782
+ : props.meta?.style?.tr || ""
1783
+
1784
+ return (
1785
+ <tr
1786
+ class={`${classes().tr} ${subMetaClass()}`}
1787
+ style={subMetaStyle() || undefined}
1788
+ data-slot="tr"
1789
+ data-row-id={subRow.id}
1790
+ data-depth={subRow.depth}
1791
+ data-selected={subRow.getIsSelected() ? "true" : undefined}
1792
+ onClick={(e) => {
1793
+ const target = e.target as HTMLElement
1794
+ if (
1795
+ target.closest(
1796
+ "a, button, input, select, textarea, [role=button]"
1797
+ )
1798
+ )
1799
+ return
1800
+ props.onSelect?.(e, subRow)
1801
+ }}
1802
+ onMouseEnter={(e) => props.onHover?.(e, subRow)}
1803
+ onMouseLeave={(e) => props.onHover?.(e, null)}
1804
+ onContextMenu={(e) => props.onContextmenu?.(e, subRow)}
1805
+ >
1806
+ <For each={visibleColumns()}>
1807
+ {(col) => {
1808
+ const colId = () => String(col.id || col.accessorKey || "")
1809
+ const cellValue = () => {
1810
+ if (typeof col.accessorFn === "function") {
1811
+ return col.accessorFn(subRow.original)
1812
+ }
1813
+ const key = (col.accessorKey || col.id || "") as string
1814
+ return key ? subRow.getValue(key) : undefined
1815
+ }
1816
+ const pinned = () =>
1817
+ colId() in columnOffsets().left ||
1818
+ colId() in columnOffsets().right
1819
+
1820
+ const metaTdClass = () =>
1821
+ typeof col.meta?.class?.td === "function"
1822
+ ? col.meta.class.td(subRow)
1823
+ : col.meta?.class?.td || ""
1824
+ const metaTdStyle = () =>
1825
+ typeof col.meta?.style?.td === "function"
1826
+ ? col.meta.style.td(subRow)
1827
+ : col.meta?.style?.td || ""
1828
+ const colSpanVal = () =>
1829
+ typeof col.meta?.colspan?.td === "function"
1830
+ ? col.meta.colspan.td(subRow)
1831
+ : col.meta?.colspan?.td
1832
+ const rowSpanVal = () =>
1833
+ typeof col.meta?.rowspan?.td === "function"
1834
+ ? col.meta.rowspan.td(subRow)
1835
+ : col.meta?.rowspan?.td
1836
+
1837
+ const sizeStyle = () => {
1838
+ const sizeVal = currentColumnSizing()[colId()] ?? col.size
1839
+ const styles: string[] = []
1840
+ if (typeof sizeVal === "number")
1841
+ styles.push(`width: ${sizeVal}px;`)
1842
+ else if (sizeVal) styles.push(`width: ${sizeVal};`)
1843
+ if (col.minSize) {
1844
+ styles.push(
1845
+ `min-width: ${typeof col.minSize === "number" ? `${col.minSize}px` : col.minSize};`
1846
+ )
1847
+ }
1848
+ if (col.maxSize) {
1849
+ styles.push(
1850
+ `max-width: ${typeof col.maxSize === "number" ? `${col.maxSize}px` : col.maxSize};`
1851
+ )
1852
+ }
1853
+ return styles.join(" ")
1854
+ }
1855
+
1856
+ const pinStyle = () => {
1857
+ if (colId() in columnOffsets().left) {
1858
+ return `left: ${columnOffsets().left[colId()]}px;`
1859
+ }
1860
+ if (colId() in columnOffsets().right) {
1861
+ return `right: ${columnOffsets().right[colId()]}px;`
1862
+ }
1863
+ return ""
1864
+ }
1865
+
1866
+ const isEditing = () =>
1867
+ currentEditingCell()?.rowId === subRow.id &&
1868
+ currentEditingCell()?.columnId === colId()
1869
+ const setEditing = (editing: boolean) => {
1870
+ tableApi.setEditingCell(
1871
+ editing ? { rowId: subRow.id, columnId: colId() } : null
1872
+ )
1873
+ }
1874
+
1875
+ const cellSlot = props.slots?.[`${colId()}-cell`]
1876
+
1877
+ return (
1878
+ <td
1879
+ class={`${classes().td} ${pinned() ? "sticky bg-default z-10" : ""} ${metaTdClass()}`}
1880
+ style={`${sizeStyle()} ${pinStyle()} ${metaTdStyle()}`}
1881
+ colSpan={colSpanVal()}
1882
+ rowSpan={rowSpanVal()}
1883
+ data-slot="td"
1884
+ data-col-id={colId()}
1885
+ >
1886
+ <Show
1887
+ when={!cellSlot}
1888
+ fallback={cellSlot?.({
842
1889
  row: subRow,
1890
+ column: col,
1891
+ cell: { value: cellValue() },
843
1892
  getValue: cellValue,
844
1893
  renderValue: cellValue,
845
- column: col,
1894
+ isEditing,
1895
+ setEditing,
846
1896
  table: tableApi
847
- })
848
- : cellValue() !== undefined && cellValue() !== null
849
- ? String(cellValue())
850
- : "—"}
851
- </td>
852
- )
853
- }}
854
- </For>
855
- </tr>
856
- )}
1897
+ })}
1898
+ >
1899
+ {renderCellContent(
1900
+ subRow,
1901
+ col,
1902
+ cellValue,
1903
+ isEditing,
1904
+ setEditing
1905
+ )}
1906
+ </Show>
1907
+ </td>
1908
+ )
1909
+ }}
1910
+ </For>
1911
+ </tr>
1912
+ )
1913
+ }}
857
1914
  </For>
858
1915
  </Show>
859
1916
  </>
860
1917
  )
861
1918
  }}
862
1919
  </For>
1920
+
1921
+ {/* Virtual Bottom Spacer */}
1922
+ <Show when={isVirtual() && virtualizedView().padBottom > 0}>
1923
+ <tr>
1924
+ <td
1925
+ colSpan={Math.max(1, visibleColumns().length)}
1926
+ style={`height: ${virtualizedView().padBottom}px; padding: 0; border: 0;`}
1927
+ />
1928
+ </tr>
1929
+ </Show>
863
1930
  </Show>
864
1931
  </Show>
865
1932
 
866
- {props.bodyBottom?.()}
1933
+ {props.slots?.["body-bottom"]
1934
+ ? props.slots["body-bottom"]({ table: tableApi })
1935
+ : props.bodyBottom?.()}
867
1936
  </tbody>
868
1937
 
869
1938
  {/* ── tfoot ── */}
@@ -872,11 +1941,19 @@ export function RLSTable<T = any>(props: RLSTableProps<T>) {
872
1941
  <tr class={classes().tr} data-slot="tr">
873
1942
  <For each={visibleColumns()}>
874
1943
  {(col) => {
1944
+ const colId = () => String(col.id || col.accessorKey || "")
1945
+ const footerSlot = props.slots?.[`${colId()}-footer`]
1946
+
875
1947
  return (
876
1948
  <td class={classes().td} data-slot="td">
877
- {typeof col.footer === "function"
878
- ? col.footer({ column: col, table: tableApi })
879
- : col.footer || ""}
1949
+ <Show
1950
+ when={!footerSlot}
1951
+ fallback={footerSlot?.({ column: col, table: tableApi })}
1952
+ >
1953
+ {typeof col.footer === "function"
1954
+ ? col.footer({ column: col, table: tableApi })
1955
+ : col.footer || ""}
1956
+ </Show>
880
1957
  </td>
881
1958
  )
882
1959
  }}