@rimelight/ui 0.0.51 → 0.0.52

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,1044 +1,1692 @@
1
- ---
2
- import type {
3
- TableProps,
4
- TableColumn,
5
- TableRow,
6
- TableGroup,
7
- SortingState
8
- } from "./table"
9
- import { tableTheme } from "./table.theme"
10
- import RLAIcon from "../icon/RLAIcon.astro"
11
- import RLAInput from "../input/RLAInput.astro"
12
- import RLADropdownMenu from "../dropdown-menu/RLADropdownMenu.astro"
13
-
14
- type AnyData = Record<string, any>
15
-
16
- const {
17
- data = [],
18
- columns = [],
19
- caption,
20
- meta,
21
- sticky = false,
22
- loading = false,
23
- loadingColor = "primary",
24
- loadingAnimation = "carousel",
25
- empty = "No data available",
26
- striped = false,
27
- bordered = false,
28
- hoverable = false,
29
- globalFilter = "",
30
- columnFilters = [],
31
- columnVisibility = {},
32
- columnPinning = {},
33
- sorting = [],
34
- grouping = [],
35
- rowPinning = {},
36
- rowSelection = {},
37
- expanded = {},
38
- pagination,
39
- getSubRows,
40
- getRowId,
41
- searchable = false,
42
- searchPlaceholder = "Filter table...",
43
- showColumnsToggle = false,
44
- class: className,
45
- ui,
46
- ...rest
47
- } = Astro.props as TableProps
48
-
49
- const classes = tableTheme({
50
- sticky,
51
- loading,
52
- loadingColor,
53
- loadingAnimation,
54
- striped,
55
- bordered,
56
- hoverable,
57
- ui,
58
- class: className,
59
- externalScroll: false
60
- })
61
-
62
- // ─── Column resolution ────────────────────────────────────────────────────────
63
-
64
- let resolvedColumns: TableColumn[] = []
65
-
66
- if (columns && columns.length > 0) {
67
- resolvedColumns = columns
68
- } else if (data && data.length > 0 && typeof data[0] === "object" && data[0] !== null) {
69
- const firstRow = data[0] as AnyData
70
- resolvedColumns = Object.keys(firstRow).map((key) => ({
71
- id: key,
72
- accessorKey: key,
73
- header: key.charAt(0).toUpperCase() + key.slice(1),
74
- enableSorting: true
75
- }))
76
- }
77
-
78
- // Filter by column visibility
79
- const visibleColumns = resolvedColumns.filter((col) => {
80
- const id = (col.id || col.accessorKey || "") as string
81
- if (!id) return true
82
- if (col.enableHiding === false) return true
83
- return columnVisibility[id] !== false
84
- })
85
-
86
- // Dropdown items for column visibility toggle
87
- const columnDropdownItems = resolvedColumns
88
- .filter((col) => col.enableHiding !== false)
89
- .map((col) => {
90
- const colId = (col.id || col.accessorKey || "") as string
91
- const rawLabel = typeof col.header === "string" ? col.header : colId
92
- return {
93
- id: colId,
94
- label: rawLabel.charAt(0).toUpperCase() + rawLabel.slice(1),
95
- type: "checkbox" as const,
96
- checked: columnVisibility[colId] !== false
97
- }
98
- })
99
-
100
- // ─── Row building ─────────────────────────────────────────────────────────────
101
-
102
- function getNestedValue(obj: AnyData, key: string): any {
103
- if (key.includes(".")) {
104
- return key.split(".").reduce((acc: any, k) => acc?.[k], obj)
105
- }
106
- return obj[key]
107
- }
108
-
109
- function buildRowTree(rawItems: any[], parentDepth = 0): TableRow[] {
110
- return rawItems.map((item, idx) => {
111
- const rId = getRowId
112
- ? getRowId(item, idx)
113
- : item?.id !== undefined
114
- ? String(item.id)
115
- : String(idx)
116
-
117
- const isSelected = !!rowSelection[rId]
118
- const isExpanded = !!expanded[rId]
119
-
120
- let subRowsList: TableRow[] | undefined = undefined
121
- if (getSubRows) {
122
- const children = getSubRows(item, idx)
123
- if (children && children.length > 0) {
124
- subRowsList = buildRowTree(children, parentDepth + 1)
125
- }
126
- } else if (Array.isArray(item?.children) && item.children.length > 0) {
127
- subRowsList = buildRowTree(item.children, parentDepth + 1)
128
- }
129
-
130
- const rowObj: TableRow = {
131
- id: rId,
132
- original: item,
133
- index: idx,
134
- depth: parentDepth,
135
- getValue: (key: string) => {
136
- if (!item || typeof item !== "object") return undefined
137
- return getNestedValue(item as AnyData, key)
138
- },
139
- getIsSelected: () => isSelected,
140
- toggleSelected: () => {},
141
- getIsExpanded: () => isExpanded,
142
- toggleExpanded: () => {},
143
- getIsPinned: () => {
144
- if (rowPinning.top?.includes(rId)) return "top"
145
- if (rowPinning.bottom?.includes(rId)) return "bottom"
146
- return false
147
- },
148
- pin: () => {},
149
- getIsGrouped: () => false,
150
- getCanExpand: () => !!(subRowsList && subRowsList.length > 0),
151
- ...(subRowsList ? { subRows: subRowsList } : {})
152
- }
153
- return rowObj
154
- })
155
- }
156
-
157
- let processedRows: TableRow[] = buildRowTree(data)
158
-
159
- // ─── Filtering ────────────────────────────────────────────────────────────────
160
-
161
- if (globalFilter) {
162
- const query = globalFilter.toLowerCase()
163
- processedRows = processedRows.filter((r) => {
164
- if (typeof r.original !== "object" || !r.original)
165
- return String(r.original).toLowerCase().includes(query)
166
- return Object.values(r.original as AnyData).some((val) =>
167
- String(val ?? "").toLowerCase().includes(query)
168
- )
169
- })
170
- }
171
-
172
- for (const filter of columnFilters) {
173
- if (!filter.id || filter.value === undefined || filter.value === null || filter.value === "")
174
- continue
175
- const fVal = String(filter.value).toLowerCase()
176
- processedRows = processedRows.filter((r) =>
177
- String(r.getValue(filter.id) ?? "").toLowerCase().includes(fVal)
178
- )
179
- }
180
-
181
- // ─── Sorting ──────────────────────────────────────────────────────────────────
182
-
183
- if (sorting && sorting.length > 0) {
184
- processedRows = [...processedRows].sort((a, b) => {
185
- for (const { id, desc } of sorting as SortingState) {
186
- const valA = a.getValue(id)
187
- const valB = b.getValue(id)
188
- if (valA === valB) continue
189
- if (valA === undefined || valA === null) return 1
190
- if (valB === undefined || valB === null) return -1
191
- let cmp: number
192
- if (typeof valA === "number" && typeof valB === "number") {
193
- cmp = valA - valB
194
- } else {
195
- cmp = String(valA).localeCompare(String(valB))
196
- }
197
- return desc ? -cmp : cmp
198
- }
199
- return 0
200
- })
201
- }
202
-
203
- // ─── Grouping ─────────────────────────────────────────────────────────────────
204
-
205
- type GroupedItem =
206
- | { type: "group"; group: TableGroup }
207
- | { type: "row"; row: TableRow }
208
-
209
- let groupedItems: GroupedItem[] = []
210
- let groups: TableGroup[] = []
211
-
212
- if (grouping && grouping.length > 0) {
213
- const groupColId = grouping[0]!
214
- const groupMap = new Map<string, TableRow[]>()
215
- const groupOrder: string[] = []
216
-
217
- for (const row of processedRows) {
218
- const val = String(row.getValue(groupColId) ?? "")
219
- if (!groupMap.has(val)) {
220
- groupMap.set(val, [])
221
- groupOrder.push(val)
222
- }
223
- groupMap.get(val)!.push(row)
224
- }
225
-
226
- for (const val of groupOrder) {
227
- const rows = groupMap.get(val)!
228
- const group: TableGroup = {
229
- id: `group-${groupColId}-${val}`,
230
- columnId: groupColId,
231
- value: val,
232
- rows
233
- }
234
- groups.push(group)
235
- groupedItems.push({ type: "group", group })
236
- for (const row of rows) {
237
- groupedItems.push({ type: "row", row, groupId: group.id } as any)
238
- }
239
- }
240
- } else {
241
- groupedItems = processedRows.map((row) => ({ type: "row", row }))
242
- }
243
-
244
- // ─── Row pinning ──────────────────────────────────────────────────────────────
245
-
246
- if ((rowPinning.top?.length ?? 0) > 0 || (rowPinning.bottom?.length ?? 0) > 0) {
247
- const topItems: GroupedItem[] = []
248
- const midItems: GroupedItem[] = []
249
- const botItems: GroupedItem[] = []
250
-
251
- for (const item of groupedItems) {
252
- if (item.type === "row") {
253
- if (rowPinning.top?.includes(item.row.id)) topItems.push(item)
254
- else if (rowPinning.bottom?.includes(item.row.id)) botItems.push(item)
255
- else midItems.push(item)
256
- } else {
257
- midItems.push(item)
258
- }
259
- }
260
- groupedItems = [...topItems, ...midItems, ...botItems]
261
- }
262
-
263
- // ─── Pagination ───────────────────────────────────────────────────────────────
264
-
265
- let paginatedItems = groupedItems
266
- if (pagination) {
267
- const { pageIndex = 0, pageSize = 10 } = pagination
268
- const rowItems = groupedItems.filter((i) => i.type === "row")
269
- const pageRows = rowItems.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)
270
- const pageRowIds = new Set(pageRows.map((i) => (i as { type: "row"; row: TableRow }).row.id))
271
-
272
- if (grouping && grouping.length > 0) {
273
- const result: GroupedItem[] = []
274
- for (const item of groupedItems) {
275
- if (item.type === "group") {
276
- if (item.group.rows.some((r) => pageRowIds.has(r.id))) result.push(item)
277
- } else if (pageRowIds.has(item.row.id)) {
278
- result.push(item)
279
- }
280
- }
281
- paginatedItems = result
282
- } else {
283
- paginatedItems = pageRows
284
- }
285
- }
286
-
287
- // ─── Column pinning offsets ───────────────────────────────────────────────────
288
-
289
- const colOffsetLeft: Record<string, number> = {}
290
- const colOffsetRight: Record<string, number> = {}
291
-
292
- if (columnPinning.left && columnPinning.left.length > 0) {
293
- let offset = 0
294
- for (const colId of columnPinning.left) {
295
- colOffsetLeft[colId] = offset
296
- const col = resolvedColumns.find((c) => (c.id || c.accessorKey) === colId)
297
- offset += typeof col?.size === "number" ? col.size : 150
298
- }
299
- }
300
-
301
- if (columnPinning.right && columnPinning.right.length > 0) {
302
- let offset = 0
303
- for (const colId of [...columnPinning.right].reverse()) {
304
- colOffsetRight[colId] = offset
305
- const col = resolvedColumns.find((c) => (c.id || c.accessorKey) === colId)
306
- offset += typeof col?.size === "number" ? col.size : 150
307
- }
308
- }
309
-
310
- // ─── Helpers ──────────────────────────────────────────────────────────────────
311
-
312
- const hasFooters = visibleColumns.some(
313
- (col) =>
314
- col.footer ||
315
- Astro.slots.has(`${String(col.id || col.accessorKey)}-footer`)
316
- )
317
-
318
- const showToolbar = searchable || showColumnsToggle || Astro.slots.has("toolbar")
319
-
320
- function getPinStyle(colId: string): string {
321
- if (colOffsetLeft[colId] !== undefined)
322
- return `left:${colOffsetLeft[colId]}px;`
323
- if (colOffsetRight[colId] !== undefined)
324
- return `right:${colOffsetRight[colId]}px;`
325
- return ""
326
- }
327
-
328
- function isPinned(colId: string): boolean {
329
- return colId in colOffsetLeft || colId in colOffsetRight
330
- }
331
- ---
332
-
333
- <rla-table
334
- class:list={[classes.root, className]}
335
- data-slot="root"
336
- {...rest}
337
- >
338
- {showToolbar && (
339
- <div
340
- class="flex items-center gap-3 px-3.5 py-3 border-b border-default bg-muted/20 overflow-x-auto"
341
- data-slot="toolbar"
342
- >
343
- {searchable && (
344
- <RLAInput
345
- type="search"
346
- size="sm"
347
- placeholder={searchPlaceholder}
348
- leadingIcon="i-lucide-search"
349
- class="max-w-xs min-w-[10ch]"
350
- data-rla-table-search
351
- />
352
- )}
353
-
354
- <slot name="toolbar" />
355
-
356
- {showColumnsToggle && (
357
- <div class="ml-auto shrink-0">
358
- <RLADropdownMenu
359
- label="Columns"
360
- icon="i-lucide-columns"
361
- items={columnDropdownItems as any}
362
- content={{ align: "end" }}
363
- size="sm"
364
- color="neutral"
365
- />
366
- </div>
367
- )}
368
- </div>
369
- )}
370
-
371
- <div class="overflow-x-auto w-full">
372
- <table class={classes.base} data-slot="base">
373
- {(caption || Astro.slots.has("caption")) && (
374
- <caption class={classes.caption} data-slot="caption">
375
- <slot name="caption">{caption}</slot>
376
- </caption>
377
- )}
378
-
379
- {/* ── thead ── */}
380
- {visibleColumns.length > 0 && (
381
- <thead class={classes.thead} data-slot="thead">
382
- <tr class={classes.tr} data-slot="tr">
383
- {visibleColumns.map((col) => {
384
- const colId = (col.id || col.accessorKey || "") as string
385
- const pinned = isPinned(colId)
386
- const metaThClass =
387
- typeof col.meta?.class?.th === "function"
388
- ? col.meta.class.th(col)
389
- : col.meta?.class?.th || ""
390
- const metaThStyle =
391
- typeof col.meta?.style?.th === "function"
392
- ? col.meta.style.th(col)
393
- : col.meta?.style?.th || ""
394
- const canSort =
395
- col.enableSorting !== false &&
396
- !!(col.accessorKey || col.id) &&
397
- colId !== "actions" &&
398
- colId !== "select"
399
- const activeSort = (sorting as SortingState).find((s) => s.id === colId)
400
- const sortDir = activeSort ? (activeSort.desc ? "desc" : "asc") : "none"
401
- const headerSlotName = `${colId}-header`
402
-
403
- const sizeStyle = col.size
404
- ? `width:${typeof col.size === "number" ? `${col.size}px` : col.size};`
405
- : ""
406
- const pinStyle = getPinStyle(colId)
407
- const combinedStyle = [sizeStyle, pinStyle, metaThStyle]
408
- .filter(Boolean)
409
- .join("")
410
-
411
- return (
412
- <th
413
- scope="col"
414
- class:list={[
415
- classes.th,
416
- pinned ? "sticky bg-default z-10" : "",
417
- metaThClass
418
- ]}
419
- style={combinedStyle || undefined}
420
- data-col-id={colId}
421
- data-slot="th"
422
- >
423
- {Astro.slots.has(headerSlotName) ? (
424
- <slot name={headerSlotName} {...({ column: col } as any)} />
425
- ) : canSort ? (
426
- <button
427
- type="button"
428
- 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"
429
- data-rla-sort-btn
430
- data-rla-col-id={colId}
431
- data-sort-dir={sortDir}
432
- aria-label={`Sort by ${colId}`}
433
- >
434
- <span>
435
- {typeof col.header === "function"
436
- ? col.header({ column: col, table: {} })
437
- : col.header || colId}
438
- </span>
439
- <span
440
- data-sort-icon
441
- class:list={[
442
- sortDir === "asc"
443
- ? "i-lucide-arrow-up-narrow-wide text-primary"
444
- : sortDir === "desc"
445
- ? "i-lucide-arrow-down-wide-narrow text-primary"
446
- : "i-lucide-arrow-up-down opacity-40 group-hover:opacity-100",
447
- "size-3.5 transition-all shrink-0"
448
- ]}
449
- />
450
- </button>
451
- ) : typeof col.header === "function" ? (
452
- <Fragment set:html={col.header({ column: col, table: {} })} />
453
- ) : (
454
- col.header || colId
455
- )}
456
- </th>
457
- )
458
- })}
459
- </tr>
460
- </thead>
461
- )}
462
-
463
- {/* ── tbody ── */}
464
- <tbody class={classes.tbody} data-slot="tbody">
465
- <slot name="body-top" />
466
-
467
- {loading ? (
468
- <tr>
469
- <td
470
- colspan={Math.max(1, visibleColumns.length)}
471
- class={classes.loading}
472
- data-slot="loading"
473
- >
474
- {Astro.slots.has("loading") ? (
475
- <slot name="loading" />
476
- ) : (
477
- <div class="flex items-center justify-center gap-2 text-muted py-4">
478
- <RLAIcon name="i-lucide-loader-2" class="size-5 animate-spin text-primary" />
479
- <span>Loading...</span>
480
- </div>
481
- )}
482
- </td>
483
- </tr>
484
- ) : paginatedItems.length === 0 ? (
485
- <tr>
486
- <td
487
- colspan={Math.max(1, visibleColumns.length)}
488
- class={classes.empty}
489
- data-slot="empty"
490
- >
491
- {Astro.slots.has("empty") ? (
492
- <slot name="empty" />
493
- ) : (
494
- <div class="flex flex-col items-center justify-center py-6 gap-2 text-muted">
495
- <RLAIcon name="i-lucide-inbox" class="size-8 opacity-40" />
496
- <p class="text-sm font-medium">{empty}</p>
497
- </div>
498
- )}
499
- </td>
500
- </tr>
501
- ) : (
502
- paginatedItems.map((item) => {
503
- // ── Group header row ──────────────────────────────────────────
504
- if (item.type === "group") {
505
- const { group } = item
506
- return (
507
- <tr class={classes.trGroup} data-slot="tr-group" data-group-id={group.id}>
508
- <td
509
- colspan={Math.max(1, visibleColumns.length)}
510
- class={classes.tdGroup}
511
- data-slot="td-group"
512
- >
513
- {Astro.slots.has("group-header") ? (
514
- <slot
515
- name="group-header"
516
- {...({ group, colSpan: visibleColumns.length } as any)}
517
- />
518
- ) : (
519
- <span class="flex items-center gap-2">
520
- <span class="i-lucide-layers size-3.5 text-primary opacity-70" />
521
- <span class="text-highlighted">{String(group.value)}</span>
522
- <span class="ml-auto text-muted font-normal normal-case">
523
- {group.rows.length} {group.rows.length === 1 ? "row" : "rows"}
524
- </span>
525
- </span>
526
- )}
527
- </td>
528
- </tr>
529
- )
530
- }
531
-
532
- // ── Data row ──────────────────────────────────────────────────
533
- const { row } = item
534
- const currentGroupId = item.type === "row" && (item as any).groupId ? (item as any).groupId : undefined
535
- const trMetaClass =
536
- typeof meta?.class?.tr === "function"
537
- ? meta.class.tr(row)
538
- : meta?.class?.tr || ""
539
- const trMetaStyle =
540
- typeof meta?.style?.tr === "function"
541
- ? meta.style.tr(row)
542
- : meta?.style?.tr || ""
543
- const isSelected = row.getIsSelected()
544
- const isExpanded = row.getIsExpanded()
545
- const isPinned_ = row.getIsPinned()
546
-
547
- return (
548
- <>
549
- <tr
550
- class:list={[classes.tr, trMetaClass]}
551
- style={trMetaStyle || undefined}
552
- data-slot="tr"
553
- data-row-id={row.id}
554
- data-group-id={currentGroupId}
555
- data-selected={isSelected ? "true" : undefined}
556
- data-expanded={isExpanded ? "true" : undefined}
557
- data-pinned={isPinned_ || undefined}
558
- >
559
- {visibleColumns.map((col) => {
560
- const colId = (col.id || col.accessorKey || "") as string
561
- const accessorKey = (col.accessorKey || col.id || "") as string
562
- const cellValue = accessorKey ? row.getValue(accessorKey) : undefined
563
- const cellSlotName = `${colId}-cell`
564
- const pinned = isPinned(colId)
565
-
566
- const metaTdClass =
567
- typeof col.meta?.class?.td === "function"
568
- ? col.meta.class.td(row)
569
- : col.meta?.class?.td || ""
570
- const metaTdStyle =
571
- typeof col.meta?.style?.td === "function"
572
- ? col.meta.style.td(row)
573
- : col.meta?.style?.td || ""
574
- const colSpanVal =
575
- typeof col.meta?.colspan?.td === "function"
576
- ? col.meta.colspan.td(row)
577
- : col.meta?.colspan?.td
578
- const rowSpanVal =
579
- typeof col.meta?.rowspan?.td === "function"
580
- ? col.meta.rowspan.td(row)
581
- : col.meta?.rowspan?.td
582
-
583
- const pinStyle_ = getPinStyle(colId)
584
- const combinedTdStyle = [pinStyle_, metaTdStyle].filter(Boolean).join("")
585
-
586
- return (
587
- <td
588
- class:list={[
589
- classes.td,
590
- pinned ? "sticky bg-default z-10" : "",
591
- metaTdClass
592
- ]}
593
- style={combinedTdStyle || undefined}
594
- colspan={colSpanVal}
595
- rowspan={rowSpanVal}
596
- data-slot="td"
597
- data-col-id={colId}
598
- >
599
- {Astro.slots.has(cellSlotName) ? (
600
- <slot
601
- name={cellSlotName}
602
- {...({
603
- row,
604
- column: col,
605
- cell: { value: cellValue },
606
- getValue: () => cellValue,
607
- renderValue: () => cellValue
608
- } as any)}
609
- />
610
- ) : typeof col.cell === "function" ? (
611
- <Fragment set:html={col.cell({
612
- row,
613
- getValue: () => cellValue,
614
- renderValue: () => cellValue,
615
- column: col,
616
- table: {}
617
- })} />
618
- ) : cellValue !== undefined && cellValue !== null ? (
619
- String(cellValue)
620
- ) : (
621
- "—"
622
- )}
623
- </td>
624
- )
625
- })}
626
- </tr>
627
-
628
- {/* Expanded sub-row */}
629
- {isExpanded && Astro.slots.has("expanded") && (
630
- <tr class={classes.trExpanded} data-slot="tr-expanded" data-row-id={row.id}>
631
- <td
632
- colspan={Math.max(1, visibleColumns.length)}
633
- class={classes.tdExpanded}
634
- data-slot="td-expanded"
635
- >
636
- <slot name="expanded" {...({ row } as any)} />
637
- </td>
638
- </tr>
639
- )}
640
-
641
- {/* Sub-rows (tree data) */}
642
- {row.subRows && row.subRows.length > 0 && isExpanded &&
643
- row.subRows.map((subRow) => {
644
- const subTrMetaClass =
645
- typeof meta?.class?.tr === "function"
646
- ? meta.class.tr(subRow)
647
- : meta?.class?.tr || ""
648
- return (
649
- <tr
650
- class:list={[classes.tr, subTrMetaClass]}
651
- data-slot="tr"
652
- data-row-id={subRow.id}
653
- data-depth={subRow.depth}
654
- >
655
- {visibleColumns.map((col) => {
656
- const colId = (col.id || col.accessorKey || "") as string
657
- const accessorKey = (col.accessorKey || col.id || "") as string
658
- const cellValue = accessorKey ? subRow.getValue(accessorKey) : undefined
659
- const cellSlotName = `${colId}-cell`
660
- const metaTdClass =
661
- typeof col.meta?.class?.td === "function"
662
- ? col.meta.class.td(subRow)
663
- : col.meta?.class?.td || ""
664
-
665
- return (
666
- <td
667
- class:list={[classes.td, metaTdClass]}
668
- data-slot="td"
669
- data-col-id={colId}
670
- >
671
- {Astro.slots.has(cellSlotName) ? (
672
- <slot
673
- name={cellSlotName}
674
- {...({
675
- row: subRow,
676
- column: col,
677
- cell: { value: cellValue },
678
- getValue: () => cellValue,
679
- renderValue: () => cellValue
680
- } as any)}
681
- />
682
- ) : typeof col.cell === "function" ? (
683
- col.cell({
684
- row: subRow,
685
- getValue: () => cellValue,
686
- renderValue: () => cellValue,
687
- column: col,
688
- table: {}
689
- })
690
- ) : cellValue !== undefined && cellValue !== null ? (
691
- String(cellValue)
692
- ) : (
693
- "—"
694
- )}
695
- </td>
696
- )
697
- })}
698
- </tr>
699
- )
700
- })}
701
- </>
702
- )
703
- })
704
- )}
705
-
706
- <slot name="body-bottom" />
707
- </tbody>
708
-
709
- {/* ── tfoot ── */}
710
- {hasFooters && (
711
- <tfoot class={classes.tfoot} data-slot="tfoot">
712
- <tr class={classes.tr} data-slot="tr">
713
- {visibleColumns.map((col) => {
714
- const colId = (col.id || col.accessorKey || "") as string
715
- const footerSlotName = `${colId}-footer`
716
- return (
717
- <td class={classes.td} data-slot="td">
718
- {Astro.slots.has(footerSlotName) ? (
719
- <slot name={footerSlotName} {...({ column: col } as any)} />
720
- ) : typeof col.footer === "function" ? (
721
- <Fragment set:html={col.footer({ column: col, table: {} })} />
722
- ) : (
723
- col.footer || ""
724
- )}
725
- </td>
726
- )
727
- })}
728
- </tr>
729
- </tfoot>
730
- )}
731
- </table>
732
- </div>
733
- </rla-table>
734
-
735
- <script>
736
- if (typeof HTMLElement !== "undefined") {
737
- class RLATableElement extends HTMLElement {
738
- private _allRows: HTMLTableRowElement[] = []
739
-
740
- connectedCallback() {
741
- const table = this.querySelector<HTMLTableElement>("table")
742
- if (!table) return
743
- const tbody = table.querySelector("tbody")
744
- if (!tbody) return
745
-
746
- // Cache original row order for sort resets
747
- this._allRows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]"))
748
-
749
- this._initSearch(tbody)
750
- this._initColumnVisibility(table, tbody)
751
- this._initSort(table, tbody)
752
- this._initRowEvents(tbody)
753
- }
754
-
755
- // ── Search ────────────────────────────────────────────────────────────
756
- private _initSearch(tbody: Element) {
757
- const input = this.querySelector<HTMLInputElement>("[data-rla-table-search]")
758
- if (!input) return
759
- input.addEventListener("input", () => {
760
- const q = input.value.toLowerCase().trim()
761
- tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]").forEach((row) => {
762
- if (!q || (row.textContent?.toLowerCase() ?? "").includes(q)) {
763
- row.style.display = ""
764
- } else {
765
- row.style.display = "none"
766
- }
767
- })
768
- })
769
- }
770
-
771
- // ── Column visibility ─────────────────────────────────────────────────
772
- private _initColumnVisibility(table: HTMLTableElement, tbody: Element) {
773
- this.addEventListener("rla-dropdown-checkbox-change", (e: Event) => {
774
- const ev = e as CustomEvent<{ id: string; checked: boolean }>
775
- const { id: colId, checked } = ev.detail ?? {}
776
- if (!colId) return
777
-
778
- const display = checked ? "" : "none"
779
-
780
- // Header th
781
- const th = table.querySelector<HTMLElement>(`th[data-col-id="${colId}"]`)
782
- if (th) th.style.display = display
783
-
784
- // Footer td (tfoot)
785
- const tfoot = table.querySelector<HTMLElement>("tfoot")
786
- if (tfoot) {
787
- tfoot
788
- .querySelectorAll<HTMLElement>(`td[data-col-id="${colId}"]`)
789
- .forEach((td) => (td.style.display = display))
790
- }
791
-
792
- // All body tds for this column
793
- tbody
794
- .querySelectorAll<HTMLElement>(`td[data-col-id="${colId}"]`)
795
- .forEach((td) => (td.style.display = display))
796
-
797
- this.dispatchEvent(
798
- new CustomEvent("rla-column-visibility-change", {
799
- detail: { id: colId, visible: checked },
800
- bubbles: true
801
- })
802
- )
803
- })
804
- }
805
-
806
- // ── Column sort ───────────────────────────────────────────────────────
807
- private _initSort(table: HTMLTableElement, tbody: Element) {
808
- const sortBtns = table.querySelectorAll<HTMLButtonElement>("[data-rla-sort-btn]")
809
-
810
- sortBtns.forEach((btn) => {
811
- btn.addEventListener("click", (e) => {
812
- const colId = btn.getAttribute("data-rla-col-id")
813
- if (!colId) return
814
-
815
- const isMulti = (e as MouseEvent).shiftKey
816
- const currentDir = btn.getAttribute("data-sort-dir") || "none"
817
- const nextDir = currentDir === "asc" ? "desc" : currentDir === "desc" ? "none" : "asc"
818
-
819
- if (!isMulti) {
820
- // Reset all other buttons
821
- sortBtns.forEach((other) => {
822
- if (other !== btn) {
823
- other.setAttribute("data-sort-dir", "none")
824
- const icon = other.querySelector<HTMLElement>("[data-sort-icon]")
825
- if (icon) {
826
- icon.className =
827
- "i-lucide-arrow-up-down size-3.5 opacity-40 group-hover:opacity-100 transition-all shrink-0"
828
- }
829
- }
830
- })
831
- }
832
-
833
- btn.setAttribute("data-sort-dir", nextDir)
834
- const icon = btn.querySelector<HTMLElement>("[data-sort-icon]")
835
- if (icon) {
836
- icon.className =
837
- nextDir === "asc"
838
- ? "i-lucide-arrow-up-narrow-wide size-3.5 text-primary transition-all shrink-0"
839
- : nextDir === "desc"
840
- ? "i-lucide-arrow-down-wide-narrow size-3.5 text-primary transition-all shrink-0"
841
- : "i-lucide-arrow-up-down size-3.5 opacity-40 group-hover:opacity-100 transition-all shrink-0"
842
- }
843
-
844
- // Gather all active sorts
845
- const activeSorts: Array<{ id: string; dir: string }> = []
846
- sortBtns.forEach((b) => {
847
- const dir = b.getAttribute("data-sort-dir") || "none"
848
- if (dir !== "none") {
849
- activeSorts.push({ id: b.getAttribute("data-rla-col-id") || "", dir })
850
- }
851
- })
852
-
853
- if (nextDir === "none" && !isMulti) {
854
- // Restore original DOM order
855
- this._allRows.forEach((row) => tbody.appendChild(row))
856
- } else {
857
- const groupRows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr-group]"))
858
-
859
- if (groupRows.length > 0) {
860
- // Sort within each group
861
- groupRows.forEach((groupHeader) => {
862
- const groupId = groupHeader.getAttribute("data-group-id")
863
- const rowsInGroup = Array.from(
864
- tbody.querySelectorAll<HTMLTableRowElement>(`tr[data-slot=tr][data-group-id="${groupId}"]`)
865
- )
866
- rowsInGroup.sort((a, b) => {
867
- for (const { id, dir } of activeSorts) {
868
- const idx = Array.from(table.querySelectorAll("thead th")).findIndex(
869
- (th) => (th as HTMLElement).getAttribute("data-col-id") === id
870
- )
871
- if (idx < 0) continue
872
- const cellA = a.children[idx]?.textContent?.trim() ?? ""
873
- const cellB = b.children[idx]?.textContent?.trim() ?? ""
874
- const cleanA = cellA.replace(/[$€£,\s]/g, "")
875
- const cleanB = cellB.replace(/[$€£,\s]/g, "")
876
- const isNumA = cleanA !== "" && !isNaN(Number(cleanA))
877
- const isNumB = cleanB !== "" && !isNaN(Number(cleanB))
878
-
879
- let cmp: number
880
- if (isNumA && isNumB) {
881
- cmp = Number(cleanA) - Number(cleanB)
882
- } else {
883
- cmp = cellA.localeCompare(cellB)
884
- }
885
- if (dir === "desc") cmp = -cmp
886
- if (cmp !== 0) return cmp
887
- }
888
- return 0
889
- })
890
- // Re-insert sorted rows directly following the group header
891
- let lastElement: Element = groupHeader
892
- rowsInGroup.forEach((r) => {
893
- lastElement.insertAdjacentElement("afterend", r)
894
- lastElement = r
895
- })
896
- })
897
- } else {
898
- const rows = Array.from(
899
- tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")
900
- )
901
- rows.sort((a, b) => {
902
- for (const { id, dir } of activeSorts) {
903
- const idx = Array.from(table.querySelectorAll("thead th")).findIndex(
904
- (th) => (th as HTMLElement).getAttribute("data-col-id") === id
905
- )
906
- if (idx < 0) continue
907
- const cellA = a.children[idx]?.textContent?.trim() ?? ""
908
- const cellB = b.children[idx]?.textContent?.trim() ?? ""
909
- const cleanA = cellA.replace(/[$€£,\s]/g, "")
910
- const cleanB = cellB.replace(/[$€£,\s]/g, "")
911
- const isNumA = cleanA !== "" && !isNaN(Number(cleanA))
912
- const isNumB = cleanB !== "" && !isNaN(Number(cleanB))
913
-
914
- let cmp: number
915
- if (isNumA && isNumB) {
916
- cmp = Number(cleanA) - Number(cleanB)
917
- } else {
918
- cmp = cellA.localeCompare(cellB)
919
- }
920
- if (dir === "desc") cmp = -cmp
921
- if (cmp !== 0) return cmp
922
- }
923
- return 0
924
- })
925
- rows.forEach((row) => tbody.appendChild(row))
926
- }
927
- }
928
-
929
- this.dispatchEvent(
930
- new CustomEvent("rla-sorting-change", {
931
- detail: { id: colId, dir: nextDir },
932
- bubbles: true
933
- })
934
- )
935
- })
936
- })
937
- }
938
-
939
- // ── Row events ────────────────────────────────────────────────────────
940
- private _initRowEvents(tbody: Element) {
941
- // Select
942
- tbody.addEventListener("click", (e) => {
943
- const target = e.target as HTMLElement
944
- if (target.closest("a, button, input, select, textarea, [role=button]")) return
945
- const tr = target.closest<HTMLElement>("tr[data-row-id]")
946
- if (!tr) return
947
- this.dispatchEvent(
948
- new CustomEvent("rla-row-select", {
949
- detail: { rowId: tr.getAttribute("data-row-id"), event: e },
950
- bubbles: true
951
- })
952
- )
953
- })
954
-
955
- // Hover
956
- tbody.addEventListener("mouseover", (e) => {
957
- const tr = (e.target as HTMLElement).closest<HTMLElement>("tr[data-row-id]")
958
- if (!tr) return
959
- this.dispatchEvent(
960
- new CustomEvent("rla-row-hover", {
961
- detail: { rowId: tr.getAttribute("data-row-id"), event: e },
962
- bubbles: true
963
- })
964
- )
965
- })
966
-
967
- tbody.addEventListener("mouseleave", (e) => {
968
- this.dispatchEvent(
969
- new CustomEvent("rla-row-hover", {
970
- detail: { rowId: null, event: e },
971
- bubbles: true
972
- })
973
- )
974
- })
975
-
976
- // Context menu
977
- tbody.addEventListener("contextmenu", (e) => {
978
- const tr = (e.target as HTMLElement).closest<HTMLElement>("tr[data-row-id]")
979
- if (!tr) return
980
- this.dispatchEvent(
981
- new CustomEvent("rla-row-contextmenu", {
982
- detail: { rowId: tr.getAttribute("data-row-id"), event: e },
983
- bubbles: true
984
- })
985
- )
986
- })
987
- }
988
-
989
- // ── Public API (imperative) ───────────────────────────────────────────
990
-
991
- /** Filter visible rows by a text query (case-insensitive). */
992
- setGlobalFilter(query: string) {
993
- const searchInput = this.querySelector<HTMLInputElement>("[data-rla-table-search]")
994
- if (searchInput) {
995
- searchInput.value = query
996
- searchInput.dispatchEvent(new Event("input"))
997
- } else {
998
- // Manual DOM filter when no search input is rendered
999
- const tbody = this.querySelector("tbody")
1000
- if (!tbody) return
1001
- const q = query.toLowerCase().trim()
1002
- tbody
1003
- .querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")
1004
- .forEach((row) => {
1005
- row.style.display =
1006
- !q || (row.textContent?.toLowerCase() ?? "").includes(q) ? "" : "none"
1007
- })
1008
- }
1009
- }
1010
-
1011
- /** Show or hide a column by its column ID. */
1012
- setColumnVisibility(colId: string, visible: boolean) {
1013
- this.dispatchEvent(
1014
- new CustomEvent("rla-dropdown-checkbox-change", {
1015
- detail: { id: colId, checked: visible },
1016
- bubbles: false
1017
- })
1018
- )
1019
- }
1020
-
1021
- /** Return all currently-visible data rows. */
1022
- getVisibleRows(): HTMLTableRowElement[] {
1023
- const tbody = this.querySelector("tbody")
1024
- if (!tbody) return []
1025
- return Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")).filter(
1026
- (r) => r.style.display !== "none"
1027
- )
1028
- }
1029
-
1030
- /** Return all selected rows (those with data-selected=true). */
1031
- getSelectedRows(): HTMLTableRowElement[] {
1032
- const tbody = this.querySelector("tbody")
1033
- if (!tbody) return []
1034
- return Array.from(
1035
- tbody.querySelectorAll<HTMLTableRowElement>("tr[data-selected=true]")
1036
- )
1037
- }
1038
- }
1039
-
1040
- if (!customElements.get("rla-table")) {
1041
- customElements.define("rla-table", RLATableElement)
1042
- }
1043
- }
1044
- </script>
1
+ ---
2
+ import type {
3
+ TableProps,
4
+ TableColumn,
5
+ TableRow,
6
+ TableGroup,
7
+ SortingState
8
+ } from "./table"
9
+ import { tableTheme } from "./table.theme"
10
+ import RLAIcon from "../icon/RLAIcon.astro"
11
+ import RLAInput from "../input/RLAInput.astro"
12
+ import RLADropdownMenu from "../dropdown-menu/RLADropdownMenu.astro"
13
+
14
+ type AnyData = Record<string, any>
15
+
16
+ const {
17
+ data = [],
18
+ columns = [],
19
+ caption,
20
+ meta,
21
+ sticky = false,
22
+ loading = false,
23
+ loadingColor = "primary",
24
+ loadingAnimation = "carousel",
25
+ empty = "No data available",
26
+ striped = false,
27
+ bordered = false,
28
+ hoverable = false,
29
+ globalFilter = "",
30
+ columnFilters = [],
31
+ columnVisibility = {},
32
+ columnPinning = {},
33
+ columnSizing = {},
34
+ columnOrder = [],
35
+ enableColumnResizing = false,
36
+ enableColumnReordering = false,
37
+ enableRowReordering = false,
38
+ sorting = [],
39
+ grouping = [],
40
+ rowPinning = {},
41
+ rowSelection = {},
42
+ expanded = {},
43
+ pagination,
44
+ getSubRows,
45
+ getRowCanExpand,
46
+ getRowId,
47
+ searchable = false,
48
+ searchPlaceholder = "Filter table...",
49
+ showColumnsToggle = false,
50
+ exportable = false,
51
+ exportFilename = "export",
52
+ actions: tableActions,
53
+ rowActions,
54
+ class: className,
55
+ ui,
56
+ ...rest
57
+ } = Astro.props as TableProps
58
+
59
+ const classes = tableTheme({
60
+ sticky,
61
+ loading,
62
+ loadingColor,
63
+ loadingAnimation,
64
+ striped,
65
+ bordered,
66
+ hoverable,
67
+ ui,
68
+ class: className,
69
+ externalScroll: false
70
+ })
71
+
72
+ // ─── Column resolution ────────────────────────────────────────────────────────
73
+
74
+ let rawColumns: TableColumn[] = []
75
+
76
+ if (columns && columns.length > 0) {
77
+ rawColumns = [...columns]
78
+ } else if (data && data.length > 0 && typeof data[0] === "object" && data[0] !== null) {
79
+ const firstRow = data[0] as AnyData
80
+ rawColumns = Object.keys(firstRow).map((key) => ({
81
+ id: key,
82
+ accessorKey: key,
83
+ header: key.charAt(0).toUpperCase() + key.slice(1),
84
+ enableSorting: true
85
+ }))
86
+ }
87
+
88
+ // Auto-inject rowActions / actions column if defined on table props and not in columns
89
+ const tableActionsDef = tableActions || rowActions
90
+ if (tableActionsDef && !rawColumns.some((c) => c.id === "actions")) {
91
+ rawColumns.push({
92
+ id: "actions",
93
+ header: "",
94
+ size: 80,
95
+ minSize: 60,
96
+ enableSorting: false,
97
+ enableHiding: false,
98
+ meta: {
99
+ class: {
100
+ th: "text-right",
101
+ td: "text-right"
102
+ }
103
+ },
104
+ actions: tableActionsDef
105
+ })
106
+ }
107
+
108
+ // Normalize actions column defaults
109
+ for (const c of rawColumns) {
110
+ if (c.id === "actions" || c.actions || c.items) {
111
+ if (c.enableSorting === undefined) c.enableSorting = false
112
+ if (c.header === undefined && c.id === "actions") c.header = ""
113
+ if (c.size === undefined && c.id === "actions") c.size = 60
114
+ if (c.minSize === undefined && c.id === "actions") c.minSize = 60
115
+ if (!c.meta?.class?.td && c.id === "actions") {
116
+ c.meta = {
117
+ ...c.meta,
118
+ class: {
119
+ ...c.meta?.class,
120
+ th: c.meta?.class?.th || "text-right",
121
+ td: c.meta?.class?.td || "text-right"
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ function getCellActions(row: TableRow, col: TableColumn) {
129
+ const actionsFn = col.actions ?? col.items ?? (col.id === "actions" ? tableActionsDef : undefined)
130
+ if (actionsFn) {
131
+ return typeof actionsFn === "function" ? actionsFn(row) : actionsFn
132
+ }
133
+ return null
134
+ }
135
+
136
+ if (enableRowReordering && !rawColumns.some((c) => c.id === "reorder" || c.id === "dragHandle")) {
137
+ const reorderCol: TableColumn = {
138
+ id: "reorder",
139
+ size: 36,
140
+ minSize: 36,
141
+ maxSize: 36,
142
+ enableSorting: false,
143
+ enableHiding: false,
144
+ header: "",
145
+ cell: () => `<div class="flex items-center justify-center"><span class="${classes.dragHandle}" data-slot="drag-handle" draggable="true"><span class="i-lucide-grip-vertical size-4"></span></span></div>`
146
+ }
147
+ rawColumns = [reorderCol, ...rawColumns]
148
+ }
149
+
150
+ let resolvedColumns: TableColumn[] = rawColumns
151
+ if (columnOrder && columnOrder.length > 0) {
152
+ const ordered: TableColumn[] = []
153
+ const map = new Map<string, TableColumn>()
154
+ for (const c of rawColumns) {
155
+ map.set(String(c.id || c.accessorKey || ""), c)
156
+ }
157
+ for (const id of columnOrder) {
158
+ if (map.has(id)) {
159
+ ordered.push(map.get(id)!)
160
+ map.delete(id)
161
+ }
162
+ }
163
+ for (const remaining of map.values()) {
164
+ ordered.push(remaining)
165
+ }
166
+ resolvedColumns = ordered
167
+ }
168
+
169
+ // Filter by column visibility
170
+ const visibleColumns = resolvedColumns.filter((col) => {
171
+ const id = (col.id || col.accessorKey || "") as string
172
+ if (!id) return true
173
+ if (col.enableHiding === false) return true
174
+ return columnVisibility[id] !== false
175
+ })
176
+
177
+ // Dropdown items for column visibility toggle
178
+ const columnDropdownItems = resolvedColumns
179
+ .filter((col) => col.enableHiding !== false && col.id !== "reorder")
180
+ .map((col) => {
181
+ const colId = (col.id || col.accessorKey || "") as string
182
+ const rawLabel = typeof col.header === "string" ? col.header : colId
183
+ return {
184
+ id: colId,
185
+ label: rawLabel.charAt(0).toUpperCase() + rawLabel.slice(1),
186
+ type: "checkbox" as const,
187
+ checked: columnVisibility[colId] !== false
188
+ }
189
+ })
190
+
191
+ const exportDropdownItems = [
192
+ {
193
+ id: "csv",
194
+ label: "Export as CSV",
195
+ icon: "i-lucide-file-spreadsheet"
196
+ },
197
+ {
198
+ id: "json",
199
+ label: "Export as JSON",
200
+ icon: "i-lucide-file-json"
201
+ }
202
+ ]
203
+
204
+ // ─── Row building ─────────────────────────────────────────────────────────────
205
+
206
+ function getNestedValue(obj: AnyData, key: string): any {
207
+ if (key.includes(".")) {
208
+ return key.split(".").reduce((acc: any, k) => acc?.[k], obj)
209
+ }
210
+ return obj[key]
211
+ }
212
+
213
+ function buildRowTree(rawItems: any[], parentDepth = 0): TableRow[] {
214
+ return rawItems.map((item, idx) => {
215
+ const rId = getRowId
216
+ ? getRowId(item, idx)
217
+ : item?.id !== undefined
218
+ ? String(item.id)
219
+ : String(idx)
220
+
221
+ const isExpanded = !!expanded[rId]
222
+
223
+ let subRowsList: TableRow[] | undefined = undefined
224
+ if (getSubRows) {
225
+ const children = getSubRows(item, idx)
226
+ if (children && children.length > 0) {
227
+ subRowsList = buildRowTree(children, parentDepth + 1)
228
+ }
229
+ } else if (Array.isArray(item?.children) && item.children.length > 0) {
230
+ subRowsList = buildRowTree(item.children, parentDepth + 1)
231
+ }
232
+
233
+ const rowObj: TableRow = {
234
+ id: rId,
235
+ original: item,
236
+ index: idx,
237
+ depth: parentDepth,
238
+ getValue: (key: string) => {
239
+ if (!item || typeof item !== "object") return undefined
240
+ const colDef = resolvedColumns.find((c) => (c.id || c.accessorKey) === key)
241
+ if (colDef && typeof colDef.accessorFn === "function") {
242
+ return colDef.accessorFn(item)
243
+ }
244
+ return getNestedValue(item as AnyData, key)
245
+ },
246
+ getIsSelected: () => {
247
+ if (rowSelection[rId]) return true
248
+ if (subRowsList && subRowsList.length > 0) {
249
+ return subRowsList.every((s) => s.getIsSelected())
250
+ }
251
+ return false
252
+ },
253
+ getIsSomeSelected: () => {
254
+ if (!subRowsList || subRowsList.length === 0) return false
255
+ const count = subRowsList.filter((s) => s.getIsSelected() || s.getIsSomeSelected()).length
256
+ return count > 0 && !subRowsList.every((s) => s.getIsSelected())
257
+ },
258
+ toggleSelected: () => {},
259
+ getToggleSelectedHandler: () => () => {},
260
+ getIsExpanded: () => isExpanded,
261
+ toggleExpanded: () => {},
262
+ getToggleExpandedHandler: () => () => {},
263
+ getIsPinned: () => {
264
+ if (rowPinning.top?.includes(rId)) return "top"
265
+ if (rowPinning.bottom?.includes(rId)) return "bottom"
266
+ return false
267
+ },
268
+ pin: () => {},
269
+ getIsGrouped: () => false,
270
+ getCanExpand: () => {
271
+ if (typeof getRowCanExpand === "function") {
272
+ return getRowCanExpand(rowObj)
273
+ }
274
+ return !!(subRowsList && subRowsList.length > 0)
275
+ },
276
+ ...(subRowsList ? { subRows: subRowsList } : {})
277
+ }
278
+ return rowObj
279
+ })
280
+ }
281
+
282
+ let processedRows: TableRow[] = buildRowTree(data)
283
+
284
+ // ─── Filtering ────────────────────────────────────────────────────────────────
285
+
286
+ if (globalFilter) {
287
+ const query = globalFilter.toLowerCase()
288
+ processedRows = processedRows.filter((r) => {
289
+ if (typeof r.original !== "object" || !r.original)
290
+ return String(r.original).toLowerCase().includes(query)
291
+ return (
292
+ Object.values(r.original as AnyData).some((val) =>
293
+ String(val ?? "").toLowerCase().includes(query)
294
+ ) ||
295
+ resolvedColumns.some((col) => {
296
+ const v = col.accessorFn
297
+ ? col.accessorFn(r.original)
298
+ : r.getValue((col.id || col.accessorKey || "") as string)
299
+ return String(v ?? "").toLowerCase().includes(query)
300
+ })
301
+ )
302
+ })
303
+ }
304
+
305
+ for (const filter of columnFilters) {
306
+ if (!filter.id || filter.value === undefined || filter.value === null || filter.value === "")
307
+ continue
308
+ const fVal = String(filter.value).toLowerCase()
309
+ processedRows = processedRows.filter((r) =>
310
+ String(r.getValue(filter.id) ?? "").toLowerCase().includes(fVal)
311
+ )
312
+ }
313
+
314
+ // ─── Sorting ──────────────────────────────────────────────────────────────────
315
+
316
+ if (sorting && sorting.length > 0) {
317
+ processedRows = [...processedRows].sort((a, b) => {
318
+ for (const { id, desc } of sorting as SortingState) {
319
+ const valA = a.getValue(id)
320
+ const valB = b.getValue(id)
321
+ if (valA === valB) continue
322
+ if (valA === undefined || valA === null) return 1
323
+ if (valB === undefined || valB === null) return -1
324
+ let cmp: number
325
+ if (typeof valA === "number" && typeof valB === "number") {
326
+ cmp = valA - valB
327
+ } else if (valA instanceof Date && valB instanceof Date) {
328
+ cmp = valA.getTime() - valB.getTime()
329
+ } else {
330
+ const numA = Number(valA)
331
+ const numB = Number(valB)
332
+ if (!isNaN(numA) && !isNaN(numB)) {
333
+ cmp = numA - numB
334
+ } else {
335
+ cmp = String(valA).localeCompare(String(valB))
336
+ }
337
+ }
338
+ return desc ? -cmp : cmp
339
+ }
340
+ return 0
341
+ })
342
+ }
343
+
344
+ // ─── Multi-level Grouping ─────────────────────────────────────────────────────
345
+
346
+ type GroupedItem =
347
+ | { type: "group"; group: TableGroup; depth: number }
348
+ | { type: "row"; row: TableRow }
349
+
350
+ function groupRowsRecursively(
351
+ rows: TableRow[],
352
+ groupCols: string[],
353
+ depth = 0,
354
+ parentPath = ""
355
+ ): GroupedItem[] {
356
+ if (depth >= groupCols.length || rows.length === 0) {
357
+ return rows.map((r) => ({ type: "row", row: r }))
358
+ }
359
+
360
+ const colId = groupCols[depth]!
361
+ const groupMap = new Map<string, TableRow[]>()
362
+ const groupOrder: string[] = []
363
+
364
+ for (const row of rows) {
365
+ const val = String(row.getValue(colId) ?? "")
366
+ if (!groupMap.has(val)) {
367
+ groupMap.set(val, [])
368
+ groupOrder.push(val)
369
+ }
370
+ row.groupingColumnId = colId
371
+ row.groupingValue = val
372
+ groupMap.get(val)!.push(row)
373
+ }
374
+
375
+ const result: GroupedItem[] = []
376
+ for (const val of groupOrder) {
377
+ const subRows = groupMap.get(val)!
378
+ const groupId = `group-${parentPath}${colId}-${val}`
379
+ const group: TableGroup = {
380
+ id: groupId,
381
+ columnId: colId,
382
+ value: val,
383
+ depth,
384
+ rows: subRows
385
+ }
386
+ result.push({ type: "group", group, depth })
387
+ result.push(...groupRowsRecursively(subRows, groupCols, depth + 1, `${groupId}/`))
388
+ }
389
+ return result
390
+ }
391
+
392
+ let groupedItems: GroupedItem[] = []
393
+ if (grouping && grouping.length > 0) {
394
+ groupedItems = groupRowsRecursively(processedRows, grouping, 0, "")
395
+ } else {
396
+ groupedItems = processedRows.map((row) => ({ type: "row", row }))
397
+ }
398
+
399
+ // ─── Row pinning ──────────────────────────────────────────────────────────────
400
+
401
+ if ((rowPinning.top?.length ?? 0) > 0 || (rowPinning.bottom?.length ?? 0) > 0) {
402
+ const topItems: GroupedItem[] = []
403
+ const midItems: GroupedItem[] = []
404
+ const botItems: GroupedItem[] = []
405
+
406
+ for (const item of groupedItems) {
407
+ if (item.type === "row") {
408
+ if (rowPinning.top?.includes(item.row.id)) topItems.push(item)
409
+ else if (rowPinning.bottom?.includes(item.row.id)) botItems.push(item)
410
+ else midItems.push(item)
411
+ } else {
412
+ midItems.push(item)
413
+ }
414
+ }
415
+ groupedItems = [...topItems, ...midItems, ...botItems]
416
+ }
417
+
418
+ // ─── Pagination ───────────────────────────────────────────────────────────────
419
+
420
+ let paginatedItems = groupedItems
421
+ if (pagination) {
422
+ const { pageIndex = 0, pageSize = 10 } = pagination
423
+ const rowItems = groupedItems.filter((i) => i.type === "row")
424
+ const pageRows = rowItems.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)
425
+ const pageRowIds = new Set(pageRows.map((i) => (i as { type: "row"; row: TableRow }).row.id))
426
+
427
+ if (grouping && grouping.length > 0) {
428
+ const result: GroupedItem[] = []
429
+ for (const item of groupedItems) {
430
+ if (item.type === "group") {
431
+ if (item.group.rows.some((r) => pageRowIds.has(r.id))) result.push(item)
432
+ } else if (pageRowIds.has(item.row.id)) {
433
+ result.push(item)
434
+ }
435
+ }
436
+ paginatedItems = result
437
+ } else {
438
+ paginatedItems = pageRows
439
+ }
440
+ }
441
+
442
+ // ─── Column pinning offsets ───────────────────────────────────────────────────
443
+
444
+ const colOffsetLeft: Record<string, number> = {}
445
+ const colOffsetRight: Record<string, number> = {}
446
+
447
+ if (columnPinning.left && columnPinning.left.length > 0) {
448
+ let offset = 0
449
+ for (const colId of columnPinning.left) {
450
+ colOffsetLeft[colId] = offset
451
+ const col = resolvedColumns.find((c) => (c.id || c.accessorKey) === colId)
452
+ const sized = columnSizing[colId]
453
+ offset += typeof sized === "number" ? sized : typeof col?.size === "number" ? col.size : 150
454
+ }
455
+ }
456
+
457
+ if (columnPinning.right && columnPinning.right.length > 0) {
458
+ let offset = 0
459
+ for (const colId of [...columnPinning.right].reverse()) {
460
+ colOffsetRight[colId] = offset
461
+ const col = resolvedColumns.find((c) => (c.id || c.accessorKey) === colId)
462
+ const sized = columnSizing[colId]
463
+ offset += typeof sized === "number" ? sized : typeof col?.size === "number" ? col.size : 150
464
+ }
465
+ }
466
+
467
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
468
+
469
+ const hasFooters = visibleColumns.some(
470
+ (col) =>
471
+ col.footer ||
472
+ Astro.slots.has(`${String(col.id || col.accessorKey)}-footer`)
473
+ )
474
+
475
+ const showToolbar = searchable || showColumnsToggle || exportable || Astro.slots.has("toolbar")
476
+
477
+ function getPinStyle(colId: string): string {
478
+ if (colOffsetLeft[colId] !== undefined)
479
+ return `left:${colOffsetLeft[colId]}px;`
480
+ if (colOffsetRight[colId] !== undefined)
481
+ return `right:${colOffsetRight[colId]}px;`
482
+ return ""
483
+ }
484
+
485
+ function isPinned(colId: string): boolean {
486
+ return colId in colOffsetLeft || colId in colOffsetRight
487
+ }
488
+
489
+ function getColumnSizeStyle(col: TableColumn): string {
490
+ const colId = (col.id || col.accessorKey || "") as string
491
+ const sizeVal = columnSizing[colId] ?? col.size
492
+ const styles: string[] = []
493
+ if (sizeVal) {
494
+ styles.push(`width:${typeof sizeVal === "number" ? `${sizeVal}px` : sizeVal};`)
495
+ }
496
+ if (col.minSize) {
497
+ styles.push(`min-width:${typeof col.minSize === "number" ? `${col.minSize}px` : col.minSize};`)
498
+ }
499
+ if (col.maxSize) {
500
+ styles.push(`max-width:${typeof col.maxSize === "number" ? `${col.maxSize}px` : col.maxSize};`)
501
+ }
502
+ return styles.join(" ")
503
+ }
504
+ ---
505
+
506
+ <rla-table
507
+ class:list={[classes.root, className]}
508
+ data-slot="root"
509
+ {...rest}
510
+ >
511
+ {showToolbar && (
512
+ <div
513
+ class={classes.toolbar}
514
+ data-slot="toolbar"
515
+ >
516
+ {searchable && (
517
+ <RLAInput
518
+ type="search"
519
+ size="sm"
520
+ placeholder={searchPlaceholder}
521
+ leadingIcon="i-lucide-search"
522
+ class="max-w-xs min-w-[10ch]"
523
+ data-rla-table-search
524
+ />
525
+ )}
526
+
527
+ <slot name="toolbar" />
528
+
529
+ <div class="ml-auto flex items-center gap-2 shrink-0">
530
+ {exportable && (
531
+ <RLADropdownMenu
532
+ label="Export"
533
+ icon="i-lucide-download"
534
+ items={exportDropdownItems as any}
535
+ content={{ align: "end" }}
536
+ size="sm"
537
+ color="neutral"
538
+ data-rla-export-menu
539
+ />
540
+ )}
541
+
542
+ {showColumnsToggle && (
543
+ <RLADropdownMenu
544
+ label="Columns"
545
+ icon="i-lucide-columns"
546
+ items={columnDropdownItems as any}
547
+ content={{ align: "end" }}
548
+ size="sm"
549
+ color="neutral"
550
+ />
551
+ )}
552
+ </div>
553
+ </div>
554
+ )}
555
+
556
+ <div class="overflow-x-auto w-full">
557
+ <table class={classes.base} data-slot="base">
558
+ <colgroup>
559
+ {visibleColumns.map((col) => {
560
+ const colId = (col.id || col.accessorKey || "") as string
561
+ const sizeStyle = getColumnSizeStyle(col)
562
+ return <col data-col-id={colId} style={sizeStyle || undefined} />
563
+ })}
564
+ </colgroup>
565
+
566
+ {(caption || Astro.slots.has("caption")) && (
567
+ <caption class={classes.caption} data-slot="caption">
568
+ <slot name="caption">{caption}</slot>
569
+ </caption>
570
+ )}
571
+
572
+ {/* ── thead ── */}
573
+ {visibleColumns.length > 0 && (
574
+ <thead class={classes.thead} data-slot="thead">
575
+ <tr class={classes.tr} data-slot="tr">
576
+ {visibleColumns.map((col) => {
577
+ const colId = (col.id || col.accessorKey || "") as string
578
+ const pinned = isPinned(colId)
579
+ const metaThClass =
580
+ typeof col.meta?.class?.th === "function"
581
+ ? col.meta.class.th(col)
582
+ : col.meta?.class?.th || ""
583
+ const metaThStyle =
584
+ typeof col.meta?.style?.th === "function"
585
+ ? col.meta.style.th(col)
586
+ : col.meta?.style?.th || ""
587
+ const canSort =
588
+ col.enableSorting !== false &&
589
+ !!(col.accessorKey || col.id || col.accessorFn) &&
590
+ colId !== "actions" &&
591
+ colId !== "select" &&
592
+ colId !== "reorder" &&
593
+ colId !== "dragHandle"
594
+ const activeSort = (sorting as SortingState).find((s) => s.id === colId)
595
+ const sortDir = activeSort ? (activeSort.desc ? "desc" : "asc") : "none"
596
+ const headerSlotName = `${colId}-header`
597
+
598
+ const sizeStyle = getColumnSizeStyle(col)
599
+ const pinStyle = getPinStyle(colId)
600
+ const combinedStyle = [sizeStyle, pinStyle, metaThStyle]
601
+ .filter(Boolean)
602
+ .join("")
603
+
604
+ const headerLabel =
605
+ typeof col.header === "string"
606
+ ? col.header
607
+ : col.header !== undefined
608
+ ? ""
609
+ : colId === "reorder" || colId === "select" || colId === "actions" || colId === "dragHandle"
610
+ ? ""
611
+ : colId.charAt(0).toUpperCase() + colId.slice(1)
612
+
613
+ return (
614
+ <th
615
+ scope="col"
616
+ class:list={[
617
+ classes.th,
618
+ pinned ? "sticky bg-default z-10" : "",
619
+ metaThClass
620
+ ]}
621
+ style={combinedStyle || undefined}
622
+ data-col-id={colId}
623
+ data-slot="th"
624
+ >
625
+ <div class="flex items-center gap-1.5">
626
+ {enableColumnReordering && colId !== "reorder" && colId !== "select" && colId !== "actions" && colId !== "dragHandle" && (
627
+ <span
628
+ class={classes.dragHandle}
629
+ data-slot="drag-handle"
630
+ draggable="true"
631
+ >
632
+ <span class="i-lucide-grip-vertical size-3.5 opacity-40 hover:opacity-100" />
633
+ </span>
634
+ )}
635
+
636
+ <div class="flex-1 min-w-0">
637
+ {Astro.slots.has(headerSlotName) ? (
638
+ <slot name={headerSlotName} {...({ column: col } as any)} />
639
+ ) : canSort ? (
640
+ <button
641
+ type="button"
642
+ 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"
643
+ data-rla-sort-btn
644
+ data-rla-col-id={colId}
645
+ data-sort-dir={sortDir}
646
+ aria-label={`Sort by ${colId}`}
647
+ >
648
+ <span>
649
+ {typeof col.header === "function"
650
+ ? col.header({ column: col, table: {} })
651
+ : headerLabel}
652
+ </span>
653
+ <span
654
+ data-sort-icon
655
+ class:list={[
656
+ sortDir === "asc"
657
+ ? "i-lucide-arrow-up-narrow-wide text-primary"
658
+ : sortDir === "desc"
659
+ ? "i-lucide-arrow-down-wide-narrow text-primary"
660
+ : "i-lucide-arrow-up-down opacity-40 group-hover:opacity-100",
661
+ "size-3.5 transition-all shrink-0"
662
+ ]}
663
+ />
664
+ </button>
665
+ ) : typeof col.header === "function" ? (
666
+ <Fragment set:html={col.header({ column: col, table: {} })} />
667
+ ) : (
668
+ headerLabel
669
+ )}
670
+ </div>
671
+ </div>
672
+
673
+ {enableColumnResizing && (
674
+ <div
675
+ class={classes.resizer}
676
+ data-slot="resizer"
677
+ data-col-id={colId}
678
+ />
679
+ )}
680
+ </th>
681
+ )
682
+ })}
683
+ </tr>
684
+ </thead>
685
+ )}
686
+
687
+ {/* ── tbody ── */}
688
+ <tbody class={classes.tbody} data-slot="tbody">
689
+ <slot name="body-top" />
690
+
691
+ {loading ? (
692
+ <tr>
693
+ <td
694
+ colspan={Math.max(1, visibleColumns.length)}
695
+ class={classes.loading}
696
+ data-slot="loading"
697
+ >
698
+ {Astro.slots.has("loading") ? (
699
+ <slot name="loading" />
700
+ ) : (
701
+ <div class="flex items-center justify-center gap-2 text-muted py-4">
702
+ <RLAIcon name="i-lucide-loader-2" class="size-5 animate-spin text-primary" />
703
+ <span>Loading...</span>
704
+ </div>
705
+ )}
706
+ </td>
707
+ </tr>
708
+ ) : paginatedItems.length === 0 ? (
709
+ <tr>
710
+ <td
711
+ colspan={Math.max(1, visibleColumns.length)}
712
+ class={classes.empty}
713
+ data-slot="empty"
714
+ >
715
+ {Astro.slots.has("empty") ? (
716
+ <slot name="empty" />
717
+ ) : (
718
+ <div class="flex flex-col items-center justify-center py-6 gap-2 text-muted">
719
+ <RLAIcon name="i-lucide-inbox" class="size-8 opacity-40" />
720
+ <p class="text-sm font-medium">{empty}</p>
721
+ </div>
722
+ )}
723
+ </td>
724
+ </tr>
725
+ ) : (
726
+ paginatedItems.map((item) => {
727
+ // ── Group header row ──────────────────────────────────────────
728
+ if (item.type === "group") {
729
+ const { group, depth } = item
730
+ return (
731
+ <tr class={classes.trGroup} data-slot="tr-group" data-group-id={group.id} data-depth={depth}>
732
+ <td
733
+ colspan={Math.max(1, visibleColumns.length)}
734
+ class={classes.tdGroup}
735
+ style={depth ? `padding-left: ${depth * 1.5 + 1}rem;` : undefined}
736
+ data-slot="td-group"
737
+ >
738
+ {Astro.slots.has("group-header") ? (
739
+ <slot
740
+ name="group-header"
741
+ {...({ group, colSpan: visibleColumns.length } as any)}
742
+ />
743
+ ) : (
744
+ <span class="flex items-center gap-2">
745
+ <span class="i-lucide-layers size-3.5 text-primary opacity-70" />
746
+ <span class="text-xs uppercase text-muted font-medium">{group.columnId}:</span>
747
+ <span class="text-highlighted">{String(group.value)}</span>
748
+ <span class="ml-auto text-muted font-normal normal-case">
749
+ {group.rows.length} {group.rows.length === 1 ? "row" : "rows"}
750
+ </span>
751
+ </span>
752
+ )}
753
+ </td>
754
+ </tr>
755
+ )
756
+ }
757
+
758
+ // ── Data row ──────────────────────────────────────────────────
759
+ const { row } = item
760
+ const currentGroupId = item.type === "row" && (item as any).groupId ? (item as any).groupId : undefined
761
+ const trMetaClass =
762
+ typeof meta?.class?.tr === "function"
763
+ ? meta.class.tr(row)
764
+ : meta?.class?.tr || ""
765
+ const trMetaStyle =
766
+ typeof meta?.style?.tr === "function"
767
+ ? meta.style.tr(row)
768
+ : meta?.style?.tr || ""
769
+ const isSelected = row.getIsSelected()
770
+ const isExpanded = row.getIsExpanded()
771
+ const isPinned_ = row.getIsPinned()
772
+
773
+ return (
774
+ <>
775
+ <tr
776
+ class:list={[classes.tr, trMetaClass]}
777
+ style={trMetaStyle || undefined}
778
+ data-slot="tr"
779
+ data-row-id={row.id}
780
+ data-row-index={row.index}
781
+ data-group-id={currentGroupId}
782
+ data-selected={isSelected ? "true" : undefined}
783
+ data-expanded={isExpanded ? "true" : undefined}
784
+ data-pinned={isPinned_ || undefined}
785
+ >
786
+ {visibleColumns.map((col) => {
787
+ const colId = (col.id || col.accessorKey || "") as string
788
+ const cellValue = typeof col.accessorFn === "function"
789
+ ? col.accessorFn(row.original)
790
+ : (col.accessorKey || col.id)
791
+ ? row.getValue((col.accessorKey || col.id) as string)
792
+ : undefined
793
+ const cellSlotName = `${colId}-cell`
794
+ const pinned = isPinned(colId)
795
+
796
+ const metaTdClass =
797
+ typeof col.meta?.class?.td === "function"
798
+ ? col.meta.class.td(row)
799
+ : col.meta?.class?.td || ""
800
+ const metaTdStyle =
801
+ typeof col.meta?.style?.td === "function"
802
+ ? col.meta.style.td(row)
803
+ : col.meta?.style?.td || ""
804
+ const colSpanVal =
805
+ typeof col.meta?.colspan?.td === "function"
806
+ ? col.meta.colspan.td(row)
807
+ : col.meta?.colspan?.td
808
+ const rowSpanVal =
809
+ typeof col.meta?.rowspan?.td === "function"
810
+ ? col.meta.rowspan.td(row)
811
+ : col.meta?.rowspan?.td
812
+
813
+ const pinStyle_ = getPinStyle(colId)
814
+ const combinedTdStyle = [pinStyle_, metaTdStyle].filter(Boolean).join("")
815
+
816
+ return (
817
+ <td
818
+ class:list={[
819
+ classes.td,
820
+ pinned ? "sticky bg-default z-10" : "",
821
+ metaTdClass
822
+ ]}
823
+ style={combinedTdStyle || undefined}
824
+ colspan={colSpanVal}
825
+ rowspan={rowSpanVal}
826
+ data-slot="td"
827
+ data-col-id={colId}
828
+ >
829
+ {Astro.slots.has(cellSlotName) ? (
830
+ <slot
831
+ name={cellSlotName}
832
+ {...({
833
+ row,
834
+ column: col,
835
+ cell: { value: cellValue },
836
+ getValue: () => cellValue,
837
+ renderValue: () => cellValue
838
+ } as any)}
839
+ />
840
+ ) : (() => {
841
+ const actionItems = getCellActions(row, col)
842
+ if (Array.isArray(actionItems) && actionItems.length > 0) {
843
+ return (
844
+ <div class="flex items-center justify-end">
845
+ <RLADropdownMenu
846
+ items={actionItems as any}
847
+ content={{ align: "end", side: "bottom" }}
848
+ size="xs"
849
+ color="neutral"
850
+ />
851
+ </div>
852
+ )
853
+ }
854
+
855
+ if (typeof col.cell === "function") {
856
+ const rendered = col.cell({
857
+ row,
858
+ getValue: () => cellValue,
859
+ renderValue: () => cellValue,
860
+ column: col,
861
+ table: {}
862
+ })
863
+ if (Array.isArray(rendered) && rendered.length > 0) {
864
+ return (
865
+ <div class="flex items-center justify-end">
866
+ <RLADropdownMenu
867
+ items={rendered as any}
868
+ content={{ align: "end", side: "bottom" }}
869
+ size="xs"
870
+ color="neutral"
871
+ />
872
+ </div>
873
+ )
874
+ }
875
+ return <Fragment set:html={rendered} />
876
+ }
877
+
878
+ return cellValue !== undefined && cellValue !== null ? (
879
+ String(cellValue)
880
+ ) : (
881
+ "—"
882
+ )
883
+ })()}
884
+ </td>
885
+ )
886
+ })}
887
+ </tr>
888
+
889
+ {/* Expanded sub-row */}
890
+ {row.getCanExpand() && isExpanded && Astro.slots.has("expanded") && (
891
+ <tr class={classes.trExpanded} data-slot="tr-expanded" data-row-id={row.id}>
892
+ <td
893
+ colspan={Math.max(1, visibleColumns.length)}
894
+ class={classes.tdExpanded}
895
+ data-slot="td-expanded"
896
+ >
897
+ <slot name="expanded" {...({ row } as any)} />
898
+ </td>
899
+ </tr>
900
+ )}
901
+
902
+ {/* Sub-rows (tree data) */}
903
+ {row.subRows && row.subRows.length > 0 && isExpanded &&
904
+ row.subRows.map((subRow) => {
905
+ const subTrMetaClass =
906
+ typeof meta?.class?.tr === "function"
907
+ ? meta.class.tr(subRow)
908
+ : meta?.class?.tr || ""
909
+ return (
910
+ <tr
911
+ class:list={[classes.tr, subTrMetaClass]}
912
+ data-slot="tr"
913
+ data-row-id={subRow.id}
914
+ data-row-index={subRow.index}
915
+ data-depth={subRow.depth}
916
+ >
917
+ {visibleColumns.map((col) => {
918
+ const colId = (col.id || col.accessorKey || "") as string
919
+ const cellValue = typeof col.accessorFn === "function"
920
+ ? col.accessorFn(subRow.original)
921
+ : (col.accessorKey || col.id)
922
+ ? subRow.getValue((col.accessorKey || col.id) as string)
923
+ : undefined
924
+ const cellSlotName = `${colId}-cell`
925
+ const pinned = isPinned(colId)
926
+ const metaTdClass =
927
+ typeof col.meta?.class?.td === "function"
928
+ ? col.meta.class.td(subRow)
929
+ : col.meta?.class?.td || ""
930
+ const metaTdStyle =
931
+ typeof col.meta?.style?.td === "function"
932
+ ? col.meta.style.td(subRow)
933
+ : col.meta?.style?.td || ""
934
+ const colSpanVal =
935
+ typeof col.meta?.colspan?.td === "function"
936
+ ? col.meta.colspan.td(subRow)
937
+ : col.meta?.colspan?.td
938
+ const rowSpanVal =
939
+ typeof col.meta?.rowspan?.td === "function"
940
+ ? col.meta.rowspan.td(subRow)
941
+ : col.meta?.rowspan?.td
942
+
943
+ const sizeStyle = getColumnSizeStyle(col)
944
+ const pinStyle_ = getPinStyle(colId)
945
+ const combinedTdStyle = [sizeStyle, pinStyle_, metaTdStyle].filter(Boolean).join("")
946
+
947
+ return (
948
+ <td
949
+ class:list={[
950
+ classes.td,
951
+ pinned ? "sticky bg-default z-10" : "",
952
+ metaTdClass
953
+ ]}
954
+ style={combinedTdStyle || undefined}
955
+ colspan={colSpanVal}
956
+ rowspan={rowSpanVal}
957
+ data-slot="td"
958
+ data-col-id={colId}
959
+ >
960
+ {Astro.slots.has(cellSlotName) ? (
961
+ <slot
962
+ name={cellSlotName}
963
+ {...({
964
+ row: subRow,
965
+ column: col,
966
+ cell: { value: cellValue },
967
+ getValue: () => cellValue,
968
+ renderValue: () => cellValue
969
+ } as any)}
970
+ />
971
+ ) : (() => {
972
+ const actionItems = getCellActions(subRow, col)
973
+ if (Array.isArray(actionItems) && actionItems.length > 0) {
974
+ return (
975
+ <div class="flex items-center justify-end">
976
+ <RLADropdownMenu
977
+ items={actionItems as any}
978
+ content={{ align: "end", side: "bottom" }}
979
+ size="xs"
980
+ color="neutral"
981
+ />
982
+ </div>
983
+ )
984
+ }
985
+
986
+ if (typeof col.cell === "function") {
987
+ const rendered = col.cell({
988
+ row: subRow,
989
+ getValue: () => cellValue,
990
+ renderValue: () => cellValue,
991
+ column: col,
992
+ table: {}
993
+ })
994
+ if (Array.isArray(rendered) && rendered.length > 0) {
995
+ return (
996
+ <div class="flex items-center justify-end">
997
+ <RLADropdownMenu
998
+ items={rendered as any}
999
+ content={{ align: "end", side: "bottom" }}
1000
+ size="xs"
1001
+ color="neutral"
1002
+ />
1003
+ </div>
1004
+ )
1005
+ }
1006
+ return <Fragment set:html={rendered} />
1007
+ }
1008
+
1009
+ return cellValue !== undefined && cellValue !== null ? (
1010
+ String(cellValue)
1011
+ ) : (
1012
+ "—"
1013
+ )
1014
+ })()}
1015
+ </td>
1016
+ )
1017
+ })}
1018
+ </tr>
1019
+ )
1020
+ })}
1021
+ </>
1022
+ )
1023
+ })
1024
+ )}
1025
+
1026
+ <slot name="body-bottom" />
1027
+ </tbody>
1028
+
1029
+ {/* ── tfoot ── */}
1030
+ {hasFooters && (
1031
+ <tfoot class={classes.tfoot} data-slot="tfoot">
1032
+ <tr class={classes.tr} data-slot="tr">
1033
+ {visibleColumns.map((col) => {
1034
+ const colId = (col.id || col.accessorKey || "") as string
1035
+ const footerSlotName = `${colId}-footer`
1036
+ return (
1037
+ <td class={classes.td} data-slot="td">
1038
+ {Astro.slots.has(footerSlotName) ? (
1039
+ <slot name={footerSlotName} {...({ column: col } as any)} />
1040
+ ) : typeof col.footer === "function" ? (
1041
+ <Fragment set:html={col.footer({ column: col, table: {} })} />
1042
+ ) : (
1043
+ col.footer || ""
1044
+ )}
1045
+ </td>
1046
+ )
1047
+ })}
1048
+ </tr>
1049
+ </tfoot>
1050
+ )}
1051
+ </table>
1052
+ </div>
1053
+ </rla-table>
1054
+
1055
+ <script>
1056
+ if (typeof window !== "undefined") {
1057
+ class RLATableElement extends HTMLElement {
1058
+ connectedCallback() {
1059
+ this._initSearch()
1060
+ this._initSorting()
1061
+ this._initRowEvents()
1062
+ this._initSelection()
1063
+ this._initExpandToggles()
1064
+ this._initColumnResizing()
1065
+ this._initColumnReordering()
1066
+ this._initRowReordering()
1067
+ }
1068
+
1069
+ private _initColumnResizing() {
1070
+ const resizers = this.querySelectorAll<HTMLElement>("[data-slot=resizer]")
1071
+ resizers.forEach((resizer) => {
1072
+ resizer.addEventListener("pointerdown", (e) => {
1073
+ e.preventDefault()
1074
+ e.stopPropagation()
1075
+ const startX = e.clientX
1076
+ const th = resizer.closest<HTMLTableCellElement>("th")
1077
+ const colId = resizer.getAttribute("data-col-id") || th?.getAttribute("data-col-id")
1078
+ if (!th || !colId) return
1079
+ const initialWidth = th.offsetWidth
1080
+ const colEl = this.querySelector<HTMLTableColElement>(`col[data-col-id="${colId}"]`)
1081
+
1082
+ const onPointerMove = (moveEv: PointerEvent) => {
1083
+ const deltaX = moveEv.clientX - startX
1084
+ const newWidth = Math.max(40, initialWidth + deltaX)
1085
+ th.style.width = `${newWidth}px`
1086
+ if (colEl) colEl.style.width = `${newWidth}px`
1087
+ }
1088
+
1089
+ const onPointerUp = () => {
1090
+ window.removeEventListener("pointermove", onPointerMove)
1091
+ window.removeEventListener("pointerup", onPointerUp)
1092
+ this.dispatchEvent(
1093
+ new CustomEvent("rla-column-resize", {
1094
+ detail: { colId, width: th.offsetWidth },
1095
+ bubbles: true
1096
+ })
1097
+ )
1098
+ }
1099
+
1100
+ window.addEventListener("pointermove", onPointerMove)
1101
+ window.addEventListener("pointerup", onPointerUp)
1102
+ })
1103
+ })
1104
+ }
1105
+
1106
+ private _initColumnReordering() {
1107
+ const handles = this.querySelectorAll<HTMLElement>("thead th [data-slot=drag-handle]")
1108
+ let draggedColId: string | null = null
1109
+
1110
+ handles.forEach((handle) => {
1111
+ handle.addEventListener("dragstart", (e) => {
1112
+ const th = handle.closest<HTMLTableCellElement>("th")
1113
+ draggedColId = th?.getAttribute("data-col-id") || null
1114
+ e.dataTransfer?.setData("text/plain", draggedColId || "")
1115
+ if (e.dataTransfer) {
1116
+ e.dataTransfer.effectAllowed = "move"
1117
+ }
1118
+ })
1119
+ handle.addEventListener("dragend", () => {
1120
+ draggedColId = null
1121
+ this.querySelectorAll("thead th").forEach((th) => th.removeAttribute("data-drop-position"))
1122
+ })
1123
+ })
1124
+
1125
+ const ths = this.querySelectorAll<HTMLTableCellElement>("thead th")
1126
+ ths.forEach((th) => {
1127
+ th.addEventListener("dragover", (e) => {
1128
+ if (!draggedColId) return
1129
+ e.preventDefault()
1130
+ const rect = th.getBoundingClientRect()
1131
+ const pos = e.clientX < rect.left + rect.width / 2 ? "left" : "right"
1132
+ ths.forEach((other) => {
1133
+ if (other !== th) other.removeAttribute("data-drop-position")
1134
+ })
1135
+ th.setAttribute("data-drop-position", pos)
1136
+ })
1137
+ th.addEventListener("dragleave", () => {
1138
+ th.removeAttribute("data-drop-position")
1139
+ })
1140
+ th.addEventListener("drop", (e) => {
1141
+ if (!draggedColId) return
1142
+ e.preventDefault()
1143
+ const targetColId = th.getAttribute("data-col-id")
1144
+ const pos = th.getAttribute("data-drop-position") || "left"
1145
+ th.removeAttribute("data-drop-position")
1146
+ if (!targetColId || draggedColId === targetColId) return
1147
+ this.dispatchEvent(
1148
+ new CustomEvent("rla-column-reorder", {
1149
+ detail: { from: draggedColId, to: targetColId, position: pos },
1150
+ bubbles: true
1151
+ })
1152
+ )
1153
+ })
1154
+ })
1155
+ }
1156
+
1157
+ private _initRowReordering() {
1158
+ const handles = this.querySelectorAll<HTMLElement>("tbody tr [data-slot=drag-handle]")
1159
+ let draggedRowId: string | null = null
1160
+
1161
+ handles.forEach((handle) => {
1162
+ handle.addEventListener("dragstart", (e) => {
1163
+ const tr = handle.closest<HTMLTableRowElement>("tr")
1164
+ draggedRowId = tr?.getAttribute("data-row-id") || null
1165
+ e.dataTransfer?.setData("text/plain", draggedRowId || "")
1166
+ if (e.dataTransfer) {
1167
+ e.dataTransfer.effectAllowed = "move"
1168
+ }
1169
+ })
1170
+ handle.addEventListener("dragend", () => {
1171
+ draggedRowId = null
1172
+ this.querySelectorAll("tbody tr").forEach((tr) => tr.removeAttribute("data-drop-position"))
1173
+ })
1174
+ })
1175
+
1176
+ const trs = this.querySelectorAll<HTMLTableRowElement>("tbody tr[data-slot=tr]")
1177
+ trs.forEach((tr) => {
1178
+ tr.addEventListener("dragover", (e) => {
1179
+ if (!draggedRowId) return
1180
+ e.preventDefault()
1181
+ const rect = tr.getBoundingClientRect()
1182
+ const pos = e.clientY < rect.top + rect.height / 2 ? "top" : "bottom"
1183
+ trs.forEach((other) => {
1184
+ if (other !== tr) other.removeAttribute("data-drop-position")
1185
+ })
1186
+ tr.setAttribute("data-drop-position", pos)
1187
+ })
1188
+ tr.addEventListener("dragleave", () => {
1189
+ tr.removeAttribute("data-drop-position")
1190
+ })
1191
+ tr.addEventListener("drop", (e) => {
1192
+ if (!draggedRowId) return
1193
+ e.preventDefault()
1194
+ const targetRowId = tr.getAttribute("data-row-id")
1195
+ const pos = tr.getAttribute("data-drop-position") || "top"
1196
+ tr.removeAttribute("data-drop-position")
1197
+ if (!targetRowId || draggedRowId === targetRowId) return
1198
+
1199
+ // Move in DOM
1200
+ const sourceTr = this.querySelector<HTMLTableRowElement>(`tbody tr[data-row-id="${draggedRowId}"]`)
1201
+ if (sourceTr && tr) {
1202
+ if (pos === "top") {
1203
+ tr.insertAdjacentElement("beforebegin", sourceTr)
1204
+ } else {
1205
+ tr.insertAdjacentElement("afterend", sourceTr)
1206
+ }
1207
+ }
1208
+
1209
+ this.dispatchEvent(
1210
+ new CustomEvent("rla-row-reorder", {
1211
+ detail: { from: draggedRowId, to: targetRowId, position: pos },
1212
+ bubbles: true
1213
+ })
1214
+ )
1215
+ })
1216
+ })
1217
+ }
1218
+
1219
+ // ── Search / Filtering ────────────────────────────────────────────────
1220
+ private _initSearch() {
1221
+ const searchInput = this.querySelector<HTMLInputElement>("[data-rla-table-search]")
1222
+ if (!searchInput) return
1223
+
1224
+ searchInput.addEventListener("input", () => {
1225
+ const query = searchInput.value.toLowerCase().trim()
1226
+ const tbody = this.querySelector("tbody")
1227
+ if (!tbody) return
1228
+
1229
+ const rows = tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")
1230
+ rows.forEach((row) => {
1231
+ const text = row.textContent?.toLowerCase() ?? ""
1232
+ const match = !query || text.includes(query)
1233
+ row.style.display = match ? "" : "none"
1234
+ const rowId = row.getAttribute("data-row-id")
1235
+ if (rowId) {
1236
+ const expandedRow = tbody.querySelector<HTMLTableRowElement>(
1237
+ `tr[data-slot=tr-expanded][data-row-id="${rowId}"]`
1238
+ )
1239
+ if (expandedRow) {
1240
+ expandedRow.style.display = match && row.getAttribute("data-expanded") === "true" ? "" : "none"
1241
+ }
1242
+ }
1243
+ })
1244
+ })
1245
+ }
1246
+
1247
+ // ── Sorting ───────────────────────────────────────────────────────────
1248
+ private _initSorting() {
1249
+ const sortButtons = this.querySelectorAll<HTMLButtonElement>("[data-rla-sort-btn]")
1250
+ const activeSorts: Array<{ id: string; dir: "asc" | "desc" }> = []
1251
+
1252
+ sortButtons.forEach((btn) => {
1253
+ btn.addEventListener("click", (e) => {
1254
+ const colId = btn.getAttribute("data-rla-col-id")
1255
+ if (!colId) return
1256
+
1257
+ const currentDir = btn.getAttribute("data-sort-dir") ?? "none"
1258
+ const isMulti = (e as MouseEvent).shiftKey
1259
+
1260
+ let nextDir: "asc" | "desc" | "none" = "asc"
1261
+ if (currentDir === "asc") nextDir = "desc"
1262
+ else if (currentDir === "desc") nextDir = "none"
1263
+
1264
+ if (!isMulti) {
1265
+ activeSorts.length = 0
1266
+ sortButtons.forEach((otherBtn) => {
1267
+ if (otherBtn !== btn) {
1268
+ otherBtn.setAttribute("data-sort-dir", "none")
1269
+ const icon = otherBtn.querySelector("[data-sort-icon]")
1270
+ if (icon) {
1271
+ icon.className =
1272
+ "i-lucide-arrow-up-down opacity-40 group-hover:opacity-100 size-3.5 transition-all shrink-0"
1273
+ }
1274
+ }
1275
+ })
1276
+ }
1277
+
1278
+ btn.setAttribute("data-sort-dir", nextDir)
1279
+ const icon = btn.querySelector("[data-sort-icon]")
1280
+ if (icon) {
1281
+ if (nextDir === "asc") {
1282
+ icon.className =
1283
+ "i-lucide-arrow-up-narrow-wide text-primary size-3.5 transition-all shrink-0"
1284
+ } else if (nextDir === "desc") {
1285
+ icon.className =
1286
+ "i-lucide-arrow-down-wide-narrow text-primary size-3.5 transition-all shrink-0"
1287
+ } else {
1288
+ icon.className =
1289
+ "i-lucide-arrow-up-down opacity-40 group-hover:opacity-100 size-3.5 transition-all shrink-0"
1290
+ }
1291
+ }
1292
+
1293
+ const existingIdx = activeSorts.findIndex((s) => s.id === colId)
1294
+ if (nextDir === "none") {
1295
+ if (existingIdx >= 0) activeSorts.splice(existingIdx, 1)
1296
+ } else if (existingIdx >= 0) {
1297
+ activeSorts[existingIdx] = { id: colId, dir: nextDir }
1298
+ } else {
1299
+ activeSorts.push({ id: colId, dir: nextDir })
1300
+ }
1301
+
1302
+ const tbody = this.querySelector("tbody")
1303
+ const table = this.querySelector("table")
1304
+ if (tbody && table) {
1305
+ const groupRows = Array.from(
1306
+ tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr-group]")
1307
+ )
1308
+
1309
+ if (groupRows.length > 0) {
1310
+ groupRows.forEach((groupHeader) => {
1311
+ const groupId = groupHeader.getAttribute("data-group-id")
1312
+ const rowsInGroup = Array.from(
1313
+ tbody.querySelectorAll<HTMLTableRowElement>(`tr[data-slot=tr][data-group-id="${groupId}"]`)
1314
+ )
1315
+ rowsInGroup.sort((a, b) => {
1316
+ for (const { id, dir } of activeSorts) {
1317
+ const idx = Array.from(table.querySelectorAll("thead th")).findIndex(
1318
+ (th) => (th as HTMLElement).getAttribute("data-col-id") === id
1319
+ )
1320
+ if (idx < 0) continue
1321
+ const cellA = a.children[idx]?.textContent?.trim() ?? ""
1322
+ const cellB = b.children[idx]?.textContent?.trim() ?? ""
1323
+ const cleanA = cellA.replace(/[$€£,\s]/g, "")
1324
+ const cleanB = cellB.replace(/[$€£,\s]/g, "")
1325
+ const isNumA = cleanA !== "" && !isNaN(Number(cleanA))
1326
+ const isNumB = cleanB !== "" && !isNaN(Number(cleanB))
1327
+
1328
+ let cmp: number
1329
+ if (isNumA && isNumB) {
1330
+ cmp = Number(cleanA) - Number(cleanB)
1331
+ } else {
1332
+ cmp = cellA.localeCompare(cellB)
1333
+ }
1334
+ if (dir === "desc") cmp = -cmp
1335
+ if (cmp !== 0) return cmp
1336
+ }
1337
+ return 0
1338
+ })
1339
+ let lastElement: Element = groupHeader
1340
+ rowsInGroup.forEach((r) => {
1341
+ lastElement.insertAdjacentElement("afterend", r)
1342
+ lastElement = r
1343
+ })
1344
+ })
1345
+ } else {
1346
+ const rows = Array.from(
1347
+ tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")
1348
+ )
1349
+ rows.sort((a, b) => {
1350
+ for (const { id, dir } of activeSorts) {
1351
+ const idx = Array.from(table.querySelectorAll("thead th")).findIndex(
1352
+ (th) => (th as HTMLElement).getAttribute("data-col-id") === id
1353
+ )
1354
+ if (idx < 0) continue
1355
+ const cellA = a.children[idx]?.textContent?.trim() ?? ""
1356
+ const cellB = b.children[idx]?.textContent?.trim() ?? ""
1357
+ const cleanA = cellA.replace(/[$€£,\s]/g, "")
1358
+ const cleanB = cellB.replace(/[$€£,\s]/g, "")
1359
+ const isNumA = cleanA !== "" && !isNaN(Number(cleanA))
1360
+ const isNumB = cleanB !== "" && !isNaN(Number(cleanB))
1361
+
1362
+ let cmp: number
1363
+ if (isNumA && isNumB) {
1364
+ cmp = Number(cleanA) - Number(cleanB)
1365
+ } else {
1366
+ cmp = cellA.localeCompare(cellB)
1367
+ }
1368
+ if (dir === "desc") cmp = -cmp
1369
+ if (cmp !== 0) return cmp
1370
+ }
1371
+ return 0
1372
+ })
1373
+ rows.forEach((row) => tbody.appendChild(row))
1374
+ }
1375
+ }
1376
+
1377
+ this.dispatchEvent(
1378
+ new CustomEvent("rla-sorting-change", {
1379
+ detail: { id: colId, dir: nextDir },
1380
+ bubbles: true
1381
+ })
1382
+ )
1383
+ })
1384
+ })
1385
+ }
1386
+
1387
+ // ── Row events ────────────────────────────────────────────────────────
1388
+ private _initRowEvents() {
1389
+ const tbody = this.querySelector("tbody")
1390
+ if (!tbody) return
1391
+
1392
+ tbody.addEventListener("click", (e) => {
1393
+ const target = e.target as HTMLElement
1394
+ if (target.closest("a, button, input, select, textarea, [role=button]")) return
1395
+ const tr = target.closest<HTMLElement>("tr[data-row-id]")
1396
+ if (!tr) return
1397
+ this.dispatchEvent(
1398
+ new CustomEvent("rla-row-select", {
1399
+ detail: { rowId: tr.getAttribute("data-row-id"), event: e },
1400
+ bubbles: true
1401
+ })
1402
+ )
1403
+ })
1404
+
1405
+ tbody.addEventListener("mouseover", (e) => {
1406
+ const tr = (e.target as HTMLElement).closest<HTMLElement>("tr[data-row-id]")
1407
+ if (!tr) return
1408
+ this.dispatchEvent(
1409
+ new CustomEvent("rla-row-hover", {
1410
+ detail: { rowId: tr.getAttribute("data-row-id"), event: e },
1411
+ bubbles: true
1412
+ })
1413
+ )
1414
+ })
1415
+
1416
+ tbody.addEventListener("mouseleave", (e) => {
1417
+ this.dispatchEvent(
1418
+ new CustomEvent("rla-row-hover", {
1419
+ detail: { rowId: null, event: e },
1420
+ bubbles: true
1421
+ })
1422
+ )
1423
+ })
1424
+
1425
+ tbody.addEventListener("contextmenu", (e) => {
1426
+ const tr = (e.target as HTMLElement).closest<HTMLElement>("tr[data-row-id]")
1427
+ if (!tr) return
1428
+ this.dispatchEvent(
1429
+ new CustomEvent("rla-row-contextmenu", {
1430
+ detail: { rowId: tr.getAttribute("data-row-id"), event: e },
1431
+ bubbles: true
1432
+ })
1433
+ )
1434
+ })
1435
+ }
1436
+
1437
+ // ── Selection handling ────────────────────────────────────────────────
1438
+ private _initSelection() {
1439
+ const tbody = this.querySelector("tbody")
1440
+ if (!tbody) return
1441
+ const selectAll = this.querySelector<HTMLInputElement>("[data-rla-select-all]")
1442
+ const rowCheckboxes = tbody.querySelectorAll<HTMLInputElement>("[data-row-checkbox]")
1443
+
1444
+ const updateSelectAllState = () => {
1445
+ if (!selectAll) return
1446
+ const checkedCount = Array.from(rowCheckboxes).filter((cb) => cb.checked).length
1447
+ if (checkedCount === 0) {
1448
+ selectAll.checked = false
1449
+ selectAll.indeterminate = false
1450
+ } else if (checkedCount === rowCheckboxes.length) {
1451
+ selectAll.checked = true
1452
+ selectAll.indeterminate = false
1453
+ } else {
1454
+ selectAll.checked = false
1455
+ selectAll.indeterminate = true
1456
+ }
1457
+ }
1458
+
1459
+ if (selectAll) {
1460
+ selectAll.addEventListener("change", () => {
1461
+ const isChecked = selectAll.checked
1462
+ rowCheckboxes.forEach((cb) => {
1463
+ cb.checked = isChecked
1464
+ const tr = cb.closest<HTMLTableRowElement>("tr[data-row-id]")
1465
+ if (tr) {
1466
+ if (isChecked) tr.setAttribute("data-selected", "true")
1467
+ else tr.removeAttribute("data-selected")
1468
+ }
1469
+ })
1470
+ })
1471
+ }
1472
+
1473
+ rowCheckboxes.forEach((cb) => {
1474
+ cb.addEventListener("change", () => {
1475
+ const tr = cb.closest<HTMLTableRowElement>("tr[data-row-id]")
1476
+ if (tr) {
1477
+ if (cb.checked) tr.setAttribute("data-selected", "true")
1478
+ else tr.removeAttribute("data-selected")
1479
+ }
1480
+ updateSelectAllState()
1481
+ })
1482
+ })
1483
+ }
1484
+
1485
+ // ── Row Expand Toggles ────────────────────────────────────────────────
1486
+ private _initExpandToggles() {
1487
+ const tbody = this.querySelector("tbody")
1488
+ if (!tbody) return
1489
+
1490
+ tbody.addEventListener("click", (e) => {
1491
+ const target = e.target as HTMLElement
1492
+ const btn = target.closest<HTMLButtonElement>("[data-row-expand]")
1493
+ if (!btn) return
1494
+ e.stopPropagation()
1495
+
1496
+ const rowId = btn.getAttribute("data-row-expand")
1497
+ if (!rowId) return
1498
+
1499
+ const tr = tbody.querySelector<HTMLTableRowElement>(`tr[data-slot=tr][data-row-id="${rowId}"]`)
1500
+ const expandedRow = tbody.querySelector<HTMLTableRowElement>(`tr[data-slot=tr-expanded][data-row-id="${rowId}"]`)
1501
+
1502
+ const isExpanded = tr?.getAttribute("data-expanded") === "true"
1503
+ const next = !isExpanded
1504
+
1505
+ if (tr) {
1506
+ if (next) tr.setAttribute("data-expanded", "true")
1507
+ else tr.removeAttribute("data-expanded")
1508
+ }
1509
+
1510
+ if (expandedRow) {
1511
+ expandedRow.style.display = next ? "" : "none"
1512
+ }
1513
+
1514
+ const chevron = btn.querySelector(".i-lucide-chevron-right, .i-lucide-chevron-down")
1515
+ if (chevron) {
1516
+ if (next) {
1517
+ chevron.classList.remove("i-lucide-chevron-right")
1518
+ chevron.classList.add("i-lucide-chevron-down")
1519
+ } else {
1520
+ chevron.classList.remove("i-lucide-chevron-down")
1521
+ chevron.classList.add("i-lucide-chevron-right")
1522
+ }
1523
+ }
1524
+
1525
+ this.dispatchEvent(
1526
+ new CustomEvent("rla-row-expand-change", {
1527
+ detail: { rowId, expanded: next },
1528
+ bubbles: true
1529
+ })
1530
+ )
1531
+ })
1532
+ }
1533
+
1534
+ // ── Public API (imperative) ───────────────────────────────────────────
1535
+
1536
+ /** Filter visible rows by a text query (case-insensitive). */
1537
+ setGlobalFilter(query: string) {
1538
+ const searchInput = this.querySelector<HTMLInputElement>("[data-rla-table-search]")
1539
+ if (searchInput) {
1540
+ searchInput.value = query
1541
+ searchInput.dispatchEvent(new Event("input"))
1542
+ } else {
1543
+ const tbody = this.querySelector("tbody")
1544
+ if (!tbody) return
1545
+ const q = query.toLowerCase().trim()
1546
+ tbody
1547
+ .querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")
1548
+ .forEach((row) => {
1549
+ row.style.display =
1550
+ !q || (row.textContent?.toLowerCase() ?? "").includes(q) ? "" : "none"
1551
+ })
1552
+ }
1553
+ }
1554
+
1555
+ /** Show or hide a column by its column ID. */
1556
+ setColumnVisibility(colId: string, visible: boolean) {
1557
+ this.dispatchEvent(
1558
+ new CustomEvent("rla-dropdown-checkbox-change", {
1559
+ detail: { id: colId, checked: visible },
1560
+ bubbles: false
1561
+ })
1562
+ )
1563
+ }
1564
+
1565
+ /** Return all currently-visible data rows. */
1566
+ getVisibleRows(): HTMLTableRowElement[] {
1567
+ const tbody = this.querySelector("tbody")
1568
+ if (!tbody) return []
1569
+ return Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr[data-slot=tr]")).filter(
1570
+ (r) => r.style.display !== "none"
1571
+ )
1572
+ }
1573
+
1574
+ /** Return all selected rows (those with data-selected=true). */
1575
+ getSelectedRows(): HTMLTableRowElement[] {
1576
+ const tbody = this.querySelector("tbody")
1577
+ if (!tbody) return []
1578
+ return Array.from(
1579
+ tbody.querySelectorAll<HTMLTableRowElement>("tr[data-selected=true]")
1580
+ )
1581
+ }
1582
+
1583
+ /** Export table data to CSV. */
1584
+ exportToCsv(options?: { filename?: string; selectedOnly?: boolean; columns?: string[] }): string {
1585
+ const table = this.querySelector("table")
1586
+ if (!table) return ""
1587
+ const ths = Array.from(table.querySelectorAll("thead th"))
1588
+ const colIds = ths
1589
+ .map((th) => th.getAttribute("data-col-id") || "")
1590
+ .filter((id) => id && id !== "select" && id !== "actions" && id !== "reorder" && id !== "dragHandle")
1591
+ const targetCols = options?.columns && options.columns.length > 0 ? options.columns : colIds
1592
+
1593
+ const escapeCsv = (val: string) => {
1594
+ if (val.includes(",") || val.includes('"') || val.includes("\n")) {
1595
+ return `"${val.replace(/"/g, '""')}"`
1596
+ }
1597
+ return val
1598
+ }
1599
+
1600
+ const headers = targetCols.map((id) => escapeCsv(id.charAt(0).toUpperCase() + id.slice(1)))
1601
+ const rows = options?.selectedOnly ? this.getSelectedRows() : this.getVisibleRows()
1602
+
1603
+ const lines: string[] = [headers.join(",")]
1604
+ rows.forEach((tr) => {
1605
+ const cells = targetCols.map((colId) => {
1606
+ const td = tr.querySelector(`td[data-col-id="${colId}"]`)
1607
+ return escapeCsv(td?.textContent?.trim() ?? "")
1608
+ })
1609
+ lines.push(cells.join(","))
1610
+ })
1611
+
1612
+ const csvContent = lines.join("\r\n")
1613
+ if (options?.filename) {
1614
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
1615
+ const url = URL.createObjectURL(blob)
1616
+ const link = document.createElement("a")
1617
+ link.href = url
1618
+ link.download = options.filename.endsWith(".csv") ? options.filename : `${options.filename}.csv`
1619
+ link.click()
1620
+ URL.revokeObjectURL(url)
1621
+ }
1622
+ return csvContent
1623
+ }
1624
+
1625
+ /** Export table data to JSON. */
1626
+ exportToJson(options?: { filename?: string; selectedOnly?: boolean; columns?: string[] }): string {
1627
+ const table = this.querySelector("table")
1628
+ if (!table) return "[]"
1629
+ const ths = Array.from(table.querySelectorAll("thead th"))
1630
+ const colIds = ths
1631
+ .map((th) => th.getAttribute("data-col-id") || "")
1632
+ .filter((id) => id && id !== "select" && id !== "actions" && id !== "reorder" && id !== "dragHandle")
1633
+ const targetCols = options?.columns && options.columns.length > 0 ? options.columns : colIds
1634
+ const rows = options?.selectedOnly ? this.getSelectedRows() : this.getVisibleRows()
1635
+
1636
+ const dataArr = rows.map((tr) => {
1637
+ const rowObj: Record<string, string> = {}
1638
+ targetCols.forEach((colId) => {
1639
+ const td = tr.querySelector(`td[data-col-id="${colId}"]`)
1640
+ rowObj[colId] = td?.textContent?.trim() ?? ""
1641
+ })
1642
+ return rowObj
1643
+ })
1644
+
1645
+ const jsonContent = JSON.stringify(dataArr, null, 2)
1646
+ if (options?.filename) {
1647
+ const blob = new Blob([jsonContent], { type: "application/json;charset=utf-8;" })
1648
+ const url = URL.createObjectURL(blob)
1649
+ const link = document.createElement("a")
1650
+ link.href = url
1651
+ link.download = options.filename.endsWith(".json") ? options.filename : `${options.filename}.json`
1652
+ link.click()
1653
+ URL.revokeObjectURL(url)
1654
+ }
1655
+ return jsonContent
1656
+ }
1657
+
1658
+ /** Return map of unique values and counts for a given column. */
1659
+ getFacetedUniqueValues(columnId: string): Map<string, number> {
1660
+ const map = new Map<string, number>()
1661
+ const rows = this.getVisibleRows()
1662
+ rows.forEach((tr) => {
1663
+ const td = tr.querySelector(`td[data-col-id="${columnId}"]`)
1664
+ const val = td?.textContent?.trim() ?? ""
1665
+ map.set(val, (map.get(val) || 0) + 1)
1666
+ })
1667
+ return map
1668
+ }
1669
+
1670
+ /** Return min/max numeric range for a given column. */
1671
+ getFacetedMinMaxValues(columnId: string): [number, number] | undefined {
1672
+ const rows = this.getVisibleRows()
1673
+ let min: number | undefined
1674
+ let max: number | undefined
1675
+ rows.forEach((tr) => {
1676
+ const td = tr.querySelector(`td[data-col-id="${columnId}"]`)
1677
+ const text = (td?.textContent?.trim() ?? "").replace(/[$€£,\s]/g, "")
1678
+ const val = Number(text)
1679
+ if (!isNaN(val)) {
1680
+ if (min === undefined || val < min) min = val
1681
+ if (max === undefined || val > max) max = val
1682
+ }
1683
+ })
1684
+ return min !== undefined && max !== undefined ? [min, max] : undefined
1685
+ }
1686
+ }
1687
+
1688
+ if (!customElements.get("rla-table")) {
1689
+ customElements.define("rla-table", RLATableElement)
1690
+ }
1691
+ }
1692
+ </script>