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.
package/dist/Table.svelte DELETED
@@ -1,763 +0,0 @@
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
-
13
- export interface TableState<T extends Record<PropertyKey, any> = Record<PropertyKey, any>> {
14
- columns: Record<string, TColumn<T, unknown>>
15
- panels: Record<string, TPanel<T>>
16
- selected: T[] | null
17
- sortby?: string
18
- positions: {
19
- sticky: string[]
20
- scroll: string[]
21
- hidden: string[]
22
- toggle(key: string): void
23
- }
24
- readonly resizeable: boolean
25
- readonly data: T[]
26
- /** Rows become anchors */
27
- readonly href?: (item: T) => string
28
- addColumn(key: string, options: TColumn<T, unknown>): void
29
- removeColumn(key: string): void
30
- }
31
-
32
- export function getTableState<T extends Record<PropertyKey, any> = Record<PropertyKey, any>>() {
33
- return getContext<TableState<T>>('svelte5-table')
34
- }
35
-
36
- export type HeaderSelectCtx<T = any> = {
37
- isSelected: boolean,
38
- /** The list of selected items */
39
- readonly selected: T[]
40
- /**
41
- * See [MDN :indeterminate](https://developer.mozilla.org/en-US/docs/Web/CSS/:indeterminate)
42
- */
43
- readonly indeterminate: boolean
44
- }
45
-
46
- export type RowSelectCtx<T = any> = {
47
- readonly item: T
48
- readonly row: RowCtx<unknown>
49
- data: T[]
50
- isSelected: boolean
51
- }
52
-
53
- </script>
54
-
55
- <script lang='ts' generics='T extends Record<PropertyKey, unknown>'>
56
-
57
- import { getContext, onMount, setContext, tick, untrack, type Snippet } from 'svelte'
58
- import Column, { type RowCtx, type Column as TColumn } from './Column.svelte'
59
- import Panel, { PanelTween, type Panel as TPanel } from './Panel.svelte'
60
- import { fly } from 'svelte/transition'
61
- import { sineInOut } from 'svelte/easing'
62
- import type { get } from 'svelte/store'
63
-
64
-
65
-
66
- interface Props {
67
- content: Snippet<[context: { Column: typeof Column<T>, Panel: typeof Panel, readonly table: TableState<T>, readonly data: T[] }]>
68
-
69
- panel?: string
70
- data?: T[]
71
- id?: string
72
- href?: (item: T) => string
73
- /**
74
- * Can you change the width of the columns?
75
- * @default true
76
- */
77
- resizeable?: boolean
78
-
79
- selected?: T[]
80
- select?: boolean | {
81
- /**
82
- * The style, in which the selection is shown
83
- *
84
- * NOTE: If using `edge` | 'side', "show" will always be `hover`. This is due to
85
- * an inconsistency/limitation of matching the scroll between the selection div and the rows.
86
- *
87
- * @default 'column'
88
- */
89
- style?: 'column'
90
- /**
91
- * When to show the row-select, when not selected?
92
- * @default 'hover'
93
- */
94
- show?: 'hover' | 'always' | 'never'
95
- /**
96
- * Custom snippet
97
- */
98
- headerSnippet?: Snippet<[context: HeaderSelectCtx]>
99
- rowSnippet?: Snippet<[context: RowSelectCtx<T>]>
100
- }
101
- // | {
102
- // /**
103
- // * The style, in which the selection is shown
104
- // *
105
- // * NOTE: If using `edge` | 'side', "show" will always be `hover`. This is due to
106
- // * an inconsistency/limitation of matching the scroll between the selection div and the rows.
107
- // *
108
- // * @default 'column'
109
- // */
110
- // style?: 'edge' | 'side'
111
- // /**
112
- // * When to show the row-select, when not selected?
113
- // * @default 'hover'
114
- // */
115
- // show?: 'hover'
116
- // /**
117
- // * Custom snippet
118
- // */
119
- // snippet?: Snippet<[context: { item: T, data: T[], selected: boolean }]>
120
- // }
121
-
122
- /*
123
- ordered?: {
124
- style?: 'column' | 'side' // combine with select if both use 'column'
125
- show?: 'hover' | 'always'
126
- // snippet?: Snippet<[context: { item: T, data: T[], selected: boolean }]>
127
- }
128
- */
129
- }
130
-
131
- let {
132
- content,
133
- selected = $bindable([]),
134
- panel = $bindable(),
135
- data: _data = [],
136
- id = Array.from({length: 12}, () => String.fromCharCode(Math.floor(Math.random() * 26) + 97)).join(''),
137
- href,
138
- resizeable = true,
139
- select
140
- }: Props = $props()
141
-
142
- let mounted = $state(false)
143
- onMount(() => mounted = true)
144
-
145
- const data = $derived([..._data])
146
-
147
- const elements = $state({}) as Record<'headers' | 'statusbar' | 'rows' | 'virtualTop' | 'virtualBottom' | 'selects', HTMLElement>
148
-
149
-
150
- // * --- Virtualization --- *
151
- let scrollTop = $state(0)
152
- let viewportHeight = $state(0)
153
-
154
- let heightPerItem = $state(8)
155
-
156
- const spacing = () => viewportHeight / 2
157
-
158
- let virtualTop = $derived.by(() => {
159
- let result = Math.max(scrollTop - spacing(), 0)
160
- result -= result % heightPerItem
161
- return result
162
- })
163
- let virtualBottom = $derived.by(() => {
164
- let result = (heightPerItem * data.length) - virtualTop - spacing() * 4
165
- result = Math.max(result, 0)
166
- return result
167
- })
168
-
169
- let renderItemLength = $derived(Math.ceil(Math.max(30, (viewportHeight / heightPerItem) * 2)))
170
-
171
- /** The area of data being rendered */
172
- let area = $derived.by(() => {
173
- const index = (virtualTop / heightPerItem) || 0
174
- const end = index + renderItemLength
175
- const result = data.slice(
176
- index,
177
- end
178
- )
179
- return result
180
- })
181
-
182
- function calculateHeightPerItem() {
183
- if(!elements.rows) {
184
- heightPerItem = 8
185
- return
186
- }
187
- tick().then(() => {
188
- const firstRow = elements.rows.children[0].getBoundingClientRect().top
189
- const lastRow = elements.rows.children[elements.rows.children.length - 1].getBoundingClientRect().bottom
190
- heightPerItem = (lastRow - firstRow) / area.length
191
- })
192
- }
193
-
194
- $effect(() => {
195
- data
196
- untrack(calculateHeightPerItem)
197
- })
198
- // * --- Virtualization --- *
199
-
200
- let cols: TableState<T>['columns'] = $state({})
201
- let positions: TableState<T>['positions'] = $state({
202
- fixed: [],
203
- sticky: [],
204
- scroll: [],
205
- hidden: [],
206
- toggle(key) {
207
- if(table.positions.hidden.includes(key))
208
- table.positions.hidden = table.positions.hidden.filter(column => column !== key)
209
- else
210
- table.positions.hidden.push(key)
211
- }
212
- })
213
-
214
-
215
- const table: TableState<T> = $state({
216
- columns: cols,
217
- selected,
218
- panels: {},
219
- positions,
220
- get href() {
221
- return href
222
- },
223
- get data() {
224
- return data
225
- },
226
- get resizeable() {
227
- return resizeable
228
- },
229
- addColumn(key, column) {
230
- table.columns[key] = column
231
-
232
- if(column.defaults.sort)
233
- table.sortby = key
234
-
235
- if(column.fixed) {
236
- // @ts-expect-error
237
- table.positions.fixed.push(key)
238
- return
239
- }
240
-
241
- if(!column.defaults.show)
242
- table.positions.hidden.push(key)
243
-
244
- if(column.defaults.sticky)
245
- table.positions.sticky.push(key)
246
- else
247
- table.positions.scroll.push(key)
248
- },
249
- removeColumn(key) {
250
- delete table.columns[key]
251
- // @ts-expect-error fixed is not typed
252
- table.positions.fixed = table.positions.fixed.filter(column => column !== key)
253
- table.positions.sticky = table.positions.sticky.filter(column => column !== key)
254
- table.positions.scroll = table.positions.scroll.filter(column => column !== key)
255
- table.positions.hidden = table.positions.hidden.filter(column => column !== key)
256
- }
257
- })
258
-
259
- setContext('svelte5-table', table)
260
-
261
- // * --- *
262
-
263
- const panelTween = new PanelTween(() => panel, 24)
264
-
265
- let hoveredRow: T | null = $state(null)
266
-
267
- /** Order of columns */
268
- const fixed = $derived(
269
- // @ts-expect-error
270
- positions.fixed
271
- ) as string[]
272
- const hidden = $derived(positions.hidden)
273
- const notHidden = (key: string) => !positions.hidden.includes(key)
274
- const sticky = $derived(positions.sticky.filter(notHidden))
275
- const scrolled = $derived(positions.scroll.filter(notHidden))
276
- const columns = $derived([ ...fixed, ...sticky, ...scrolled ])
277
-
278
- /** Width of each column */
279
- const columnWidths = $state({}) as Record<string, number>
280
-
281
- const getWidth = (key: string, def: number = 150) => columnWidths[key] || table.columns[key]?.defaults.width || def
282
-
283
- /** grid-template-columns for widths */
284
- const style = $derived.by(() => {
285
- if(!mounted) return ''
286
- const templateColumns = `
287
- #${id} > .headers,
288
- #${id} > tbody > .row,
289
- #${id} > tfoot > tr,
290
- #${id} > .content > .virtual.bottom {
291
- grid-template-columns: ${
292
- columns.map((key, i, arr) => {
293
- const width = getWidth(key)
294
- if(i === arr.length - 1)
295
- return `minmax(${width}px, 1fr)`
296
- return `${width}px`
297
- }).join(' ')
298
- };
299
- }
300
- `
301
-
302
- let sum = 0
303
- const stickyLeft = [...fixed, ...sticky].map((key, i, arr) => {
304
- sum += getWidth(arr[i - 1], i === 0 ? 0 : undefined)
305
- return `
306
- #${id} .column.sticky[data-column='${key}'] {
307
- left: ${sum}px;
308
- }
309
- `
310
- }).join('')
311
-
312
- return templateColumns + stickyLeft
313
- })
314
-
315
- function observeColumnWidth(node: HTMLDivElement, isHeader = false) {
316
- if(!isHeader) return
317
-
318
- const key = node.getAttribute('data-column')!
319
- node.style.width = getWidth(key) + 'px'
320
-
321
- const observer = new MutationObserver(() => {
322
- columnWidths[key] = parseFloat(node.style.width)
323
- })
324
-
325
- observer.observe(node, {attributes: true})
326
- return { destroy: () => observer.disconnect() }
327
- }
328
-
329
- async function onscroll() {
330
- const target = elements.rows
331
- if(target.scrollTop !== scrollTop) {
332
- scrollTop = target?.scrollTop ?? scrollTop
333
- }
334
-
335
- if(elements.selects) {
336
- elements.selects.scrollTop = target?.scrollTop
337
- }
338
-
339
- if(!elements.headers) return
340
- elements.headers.scrollLeft = target.scrollLeft
341
- elements.statusbar.scrollLeft = target.scrollLeft
342
- }
343
-
344
-
345
- export {
346
- selected,
347
- positions,
348
- data,
349
- href,
350
- cols as columns
351
- }
352
-
353
- </script>
354
- <!---------------------------------------------------->
355
-
356
- <svelte:head>
357
- {@html `<style>${style}</style>`}
358
- </svelte:head>
359
-
360
- {#snippet columnsSnippet(
361
- renderable: (column: string) => Snippet<[arg0?: any, arg1?: any]> | undefined,
362
- arg: null | ((column: string) => any[]) = null,
363
- isHeader = false
364
- )}
365
- {#each fixed as column, i (column)}
366
- {#if !hidden.includes(column)}
367
- {@const args = arg ? arg(column) : []}
368
- <svelte:element
369
- this={isHeader ? 'th' : 'td'}
370
- class='column sticky fixed'
371
- data-column={column}
372
- class:header={isHeader}
373
- >
374
- {@render renderable(column)?.(args[0], args[1])}
375
- </svelte:element>
376
- {/if}
377
- {/each}
378
- {#each sticky as column, i (column)}
379
- {#if !hidden.includes(column)}
380
- {@const args = arg ? arg(column) : []}
381
- <svelte:element
382
- this={isHeader ? 'th' : 'td'}
383
- class='column sticky'
384
- use:observeColumnWidth={isHeader}
385
- data-column={column}
386
- class:header={isHeader}
387
- class:resizeable={isHeader && table.columns[column].options.resizeable && table.resizeable}
388
- class:border={i == sticky.length - 1}
389
- >
390
- {@render renderable(column)?.(args[0], args[1])}
391
- </svelte:element>
392
- {/if}
393
- {/each}
394
- {#each scrolled as column, i (column)}
395
- {#if !hidden.includes(column)}
396
- {@const args = arg ? arg(column) : []}
397
- <svelte:element
398
- this={isHeader ? 'th' : 'td'}
399
- class='column'
400
- data-column={column}
401
- use:observeColumnWidth={isHeader}
402
- class:resizeable={isHeader && table.columns[column].options.resizeable && table.resizeable}
403
- >
404
- {@render renderable(column)?.(args[0], args[1])}
405
- </svelte:element>
406
- {/if}
407
- {/each}
408
- {/snippet}
409
-
410
- <table
411
- id={id}
412
- class='table svelte-tably'
413
- style='--t: {virtualTop}px; --b: {virtualBottom}px;'
414
- aria-rowcount='{data.length}'
415
- >
416
-
417
- <thead class='headers' bind:this={elements.headers}>
418
- {@render columnsSnippet((column) => table.columns[column]?.header, () => [true], true)}
419
- </thead>
420
-
421
- <tbody class='content' bind:this={elements.rows} onscrollcapture={onscroll} bind:clientHeight={viewportHeight}>
422
- {#each area as item, i (item)}
423
- {@const props = table.href ? { href: table.href(item) } : {}}
424
- {@const index = data.indexOf(item) + 1}
425
- <svelte:element
426
- this={table.href ? 'a' : 'tr'}
427
- class='row'
428
- class:hover={hoveredRow === item}
429
- class:selected={table.selected?.includes(item)}
430
- class:first={i === 0}
431
- class:last={i === area.length - 1}
432
- {...props}
433
- aria-rowindex='{index}'
434
- onpointerenter={() => hoveredRow = item}
435
- onpointerleave={() => hoveredRow = null}
436
- >
437
- {@render columnsSnippet(
438
- (column) => table.columns[column]!.row,
439
- (column) => {
440
- const col = table.columns[column]!
441
- return [item, {
442
- get index() { return index - 1 },
443
- get value() { return col.options.value ? col.options.value(item) : undefined },
444
- get isHovered() { return hoveredRow === item },
445
- get selected() { return table.selected?.includes(item) },
446
- set selected(value) { value ? table.selected!.push(item) : table.selected!.splice(table.selected!.indexOf(item), 1) }
447
- }]
448
- }
449
- )}
450
- </svelte:element>
451
- {/each}
452
- </tbody>
453
-
454
- <tfoot class='statusbar' bind:this={elements.statusbar}>
455
- <tr>
456
- {@render columnsSnippet((column) => table.columns[column]?.statusbar)}
457
- </tr>
458
- </tfoot>
459
-
460
- <caption class='panel' style='width: {(panelTween.current)}px;' style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}>
461
- {#if panel && panel in table.panels}
462
- <div
463
- class='panel-content'
464
- bind:clientWidth={panelTween.width}
465
- in:fly={{ x: 100, easing: sineInOut, duration:300 }}
466
- out:fly={{ x:100, duration:200, easing: sineInOut }}
467
- >
468
- {@render table.panels[panel].content({ get table() { return table }, get data() { return data } })}
469
- </div>
470
- {/if}
471
- </caption>
472
- <caption
473
- class='backdrop'
474
- aria-hidden={panel && table.panels[panel]?.backdrop ? false : true}
475
- >
476
- <button
477
- aria-label='Panel backdrop'
478
- tabindex='-1'
479
- onclick={() => panel = undefined}
480
- ></button>
481
- </caption>
482
- </table>
483
-
484
- {#snippet headerSelected(ctx: HeaderSelectCtx<T>)}
485
- <input
486
- type='checkbox'
487
- indeterminate={ctx.indeterminate}
488
- bind:checked={ctx.isSelected}
489
- />
490
- {/snippet}
491
-
492
- {#snippet rowSelected(ctx: RowSelectCtx<T>)}
493
- <input
494
- type='checkbox'
495
- bind:checked={ctx.isSelected}
496
- />
497
- {/snippet}
498
-
499
- {#if select}
500
- {@const { show = 'hover', style = 'column', rowSnippet = rowSelected, headerSnippet = headerSelected } = typeof select === 'boolean' ? {} : select}
501
- {#if show !== 'never'}
502
- <Column id='__fixed' {table} fixed width={56} resizeable={false}>
503
- {#snippet header()}
504
- {@render headerSnippet({
505
- get isSelected() {
506
- return table.data.length === table.selected?.length
507
- },
508
- set isSelected(value) {
509
- if(value) {
510
- table.selected = table.data
511
- } else {
512
- table.selected = []
513
- }
514
- },
515
- get selected() {
516
- return table.selected!
517
- },
518
- get indeterminate() {
519
- return (table.selected?.length || 0) > 0
520
- && table.data.length !== table.selected?.length
521
- }
522
- })}
523
- {/snippet}
524
- {#snippet row(item, row)}
525
- <div class='__fixed'>
526
- {#if row.selected || show === 'always' || (row.isHovered && show === 'hover')}
527
- {@render rowSnippet({
528
- get isSelected() { return row.selected },
529
- set isSelected(value) { row.selected = value },
530
- get row() { return row },
531
- get item() { return item },
532
- get data() { return table.data }
533
- })}
534
- {/if}
535
- </div>
536
- {/snippet}
537
- </Column>
538
- {/if}
539
- {/if}
540
-
541
- {@render content?.({ Column, Panel, get table() { return table }, get data() { return data } })}
542
-
543
-
544
-
545
- <!---------------------------------------------------->
546
- <style>
547
-
548
- .svelte-tably *, .svelte-tably {
549
- all: unset;
550
- box-sizing: border-box;
551
- background-color: inherit;
552
- }
553
-
554
- .svelte-tably {
555
- position: relative;
556
- overflow: visible;
557
- }
558
-
559
- input[type='checkbox'] {
560
- all: revert;
561
- width: 18px;
562
- height: 18px;
563
- cursor: pointer;
564
- }
565
-
566
- .__fixed {
567
- display: flex;
568
- align-items: center;
569
- justify-content: center;
570
- gap: .5rem;
571
- position: absolute;
572
- top: 0; left: 0;
573
- right: 0; bottom: 0;
574
- width: 100%;
575
- }
576
-
577
- .first .__fixed {
578
- top: var(--tably-padding-y, .5rem);
579
- }
580
- .last .__fixed {
581
- bottom: var(--tably-padding-y, .5rem);
582
- }
583
-
584
- tbody::before, tbody::after, selects::before, selects::after {
585
- content: '';
586
- display: grid;
587
- min-height: 100%;
588
- }
589
-
590
- tbody::before, selects::before {
591
- height: var(--t);
592
- }
593
- tbody::after, selects::after {
594
- height: var(--b);
595
- }
596
-
597
- a.row {
598
- color: inherit;
599
- text-decoration: inherit;
600
- }
601
-
602
-
603
-
604
- .backdrop {
605
- position: absolute;
606
- left: 0px;
607
- top: 0px;
608
- bottom: 0px;
609
- right: 0px;
610
- background-color: hsla(0, 0%, 0%, .3);
611
- z-index: 3;
612
- opacity: 1;
613
- transition: .15s ease;
614
- border: none;
615
- outline: none;
616
- cursor: pointer;
617
-
618
- > button {
619
- position: absolute;
620
- left: 0px;
621
- top: 0px;
622
- bottom: 0px;
623
- right: 0px;
624
- }
625
-
626
- &[aria-hidden='true'] {
627
- opacity: 0;
628
- pointer-events: none;
629
- }
630
- }
631
-
632
- .headers, .statusbar {
633
- /* So that the scrollbar doesn't cause the headers/statusbar to shift */
634
- padding-right: 11px;
635
- }
636
-
637
- .table {
638
- color: var(--tably-color, hsl(0, 0%, 0%));
639
- background-color: var(--tably-bg, hsl(0, 0%, 100%));
640
- }
641
-
642
- .sticky {
643
- position: sticky;
644
- /* right: 100px; */
645
- z-index: 1;
646
- }
647
-
648
- .sticky.border {
649
- border-right: 1px solid var(--tably-border, hsl(0, 0%, 90%));
650
- }
651
-
652
- .headers > .column {
653
- border-right: 1px solid var(--tably-border, hsl(0, 0%, 90%));
654
- overflow: hidden;
655
- padding: var(--tably-padding-y, .5rem) 0;
656
-
657
- &.resizeable {
658
- resize: horizontal;
659
- }
660
- }
661
-
662
- .table {
663
- display: grid;
664
- height: 100%;
665
- position: relative;
666
-
667
- grid-template-areas:
668
- 'headers panel'
669
- 'rows panel'
670
- 'statusbar panel'
671
- ;
672
-
673
- grid-template-columns: auto min-content;
674
- grid-template-rows: auto 1fr auto;
675
-
676
- border: 1px solid var(--tably-border, hsl(0, 0%, 90%));
677
- border-radius: var(--tably-radius, .25rem);
678
-
679
- max-height: 100%;
680
- }
681
-
682
- .headers {
683
- grid-area: headers;
684
- z-index: 2;
685
- overflow: hidden;
686
- }
687
-
688
- .headers > .column {
689
- width: auto !important;
690
- border-bottom: 1px solid var(--tably-border, hsl(0, 0%, 90%));
691
- }
692
-
693
- .content {
694
- display: grid;
695
- grid-auto-rows: max-content;
696
-
697
- grid-area: rows;
698
- scrollbar-width: thin;
699
- overflow: auto;
700
- /* height: 100%; */
701
- }
702
-
703
- .statusbar {
704
- grid-area: statusbar;
705
- overflow: hidden;
706
- background-color: var(--tably-statusbar, hsl(0, 0%, 98%));
707
- }
708
-
709
- .statusbar > tr > .column {
710
- border-top: 1px solid var(--tably-border, hsl(0, 0%, 90%));
711
- padding: calc(var(--tably-padding-y, .5rem) / 2) 0;
712
- }
713
-
714
- .headers, .row, .statusbar > tr {
715
- position: relative;
716
- display: grid;
717
- width: 100%;
718
- height: 100%;
719
-
720
- & > .column {
721
- display: flex;
722
- padding-left: var(--tably-padding-x, 1rem);
723
- overflow: hidden;
724
- }
725
-
726
- & > *:last-child {
727
- width: 100%;
728
- padding-right: var(--tably-padding-x, 1rem);
729
- }
730
- }
731
-
732
- .row:first-child > * {
733
- padding-top: calc(var(--tably-padding-y, .5rem) + calc(var(--tably-padding-y, .5rem) / 2));
734
- }
735
- .row:last-child > * {
736
- padding-bottom: calc(var(--tably-padding-y, .5rem) + calc(var(--tably-padding-y, .5rem) / 2));
737
- }
738
-
739
- .row > * {
740
- padding: calc(var(--tably-padding-y, .5rem) / 2) 0;
741
- }
742
-
743
- .panel {
744
- position: relative;
745
- grid-area: panel;
746
- height: 100%;
747
-
748
- border-left: 1px solid var(--tably-border, hsl(0, 0%, 90%));
749
- scrollbar-gutter: stable both-edges;
750
- scrollbar-width: thin;
751
- z-index: 4;
752
-
753
- > .panel-content {
754
- position: absolute;
755
- top: 0;
756
- right: 0;
757
- width: min-content;
758
- overflow: auto;
759
- padding: var(--tably-padding-y, .5rem) 0;
760
- }
761
- }
762
-
763
- </style>