svelte-tably 1.0.0-next.0 → 1.0.0-next.3
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/README.md +58 -3
- package/dist/Table/Table.svelte +131 -74
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# svelte-tably
|
|
2
2
|
|
|
3
|
-
Work in progress. I needed a break from my primary project, so here's a little side-project exploring
|
|
3
|
+
Work in progress. I needed a break from my primary project, so here's a little side-project exploring the amazing capabilities of Svelte 5 with a Dynamic table!
|
|
4
|
+
|
|
5
|
+
Example on [Svelte 5 Playground](https://svelte.dev/playground/a16d71c97445455e80a55b77ec1cf915?version=5)
|
|
4
6
|
|
|
5
7
|
A high performant dynamic table
|
|
6
8
|
|
|
@@ -9,11 +11,64 @@ A high performant dynamic table
|
|
|
9
11
|
- [x] Re-order columns
|
|
10
12
|
- [x] Resize columns
|
|
11
13
|
- [x] Statusbar
|
|
12
|
-
- [x] Virtual data (for sorting/filtering)
|
|
14
|
+
- [x] "Virtual" data (for sorting/filtering)
|
|
13
15
|
- [x] Panels
|
|
16
|
+
- [x] Virtual elements
|
|
14
17
|
- [ ] sorting
|
|
15
18
|
- [ ] select
|
|
16
19
|
- [ ] filtering
|
|
17
20
|
- [ ] orderable table
|
|
18
21
|
- [ ] row context-menu
|
|
19
|
-
- [ ] dropout section
|
|
22
|
+
- [ ] dropout section
|
|
23
|
+
|
|
24
|
+
### Usage Notes
|
|
25
|
+
|
|
26
|
+
Simple example.
|
|
27
|
+
|
|
28
|
+
Create a state for your data and a state for your active panel:
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
<script lang='ts'>
|
|
32
|
+
import { Table } from '$lib/index.js'
|
|
33
|
+
|
|
34
|
+
const data = $state([
|
|
35
|
+
{ name: 'John Doe', age: 30, email: 'johndoe@example.com' },
|
|
36
|
+
{ name: 'Jane Doe', age: 25, email: 'janedoe@example.com' },
|
|
37
|
+
])
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<Table {data}>
|
|
41
|
+
<Table.Name>
|
|
42
|
+
{#snippet header()}
|
|
43
|
+
Name
|
|
44
|
+
{/snippet}
|
|
45
|
+
{#snippet row(item)}
|
|
46
|
+
{item.name}
|
|
47
|
+
{/snippet}
|
|
48
|
+
</Table.Name>
|
|
49
|
+
<Table.Age>
|
|
50
|
+
{#snippet header()}
|
|
51
|
+
Age
|
|
52
|
+
{/snippet}
|
|
53
|
+
{#snippet row(item)}
|
|
54
|
+
{item.age}
|
|
55
|
+
{/snippet}
|
|
56
|
+
</Table.Age>
|
|
57
|
+
<Table.Email>
|
|
58
|
+
{#snippet header()}
|
|
59
|
+
Email
|
|
60
|
+
{/snippet}
|
|
61
|
+
{#snippet row(item)}
|
|
62
|
+
{item.email}
|
|
63
|
+
{/snippet}
|
|
64
|
+
</Table.Email>
|
|
65
|
+
</Table>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To create a column, simply add a new `<Table.ColumnName>` component inside the `<Table>` component. Replace `ColumnName` with the actual name of the column you want to create.
|
|
69
|
+
|
|
70
|
+
Inside the column component, you need to define three snippets:
|
|
71
|
+
|
|
72
|
+
* `header`: the content of the column header
|
|
73
|
+
* `row`: the content of each row in the column
|
|
74
|
+
* `statusbar`: (optional) the content of the status bar for the column
|
package/dist/Table/Table.svelte
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
<script lang='ts' generics='T extends Record<PropertyKey, unknown>'>
|
|
35
35
|
|
|
36
|
-
import { getContext, setContext, type Snippet } from 'svelte'
|
|
36
|
+
import { getContext, setContext, untrack, type Snippet } from 'svelte'
|
|
37
37
|
import { type Column } from './Column.svelte'
|
|
38
38
|
import { PanelTween, type Panel } from './Panel.svelte'
|
|
39
39
|
import { fly } from 'svelte/transition'
|
|
@@ -49,10 +49,56 @@
|
|
|
49
49
|
let {
|
|
50
50
|
children,
|
|
51
51
|
panel,
|
|
52
|
-
data = [],
|
|
52
|
+
data: _data = [],
|
|
53
53
|
id = Array.from({length: 12}, () => String.fromCharCode(Math.floor(Math.random() * 26) + 97)).join('')
|
|
54
54
|
}: Props = $props()
|
|
55
55
|
|
|
56
|
+
const data = $derived(_data.toSorted())
|
|
57
|
+
|
|
58
|
+
const elements = $state({}) as Record<'headers' | 'statusbar' | 'content', HTMLElement>
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
// * --- Virtualization --- *
|
|
62
|
+
let scrollTop = $state(0)
|
|
63
|
+
let viewportHeight = $state(0)
|
|
64
|
+
|
|
65
|
+
let _heightPerItem = 24
|
|
66
|
+
let renderItemLength = $derived(Math.ceil(Math.max(30, viewportHeight / (_heightPerItem / 3))))
|
|
67
|
+
|
|
68
|
+
let heightPerItem = $derived.by(() => {
|
|
69
|
+
if(!elements.content)
|
|
70
|
+
return 8
|
|
71
|
+
const rows = elements.content.querySelectorAll('.row') as NodeListOf<HTMLDivElement>
|
|
72
|
+
const result = ((
|
|
73
|
+
rows[rows.length - 1].offsetTop - rows[0].offsetTop
|
|
74
|
+
) / rows.length)
|
|
75
|
+
_heightPerItem = result
|
|
76
|
+
return result
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
let virtualTop = $derived.by(() => {
|
|
80
|
+
let spacing = untrack(() => (renderItemLength/3)) * heightPerItem
|
|
81
|
+
let scroll = scrollTop - spacing
|
|
82
|
+
let virtualTop = Math.max(scroll, 0)
|
|
83
|
+
virtualTop -= virtualTop % untrack(() => heightPerItem)
|
|
84
|
+
return virtualTop
|
|
85
|
+
})
|
|
86
|
+
let virtualBottom = $derived.by(() => {
|
|
87
|
+
const virtualBottom = (untrack(() => heightPerItem) * data.length) - virtualTop
|
|
88
|
+
return virtualBottom
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** The area of data that is rendered */
|
|
92
|
+
const area = $derived.by(() => {
|
|
93
|
+
const index = (virtualTop / untrack(() => heightPerItem)) || 0
|
|
94
|
+
return data.slice(
|
|
95
|
+
index,
|
|
96
|
+
index + untrack(() => renderItemLength)
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
// * --- Virtualization --- *
|
|
100
|
+
|
|
101
|
+
|
|
56
102
|
const table: TableState<T> = $state({
|
|
57
103
|
columns: {},
|
|
58
104
|
panels: {},
|
|
@@ -97,7 +143,6 @@
|
|
|
97
143
|
// * --- *
|
|
98
144
|
|
|
99
145
|
const panelTween = new PanelTween(() => panel)
|
|
100
|
-
const elements = $state({}) as Record<'headers' | 'statusbar', HTMLElement>
|
|
101
146
|
|
|
102
147
|
/** Order of columns */
|
|
103
148
|
const columns = $derived([...table.positions.sticky, ...table.positions.scroll].filter(key => !table.positions.hidden.includes(key)))
|
|
@@ -107,7 +152,7 @@
|
|
|
107
152
|
|
|
108
153
|
/** grid-template-columns for widths */
|
|
109
154
|
const style = $derived(`
|
|
110
|
-
#${id} > .headers, #${id} > .rows > .row, #${id} > .statusbar {
|
|
155
|
+
#${id} > .headers, #${id} > .content > .rows > .row, #${id} > .statusbar, #${id} > .content > .virtual.bottom {
|
|
111
156
|
grid-template-columns: ${columns.map((key, i, arr) => i === arr.length - 1 ? `minmax(${widths[key] || 150}px, 1fr)` : `${widths[key] || 150}px`).join(' ')};
|
|
112
157
|
}
|
|
113
158
|
`)
|
|
@@ -117,13 +162,19 @@
|
|
|
117
162
|
widths[target.getAttribute('data-column')!] = parseFloat(target.style.width)
|
|
118
163
|
})
|
|
119
164
|
|
|
120
|
-
function observe(node: HTMLDivElement,
|
|
165
|
+
function observe(node: HTMLDivElement, isHeader = false) {
|
|
166
|
+
if(!isHeader) return
|
|
121
167
|
observer?.observe(node, {attributes: true})
|
|
122
168
|
return { destroy: () => observer?.disconnect() }
|
|
123
169
|
}
|
|
124
170
|
|
|
125
171
|
function onscroll(event: Event) {
|
|
126
172
|
const target = event.target as HTMLDivElement
|
|
173
|
+
if(target.scrollTop !== scrollTop) {
|
|
174
|
+
scrollTop = target.scrollTop || 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if(!elements.headers) return
|
|
127
178
|
elements.headers.scrollLeft = target.scrollLeft
|
|
128
179
|
elements.statusbar.scrollLeft = target.scrollLeft
|
|
129
180
|
}
|
|
@@ -135,66 +186,63 @@
|
|
|
135
186
|
{@html `<style>${style}</style>`}
|
|
136
187
|
</svelte:head>
|
|
137
188
|
|
|
189
|
+
{#snippet columnsSnippet(
|
|
190
|
+
renderable: (column: string) => Snippet<[arg0?: any, arg1?: any, arg2?: any, arg3?: any]> | undefined,
|
|
191
|
+
arg: null | ((column: string) => any[]) = null,
|
|
192
|
+
isHeader = false
|
|
193
|
+
)}
|
|
194
|
+
{#each table.positions.sticky as column, i (column)}
|
|
195
|
+
{#if !table.positions.hidden.includes(column)}
|
|
196
|
+
{@const args = arg ? arg(column) : []}
|
|
197
|
+
{@const props = isHeader ? { 'data-column': column } : {}}
|
|
198
|
+
<div class='column sticky' {...props} use:observe={isHeader} class:border={i == table.positions.sticky.length - 1}>
|
|
199
|
+
{@render renderable(column)?.(args[0], args[1], args[2], args[3])}
|
|
200
|
+
</div>
|
|
201
|
+
{/if}
|
|
202
|
+
{/each}
|
|
203
|
+
{#each table.positions.scroll as column, i (column)}
|
|
204
|
+
{#if !table.positions.hidden.includes(column)}
|
|
205
|
+
{@const args = arg ? arg(column) : []}
|
|
206
|
+
{@const props = isHeader ? { 'data-column': column } : {}}
|
|
207
|
+
<div class='column' {...props} use:observe={isHeader}>
|
|
208
|
+
{@render renderable(column)?.(args[0], args[1], args[2], args[3])}
|
|
209
|
+
</div>
|
|
210
|
+
{/if}
|
|
211
|
+
{/each}
|
|
212
|
+
{/snippet}
|
|
213
|
+
|
|
138
214
|
<div id={id} class='table'>
|
|
139
215
|
|
|
140
216
|
<div class='headers' bind:this={elements.headers}>
|
|
141
|
-
{
|
|
142
|
-
{#if !table.positions.hidden.includes(column)}
|
|
143
|
-
<div class='column sticky' data-column="{column}" use:observe={column}>
|
|
144
|
-
{@render table.columns[column]?.header()}
|
|
145
|
-
</div>
|
|
146
|
-
{/if}
|
|
147
|
-
{/each}
|
|
148
|
-
{#each table.positions.scroll as column, i (column)}
|
|
149
|
-
{#if !table.positions.hidden.includes(column)}
|
|
150
|
-
<div class='column' use:observe={column}>
|
|
151
|
-
{@render table.columns[column]?.header()}
|
|
152
|
-
</div>
|
|
153
|
-
{/if}
|
|
154
|
-
{/each}
|
|
217
|
+
{@render columnsSnippet((column) => table.columns[column]?.header, null, true)}
|
|
155
218
|
</div>
|
|
156
219
|
|
|
157
|
-
<div class=
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
</div>
|
|
177
|
-
{/each}
|
|
220
|
+
<div class='content' {onscroll} bind:clientHeight={viewportHeight} bind:this={elements.content}>
|
|
221
|
+
<div class='virtual top' style='height: {virtualTop}px'></div>
|
|
222
|
+
|
|
223
|
+
<div class="rows">
|
|
224
|
+
{#each area as item, i (item)}
|
|
225
|
+
<div class='row'>
|
|
226
|
+
{@render columnsSnippet(
|
|
227
|
+
(column) => table.columns[column]!.row,
|
|
228
|
+
(column) => {
|
|
229
|
+
const col = table.columns[column]!
|
|
230
|
+
return [item, col.options.value ? col.options.value(item) : undefined]
|
|
231
|
+
}
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
{/each}
|
|
235
|
+
</div>
|
|
236
|
+
<div class='virtual bottom' style='height: {virtualBottom}px'>
|
|
237
|
+
{@render columnsSnippet(() => undefined)}
|
|
238
|
+
</div>
|
|
178
239
|
</div>
|
|
179
240
|
|
|
180
241
|
<div class='statusbar' bind:this={elements.statusbar}>
|
|
181
|
-
{
|
|
182
|
-
{#if !table.positions.hidden.includes(column)}
|
|
183
|
-
<div class='column sticky' class:border={i == table.positions.sticky.length - 1}>
|
|
184
|
-
{@render table.columns[column]?.statusbar?.(data)}
|
|
185
|
-
</div>
|
|
186
|
-
{/if}
|
|
187
|
-
{/each}
|
|
188
|
-
{#each table.positions.scroll as column, i (column)}
|
|
189
|
-
{#if !table.positions.hidden.includes(column)}
|
|
190
|
-
<div class='column'>
|
|
191
|
-
{@render table.columns[column]?.statusbar?.(data)}
|
|
192
|
-
</div>
|
|
193
|
-
{/if}
|
|
194
|
-
{/each}
|
|
242
|
+
{@render columnsSnippet((column) => table.columns[column]?.statusbar, () => [data])}
|
|
195
243
|
</div>
|
|
196
244
|
|
|
197
|
-
<div class='panel' style='width: {panelTween.current}px;' style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}>
|
|
245
|
+
<div class='panel' style='width: {panelTween.current + 30}px;' style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}>
|
|
198
246
|
{#if panel && panel in table.panels}
|
|
199
247
|
<div
|
|
200
248
|
class="panel-content"
|
|
@@ -224,16 +272,16 @@
|
|
|
224
272
|
position: sticky;
|
|
225
273
|
left: 0px;
|
|
226
274
|
/* right: 100px; */
|
|
227
|
-
background-color:
|
|
275
|
+
background-color: var(--tably-bg, hsl(0, 0%, 100%));
|
|
228
276
|
z-index: 1;
|
|
229
277
|
}
|
|
230
278
|
|
|
231
279
|
.sticky.border {
|
|
232
|
-
border-right: 1px solid
|
|
280
|
+
border-right: 1px solid var(--tably-border, hsl(0, 0%, 90%));
|
|
233
281
|
}
|
|
234
282
|
|
|
235
283
|
.headers > .column {
|
|
236
|
-
border-right: 1px solid
|
|
284
|
+
border-right: 1px solid var(--tably-border, hsl(0, 0%, 90%));
|
|
237
285
|
resize: horizontal;
|
|
238
286
|
overflow: hidden;
|
|
239
287
|
padding: var(--padding-y) 0;
|
|
@@ -247,6 +295,7 @@
|
|
|
247
295
|
--header-height: 2.5rem;
|
|
248
296
|
|
|
249
297
|
display: grid;
|
|
298
|
+
height: 100%;
|
|
250
299
|
|
|
251
300
|
grid-template-areas:
|
|
252
301
|
"headers panel"
|
|
@@ -257,7 +306,7 @@
|
|
|
257
306
|
grid-template-columns: auto min-content;
|
|
258
307
|
grid-template-rows: auto 1fr auto;
|
|
259
308
|
|
|
260
|
-
border: 1px solid
|
|
309
|
+
border: 1px solid var(--tably-border, hsl(0, 0%, 90%));
|
|
261
310
|
border-radius: .25rem;
|
|
262
311
|
|
|
263
312
|
max-height: 100%;
|
|
@@ -267,31 +316,38 @@
|
|
|
267
316
|
grid-area: headers;
|
|
268
317
|
z-index: 2;
|
|
269
318
|
overflow: hidden;
|
|
270
|
-
padding-right: 1rem;
|
|
271
319
|
}
|
|
272
320
|
|
|
273
321
|
.headers > .column {
|
|
274
322
|
width: auto !important;
|
|
275
|
-
background-color:
|
|
276
|
-
border-bottom: 1px solid
|
|
323
|
+
background-color: var(--tably-bg, hsl(0, 0%, 100%));
|
|
324
|
+
border-bottom: 1px solid var(--tably-border, hsl(0, 0%, 90%));
|
|
277
325
|
}
|
|
278
326
|
|
|
279
|
-
.
|
|
327
|
+
.content {
|
|
280
328
|
grid-area: rows;
|
|
281
329
|
display: grid;
|
|
282
|
-
overflow: auto;
|
|
283
330
|
scrollbar-width: thin;
|
|
331
|
+
overflow: auto;
|
|
332
|
+
height: 100%;
|
|
333
|
+
grid-template-rows: auto auto 1fr;
|
|
334
|
+
|
|
335
|
+
> .rows, > .virtual.bottom {
|
|
336
|
+
display: grid;
|
|
337
|
+
}
|
|
338
|
+
> .virtual.bottom {
|
|
339
|
+
min-height: 100%;
|
|
340
|
+
}
|
|
284
341
|
}
|
|
285
342
|
|
|
286
343
|
.statusbar {
|
|
287
344
|
grid-area: statusbar;
|
|
288
345
|
overflow: hidden;
|
|
289
|
-
padding-right: 1rem;
|
|
290
346
|
}
|
|
291
347
|
|
|
292
348
|
.statusbar > .column {
|
|
293
|
-
background-color:
|
|
294
|
-
border-top: 1px solid
|
|
349
|
+
background-color: var(--tably-bg-statusbar, hsl(0, 0%, 99%));
|
|
350
|
+
border-top: 1px solid var(--tably-border, hsl(0, 0%, 90%));
|
|
295
351
|
padding: calc(var(--padding-y) / 2) 0;
|
|
296
352
|
}
|
|
297
353
|
|
|
@@ -312,12 +368,12 @@
|
|
|
312
368
|
}
|
|
313
369
|
}
|
|
314
370
|
|
|
315
|
-
.row:nth-child(1) > * {
|
|
371
|
+
/* .row:nth-child(1) > * {
|
|
316
372
|
padding-top: calc(var(--padding-y) + var(--gap));
|
|
317
373
|
}
|
|
318
374
|
.row:nth-last-child(1) > * {
|
|
319
375
|
padding-bottom: calc(var(--padding-y) + var(--gap));
|
|
320
|
-
}
|
|
376
|
+
} */
|
|
321
377
|
|
|
322
378
|
.row > * {
|
|
323
379
|
padding: var(--gap) 0;
|
|
@@ -326,19 +382,20 @@
|
|
|
326
382
|
.panel {
|
|
327
383
|
position: relative;
|
|
328
384
|
grid-area: panel;
|
|
329
|
-
width: var(--panel);
|
|
330
385
|
height: 100%;
|
|
331
|
-
background-color:
|
|
386
|
+
background-color: var(--tably-bg, hsl(0, 0%, 100%));
|
|
332
387
|
|
|
333
|
-
border-left: 1px solid
|
|
388
|
+
border-left: 1px solid var(--tably-border, hsl(0, 0%, 90%));
|
|
389
|
+
scrollbar-gutter: stable both-edges;
|
|
390
|
+
scrollbar-width: thin;
|
|
334
391
|
|
|
335
392
|
> .panel-content {
|
|
336
393
|
position: absolute;
|
|
337
394
|
top: 0;
|
|
338
395
|
right: 0;
|
|
339
396
|
width: min-content;
|
|
340
|
-
overflow:
|
|
341
|
-
padding: var(--padding-y)
|
|
397
|
+
overflow: auto;
|
|
398
|
+
padding: var(--padding-y) 0;
|
|
342
399
|
}
|
|
343
400
|
}
|
|
344
401
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-tably",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.3",
|
|
4
|
+
"repository": "github:refzlund/svelte-tably",
|
|
5
|
+
"homepage": "https://github.com/Refzlund/svelte-tably",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/Refzlund/svelte-tably/issues"
|
|
8
|
+
},
|
|
4
9
|
"devDependencies": {
|
|
5
10
|
"@sveltejs/adapter-auto": "^3.0.0",
|
|
6
11
|
"@sveltejs/kit": "^2.9.0",
|