svelte-tably 1.0.0-next.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1139 @@
1
+ <!-- @component
2
+
3
+ This is a description, \
4
+ on how to use this.
5
+
6
+ @example
7
+ <Component />
8
+
9
+ -->
10
+
11
+ <script module lang="ts">
12
+ </script>
13
+
14
+ <script lang="ts">
15
+ import { type Snippet } from 'svelte'
16
+ import { fly } from 'svelte/transition'
17
+ import { sineInOut } from 'svelte/easing'
18
+ import reorder, { type ItemState } from 'runic-reorder'
19
+ import { Virtualization } from './virtualization.svelte.js'
20
+ import {
21
+ TableState,
22
+ type HeaderSelectCtx,
23
+ type RowCtx,
24
+ type RowSelectCtx,
25
+ type TableProps
26
+ } from './table.svelte.js'
27
+ import Panel from '../panel/Panel.svelte'
28
+ import Column from '../column/Column.svelte'
29
+ import { assignDescriptors, capitalize, fromProps, mounted, segmentize } from '../utility.svelte.js'
30
+ import { conditional } from '../conditional.svelte.js'
31
+ import { ColumnState, type RowColumnCtx } from '../column/column.svelte.js'
32
+ import Expandable from '../expandable/Expandable.svelte'
33
+ import { SizeTween } from '../size-tween.svelte.js'
34
+ import { on } from 'svelte/events'
35
+ import Row from '../row/Row.svelte'
36
+
37
+ type T = $$Generic<Record<PropertyKey, unknown>>
38
+
39
+ type ConstructorReturnType<T extends new (...args: any[]) => any> =
40
+ T extends new (...args: any[]) => infer K ? K : never
41
+ type ConstructorParams<T extends new (...args: any[]) => any> =
42
+ T extends new (...args: infer K) => any ? K : never
43
+
44
+ type ContentCtx<T extends Record<PropertyKey, unknown>> = {
45
+ Column: {
46
+ new <V>(...args: ConstructorParams<typeof Column<T, V>>): ConstructorReturnType<typeof Column<T, V>>
47
+ <V>(...args: Parameters<typeof Column<T, V>>): ReturnType<typeof Column<T, V>>
48
+ }
49
+ Panel: typeof Panel<T>
50
+ Expandable: typeof Expandable<T>
51
+ Row: typeof Row<T>
52
+ readonly table: TableState<T>
53
+ }
54
+
55
+ type ContentSnippet = Snippet<[context: ContentCtx<T>]>
56
+
57
+ let {
58
+ content,
59
+ selected: _selected = $bindable([]),
60
+ panel: _panel = $bindable(),
61
+ data: _data = $bindable([]),
62
+ ...restProps
63
+ }: TableProps<T> & { content?: ContentSnippet } = $props()
64
+
65
+ const properties = fromProps(restProps, {
66
+ selected: [() => _selected, (v) => (_selected = v)],
67
+ panel: [() => _panel, (v) => (_panel = v)],
68
+ data: [() => _data, (v) => (_data = v)]
69
+ }) as TableProps<T>
70
+
71
+ const mount = mounted()
72
+
73
+ const reorderArea = reorder(rowSnippet)
74
+
75
+ const elements = $state({}) as Record<
76
+ 'headers' | 'statusbar' | 'rows' | 'virtualTop' | 'virtualBottom' | 'selects',
77
+ HTMLElement
78
+ >
79
+
80
+ const table = new TableState<T>(properties) as TableState<T>
81
+
82
+ const virtualization = new Virtualization(table)
83
+
84
+ const panelTween = new SizeTween(() => !!properties.panel)
85
+
86
+ let hoveredRow: T | null = $state(null)
87
+ let hoveredColumn: ColumnState | null = $state(null)
88
+
89
+ /** Order of columns */
90
+ const fixed = $derived(table.positions.fixed)
91
+ const hidden = $derived(table.positions.hidden)
92
+ const notHidden = (column: ColumnState) => !table.positions.hidden.includes(column)
93
+ const sticky = $derived(table.positions.sticky.filter(notHidden))
94
+ const scrolled = $derived(table.positions.scroll.filter(notHidden))
95
+ const columns = $derived([...fixed, ...sticky, ...scrolled])
96
+
97
+ /** Width of each column */
98
+ const columnWidths = $state({}) as Record<string, number>
99
+
100
+ const getWidth = (key: string, def: number = 150) =>
101
+ columnWidths[key] || table.columns[key]?.defaults.width || def
102
+
103
+ /** grid-template-columns for widths */
104
+ const style = $derived.by(() => {
105
+ if (!mount.isMounted) return ''
106
+
107
+ const context = table.row?.snippets.context ? table.row?.options.context.width : ''
108
+
109
+ const templateColumns =
110
+ columns
111
+ .map((column, i, arr) => {
112
+ const width = getWidth(column.id)
113
+ if (i === arr.length - 1) return `minmax(${width}px, 1fr)`
114
+ return `${width}px`
115
+ })
116
+ .join(' ') + context
117
+
118
+ const theadTempla3teColumns = `
119
+ #${table.id} > thead > tr,
120
+ #${table.id} > tfoot > tr {
121
+ grid-template-columns: ${templateColumns};
122
+ }
123
+ `
124
+
125
+ const tbodyTemplateColumns = `
126
+ [data-area-class='${table.id}'] tr.row,
127
+ #${table.id} > tbody::after {
128
+ grid-template-columns: ${templateColumns};
129
+ }
130
+ `
131
+
132
+ let sum = 0
133
+ const stickyLeft = [...fixed, ...sticky]
134
+ .map((column, i, arr) => {
135
+ sum += getWidth(arr[i - 1]?.id, i === 0 ? 0 : undefined)
136
+ return `
137
+ #${table.id} .column.sticky[data-column='${column.id}'],
138
+ [data-svelte-tably='${table.id}'] .column.sticky[data-column='${column.id}'] {
139
+ left: ${sum}px;
140
+ }
141
+ `
142
+ })
143
+ .join('')
144
+
145
+ const columnStyling = columns
146
+ .map((column) =>
147
+ !column.options.style ?
148
+ ''
149
+ : `
150
+ [data-area-class='${table.id}'] .column[data-column='${column.id}'] {
151
+ ${column.options.style}
152
+ }
153
+ `
154
+ )
155
+ .join('')
156
+
157
+ return theadTempla3teColumns + tbodyTemplateColumns + stickyLeft + columnStyling
158
+ })
159
+
160
+ function observeColumnWidth(node: HTMLDivElement, isHeader = false) {
161
+ if (!isHeader) return
162
+
163
+ const key = node.getAttribute('data-column')!
164
+ node.style.width = getWidth(key) + 'px'
165
+
166
+ let mouseup = false
167
+
168
+ const observer = new MutationObserver(() => {
169
+ const width = parseFloat(node.style.width)
170
+ if (width === columnWidths[key]) return
171
+ columnWidths[key] = width
172
+ if (!mouseup) {
173
+ mouseup = true
174
+ window.addEventListener(
175
+ 'click',
176
+ (e) => {
177
+ e.preventDefault()
178
+ e.stopPropagation()
179
+ mouseup = false
180
+ },
181
+ { once: true, capture: true }
182
+ )
183
+ }
184
+ })
185
+
186
+ observer.observe(node, { attributes: true })
187
+ return { destroy: () => observer.disconnect() }
188
+ }
189
+
190
+ let tbody = $state({
191
+ width: 0
192
+ })
193
+ async function onscroll() {
194
+ const target = virtualization.viewport.element!
195
+ if (target.scrollTop !== virtualization.scrollTop) {
196
+ virtualization.scrollTop = target?.scrollTop ?? virtualization.scrollTop
197
+ }
198
+
199
+ if (elements.selects) {
200
+ elements.selects.scrollTop = target?.scrollTop
201
+ }
202
+
203
+ if (!elements.headers) return
204
+ elements.headers.scrollLeft = target.scrollLeft
205
+ elements.statusbar.scrollLeft = target.scrollLeft
206
+ }
207
+
208
+ // * --- CSV --- *
209
+ let csv = $state(false) as false | { selected?: boolean }
210
+ let csvElement = $state() as undefined | HTMLTableElement
211
+ interface CSVOptions {
212
+ /** Semi-colons as separator? */
213
+ semicolon?: boolean
214
+ /** Only selected rows */
215
+ selected?: boolean
216
+ }
217
+ export async function toCSV(opts: CSVOptions = {}) {
218
+ csv = { selected: !!opts.selected }
219
+ let resolve: (value: HTMLTableElement) => void
220
+ const promise = new Promise<HTMLTableElement>((r) => (resolve = r))
221
+
222
+ const clean = $effect.root(() => {
223
+ $effect(() => {
224
+ if (csvElement) {
225
+ resolve(csvElement)
226
+ }
227
+ })
228
+ })
229
+
230
+ let table = await promise
231
+ clean()
232
+
233
+ const separator = opts.semicolon ? ';' : ','
234
+ const rows = Array.from(table.rows)
235
+ const csvRows = [] as string[]
236
+
237
+ for (const row of rows) {
238
+ const cells = Array.from(row.cells)
239
+ const csvCells = cells.map((cell) => {
240
+ let text = cell.textContent?.trim() || ''
241
+
242
+ // Escape double quotes and wrap in quotes if needed
243
+ if (text.includes('"')) {
244
+ text = text.replace(/"/g, '""')
245
+ }
246
+ if (text.includes(separator) || text.includes('"') || text.includes('\n')) {
247
+ text = `"${text}"`
248
+ }
249
+
250
+ return text
251
+ })
252
+ csvRows.push(csvCells.join(separator))
253
+ }
254
+
255
+ csv = false
256
+ return csvRows.join('\n')
257
+ }
258
+ // * --- CSV --- *
259
+
260
+ let expandedRow = $state([]) as T[]
261
+ let expandTick = false
262
+ function toggleExpand(item: T, value?: boolean) {
263
+ if (expandTick) return
264
+ expandTick = true
265
+ requestAnimationFrame(() => (expandTick = false))
266
+
267
+ let indexOf = expandedRow.indexOf(item)
268
+ if (value === undefined) {
269
+ value = indexOf === -1
270
+ }
271
+ if (!value) {
272
+ expandedRow.splice(indexOf, 1)
273
+ return
274
+ }
275
+ if (table.expandable?.options.multiple === true) {
276
+ expandedRow.push(item)
277
+ } else {
278
+ expandedRow[0] = item
279
+ }
280
+ }
281
+
282
+ function addRowColumnEvents(
283
+ node: HTMLTableColElement,
284
+ opts: ['header' | 'row' | 'statusbar', ColumnState, () => RowColumnCtx<T, any>]
285
+ ) {
286
+ const [where, column, value] = opts
287
+ if (where !== 'row') return
288
+ if (column.options.onclick) {
289
+ $effect(() => on(node, 'click', (e) => column.options.onclick!(e, value())))
290
+ }
291
+ }
292
+
293
+ function addRowEvents(node: HTMLTableRowElement, ctx: RowCtx<T>) {
294
+ if (table.row?.events.onclick) {
295
+ $effect(() => on(node, 'click', (e) => table.row?.events.onclick!(e, ctx)))
296
+ }
297
+ if (table.row?.events.oncontextmenu) {
298
+ $effect(() => on(node, 'contextmenu', (e) => table.row?.events.oncontextmenu!(e, ctx)))
299
+ }
300
+ }
301
+ </script>
302
+
303
+ <!---------------------------------------------------->
304
+
305
+ {#if csv !== false}
306
+ {@const renderedColumns = columns.filter((v) => v.id !== '__fixed')}
307
+ <table bind:this={csvElement} hidden>
308
+ <thead>
309
+ <tr>
310
+ {#each renderedColumns as column}
311
+ <th>{@render column.snippets.title()}</th>
312
+ {/each}
313
+ </tr>
314
+ </thead>
315
+ <tbody>
316
+ {#each table.data as row, i}
317
+ {#if (csv.selected && table.selected.includes(row)) || !csv.selected}
318
+ <tr>
319
+ {#each renderedColumns as column}
320
+ <td>
321
+ {#if column.snippets.row}
322
+ {@render column.snippets.row(row, {
323
+ index: i,
324
+ value: column.options.value?.(row),
325
+ columnHovered: false,
326
+ rowHovered: false,
327
+ itemState: {
328
+ index: i,
329
+ dragging: false,
330
+ positioning: false
331
+ } as ItemState<any>,
332
+ selected: false,
333
+ expanded: false,
334
+ })}
335
+ {:else}
336
+ {column.options.value?.(row)}
337
+ {/if}
338
+ </td>
339
+ {/each}
340
+ </tr>
341
+ {/if}
342
+ {/each}
343
+ </tbody>
344
+ </table>
345
+ {/if}
346
+
347
+ <svelte:head>
348
+ {@html `<style>${style}</style>`}
349
+ </svelte:head>
350
+
351
+ {#snippet chevronSnippet(rotation: number = 0)}
352
+ <svg
353
+ xmlns="http://www.w3.org/2000/svg"
354
+ width="16"
355
+ height="16"
356
+ viewBox="0 0 16 16"
357
+ style="transform: rotate({rotation}deg)"
358
+ >
359
+ <path
360
+ fill="currentColor"
361
+ d="M3.2 10.26a.75.75 0 0 0 1.06.04L8 6.773l3.74 3.527a.75.75 0 1 0 1.02-1.1l-4.25-4a.75.75 0 0 0-1.02 0l-4.25 4a.75.75 0 0 0-.04 1.06"
362
+ ></path>
363
+ </svg>
364
+ {/snippet}
365
+
366
+ {#snippet dragSnippet()}
367
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" style="opacity: .3">
368
+ <path
369
+ fill="currentColor"
370
+ d="M5.5 5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m0 4.5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m1.5 3a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0M10.5 5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3M12 8a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0m-1.5 6a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3"
371
+ ></path>
372
+ </svg>
373
+ {/snippet}
374
+
375
+ {#snippet columnsSnippet(
376
+ renderable: (column: ColumnState) => Snippet<[arg0?: any, arg1?: any]> | undefined,
377
+ arg: null | ((column: ColumnState) => any[]) = null,
378
+ where: 'header' | 'row' | 'statusbar'
379
+ )}
380
+ {@const isHeader = where === 'header'}
381
+ {#each fixed as column, i (column)}
382
+ {#if !hidden.includes(column)}
383
+ {@const args = arg ? arg(column) : []}
384
+ {@const sortable = isHeader && column.options.sort && !table.options.reorderable}
385
+ <svelte:element
386
+ this={isHeader ? 'th' : 'td'}
387
+ class={column.options.class ?? ''}
388
+ class:column={true}
389
+ class:sticky={true}
390
+ class:fixed={true}
391
+ use:addRowColumnEvents={[where, column, () => args[1]]}
392
+ data-column={column.id}
393
+ class:pad={(isHeader && column.options.padHeader) || (!isHeader && column.options.padRow)}
394
+ class:header={isHeader}
395
+ class:sortable
396
+ use:conditional={[isHeader, (node) => table.dataState.sortAction(node, column.id)]}
397
+ onpointerenter={() => (hoveredColumn = column)}
398
+ onpointerleave={() => (hoveredColumn = null)}
399
+ >
400
+ {@render renderable(column)?.(args[0], args[1])}
401
+ {#if isHeader && table.dataState.sortby === column.id && sortable}
402
+ <span class='sorting-icon'>
403
+ {@render chevronSnippet(table.dataState.sortReverse ? 0 : 180)}
404
+ </span>
405
+ {/if}
406
+ </svelte:element>
407
+ {/if}
408
+ {/each}
409
+ {#each sticky as column, i (column)}
410
+ {#if !hidden.includes(column)}
411
+ {@const args = arg ? arg(column) : []}
412
+ {@const sortable = isHeader && column.options.sort && !table.options.reorderable}
413
+ <svelte:element
414
+ this={isHeader ? 'th' : 'td'}
415
+ class={column.options.class ?? ''}
416
+ class:column={true}
417
+ class:sticky={true}
418
+ use:addRowColumnEvents={[where, column, () => args[1]]}
419
+ use:observeColumnWidth={isHeader}
420
+ data-column={column.id}
421
+ class:pad={(isHeader && column.options.padHeader) || (!isHeader && column.options.padRow)}
422
+ class:header={isHeader}
423
+ class:resizeable={isHeader && column.options.resizeable && table.options.resizeable}
424
+ class:border={i == sticky.length - 1}
425
+ class:sortable
426
+ use:conditional={[isHeader, (node) => table.dataState.sortAction(node, column.id)]}
427
+ onpointerenter={() => (hoveredColumn = column)}
428
+ onpointerleave={() => (hoveredColumn = null)}
429
+ >
430
+ {@render renderable(column)?.(args[0], args[1])}
431
+ {#if isHeader && table.dataState.sortby === column.id && sortable}
432
+ <span class='sorting-icon'>
433
+ {@render chevronSnippet(table.dataState.sortReverse ? 0 : 180)}
434
+ </span>
435
+ {/if}
436
+ </svelte:element>
437
+ {/if}
438
+ {/each}
439
+ {#each scrolled as column, i (column)}
440
+ {#if !hidden.includes(column)}
441
+ {@const args = arg ? arg(column) : []}
442
+ {@const sortable = isHeader && column!.options.sort && !table.options.reorderable}
443
+ <svelte:element
444
+ this={isHeader ? 'th' : 'td'}
445
+ class={column.options.class ?? ''}
446
+ class:column={true}
447
+ data-column={column.id}
448
+ class:pad={(isHeader && column.options.padHeader) || (!isHeader && column.options.padRow)}
449
+ use:addRowColumnEvents={[where, column, () => args[1]]}
450
+ use:observeColumnWidth={isHeader}
451
+ class:resizeable={isHeader && column.options.resizeable && table.options.resizeable}
452
+ class:sortable
453
+ use:conditional={[isHeader, (node) => table.dataState.sortAction(node, column.id)]}
454
+ onpointerenter={() => (hoveredColumn = column)}
455
+ onpointerleave={() => (hoveredColumn = null)}
456
+ >
457
+ {@render renderable(column)?.(args[0], args[1])}
458
+ {#if isHeader && table.dataState.sortby === column.id && sortable}
459
+ <span class='sorting-icon'>
460
+ {@render chevronSnippet(table.dataState.sortReverse ? 0 : 180)}
461
+ </span>
462
+ {/if}
463
+ </svelte:element>
464
+ {/if}
465
+ {/each}
466
+ {/snippet}
467
+
468
+ {#snippet defaultRow(item: T, ctx: RowColumnCtx<T, any>)}
469
+ {ctx.value}
470
+ {/snippet}
471
+
472
+ {#snippet rowSnippet(item: T, itemState?: ItemState<T>)}
473
+ {@const index = itemState?.index ?? 0}
474
+
475
+ {@const ctx: RowCtx<T> = {
476
+ get index() {
477
+ return index
478
+ },
479
+ get rowHovered() {
480
+ return hoveredRow === item
481
+ },
482
+ get selected() {
483
+ return table.selected?.includes(item)
484
+ },
485
+ set selected(value) {
486
+ value ?
487
+ table.selected!.push(item)
488
+ : table.selected!.splice(table.selected!.indexOf(item), 1)
489
+ },
490
+ get itemState() {
491
+ return itemState
492
+ },
493
+ get expanded() {
494
+ return expandedRow.includes(item)
495
+ },
496
+ set expanded(value) {
497
+ toggleExpand(item, value)
498
+ }
499
+ }}
500
+
501
+ <tr
502
+ aria-rowindex={index + 1}
503
+ style:opacity={itemState?.positioning ? 0 : 1}
504
+ class="row"
505
+ class:dragging={itemState?.dragging}
506
+ class:selected={table.selected?.includes(item)}
507
+ class:first={index === 0}
508
+ class:last={index === virtualization.area.length - 1}
509
+ {...itemState?.dragging ? { 'data-svelte-tably': table.id } : {}}
510
+ onpointerenter={() => (hoveredRow = item)}
511
+ onpointerleave={() => (hoveredRow = null)}
512
+ use:addRowEvents={ctx}
513
+ onclick={(e) => {
514
+ if (table.expandable?.options.click === true) {
515
+ let target = e.target as HTMLElement
516
+ if (['INPUT', 'TEXTAREA', 'BUTTON', 'A'].includes(target.tagName)) {
517
+ return
518
+ }
519
+ ctx.expanded = !ctx.expanded
520
+ }
521
+ }}
522
+ >
523
+ {@render columnsSnippet(
524
+ (column) => column.snippets.row ?? defaultRow,
525
+ (column) => {
526
+ return [
527
+ item,
528
+ assignDescriptors(
529
+ {
530
+ get value() {
531
+ return column.options.value ? column.options.value(item) : undefined
532
+ },
533
+ get columnHovered() {
534
+ return hoveredColumn === column
535
+ }
536
+ },
537
+ ctx
538
+ )
539
+ ]
540
+ },
541
+ 'row'
542
+ )}
543
+ {#if table.row?.snippets.context}
544
+ {#if table.row?.snippets.contextHeader || !table.row?.options.context.hover || hoveredRow === item}
545
+ <td
546
+ class="context-col"
547
+ class:hover={!table.row?.snippets.contextHeader && table.row?.options.context.hover}
548
+ class:hidden={table.row?.options.context.hover &&
549
+ table.row?.snippets.contextHeader &&
550
+ hoveredRow !== item}
551
+ >
552
+ {@render table.row?.snippets.context?.(item, ctx)}
553
+ </td>
554
+ {/if}
555
+ {/if}
556
+ </tr>
557
+
558
+ {@const expandableTween = new SizeTween(() => table.expandable && expandedRow.includes(item), {
559
+ min: 1,
560
+ duration: table.expandable?.options.slide.duration,
561
+ easing: table.expandable?.options.slide.easing
562
+ })}
563
+ {#if expandableTween.current > 0}
564
+ <tr class="expandable" style="height: {expandableTween.current}px">
565
+ <td colspan={columns.length} style="height: {expandableTween.current}px">
566
+ <div bind:offsetHeight={expandableTween.size} style="width: {tbody.width - 3}px">
567
+ {@render table.expandable!.snippets.content?.(item, ctx)}
568
+ </div>
569
+ </td>
570
+ </tr>
571
+ {/if}
572
+ {/snippet}
573
+
574
+ <table
575
+ id={table.id}
576
+ class="table svelte-tably"
577
+ style="--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px;"
578
+ aria-rowcount={table.data.length}
579
+ >
580
+ {#if columns.some((v) => v.snippets.header)}
581
+ <thead class="headers" bind:this={elements.headers}>
582
+ <tr style="min-width: {tbody.width}px">
583
+ {@render columnsSnippet(
584
+ (column) => column.snippets.header,
585
+ () => [
586
+ {
587
+ get header() {
588
+ return true
589
+ },
590
+ get data() {
591
+ return table.data
592
+ }
593
+ }
594
+ ],
595
+ 'header'
596
+ )}
597
+ {#if table.row?.snippets.contextHeader}
598
+ <th class="context-col">
599
+ {@render table.row?.snippets.contextHeader()}
600
+ </th>
601
+ {/if}
602
+ </tr>
603
+ <tr style="width:400px;background:none;pointer-events:none;"></tr>
604
+ </thead>
605
+ {/if}
606
+
607
+ <tbody
608
+ class="content"
609
+ use:reorderArea={{ axis: 'y', class: table.id }}
610
+ bind:this={virtualization.viewport.element}
611
+ onscrollcapture={onscroll}
612
+ bind:clientHeight={virtualization.viewport.height}
613
+ bind:clientWidth={tbody.width}
614
+ >
615
+ {#if table.options.reorderable}
616
+ {@render reorderArea({
617
+ get view() {
618
+ return virtualization.area
619
+ },
620
+ get modify() {
621
+ return table.dataState.origin
622
+ },
623
+ get startIndex() {
624
+ return virtualization.topIndex
625
+ }
626
+ })}
627
+ {:else}
628
+ {#each virtualization.area as item, i (item)}
629
+ {@render rowSnippet(item, { index: i + virtualization.topIndex } as ItemState)}
630
+ {/each}
631
+ {/if}
632
+ </tbody>
633
+
634
+ {#if columns.some((v) => v.snippets.statusbar)}
635
+ <tfoot class="statusbar" bind:this={elements.statusbar}>
636
+ <tr>
637
+ {@render columnsSnippet(
638
+ (column) => column.snippets.statusbar,
639
+ () => [
640
+ {
641
+ get data() {
642
+ return table.data
643
+ }
644
+ }
645
+ ],
646
+ 'statusbar'
647
+ )}
648
+ </tr>
649
+ <tr style="width:400px;background:none;pointer-events:none;"></tr>
650
+ </tfoot>
651
+ {/if}
652
+
653
+ <caption class="panel" style="width: {panelTween.current}px;">
654
+ {#if properties.panel && properties.panel in table.panels}
655
+ <div
656
+ class="panel-content"
657
+ bind:offsetWidth={panelTween.size}
658
+ in:fly={{ x: 100, easing: sineInOut, duration: 300 }}
659
+ out:fly={{ x: 100, duration: 200, easing: sineInOut }}
660
+ >
661
+ {@render table.panels[properties.panel].children({
662
+ get table() {
663
+ return table
664
+ },
665
+ get data() {
666
+ return table.data
667
+ }
668
+ })}
669
+ </div>
670
+ {/if}
671
+ </caption>
672
+ <caption
673
+ class="backdrop"
674
+ aria-hidden={properties.panel && table.panels[properties.panel]?.backdrop ? false : true}
675
+ >
676
+ <button
677
+ aria-label="Panel backdrop"
678
+ class="btn-backdrop"
679
+ tabindex="-1"
680
+ onclick={() => (properties.panel = undefined)}
681
+ ></button>
682
+ </caption>
683
+ </table>
684
+
685
+ {#snippet headerSelected(ctx: HeaderSelectCtx<T>)}
686
+ <input type="checkbox" indeterminate={ctx.indeterminate} bind:checked={ctx.isSelected} />
687
+ {/snippet}
688
+
689
+ {#snippet rowSelected(ctx: RowSelectCtx<T>)}
690
+ <input type="checkbox" bind:checked={ctx.isSelected} tabindex="-1" />
691
+ {/snippet}
692
+
693
+ {#if table.options.select || table.options.reorderable || table.expandable}
694
+ {@const { select, reorderable } = table.options}
695
+ {@const expandable = table.expandable}
696
+ {@const {
697
+ show = 'hover',
698
+ style = 'column',
699
+ rowSnippet = rowSelected,
700
+ headerSnippet = headerSelected
701
+ } = typeof select === 'boolean' ? {} : select}
702
+ {#if show !== 'never' || reorderable || expandable?.options.chevron !== 'never'}
703
+ <Column
704
+ id="__fixed"
705
+ {table}
706
+ fixed
707
+ width={Math.max(
708
+ 48,
709
+ 0 +
710
+ (select && show !== 'never' ? 34 : 0) +
711
+ (reorderable ? 34 : 0) +
712
+ (expandable && expandable?.options.chevron !== 'never' ? 34 : 0)
713
+ )}
714
+ resizeable={false}
715
+ >
716
+ {#snippet header()}
717
+ <div class="__fixed">
718
+ {#if reorderable}
719
+ <span style="width: 16px; display: flex; align-items: center;"></span>
720
+ {/if}
721
+ {#if select}
722
+ {@render headerSnippet({
723
+ get isSelected() {
724
+ return table.data.length === table.selected?.length && table.data.length > 0
725
+ },
726
+ set isSelected(value) {
727
+ if (value) {
728
+ table.selected = table.data
729
+ } else {
730
+ table.selected = []
731
+ }
732
+ },
733
+ get selected() {
734
+ return table.selected!
735
+ },
736
+ get indeterminate() {
737
+ return (
738
+ (table.selected?.length || 0) > 0 &&
739
+ table.data.length !== table.selected?.length
740
+ )
741
+ }
742
+ })}
743
+ {/if}
744
+ </div>
745
+ {/snippet}
746
+ {#snippet row(item, row)}
747
+ <div class="__fixed">
748
+ {#if reorderable && row.itemState}
749
+ <span style="width: 16px; display: flex; align-items: center;" use:row.itemState.handle>
750
+ {#if (row.rowHovered && !row.itemState.area.isTarget) || row.itemState.dragging}
751
+ {@render dragSnippet()}
752
+ {/if}
753
+ </span>
754
+ {/if}
755
+ {#if select && (row.selected || show === 'always' || (row.rowHovered && show === 'hover') || row.expanded)}
756
+ {@render rowSnippet({
757
+ get isSelected() {
758
+ return row.selected
759
+ },
760
+ set isSelected(value) {
761
+ row.selected = value
762
+ },
763
+ get row() {
764
+ return row
765
+ },
766
+ get item() {
767
+ return item
768
+ },
769
+ get data() {
770
+ return table.data
771
+ }
772
+ })}
773
+ {/if}
774
+ {#if expandable && expandable?.options.chevron !== 'never'}
775
+ <button class="expand-row" tabindex="-1" onclick={() => (row.expanded = !row.expanded)}>
776
+ {#if row.expanded || expandable.options.chevron === 'always' || (row.rowHovered && expandable.options.chevron === 'hover')}
777
+ {@render chevronSnippet(row.expanded ? 180 : 90)}
778
+ {/if}
779
+ </button>
780
+ {/if}
781
+ </div>
782
+ {/snippet}
783
+ </Column>
784
+ {/if}
785
+ {/if}
786
+
787
+ {#if table.options.auto}
788
+ {#each Object.keys(table.data[0] || {}) as key}
789
+ <Column
790
+ id={key}
791
+ value={(r) => r[key]}
792
+ header={capitalize(segmentize(key))}
793
+ sort={typeof table.data[0]?.[key] === 'number' ?
794
+ (a, b) => a - b
795
+ : (a, b) => String(a).localeCompare(String(b))}
796
+ />
797
+ {/each}
798
+ {/if}
799
+
800
+ {@render content?.({
801
+ Column,
802
+ Panel,
803
+ Expandable,
804
+ Row,
805
+ get table() {
806
+ return table
807
+ }
808
+ })}
809
+
810
+ <!---------------------------------------------------->
811
+ <style>
812
+ .svelte-tably *,
813
+ .svelte-tably {
814
+ box-sizing: border-box;
815
+ background-color: inherit;
816
+ }
817
+
818
+ .context-col {
819
+ display: flex;
820
+ align-items: center;
821
+ justify-content: center;
822
+ position: sticky;
823
+ right: 0;
824
+ height: 100%;
825
+ z-index: 3;
826
+ padding: 0;
827
+
828
+ &.hover {
829
+ position: absolute;
830
+ }
831
+ &.hidden {
832
+ pointer-events: none;
833
+ user-select: none;
834
+ border-left: none;
835
+ background: none;
836
+ > :global(*) {
837
+ opacity: 0;
838
+ }
839
+ }
840
+ }
841
+
842
+ :global(:root) {
843
+ --tably-color: hsl(0, 0%, 0%);
844
+ --tably-bg: hsl(0, 0%, 100%);
845
+ --tably-statusbar: hsl(0, 0%, 98%);
846
+
847
+ --tably-border: hsl(0, 0%, 90%);
848
+ --tably-border-grid: hsl(0, 0%, 98%);
849
+
850
+ --tably-padding-x: 1rem;
851
+ --tably-padding-y: 0.5rem;
852
+
853
+ --tably-radius: 0.25rem;
854
+ }
855
+
856
+ .svelte-tably {
857
+ position: relative;
858
+ overflow: visible;
859
+ }
860
+
861
+ .expandable {
862
+ position: relative;
863
+
864
+ & > td {
865
+ position: sticky;
866
+ left: 1px;
867
+ > div {
868
+ position: absolute;
869
+ overflow: auto;
870
+ top: -1.5px;
871
+ left: 0;
872
+ }
873
+ }
874
+ }
875
+
876
+ .expand-row {
877
+ display: flex;
878
+ justify-content: center;
879
+ align-items: center;
880
+ padding: 0;
881
+ outline: none;
882
+ border: none;
883
+ cursor: pointer;
884
+ background-color: transparent;
885
+ color: inherit;
886
+ width: 20px;
887
+ height: 100%;
888
+
889
+ > svg {
890
+ transition: transform 0.15s ease;
891
+ }
892
+ }
893
+
894
+ caption {
895
+ all: unset;
896
+ }
897
+
898
+ input[type='checkbox'] {
899
+ width: 18px;
900
+ height: 18px;
901
+ cursor: pointer;
902
+ }
903
+
904
+ button.btn-backdrop {
905
+ outline: none;
906
+ border: none;
907
+ cursor: pointer;
908
+ }
909
+
910
+ .sorting-icon {
911
+ align-items: center;
912
+ justify-items: end;
913
+ margin: 0;
914
+ margin-left: auto;
915
+ > svg {
916
+ transition: transform 0.15s ease;
917
+ }
918
+ }
919
+
920
+ th:not(:last-child) .sorting-icon {
921
+ margin-right: var(--tably-padding-x);
922
+ }
923
+
924
+ .__fixed {
925
+ display: flex;
926
+ align-items: center;
927
+ justify-content: center;
928
+ gap: 0.25rem;
929
+ position: absolute;
930
+ top: 0;
931
+ left: 0;
932
+ right: 0;
933
+ bottom: 0;
934
+ width: 100%;
935
+ }
936
+
937
+ tbody::before,
938
+ tbody::after,
939
+ selects::before,
940
+ selects::after {
941
+ content: '';
942
+ display: grid;
943
+ min-height: 100%;
944
+ }
945
+
946
+ tbody::before,
947
+ selects::before {
948
+ height: var(--t);
949
+ }
950
+ tbody::after,
951
+ selects::after {
952
+ height: var(--b);
953
+ }
954
+
955
+ .row:global(:is(a)) {
956
+ color: inherit;
957
+ text-decoration: inherit;
958
+ }
959
+
960
+ .backdrop {
961
+ position: absolute;
962
+ left: 0px;
963
+ top: 0px;
964
+ bottom: 0px;
965
+ right: 0px;
966
+ background-color: hsla(0, 0%, 0%, 0.3);
967
+ z-index: 3;
968
+ opacity: 1;
969
+ transition: 0.15s ease;
970
+ border: none;
971
+ outline: none;
972
+ cursor: pointer;
973
+
974
+ > button {
975
+ position: absolute;
976
+ left: 0px;
977
+ top: 0px;
978
+ bottom: 0px;
979
+ right: 0px;
980
+ }
981
+
982
+ &[aria-hidden='true'] {
983
+ opacity: 0;
984
+ pointer-events: none;
985
+ }
986
+ }
987
+
988
+ .sticky {
989
+ position: sticky;
990
+ /* right: 100px; */
991
+ z-index: 1;
992
+ }
993
+
994
+ .sticky.border {
995
+ border-right: 1px solid var(--tably-border);
996
+ }
997
+
998
+ .headers > tr > .column {
999
+ overflow: hidden;
1000
+ padding: var(--tably-padding-y) 0;
1001
+ cursor: default;
1002
+ user-select: none;
1003
+
1004
+ &:last-child {
1005
+ border-right: none;
1006
+ }
1007
+
1008
+ &.sortable {
1009
+ cursor: pointer;
1010
+ }
1011
+
1012
+ &.resizeable {
1013
+ resize: horizontal;
1014
+ }
1015
+ }
1016
+
1017
+ .table {
1018
+ display: grid;
1019
+ height: auto;
1020
+ max-height: 100%;
1021
+ position: relative;
1022
+
1023
+ color: var(--tably-color);
1024
+ background-color: var(--tably-bg);
1025
+
1026
+ grid-template-areas:
1027
+ 'headers panel'
1028
+ 'rows panel'
1029
+ 'statusbar panel';
1030
+
1031
+ grid-template-columns: auto min-content;
1032
+ grid-template-rows: auto 1fr auto;
1033
+
1034
+ border: 1px solid var(--tably-border);
1035
+ border-radius: var(--tably-radius);
1036
+ }
1037
+
1038
+ .headers {
1039
+ display: flex;
1040
+ grid-area: headers;
1041
+ z-index: 2;
1042
+ overflow: hidden;
1043
+ }
1044
+
1045
+ .headers > tr > .column {
1046
+ width: auto !important;
1047
+ border-bottom: 1px solid var(--tably-border);
1048
+ }
1049
+ .headers > tr > .column, .headers > tr > .context-col {
1050
+ border-bottom: 1px solid var(--tably-border);
1051
+ border-left: 1px solid var(--tably-border-grid);
1052
+ }
1053
+
1054
+ .content {
1055
+ display: grid;
1056
+ grid-auto-rows: max-content;
1057
+
1058
+ grid-area: rows;
1059
+ scrollbar-width: thin;
1060
+ overflow: auto;
1061
+ }
1062
+
1063
+ .statusbar {
1064
+ display: flex;
1065
+ grid-area: statusbar;
1066
+ overflow: hidden;
1067
+ background-color: var(--tably-statusbar);
1068
+ }
1069
+
1070
+ .statusbar > tr > .column {
1071
+ border-top: 1px solid var(--tably-border);
1072
+ padding: calc(var(--tably-padding-y) / 2) 0;
1073
+ }
1074
+
1075
+ .headers > tr,
1076
+ .row,
1077
+ .statusbar > tr {
1078
+ position: relative;
1079
+ display: grid;
1080
+ width: 100%;
1081
+ height: 100%;
1082
+
1083
+ & > .column {
1084
+ display: flex;
1085
+ overflow: hidden;
1086
+
1087
+ &:not(.pad), &.pad > :global(*:first-child) {
1088
+ padding-left: var(--tably-padding-x);
1089
+ }
1090
+ }
1091
+
1092
+ & > *:last-child:not(.context-col) {
1093
+ width: 100%;
1094
+
1095
+ &:not(.pad), &.pad > :global(*:first-child) {
1096
+ padding-right: var(--tably-padding-x);
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ .row > .column {
1102
+ background-color: var(--tably-bg);
1103
+ &:not(.pad), &.pad > :global(*:first-child) {
1104
+ padding-top: var(--tably-padding-y);
1105
+ padding-bottom: var(--tably-padding-y);
1106
+ }
1107
+ }
1108
+
1109
+ :global(#runic-drag .row) {
1110
+ border: 1px solid var(--tably-border-grid);
1111
+ border-top: 2px solid var(--tably-border-grid);
1112
+ }
1113
+
1114
+ .row > * {
1115
+ border-left: 1px solid var(--tably-border-grid);
1116
+ border-bottom: 1px solid var(--tably-border-grid);
1117
+ }
1118
+
1119
+ .panel {
1120
+ position: relative;
1121
+ grid-area: panel;
1122
+ height: 100%;
1123
+ overflow: hidden;
1124
+ border-left: 1px solid var(--tably-border);
1125
+
1126
+ z-index: 4;
1127
+
1128
+ > .panel-content {
1129
+ position: absolute;
1130
+ top: 0;
1131
+ right: 0;
1132
+ bottom: 0;
1133
+ width: min-content;
1134
+ overflow: auto;
1135
+ scrollbar-width: thin;
1136
+ padding: var(--tably-padding-y) 0;
1137
+ }
1138
+ }
1139
+ </style>