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 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 that amazing capabilities of Svelte 5 with a Dynamic table!
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
@@ -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, column: string) {
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
- {#each table.positions.sticky as column, i (column)}
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="rows" {onscroll}>
158
- {#each data as item}
159
- <div class='row'>
160
- {#each table.positions.sticky as column, i (column)}
161
- {#if !table.positions.hidden.includes(column)}
162
- {@const col = table.columns[column]}
163
- <div class='column sticky' class:border={i == table.positions.sticky.length - 1}>
164
- {@render col.row(item, col.options.value ? col.options.value(item) : undefined)}
165
- </div>
166
- {/if}
167
- {/each}
168
- {#each table.positions.scroll as column, i (column)}
169
- {#if !table.positions.hidden.includes(column)}
170
- {@const col = table.columns[column]}
171
- <div class='column'>
172
- {@render col.row(item, col.options.value ? col.options.value(item) : undefined)}
173
- </div>
174
- {/if}
175
- {/each}
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
- {#each table.positions.sticky as column, i (column)}
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: white;
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 hsla(0, 0%, 90%);
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 hsla(0, 0%, 90%);
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 hsla(0, 0%, 90%);
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: hsla(0, 0%, 100%);
276
- border-bottom: 1px solid hsla(0, 0%, 90%);
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
- .rows {
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: hsla(0, 0%, 99%);
294
- border-top: 1px solid hsla(0, 0%, 90%);
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: white;
386
+ background-color: var(--tably-bg, hsl(0, 0%, 100%));
332
387
 
333
- border-left: 1px solid hsla(0, 0%, 90%);
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: hidden;
341
- padding: var(--padding-y) var(--padding-x);
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.0",
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",