svelte-tably 1.0.0-next.7 → 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>
@@ -181,10 +282,11 @@
181
282
 
182
283
  /** grid-template-columns for widths */
183
284
  const style = $derived.by(() => {
285
+ if(!mounted) return ''
184
286
  const templateColumns = `
185
287
  #${id} > .headers,
186
- #${id} > .content > .rows > .row,
187
- #${id} > .statusbar,
288
+ #${id} > tbody > .row,
289
+ #${id} > tfoot > tr,
188
290
  #${id} > .content > .virtual.bottom {
189
291
  grid-template-columns: ${
190
292
  columns.map((key, i, arr) => {
@@ -198,34 +300,40 @@
198
300
  `
199
301
 
200
302
  let sum = 0
201
- const stickyLeft = sticky.map((key, i, arr) => {
303
+ const stickyLeft = [...fixed, ...sticky].map((key, i, arr) => {
202
304
  sum += getWidth(arr[i - 1], i === 0 ? 0 : undefined)
203
305
  return `
204
306
  #${id} .column.sticky[data-column='${key}'] {
205
307
  left: ${sum}px;
206
308
  }
207
- `
309
+ `
208
310
  }).join('')
209
311
 
210
312
  return templateColumns + stickyLeft
211
313
  })
212
-
314
+
213
315
  function observeColumnWidth(node: HTMLDivElement, isHeader = false) {
214
316
  if(!isHeader) return
215
317
 
216
318
  const key = node.getAttribute('data-column')!
217
319
  node.style.width = getWidth(key) + 'px'
218
320
 
219
- const observer = new MutationObserver(() => columnWidths[key] = parseFloat(node.style.width))
321
+ const observer = new MutationObserver(() => {
322
+ columnWidths[key] = parseFloat(node.style.width)
323
+ })
220
324
 
221
325
  observer.observe(node, {attributes: true})
222
326
  return { destroy: () => observer.disconnect() }
223
327
  }
224
328
 
225
- function onscroll(event: Event) {
226
- const target = event.target as HTMLDivElement
329
+ async function onscroll() {
330
+ const target = elements.rows
227
331
  if(target.scrollTop !== scrollTop) {
228
- scrollTop = target.scrollTop || 0
332
+ scrollTop = target?.scrollTop ?? scrollTop
333
+ }
334
+
335
+ if(elements.selects) {
336
+ elements.selects.scrollTop = target?.scrollTop
229
337
  }
230
338
 
231
339
  if(!elements.headers) return
@@ -233,8 +341,13 @@
233
341
  elements.statusbar.scrollLeft = target.scrollLeft
234
342
  }
235
343
 
344
+
236
345
  export {
237
- table as state
346
+ selected,
347
+ positions,
348
+ data,
349
+ href,
350
+ cols as columns
238
351
  }
239
352
 
240
353
  </script>
@@ -249,88 +362,102 @@
249
362
  arg: null | ((column: string) => any[]) = null,
250
363
  isHeader = false
251
364
  )}
252
- {#each table.positions.sticky as column, i (column)}
253
- {#if !table.positions.hidden.includes(column)}
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)}
254
380
  {@const args = arg ? arg(column) : []}
255
- <div
381
+ <svelte:element
382
+ this={isHeader ? 'th' : 'td'}
256
383
  class='column sticky'
257
384
  use:observeColumnWidth={isHeader}
258
385
  data-column={column}
259
- class:resizeable={table.columns[column].options.resizeable && table.resizeable}
260
- 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}
261
389
  >
262
390
  {@render renderable(column)?.(args[0], args[1])}
263
- </div>
391
+ </svelte:element>
264
392
  {/if}
265
393
  {/each}
266
- {#each table.positions.scroll as column, i (column)}
267
- {#if !table.positions.hidden.includes(column)}
394
+ {#each scrolled as column, i (column)}
395
+ {#if !hidden.includes(column)}
268
396
  {@const args = arg ? arg(column) : []}
269
- <div
397
+ <svelte:element
398
+ this={isHeader ? 'th' : 'td'}
270
399
  class='column'
271
400
  data-column={column}
272
401
  use:observeColumnWidth={isHeader}
273
- class:resizeable={table.columns[column].options.resizeable && table.resizeable}
402
+ class:resizeable={isHeader && table.columns[column].options.resizeable && table.resizeable}
274
403
  >
275
404
  {@render renderable(column)?.(args[0], args[1])}
276
- </div>
405
+ </svelte:element>
277
406
  {/if}
278
407
  {/each}
279
408
  {/snippet}
280
409
 
281
- <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
+ >
282
416
 
283
- <div class='headers' bind:this={elements.headers}>
417
+ <thead class='headers' bind:this={elements.headers}>
284
418
  {@render columnsSnippet((column) => table.columns[column]?.header, () => [true], true)}
285
- </div>
286
-
287
- <div class='content' {onscroll} bind:clientHeight={viewportHeight}>
288
- <div class='virtual top' style='height: {virtualTop}px'></div>
289
-
290
- <div class='rows' bind:this={elements.rows}>
291
- {#each area as item, i (item)}
292
- {@const props = table.href ? { href: table.href(item) } : {}}
293
- <!-- note: <svelte:element this={table.href ? 'a' : 'div'}> will break the virtualization for some reason -->
294
- <a
295
- class='row'
296
- {...props}
297
- onpointerenter={() => hoveredRow = item}
298
- onpointerleave={() => hoveredRow = null}
299
- onclickcapture={e => !table.href && e.preventDefault()}
300
- >
301
- {#if table.selected && (((selectable === 'hover' && hoveredRow === item) || selectable === 'always') || table.selected.includes(item))}
302
- <div class='select' class:hover={selectable === 'hover'}>
303
- <input type='checkbox' bind:checked={
304
- () => table.selected!.includes(item),
305
- (value) => value ? table.selected!.push(item) : table.selected!.splice(table.selected!.indexOf(item), 1)
306
- }>
307
- </div>
308
- {/if}
309
-
310
- {@render columnsSnippet(
311
- (column) => table.columns[column]!.row,
312
- (column) => {
313
- const col = table.columns[column]!
314
- return [item, {
315
- get index() { return _data.indexOf(item) },
316
- get value() { return col.options.value ? col.options.value(item) : undefined },
317
- get isHovered() { return hoveredRow === item }
318
- }]
319
- }
320
- )}
321
- </a>
322
- {/each}
323
- </div>
324
- <div class='virtual bottom' style='height: {virtualBottom}px'>
325
- {@render columnsSnippet(() => undefined)}
326
- </div>
327
- </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>
328
453
 
329
- <div class='statusbar' bind:this={elements.statusbar}>
330
- {@render columnsSnippet((column) => table.columns[column]?.statusbar)}
331
- </div>
454
+ <tfoot class='statusbar' bind:this={elements.statusbar}>
455
+ <tr>
456
+ {@render columnsSnippet((column) => table.columns[column]?.statusbar)}
457
+ </tr>
458
+ </tfoot>
332
459
 
333
- <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'}>
334
461
  {#if panel && panel in table.panels}
335
462
  <div
336
463
  class='panel-content'
@@ -341,16 +468,75 @@
341
468
  {@render table.panels[panel].content({ get table() { return table }, get data() { return data } })}
342
469
  </div>
343
470
  {/if}
344
- </div>
345
- <button
471
+ </caption>
472
+ <caption
346
473
  class='backdrop'
347
- aria-label='Panel backdrop'
348
- tabindex='-1'
349
474
  aria-hidden={panel && table.panels[panel]?.backdrop ? false : true}
350
- onclick={() => panel = undefined}
351
- ></button>
352
- </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}
353
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}
354
540
 
355
541
  {@render content?.({ Column, Panel, get table() { return table }, get data() { return data } })}
356
542
 
@@ -359,42 +545,61 @@
359
545
  <!---------------------------------------------------->
360
546
  <style>
361
547
 
362
- .row {
363
- > .select {
364
- display: block;
365
- position: absolute;
366
- z-index: 3;
367
- opacity: 1;
368
- left: 2px;
369
- overflow: visible;
370
- background-color: transparent;
371
- transition: .15s ease;
372
- > input {
373
- width: 18px;
374
- height: 18px;
375
- border-radius: 1rem;
376
- cursor: pointer;
377
- }
548
+ .svelte-tably *, .svelte-tably {
549
+ all: unset;
550
+ box-sizing: border-box;
551
+ background-color: inherit;
552
+ }
378
553
 
379
- &.hover {
380
- @starting-style {
381
- opacity: 0;
382
- left: -2px;
383
- }
384
- }
385
- }
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;
386
564
  }
387
-
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
+ }
388
596
 
389
597
  a.row {
390
598
  color: inherit;
391
599
  text-decoration: inherit;
392
600
  }
393
601
 
394
- .table, .table * {
395
- box-sizing: border-box;
396
- background-color: inherit;
397
- }
602
+
398
603
 
399
604
  .backdrop {
400
605
  position: absolute;
@@ -410,6 +615,14 @@
410
615
  outline: none;
411
616
  cursor: pointer;
412
617
 
618
+ > button {
619
+ position: absolute;
620
+ left: 0px;
621
+ top: 0px;
622
+ bottom: 0px;
623
+ right: 0px;
624
+ }
625
+
413
626
  &[aria-hidden='true'] {
414
627
  opacity: 0;
415
628
  pointer-events: none;
@@ -478,19 +691,13 @@
478
691
  }
479
692
 
480
693
  .content {
481
- grid-area: rows;
482
694
  display: grid;
695
+ grid-auto-rows: max-content;
696
+
697
+ grid-area: rows;
483
698
  scrollbar-width: thin;
484
699
  overflow: auto;
485
- height: 100%;
486
- grid-template-rows: auto auto 1fr;
487
-
488
- > .rows, > .virtual.bottom {
489
- display: grid;
490
- }
491
- > .virtual.bottom {
492
- min-height: 100%;
493
- }
700
+ /* height: 100%; */
494
701
  }
495
702
 
496
703
  .statusbar {
@@ -499,12 +706,12 @@
499
706
  background-color: var(--tably-statusbar, hsl(0, 0%, 98%));
500
707
  }
501
708
 
502
- .statusbar > .column {
709
+ .statusbar > tr > .column {
503
710
  border-top: 1px solid var(--tably-border, hsl(0, 0%, 90%));
504
711
  padding: calc(var(--tably-padding-y, .5rem) / 2) 0;
505
712
  }
506
713
 
507
- .headers, .row, .statusbar {
714
+ .headers, .row, .statusbar > tr {
508
715
  position: relative;
509
716
  display: grid;
510
717
  width: 100%;
@@ -553,8 +760,4 @@
553
760
  }
554
761
  }
555
762
 
556
- .statusbar {
557
- grid-area: statusbar;
558
- }
559
-
560
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.7",
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": {