svelte-tably 1.0.0-next.7 → 1.0.0-next.9

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