svelte-tably 1.0.0-next.6 → 1.0.0-next.8

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
@@ -15,7 +15,7 @@ A high performant dynamic table
15
15
  - [x] Panels
16
16
  - [x] Virtual elements
17
17
  - [ ] sorting
18
- - [ ] select
18
+ - [x] select
19
19
  - [ ] filtering
20
20
  - [ ] orderable table
21
21
  - [ ] row context-menu
@@ -33,9 +33,10 @@ A high performant dynamic table
33
33
  ])
34
34
 
35
35
  let activePanel = $state('columns') as string | undefined
36
+ let selected = $state([]) as typeof data
36
37
  </script>
37
38
 
38
- <Table {data} panel={activePanel}>
39
+ <Table {data} panel={activePanel} select bind:selected>
39
40
  {#snippet content({ Column, Panel, state, data })}
40
41
  <Column id='name' sticky>
41
42
  {#snippet header()}
@@ -10,6 +10,13 @@
10
10
 
11
11
  <script module lang='ts'>
12
12
 
13
+ export type RowCtx<V> = {
14
+ readonly value: V
15
+ readonly isHovered: boolean
16
+ readonly index: number
17
+ selected: boolean
18
+ }
19
+
13
20
  export interface Column<T = unknown, V = unknown> {
14
21
  header?: Snippet<[
15
22
  /**
@@ -19,12 +26,10 @@
19
26
  */
20
27
  header?: boolean
21
28
  ]>
22
- row: Snippet<[item: T, row: {
23
- readonly value: V
24
- readonly isHovered: boolean
25
- readonly index: number
26
- }]>
29
+ row: Snippet<[item: T, row: RowCtx<V>]>
27
30
  statusbar?: Snippet
31
+
32
+ fixed?: boolean
28
33
 
29
34
  /** Default options for initial table */
30
35
  defaults: {
@@ -46,7 +51,7 @@
46
51
  <script lang='ts' generics='T extends Record<PropertyKey, any>, V = unknown'>
47
52
 
48
53
  import { onDestroy, type Snippet } from 'svelte'
49
- import { getTableState } from './Table.svelte'
54
+ import { getTableState, type TableState } from './Table.svelte'
50
55
 
51
56
  interface Props {
52
57
  header?: Column<T, V>['header']
@@ -57,6 +62,8 @@
57
62
 
58
63
  // options
59
64
  sticky?: boolean
65
+ /** Fixed is like sticky, but in its own category — meant to not be moved/hidden ex. select-boxes */
66
+ fixed?: boolean
60
67
  sort?: boolean
61
68
  show?: boolean
62
69
  width?: number
@@ -64,24 +71,31 @@
64
71
  sorting?: Column<T, V>['options']['sorting']
65
72
  /** @default true */
66
73
  resizeable?: boolean
74
+
75
+ /** Optional: Provide the table it is a part of */
76
+ table?: TableState
67
77
  }
68
78
 
69
79
  let {
70
80
  header, row, statusbar, id,
71
81
 
72
82
  sticky = false,
83
+ fixed = false,
73
84
  sort = false,
74
85
  show = true,
75
86
  width,
76
87
 
77
88
  resizeable = true,
78
- value, sorting
89
+ value, sorting,
90
+
91
+ table
79
92
  }: Props = $props()
80
93
 
81
94
  const column: Column<T, V> = $state({
82
95
  header,
83
96
  row,
84
97
  statusbar,
98
+ fixed,
85
99
  defaults: {
86
100
  sticky,
87
101
  sort,
@@ -95,7 +109,7 @@
95
109
  }
96
110
  })
97
111
 
98
- const table = getTableState()
112
+ table ??= getTableState()
99
113
  table.addColumn(id, column as Column)
100
114
 
101
115
  onDestroy(() => {
@@ -1,3 +1,9 @@
1
+ export type RowCtx<V> = {
2
+ readonly value: V;
3
+ readonly isHovered: boolean;
4
+ readonly index: number;
5
+ selected: boolean;
6
+ };
1
7
  export interface Column<T = unknown, V = unknown> {
2
8
  header?: Snippet<[
3
9
  /**
@@ -7,15 +13,9 @@ export interface Column<T = unknown, V = unknown> {
7
13
  */
8
14
  header?: boolean
9
15
  ]>;
10
- row: Snippet<[
11
- item: T,
12
- row: {
13
- readonly value: V;
14
- readonly isHovered: boolean;
15
- readonly index: number;
16
- }
17
- ]>;
16
+ row: Snippet<[item: T, row: RowCtx<V>]>;
18
17
  statusbar?: Snippet;
18
+ fixed?: boolean;
19
19
  /** Default options for initial table */
20
20
  defaults: {
21
21
  sticky?: boolean;
@@ -31,6 +31,7 @@ export interface Column<T = unknown, V = unknown> {
31
31
  };
32
32
  }
33
33
  import { type Snippet } from 'svelte';
34
+ import { type TableState } from './Table.svelte';
34
35
  declare class __sveltets_Render<T extends Record<PropertyKey, any>, V = unknown> {
35
36
  props(): {
36
37
  header?: Column<T_1, V_1>["header"];
@@ -38,6 +39,8 @@ declare class __sveltets_Render<T extends Record<PropertyKey, any>, V = unknown>
38
39
  statusbar?: Column<T_1, V_1>["statusbar"];
39
40
  id: string;
40
41
  sticky?: boolean;
42
+ /** Fixed is like sticky, but in its own category — meant to not be moved/hidden ex. select-boxes */
43
+ fixed?: boolean;
41
44
  sort?: boolean;
42
45
  show?: boolean;
43
46
  width?: number;
@@ -45,6 +48,8 @@ declare class __sveltets_Render<T extends Record<PropertyKey, any>, V = unknown>
45
48
  sorting?: Column<T_1, V_1>["options"]["sorting"];
46
49
  /** @default true */
47
50
  resizeable?: boolean;
51
+ /** Optional: Provide the table it is a part of */
52
+ table?: TableState;
48
53
  };
49
54
  events(): {};
50
55
  slots(): {};
package/dist/Table.svelte CHANGED
@@ -33,18 +33,35 @@
33
33
  return getContext<TableState<T>>('svelte5-table')
34
34
  }
35
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
+
36
53
  </script>
37
54
 
38
55
  <script lang='ts' generics='T extends Record<PropertyKey, unknown>'>
39
56
 
40
- import { getContext, setContext, untrack, type Snippet } from 'svelte'
41
- import Column, { type Column as TColumn } from './Column.svelte'
57
+ import { getContext, onMount, setContext, tick, untrack, type Snippet } from 'svelte'
58
+ import Column, { type RowCtx, type Column as TColumn } from './Column.svelte'
42
59
  import Panel, { PanelTween, type Panel as TPanel } from './Panel.svelte'
43
60
  import { fly } from 'svelte/transition'
44
61
  import { sineInOut } from 'svelte/easing'
45
- import { get } from 'svelte/store'
46
- import { on } from 'svelte/events'
47
- import { ka_GE } from '@faker-js/faker'
62
+ import type { get } from 'svelte/store'
63
+
64
+
48
65
 
49
66
  interface Props {
50
67
  content: Snippet<[context: { Column: typeof Column<T>, Panel: typeof Panel, readonly table: TableState<T>, readonly data: T[] }]>
@@ -58,77 +75,148 @@
58
75
  * @default true
59
76
  */
60
77
  resizeable?: boolean
61
- selectable?: 'hover' | 'always' | 'never'
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
+ */
62
129
  }
63
130
 
64
131
  let {
65
132
  content,
66
-
133
+ selected = $bindable([]),
67
134
  panel = $bindable(),
68
135
  data: _data = [],
69
136
  id = Array.from({length: 12}, () => String.fromCharCode(Math.floor(Math.random() * 26) + 97)).join(''),
70
137
  href,
71
138
  resizeable = true,
72
- selectable = 'never'
139
+ select
73
140
  }: Props = $props()
74
141
 
142
+ let mounted = $state(false)
143
+ onMount(() => mounted = true)
144
+
75
145
  const data = $derived([..._data])
76
146
 
77
- const elements = $state({}) as Record<'headers' | 'statusbar' | 'rows', HTMLElement>
147
+ const elements = $state({}) as Record<'headers' | 'statusbar' | 'rows' | 'virtualTop' | 'virtualBottom' | 'selects', HTMLElement>
78
148
 
79
149
 
80
150
  // * --- Virtualization --- *
81
151
  let scrollTop = $state(0)
82
152
  let viewportHeight = $state(0)
83
-
84
- let heightPerItem = $derived.by(() => {
85
- data
86
- if(!elements.rows)
87
- return 8
88
- const result = elements.rows.scrollHeight / elements.rows.childNodes.length
89
- return result
90
- })
91
-
92
- let renderItemLength = $derived(Math.ceil(Math.max(30, (viewportHeight / heightPerItem) * 2)))
153
+
154
+ let heightPerItem = $state(8)
93
155
 
94
156
  const spacing = () => viewportHeight / 2
157
+
95
158
  let virtualTop = $derived.by(() => {
96
- let scroll = scrollTop - spacing()
97
- let virtualTop = Math.max(scroll, 0)
98
- virtualTop -= virtualTop % heightPerItem
99
- return virtualTop
159
+ let result = Math.max(scrollTop - spacing(), 0)
160
+ result -= result % heightPerItem
161
+ return result
100
162
  })
101
163
  let virtualBottom = $derived.by(() => {
102
- const virtualBottom = (heightPerItem * data.length) - virtualTop - spacing() * 4
103
- return Math.max(virtualBottom, 0)
164
+ let result = (heightPerItem * data.length) - virtualTop - spacing() * 4
165
+ result = Math.max(result, 0)
166
+ return result
104
167
  })
105
- /** The area of data that is rendered */
106
- const area = $derived.by(() => {
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(() => {
107
173
  const index = (virtualTop / heightPerItem) || 0
108
- const end = index + untrack(() => renderItemLength)
109
- return data.slice(
174
+ const end = index + renderItemLength
175
+ const result = data.slice(
110
176
  index,
111
177
  end
112
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)
113
197
  })
114
198
  // * --- Virtualization --- *
115
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
+
116
214
 
117
215
  const table: TableState<T> = $state({
118
- columns: {},
119
- selected: selectable === 'never' ? null : [],
216
+ columns: cols,
217
+ selected,
120
218
  panels: {},
121
- positions: {
122
- sticky: [],
123
- scroll: [],
124
- hidden: [],
125
- toggle(key) {
126
- if(table.positions.hidden.includes(key))
127
- table.positions.hidden = table.positions.hidden.filter(column => column !== key)
128
- else
129
- table.positions.hidden.push(key)
130
- }
131
- },
219
+ positions,
132
220
  get href() {
133
221
  return href
134
222
  },
@@ -144,6 +232,12 @@
144
232
  if(column.defaults.sort)
145
233
  table.sortby = key
146
234
 
235
+ if(column.fixed) {
236
+ // @ts-expect-error
237
+ table.positions.fixed.push(key)
238
+ return
239
+ }
240
+
147
241
  if(!column.defaults.show)
148
242
  table.positions.hidden.push(key)
149
243
 
@@ -154,6 +248,8 @@
154
248
  },
155
249
  removeColumn(key) {
156
250
  delete table.columns[key]
251
+ // @ts-expect-error fixed is not typed
252
+ table.positions.fixed = table.positions.fixed.filter(column => column !== key)
157
253
  table.positions.sticky = table.positions.sticky.filter(column => column !== key)
158
254
  table.positions.scroll = table.positions.scroll.filter(column => column !== key)
159
255
  table.positions.hidden = table.positions.hidden.filter(column => column !== key)
@@ -169,10 +265,15 @@
169
265
  let hoveredRow: T | null = $state(null)
170
266
 
171
267
  /** Order of columns */
172
- const notHidden = (key: string) => !table.positions.hidden.includes(key)
173
- const sticky = $derived(table.positions.sticky.filter(notHidden))
174
- const scrolled = $derived(table.positions.scroll.filter(notHidden))
175
- const columns = $derived([...sticky, ...scrolled])
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 ])
176
277
 
177
278
  /** Width of each column */
178
279
  const columnWidths = $state({}) as Record<string, number>
@@ -180,10 +281,12 @@
180
281
  const getWidth = (key: string, def: number = 150) => columnWidths[key] || table.columns[key]?.defaults.width || def
181
282
 
182
283
  /** grid-template-columns for widths */
183
- const style = $derived(`
284
+ const style = $derived.by(() => {
285
+ if(!mounted) return ''
286
+ const templateColumns = `
184
287
  #${id} > .headers,
185
- #${id} > .content > .rows > .row,
186
- #${id} > .statusbar,
288
+ #${id} > tbody > .row,
289
+ #${id} > tfoot > tr,
187
290
  #${id} > .content > .virtual.bottom {
188
291
  grid-template-columns: ${
189
292
  columns.map((key, i, arr) => {
@@ -194,26 +297,43 @@
194
297
  }).join(' ')
195
298
  };
196
299
  }
197
- ` + sticky.map((key, i, arr) => `
198
- #${id} .column.sticky[data-column='${key}'] {
199
- left: ${getWidth(arr[i - 1], 0)}px;
200
- }
201
- `).join(''))
300
+ `
202
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
+
203
315
  function observeColumnWidth(node: HTMLDivElement, isHeader = false) {
204
316
  if(!isHeader) return
205
- const observer = new MutationObserver(mutations => {
206
- const target = mutations[0].target as HTMLElement
207
- columnWidths[target.getAttribute('data-column')!] = parseFloat(target.style.width)
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)
208
323
  })
324
+
209
325
  observer.observe(node, {attributes: true})
210
326
  return { destroy: () => observer.disconnect() }
211
327
  }
212
328
 
213
- function onscroll(event: Event) {
214
- const target = event.target as HTMLDivElement
329
+ async function onscroll() {
330
+ const target = elements.rows
215
331
  if(target.scrollTop !== scrollTop) {
216
- scrollTop = target.scrollTop || 0
332
+ scrollTop = target?.scrollTop ?? scrollTop
333
+ }
334
+
335
+ if(elements.selects) {
336
+ elements.selects.scrollTop = target?.scrollTop
217
337
  }
218
338
 
219
339
  if(!elements.headers) return
@@ -221,8 +341,13 @@
221
341
  elements.statusbar.scrollLeft = target.scrollLeft
222
342
  }
223
343
 
344
+
224
345
  export {
225
- table as state
346
+ selected,
347
+ positions,
348
+ data,
349
+ href,
350
+ cols as columns
226
351
  }
227
352
 
228
353
  </script>
@@ -237,87 +362,102 @@
237
362
  arg: null | ((column: string) => any[]) = null,
238
363
  isHeader = false
239
364
  )}
240
- {#each table.positions.sticky as column, i (column)}
241
- {#if !table.positions.hidden.includes(column)}
365
+ {#each fixed as column, i (column)}
366
+ {#if !hidden.includes(column)}
242
367
  {@const args = arg ? arg(column) : []}
243
- <div
244
- class='column sticky'
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'
245
384
  use:observeColumnWidth={isHeader}
246
385
  data-column={column}
247
- class:resizeable={table.columns[column].options.resizeable && table.resizeable}
248
- class:border={i == table.positions.sticky.length - 1}
386
+ class:header={isHeader}
387
+ class:resizeable={isHeader && table.columns[column].options.resizeable && table.resizeable}
388
+ class:border={i == sticky.length - 1}
249
389
  >
250
390
  {@render renderable(column)?.(args[0], args[1])}
251
- </div>
391
+ </svelte:element>
252
392
  {/if}
253
393
  {/each}
254
- {#each table.positions.scroll as column, i (column)}
255
- {#if !table.positions.hidden.includes(column)}
394
+ {#each scrolled as column, i (column)}
395
+ {#if !hidden.includes(column)}
256
396
  {@const args = arg ? arg(column) : []}
257
- <div
397
+ <svelte:element
398
+ this={isHeader ? 'th' : 'td'}
258
399
  class='column'
259
400
  data-column={column}
260
401
  use:observeColumnWidth={isHeader}
261
- class:resizeable={table.columns[column].options.resizeable && table.resizeable}
402
+ class:resizeable={isHeader && table.columns[column].options.resizeable && table.resizeable}
262
403
  >
263
404
  {@render renderable(column)?.(args[0], args[1])}
264
- </div>
405
+ </svelte:element>
265
406
  {/if}
266
407
  {/each}
267
408
  {/snippet}
268
409
 
269
- <div id={id} class='table svelte-tably'>
410
+ <table
411
+ id={id}
412
+ class='table svelte-tably'
413
+ style='--t: {virtualTop}px; --b: {virtualBottom}px;'
414
+ aria-rowcount='{data.length}'
415
+ >
270
416
 
271
- <div class='headers' bind:this={elements.headers}>
417
+ <thead class='headers' bind:this={elements.headers}>
272
418
  {@render columnsSnippet((column) => table.columns[column]?.header, () => [true], true)}
273
- </div>
274
-
275
- <div class='content' {onscroll} bind:clientHeight={viewportHeight}>
276
- <div class='virtual top' style='height: {virtualTop}px'></div>
277
-
278
- <div class='rows' bind:this={elements.rows}>
279
- {#each area as item, i (item)}
280
- {@const props = table.href ? { href: table.href(item) } : {}}
281
- <!-- note: <svelte:element this={table.href ? 'a' : 'div'}> will break the virtualization for some reason -->
282
- <a
283
- class='row'
284
- {...props}
285
- onpointerenter={() => hoveredRow = item}
286
- onpointerleave={() => hoveredRow = null}
287
- >
288
- {#if table.selected && (((selectable === 'hover' && hoveredRow === item) || selectable === 'always') || table.selected.includes(item))}
289
- <div class='select' class:hover={selectable === 'hover'}>
290
- <input type='checkbox' bind:checked={
291
- () => table.selected!.includes(item),
292
- (value) => value ? table.selected!.push(item) : table.selected!.splice(table.selected!.indexOf(item), 1)
293
- }>
294
- </div>
295
- {/if}
296
-
297
- {@render columnsSnippet(
298
- (column) => table.columns[column]!.row,
299
- (column) => {
300
- const col = table.columns[column]!
301
- return [item, {
302
- get index() { return _data.indexOf(item) },
303
- get value() { return col.options.value ? col.options.value(item) : undefined },
304
- get isHovered() { return hoveredRow === item }
305
- }]
306
- }
307
- )}
308
- </a>
309
- {/each}
310
- </div>
311
- <div class='virtual bottom' style='height: {virtualBottom}px'>
312
- {@render columnsSnippet(() => undefined)}
313
- </div>
314
- </div>
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>
315
453
 
316
- <div class='statusbar' bind:this={elements.statusbar}>
317
- {@render columnsSnippet((column) => table.columns[column]?.statusbar)}
318
- </div>
454
+ <tfoot class='statusbar' bind:this={elements.statusbar}>
455
+ <tr>
456
+ {@render columnsSnippet((column) => table.columns[column]?.statusbar)}
457
+ </tr>
458
+ </tfoot>
319
459
 
320
- <div class='panel' style='width: {(panelTween.current)}px;' style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}>
460
+ <caption class='panel' style='width: {(panelTween.current)}px;' style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}>
321
461
  {#if panel && panel in table.panels}
322
462
  <div
323
463
  class='panel-content'
@@ -328,16 +468,75 @@
328
468
  {@render table.panels[panel].content({ get table() { return table }, get data() { return data } })}
329
469
  </div>
330
470
  {/if}
331
- </div>
332
- <button
471
+ </caption>
472
+ <caption
333
473
  class='backdrop'
334
- aria-label='Panel backdrop'
335
- tabindex='-1'
336
474
  aria-hidden={panel && table.panels[panel]?.backdrop ? false : true}
337
- onclick={() => panel = undefined}
338
- ></button>
339
- </div>
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}
340
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}
341
540
 
342
541
  {@render content?.({ Column, Panel, get table() { return table }, get data() { return data } })}
343
542
 
@@ -346,42 +545,61 @@
346
545
  <!---------------------------------------------------->
347
546
  <style>
348
547
 
349
- .row {
350
- > .select {
351
- display: block;
352
- position: absolute;
353
- z-index: 3;
354
- opacity: 1;
355
- left: 2px;
356
- overflow: visible;
357
- background-color: transparent;
358
- transition: .15s ease;
359
- > input {
360
- width: 18px;
361
- height: 18px;
362
- border-radius: 1rem;
363
- cursor: pointer;
364
- }
548
+ .svelte-tably *, .svelte-tably {
549
+ all: unset;
550
+ box-sizing: border-box;
551
+ background-color: inherit;
552
+ }
365
553
 
366
- &.hover {
367
- @starting-style {
368
- opacity: 0;
369
- left: -2px;
370
- }
371
- }
372
- }
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);
373
582
  }
374
-
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
+ }
375
596
 
376
597
  a.row {
377
598
  color: inherit;
378
599
  text-decoration: inherit;
379
600
  }
380
601
 
381
- .table, .table * {
382
- box-sizing: border-box;
383
- background-color: inherit;
384
- }
602
+
385
603
 
386
604
  .backdrop {
387
605
  position: absolute;
@@ -397,6 +615,14 @@
397
615
  outline: none;
398
616
  cursor: pointer;
399
617
 
618
+ > button {
619
+ position: absolute;
620
+ left: 0px;
621
+ top: 0px;
622
+ bottom: 0px;
623
+ right: 0px;
624
+ }
625
+
400
626
  &[aria-hidden='true'] {
401
627
  opacity: 0;
402
628
  pointer-events: none;
@@ -465,19 +691,13 @@
465
691
  }
466
692
 
467
693
  .content {
468
- grid-area: rows;
469
694
  display: grid;
695
+ grid-auto-rows: max-content;
696
+
697
+ grid-area: rows;
470
698
  scrollbar-width: thin;
471
699
  overflow: auto;
472
- height: 100%;
473
- grid-template-rows: auto auto 1fr;
474
-
475
- > .rows, > .virtual.bottom {
476
- display: grid;
477
- }
478
- > .virtual.bottom {
479
- min-height: 100%;
480
- }
700
+ /* height: 100%; */
481
701
  }
482
702
 
483
703
  .statusbar {
@@ -486,12 +706,12 @@
486
706
  background-color: var(--tably-statusbar, hsl(0, 0%, 98%));
487
707
  }
488
708
 
489
- .statusbar > .column {
709
+ .statusbar > tr > .column {
490
710
  border-top: 1px solid var(--tably-border, hsl(0, 0%, 90%));
491
711
  padding: calc(var(--tably-padding-y, .5rem) / 2) 0;
492
712
  }
493
713
 
494
- .headers, .row, .statusbar {
714
+ .headers, .row, .statusbar > tr {
495
715
  position: relative;
496
716
  display: grid;
497
717
  width: 100%;
@@ -540,8 +760,4 @@
540
760
  }
541
761
  }
542
762
 
543
- .statusbar {
544
- grid-area: statusbar;
545
- }
546
-
547
763
  </style>
@@ -18,8 +18,23 @@ export interface TableState<T extends Record<PropertyKey, any> = Record<Property
18
18
  removeColumn(key: string): void;
19
19
  }
20
20
  export declare function getTableState<T extends Record<PropertyKey, any> = Record<PropertyKey, any>>(): TableState<T>;
21
+ export type HeaderSelectCtx<T = any> = {
22
+ isSelected: boolean;
23
+ /** The list of selected items */
24
+ readonly selected: T[];
25
+ /**
26
+ * See [MDN :indeterminate](https://developer.mozilla.org/en-US/docs/Web/CSS/:indeterminate)
27
+ */
28
+ readonly indeterminate: boolean;
29
+ };
30
+ export type RowSelectCtx<T = any> = {
31
+ readonly item: T;
32
+ readonly row: RowCtx<unknown>;
33
+ data: T[];
34
+ isSelected: boolean;
35
+ };
21
36
  import { type Snippet } from 'svelte';
22
- import Column, { type Column as TColumn } from './Column.svelte';
37
+ import Column, { type RowCtx, type Column as TColumn } from './Column.svelte';
23
38
  import Panel, { type Panel as TPanel } from './Panel.svelte';
24
39
  declare class __sveltets_Render<T extends Record<PropertyKey, unknown>> {
25
40
  props(): {
@@ -31,12 +46,14 @@ declare class __sveltets_Render<T extends Record<PropertyKey, unknown>> {
31
46
  statusbar?: Column<T_1, V>["statusbar"];
32
47
  id: string;
33
48
  sticky?: boolean;
49
+ fixed?: boolean;
34
50
  sort?: boolean;
35
51
  show?: boolean;
36
52
  width?: number;
37
53
  value?: Column<T_1, V>["options"]["value"];
38
54
  sorting?: Column<T_1, V>["options"]["sorting"];
39
55
  resizeable?: boolean;
56
+ table?: TableState;
40
57
  }): {};
41
58
  new (options: import("svelte").ComponentConstructorOptions<{
42
59
  header?: Column<T_1, V>["header"];
@@ -44,24 +61,28 @@ declare class __sveltets_Render<T extends Record<PropertyKey, unknown>> {
44
61
  statusbar?: Column<T_1, V>["statusbar"];
45
62
  id: string;
46
63
  sticky?: boolean;
64
+ fixed?: boolean;
47
65
  sort?: boolean;
48
66
  show?: boolean;
49
67
  width?: number;
50
68
  value?: Column<T_1, V>["options"]["value"];
51
69
  sorting?: Column<T_1, V>["options"]["sorting"];
52
70
  resizeable?: boolean;
71
+ table?: TableState;
53
72
  }>): SvelteComponent<{
54
73
  header?: Column<T_1, V>["header"];
55
74
  row: Column<T_1, V>["row"];
56
75
  statusbar?: Column<T_1, V>["statusbar"];
57
76
  id: string;
58
77
  sticky?: boolean;
78
+ fixed?: boolean;
59
79
  sort?: boolean;
60
80
  show?: boolean;
61
81
  width?: number;
62
82
  value?: Column<T_1, V>["options"]["value"];
63
83
  sorting?: Column<T_1, V>["options"]["sorting"];
64
84
  resizeable?: boolean;
85
+ table?: TableState;
65
86
  }, {}, {}> & {
66
87
  $$bindings?: ReturnType<() => "">;
67
88
  };
@@ -80,13 +101,38 @@ declare class __sveltets_Render<T extends Record<PropertyKey, unknown>> {
80
101
  * @default true
81
102
  */
82
103
  resizeable?: boolean;
83
- selectable?: "hover" | "always" | "never";
104
+ selected?: T[] | undefined;
105
+ select?: boolean | {
106
+ /**
107
+ * The style, in which the selection is shown
108
+ *
109
+ * NOTE: If using `edge` | 'side', "show" will always be `hover`. This is due to
110
+ * an inconsistency/limitation of matching the scroll between the selection div and the rows.
111
+ *
112
+ * @default 'column'
113
+ */
114
+ style?: "column";
115
+ /**
116
+ * When to show the row-select, when not selected?
117
+ * @default 'hover'
118
+ */
119
+ show?: "hover" | "always" | "never";
120
+ /**
121
+ * Custom snippet
122
+ */
123
+ headerSnippet?: Snippet<[context: HeaderSelectCtx]>;
124
+ rowSnippet?: Snippet<[context: RowSelectCtx<T>]> | undefined;
125
+ } | undefined;
84
126
  };
85
127
  events(): {};
86
128
  slots(): {};
87
- bindings(): "panel";
129
+ bindings(): "selected" | "panel";
88
130
  exports(): {
89
- state: TableState<T>;
131
+ selected: T[];
132
+ positions: TableState<T_1>["positions"];
133
+ data: T[];
134
+ href: ((item: T) => string) | undefined;
135
+ columns: Record<string, TColumn<T, unknown>>;
90
136
  };
91
137
  }
92
138
  interface $$IsomorphicComponent {
@@ -0,0 +1,10 @@
1
+ export declare class Trigger<T> {
2
+ #private;
3
+ constructor();
4
+ trigger(value?: T): void;
5
+ onTrigger<E extends HTMLElement>(node: E, fn: (node: E, value?: T) => void): {
6
+ destroy: () => boolean;
7
+ };
8
+ /** Subscribe to the trigger; returns the value if any. */
9
+ get current(): T | undefined;
10
+ }
@@ -0,0 +1,27 @@
1
+ import { createSubscriber } from 'svelte/reactivity';
2
+ export class Trigger {
3
+ #subscribe;
4
+ #update = () => { };
5
+ #subscribers = new Set();
6
+ #value;
7
+ constructor() {
8
+ this.#subscribe = createSubscriber(update => this.#update = update);
9
+ }
10
+ trigger(value) {
11
+ this.#value = value;
12
+ this.#update();
13
+ this.#subscribers.forEach(fn => fn(value));
14
+ }
15
+ onTrigger(node, fn) {
16
+ const f = (value) => fn(node, value);
17
+ this.#subscribers.add(f);
18
+ return {
19
+ destroy: () => this.#subscribers.delete(f)
20
+ };
21
+ }
22
+ /** Subscribe to the trigger; returns the value if any. */
23
+ get current() {
24
+ this.#subscribe();
25
+ return this.#value;
26
+ }
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-tably",
3
- "version": "1.0.0-next.6",
3
+ "version": "1.0.0-next.8",
4
4
  "repository": "github:refzlund/svelte-tably",
5
5
  "homepage": "https://github.com/Refzlund/svelte-tably",
6
6
  "bugs": {