svelte-tably 1.0.0-next.12 → 1.0.0-next.14

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
@@ -19,8 +19,8 @@ A high performant, feature rich, dynamic table
19
19
  - [x] filtering
20
20
  - [x] reorderable table
21
21
  - [ ] row context-menu
22
- - [ ] dropout section
23
- - [ ] csv export
22
+ - [x] Expandable rows
23
+ - [x] to CSV
24
24
 
25
25
  ### Usage Notes
26
26
 
@@ -34,7 +34,7 @@ A high performant, feature rich, dynamic table
34
34
  ])
35
35
 
36
36
  let activePanel = $state('columns') as string | undefined
37
- let selected = $state([]) as typeof data
37
+ let selected = $state([]) as typeof data
38
38
  </script>
39
39
 
40
40
  <Table {data} panel={activePanel} select bind:selected>
@@ -40,6 +40,9 @@ export class ColumnState {
40
40
  filter: this.#props.filter,
41
41
  value: this.#props.value,
42
42
  resizeable: this.#props.resizeable ?? true,
43
+ style: this.#props.style,
44
+ class: this.#props.class,
45
+ onclick: this.#props.onclick
43
46
  });
44
47
  toggleVisiblity() {
45
48
  const index = this.table.positions.hidden.indexOf(this);
@@ -0,0 +1,24 @@
1
+ <!-- @component
2
+
3
+ This is a description, \
4
+ on how to use this.
5
+
6
+ @example
7
+ <Component />
8
+
9
+ -->
10
+
11
+ <script lang='ts'>
12
+
13
+ import { ExpandableState, type ExpandableProps } from './expandable.svelte.js'
14
+ import type { AnyRecord } from '../utility.svelte.js'
15
+ import { fromProps } from '../utility.svelte.js'
16
+
17
+ type T = $$Generic<AnyRecord>
18
+
19
+ let { ...restProps }: ExpandableProps<T> = $props()
20
+
21
+ const properties = fromProps(restProps)
22
+ new ExpandableState<T>(properties)
23
+
24
+ </script>
@@ -0,0 +1,25 @@
1
+ import type { AnyRecord } from '../utility.svelte.js';
2
+ declare class __sveltets_Render<T extends AnyRecord> {
3
+ props(): ExpandableProps<T_1>;
4
+ events(): {};
5
+ slots(): {};
6
+ bindings(): "";
7
+ exports(): {};
8
+ }
9
+ interface $$IsomorphicComponent {
10
+ new <T extends AnyRecord>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
11
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
12
+ } & ReturnType<__sveltets_Render<T>['exports']>;
13
+ <T extends AnyRecord>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
14
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
15
+ }
16
+ /**
17
+ * This is a description, \
18
+ * on how to use this.
19
+ *
20
+ * @example
21
+ * <Component />
22
+ */
23
+ declare const Expandable: $$IsomorphicComponent;
24
+ type Expandable<T extends AnyRecord> = InstanceType<typeof Expandable<T>>;
25
+ export default Expandable;
@@ -0,0 +1,27 @@
1
+ import { TableState } from '../table/table.svelte.js';
2
+ import { sineInOut } from 'svelte/easing';
3
+ export class ExpandableState {
4
+ #table;
5
+ #props = {};
6
+ snippets = $derived({
7
+ content: this.#props.content
8
+ });
9
+ options = $derived({
10
+ slide: {
11
+ duration: this.#props.slide?.duration ?? 150,
12
+ easing: this.#props.slide?.easing ?? sineInOut
13
+ },
14
+ click: this.#props.click ?? true,
15
+ chevron: this.#props.chevron ?? 'hover',
16
+ multiple: this.#props.multiple ?? false
17
+ });
18
+ constructor(props) {
19
+ this.#props = props;
20
+ this.#table = TableState.getContext();
21
+ if (!this.#table) {
22
+ throw new Error('svelte-tably: Expandable must be associated with a Table');
23
+ }
24
+ this.#table.expandable = this;
25
+ $effect(() => () => this.#table.expandable === this && (this.#table.expandable = undefined));
26
+ }
27
+ }
@@ -0,0 +1,16 @@
1
+ import type { EasingFunction } from 'svelte/transition';
2
+ interface SizeOptions {
3
+ min?: number;
4
+ duration?: number;
5
+ easing?: EasingFunction;
6
+ }
7
+ export declare class SizeTween {
8
+ #private;
9
+ current: number;
10
+ transitioning: boolean;
11
+ /** bind:offsetWidth bind:offsetHeight */
12
+ size: number;
13
+ set target(value: number);
14
+ constructor(cb: () => boolean | undefined, opts?: SizeOptions);
15
+ }
16
+ export {};
@@ -0,0 +1,33 @@
1
+ import { tick, untrack } from 'svelte';
2
+ import { sineInOut } from 'svelte/easing';
3
+ import { Tween } from 'svelte/motion';
4
+ export class SizeTween {
5
+ #tweenOptions = { duration: 300, easing: sineInOut };
6
+ #tween = new Tween(0, this.#tweenOptions);
7
+ current = $derived(this.#tween.current);
8
+ transitioning = $state(false);
9
+ /** bind:offsetWidth bind:offsetHeight */
10
+ size = $state(0);
11
+ set target(value) {
12
+ this.transitioning = true;
13
+ this.#tween.set(value, this.#tweenOptions).then(() => this.transitioning = false);
14
+ }
15
+ constructor(cb, opts = {}) {
16
+ if ('duration' in opts) {
17
+ this.#tweenOptions.duration = opts.duration;
18
+ }
19
+ if ('easing' in opts) {
20
+ this.#tweenOptions.easing = opts.easing;
21
+ }
22
+ untrack(() => {
23
+ if (cb()) {
24
+ requestAnimationFrame(() => {
25
+ this.#tween.set(Math.max(this.size, opts.min ?? 0), { duration: 0 });
26
+ });
27
+ }
28
+ });
29
+ $effect.pre(() => {
30
+ this.target = cb() ? Math.max(this.size, opts.min ?? 0) : 0;
31
+ });
32
+ }
33
+ }
@@ -18,12 +18,15 @@
18
18
  import { sineInOut } from 'svelte/easing'
19
19
  import reorder, { type ItemState } from 'runic-reorder'
20
20
  import { Virtualization } from './virtualization.svelte.js'
21
- import { TableState, type HeaderSelectCtx, type RowSelectCtx, type TableProps } from './table.svelte.js'
21
+ import { TableState, type HeaderSelectCtx, type RowCtx, type RowSelectCtx, type TableProps } from './table.svelte.js'
22
22
  import Panel, { PanelTween } from '../panel/Panel.svelte'
23
23
  import Column from '../column/Column.svelte'
24
- import { fromProps, mounted } from '../utility.svelte.js'
24
+ import { assignDescriptors, fromProps, mounted } from '../utility.svelte.js'
25
25
  import { conditional } from '../conditional.svelte.js'
26
- import { ColumnState } from '../column/column.svelte.js'
26
+ import { ColumnState, type RowColumnCtx } from '../column/column.svelte.js'
27
+ import Expandable from '../expandable/Expandable.svelte'
28
+ import { SizeTween } from '../size-tween.svelte.js'
29
+ import { on } from 'svelte/events'
27
30
 
28
31
  type T = $$Generic<Record<PropertyKey, unknown>>
29
32
 
@@ -35,7 +38,8 @@
35
38
  new <V>(...args: ConstructorParams<typeof Column<T, V>>): ConstructorReturnType<typeof Column<T, V>>
36
39
  <V>(...args: Parameters<typeof Column<T, V>>): ReturnType<typeof Column<T, V>>
37
40
  }
38
- Panel: typeof Panel
41
+ Panel: typeof Panel<T>
42
+ Expandable: typeof Expandable<T>
39
43
  readonly table: TableState<T>
40
44
  readonly data: T[]
41
45
  }
@@ -70,7 +74,7 @@
70
74
 
71
75
  const virtualization = new Virtualization(table)
72
76
 
73
- const panelTween = new PanelTween(() => properties.panel, 24)
77
+ const panelTween = new SizeTween(() => !!properties.panel)
74
78
 
75
79
  let hoveredRow: T | null = $state(null)
76
80
 
@@ -111,6 +115,7 @@
111
115
  .map((column, i, arr) => {
112
116
  sum += getWidth(arr[i - 1]?.id, i === 0 ? 0 : undefined)
113
117
  return `
118
+ #${table.id} .column.sticky[data-column='${column.id}'],
114
119
  [data-svelte-tably='${table.id}'] .column.sticky[data-column='${column.id}'] {
115
120
  left: ${sum}px;
116
121
  }
@@ -118,7 +123,13 @@
118
123
  })
119
124
  .join('')
120
125
 
121
- return templateColumns + stickyLeft
126
+ const columnStyling = columns.map(column => !column.options.style ? '' : `
127
+ #${table.id} .column[data-column='${column.id}'] {
128
+ ${column.options.style}
129
+ }
130
+ `).join('')
131
+
132
+ return templateColumns + stickyLeft + columnStyling
122
133
  })
123
134
 
124
135
  function observeColumnWidth(node: HTMLDivElement, isHeader = false) {
@@ -147,6 +158,9 @@
147
158
  return { destroy: () => observer.disconnect() }
148
159
  }
149
160
 
161
+ let tbody = $state({
162
+ width: 0
163
+ })
150
164
  async function onscroll() {
151
165
  const target = virtualization.viewport.element!
152
166
  if (target.scrollTop !== virtualization.scrollTop) {
@@ -161,27 +175,126 @@
161
175
  elements.headers.scrollLeft = target.scrollLeft
162
176
  elements.statusbar.scrollLeft = target.scrollLeft
163
177
  }
178
+
179
+
180
+ // * --- CSV --- *
181
+ let csv = $state(false) as false | { selected?: boolean }
182
+ let csvElement = $state() as undefined | HTMLTableElement
183
+ interface CSVOptions {
184
+ /** Semi-colons as separator? */
185
+ semicolon?: boolean
186
+ /** Only selected rows */
187
+ selected?: boolean
188
+ }
189
+ export async function toCSV(opts: CSVOptions = {}) {
190
+ csv = { selected: !!opts.selected }
191
+ let resolve: (value: HTMLTableElement) => void
192
+ const promise = new Promise<HTMLTableElement>(r => resolve = r)
193
+
194
+ const clean = $effect.root(() => {
195
+ $effect(() => {
196
+ if(csvElement) {
197
+ resolve(csvElement)
198
+ }
199
+ })
200
+ })
201
+
202
+ let table = await promise
203
+ clean()
204
+
205
+ const separator = opts.semicolon ? ";" : ","
206
+ const rows = Array.from(table.rows)
207
+ const csvRows = []
208
+
209
+ for (const row of rows) {
210
+ const cells = Array.from(row.cells)
211
+ const csvCells = cells.map(cell => {
212
+ let text = cell.textContent?.trim() || ''
213
+
214
+ // Escape double quotes and wrap in quotes if needed
215
+ if(text.includes('"')) {
216
+ text = text.replace(/"/g, '""')
217
+ }
218
+ if(text.includes(separator) || text.includes('"') || text.includes('\n')) {
219
+ text = `"${text}"`
220
+ }
221
+
222
+ return text
223
+ })
224
+ csvRows.push(csvCells.join(separator))
225
+ }
226
+
227
+ csv = false
228
+ return csvRows.join("\n")
229
+ }
230
+ // * --- CSV --- *
231
+
232
+
233
+ let expandedRow = $state([]) as T[]
234
+
235
+ function addRowColumnEvents(
236
+ node: HTMLTableColElement,
237
+ opts: ['header' | 'row' | 'statusbar', ColumnState, () => RowColumnCtx<T, any>]
238
+ ) {
239
+ const [where, column, value] = opts
240
+ if(where !== 'row') return
241
+ if(column.options.onclick) {
242
+ $effect(() => on(node, 'click', e => column.options.onclick!(e, value())))
243
+ }
244
+ }
245
+
164
246
  </script>
165
247
 
166
248
  <!---------------------------------------------------->
167
249
 
250
+ {#if csv !== false}
251
+ {@const renderedColumns = columns.filter(v => v.id !== '__fixed')}
252
+ <table bind:this={csvElement} hidden>
253
+ <thead>
254
+ <tr>
255
+ {#each renderedColumns as column}
256
+ <th>{@render column.snippets.title()}</th>
257
+ {/each}
258
+ </tr>
259
+ </thead>
260
+ <tbody>
261
+ {#each data.current as row, i}
262
+ {#if (csv.selected && table.selected.includes(row)) || !csv.selected}
263
+ <tr>
264
+ {#each renderedColumns as column}
265
+ <td>
266
+ {@render column.snippets.row?.(row, {
267
+ index: i,
268
+ value: column.options.value?.(row),
269
+ isHovered: false,
270
+ itemState: { index: i, dragging: false, positioning: false } as ItemState<any>,
271
+ selected: false,
272
+ expanded: false
273
+ })}
274
+ </td>
275
+ {/each}
276
+ </tr>
277
+ {/if}
278
+ {/each}
279
+ </tbody>
280
+ </table>
281
+ {/if}
282
+
168
283
  <svelte:head>
169
284
  {@html `<style>${style}</style>`}
170
285
  </svelte:head>
171
286
 
172
- {#snippet chevronSnippet(reversed: boolean)}
287
+ {#snippet chevronSnippet(rotation: number = 0)}
173
288
  <svg
174
- class="sorting-icon"
175
- class:reversed
176
289
  xmlns="http://www.w3.org/2000/svg"
177
290
  width="16"
178
291
  height="16"
179
292
  viewBox="0 0 16 16"
180
- style="margin: auto; margin-right: var(--tably-padding-x, 1rem);"
293
+ style="transform: rotate({rotation}deg)"
181
294
  >
182
295
  <path
183
296
  fill="currentColor"
184
- 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"
297
+ d="M3.2 10.26a.75.75 0 0 0 1.06.04L8 6.773l3.74 3.527a.75.75 0 1 0 1.02-1.1l-4.25-4a.75.75 0 0 0-1.02 0l-4.25 4a.75.75 0 0 0-.04 1.06"
185
298
  ></path>
186
299
  </svg>
187
300
  {/snippet}
@@ -198,15 +311,20 @@
198
311
  {#snippet columnsSnippet(
199
312
  renderable: (column: ColumnState) => Snippet<[arg0?: any, arg1?: any]> | undefined,
200
313
  arg: null | ((column: ColumnState) => any[]) = null,
201
- isHeader = false
314
+ where: 'header' | 'row' | 'statusbar'
202
315
  )}
316
+ {@const isHeader = where === 'header'}
203
317
  {#each fixed as column, i (column)}
204
318
  {#if !hidden.includes(column)}
205
319
  {@const args = arg ? arg(column) : []}
206
320
  {@const sortable = isHeader && column.options.sort && !table.options.reorderable}
207
321
  <svelte:element
208
322
  this={isHeader ? 'th' : 'td'}
209
- class="column sticky fixed"
323
+ class={column.options.class ?? ''}
324
+ class:column={true}
325
+ class:sticky={true}
326
+ class:fixed={true}
327
+ use:addRowColumnEvents={[where, column, () => args[1]]}
210
328
  data-column={column.id}
211
329
  class:header={isHeader}
212
330
  class:sortable
@@ -214,7 +332,9 @@
214
332
  >
215
333
  {@render renderable(column)?.(args[0], args[1])}
216
334
  {#if isHeader && data.sortby === column.id && sortable}
217
- {@render chevronSnippet(data.sortReverse)}
335
+ <span class='sorting-icon'>
336
+ {@render chevronSnippet(data.sortReverse ? 0 : 180)}
337
+ </span>
218
338
  {/if}
219
339
  </svelte:element>
220
340
  {/if}
@@ -225,7 +345,10 @@
225
345
  {@const sortable = isHeader && column.options.sort && !table.options.reorderable}
226
346
  <svelte:element
227
347
  this={isHeader ? 'th' : 'td'}
228
- class="column sticky"
348
+ class={column.options.class ?? ''}
349
+ class:column={true}
350
+ class:sticky={true}
351
+ use:addRowColumnEvents={[where, column, () => args[1]]}
229
352
  use:observeColumnWidth={isHeader}
230
353
  data-column={column.id}
231
354
  class:header={isHeader}
@@ -236,7 +359,9 @@
236
359
  >
237
360
  {@render renderable(column)?.(args[0], args[1])}
238
361
  {#if isHeader && data.sortby === column.id && sortable}
239
- {@render chevronSnippet(data.sortReverse)}
362
+ <span class='sorting-icon'>
363
+ {@render chevronSnippet(data.sortReverse ? 0 : 180)}
364
+ </span>
240
365
  {/if}
241
366
  </svelte:element>
242
367
  {/if}
@@ -247,8 +372,10 @@
247
372
  {@const sortable = isHeader && column!.options.sort && !table.options.reorderable}
248
373
  <svelte:element
249
374
  this={isHeader ? 'th' : 'td'}
250
- class="column"
375
+ class={column.options.class ?? ''}
376
+ class:column={true}
251
377
  data-column={column.id}
378
+ use:addRowColumnEvents={[where, column, () => args[1]]}
252
379
  use:observeColumnWidth={isHeader}
253
380
  class:resizeable={isHeader && column.options.resizeable && table.options.resizeable}
254
381
  class:sortable
@@ -256,7 +383,9 @@
256
383
  >
257
384
  {@render renderable(column)?.(args[0], args[1])}
258
385
  {#if isHeader && data.sortby === column.id && sortable}
259
- {@render chevronSnippet(data.sortReverse)}
386
+ <span class='sorting-icon'>
387
+ {@render chevronSnippet(data.sortReverse ? 0 : 180)}
388
+ </span>
260
389
  {/if}
261
390
  </svelte:element>
262
391
  {/if}
@@ -264,80 +393,137 @@
264
393
  {/snippet}
265
394
 
266
395
  {#snippet rowSnippet(item: T, itemState?: ItemState<T>)}
267
- {@const props = table.options.href ? { href: table.options.href(item) } : {}}
268
- {@const i = itemState?.index ?? 0}
269
- {@const index = (itemState?.index ?? 0)}
270
- <svelte:element
271
- this={table.options.href ? 'a' : 'tr'}
396
+ {@const index = itemState?.index ?? 0}
397
+ {@const toggleExpand = (value?: boolean) => {
398
+ let indexOf = expandedRow.indexOf(item)
399
+ if(value !== undefined) {
400
+ value = indexOf === -1
401
+ }
402
+
403
+ if(!value) {
404
+ expandedRow.splice(indexOf, 1)
405
+ return
406
+ }
407
+ if(table.expandable?.options.multiple === true) {
408
+ expandedRow.push(item)
409
+ }
410
+ else {
411
+ expandedRow[0] = item
412
+ }
413
+ }}
414
+
415
+ {@const ctx: RowCtx<T> = {
416
+ get index() {
417
+ return index
418
+ },
419
+ get isHovered() {
420
+ return hoveredRow === item
421
+ },
422
+ get selected() {
423
+ return table.selected?.includes(item)
424
+ },
425
+ set selected(value) {
426
+ value ?
427
+ table.selected!.push(item)
428
+ : table.selected!.splice(table.selected!.indexOf(item), 1)
429
+ },
430
+ get itemState() {
431
+ return itemState
432
+ },
433
+ get expanded() {
434
+ return expandedRow.includes(item)
435
+ },
436
+ set expanded(value) {
437
+ toggleExpand(value)
438
+ }
439
+ }}
440
+
441
+ <tr
272
442
  aria-rowindex={index + 1}
273
443
  data-svelte-tably={table.id}
274
444
  style:opacity={itemState?.positioning ? 0 : 1}
275
- class="row"
445
+ class='row'
276
446
  class:hover={hoveredRow === item}
277
447
  class:dragging={itemState?.dragging}
278
448
  class:selected={table.selected?.includes(item)}
279
- class:first={i === 0}
280
- class:last={i === virtualization.area.length - 1}
281
- {...props}
449
+ class:first={index === 0}
450
+ class:last={index === virtualization.area.length - 1}
451
+ {...(table.options.href ? { href: table.options.href(item) } : {})}
452
+ {...(itemState?.dragging ? { 'data-svelte-tably': table.id } : {})}
282
453
  onpointerenter={() => (hoveredRow = item)}
283
454
  onpointerleave={() => (hoveredRow = null)}
455
+ onclick={(e) => {
456
+ if (table.expandable?.options.click === true) {
457
+ let target = e.target as HTMLElement
458
+ if(['INPUT', 'TEXTAREA', 'BUTTON', 'A'].includes(target.tagName)) {
459
+ return
460
+ }
461
+ toggleExpand()
462
+ }
463
+ }}
284
464
  >
285
465
  {@render columnsSnippet(
286
466
  (column) => column.snippets.row,
287
467
  (column) => {
288
468
  return [
289
469
  item,
290
- {
291
- get index() {
292
- return index
293
- },
470
+ assignDescriptors({
294
471
  get value() {
295
472
  return column.options.value ? column.options.value(item) : undefined
296
- },
297
- get isHovered() {
298
- return hoveredRow === item
299
- },
300
- get selected() {
301
- return table.selected?.includes(item)
302
- },
303
- set selected(value) {
304
- value ?
305
- table.selected!.push(item)
306
- : table.selected!.splice(table.selected!.indexOf(item), 1)
307
- },
308
- get itemState() {
309
- return itemState
310
473
  }
311
- }
474
+ }, ctx)
312
475
  ]
313
- }
476
+ },
477
+ 'row'
314
478
  )}
315
- </svelte:element>
479
+ </tr>
480
+
481
+ {@const expandableTween = new SizeTween(
482
+ () => table.expandable && expandedRow.includes(item),
483
+ { min: 1, duration: table.expandable?.options.slide.duration, easing: table.expandable?.options.slide.easing })}
484
+ {#if expandableTween.current > 0}
485
+ <tr class='expandable' style='height: {expandableTween.current}px'>
486
+ <td
487
+ colspan={columns.length}
488
+ style='height: {expandableTween.current}px'
489
+ >
490
+ <div
491
+ bind:offsetHeight={expandableTween.size}
492
+ style='width: {tbody.width - 3}px'
493
+ >
494
+ {@render table.expandable!.snippets.content?.(item, ctx)}
495
+ </div>
496
+ </td>
497
+ </tr>
498
+ {/if}
316
499
  {/snippet}
317
500
 
318
501
  <table
319
502
  id={table.id}
320
- class="table svelte-tably"
321
- style="--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px;"
503
+ class='table svelte-tably'
504
+ style='--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px;'
322
505
  aria-rowcount={data.current.length}
323
506
  >
324
- <thead class="headers" bind:this={elements.headers}>
325
- {@render columnsSnippet(
326
- (column) => column.snippets.header,
327
- () => [{
328
- get header() { return true } ,
329
- get data() { return data.current }
330
- }],
331
- true
332
- )}
333
- </thead>
507
+ {#if columns.some(v => v.snippets.header)}
508
+ <thead class='headers' bind:this={elements.headers}>
509
+ {@render columnsSnippet(
510
+ (column) => column.snippets.header,
511
+ () => [{
512
+ get header() { return true },
513
+ get data() { return data.current }
514
+ }],
515
+ 'header'
516
+ )}
517
+ </thead>
518
+ {/if}
334
519
 
335
520
  <tbody
336
- class="content"
521
+ class='content'
337
522
  use:reorderArea={{ axis: 'y' }}
338
523
  bind:this={virtualization.viewport.element}
339
524
  onscrollcapture={onscroll}
340
525
  bind:clientHeight={virtualization.viewport.height}
526
+ bind:clientWidth={tbody.width}
341
527
  >
342
528
  {#if table.options.reorderable}
343
529
  {@render reorderArea({
@@ -352,32 +538,34 @@
352
538
  }
353
539
  })}
354
540
  {:else}
355
- {#each virtualization.area as item, i (item)}
356
- {@render rowSnippet(item, { index: i + virtualization.topIndex } as ItemState)}
357
- {/each}
541
+ {#each virtualization.area as item, i (item)}
542
+ {@render rowSnippet(item, { index: i + virtualization.topIndex } as ItemState)}
543
+ {/each}
358
544
  {/if}
359
545
  </tbody>
360
546
 
361
- <tfoot class="statusbar" bind:this={elements.statusbar}>
362
- <tr>
363
- {@render columnsSnippet(
364
- (column) => column.snippets.statusbar,
365
- () => [{
366
- get data() { return data.current }
367
- }]
368
- )}
369
- </tr>
370
- </tfoot>
547
+ {#if columns.some(v => v.snippets.statusbar)}
548
+ <tfoot class='statusbar' bind:this={elements.statusbar}>
549
+ <tr>
550
+ {@render columnsSnippet(
551
+ (column) => column.snippets.statusbar,
552
+ () => [{
553
+ get data() { return data.current }
554
+ }],
555
+ 'statusbar'
556
+ )}
557
+ </tr>
558
+ </tfoot>
559
+ {/if}
371
560
 
372
561
  <caption
373
- class="panel"
374
- style="width: {panelTween.current}px;"
375
- style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}
562
+ class='panel'
563
+ style='width: {panelTween.current}px;'
376
564
  >
377
565
  {#if properties.panel && properties.panel in table.panels}
378
566
  <div
379
- class="panel-content"
380
- bind:clientWidth={panelTween.width}
567
+ class='panel-content'
568
+ bind:offsetWidth={panelTween.size}
381
569
  in:fly={{ x: 100, easing: sineInOut, duration: 300 }}
382
570
  out:fly={{ x: 100, duration: 200, easing: sineInOut }}
383
571
  >
@@ -392,40 +580,45 @@
392
580
  </div>
393
581
  {/if}
394
582
  </caption>
395
- <caption class="backdrop" aria-hidden={properties.panel && table.panels[properties.panel]?.backdrop ? false : true}>
396
- <button aria-label="Panel backdrop" class="btn-backdrop" tabindex="-1" onclick={() => (properties.panel = undefined)}
583
+ <caption class='backdrop' aria-hidden={properties.panel && table.panels[properties.panel]?.backdrop ? false : true}>
584
+ <button aria-label='Panel backdrop' class='btn-backdrop' tabindex='-1' onclick={() => (properties.panel = undefined)}
397
585
  ></button>
398
586
  </caption>
399
587
  </table>
400
588
 
401
589
  {#snippet headerSelected(ctx: HeaderSelectCtx<T>)}
402
- <input type="checkbox" indeterminate={ctx.indeterminate} bind:checked={ctx.isSelected} />
590
+ <input type='checkbox' indeterminate={ctx.indeterminate} bind:checked={ctx.isSelected} />
403
591
  {/snippet}
404
592
 
405
593
  {#snippet rowSelected(ctx: RowSelectCtx<T>)}
406
- <input type="checkbox" bind:checked={ctx.isSelected} />
594
+ <input type='checkbox' bind:checked={ctx.isSelected} />
407
595
  {/snippet}
408
596
 
409
- {#if table.options.select || table.options.reorderable}
597
+ {#if table.options.select || table.options.reorderable || table.expandable}
410
598
  {@const { select, reorderable } = table.options}
599
+ {@const expandable = table.expandable}
411
600
  {@const {
412
601
  show = 'hover',
413
602
  style = 'column',
414
603
  rowSnippet = rowSelected,
415
604
  headerSnippet = headerSelected
416
605
  } = typeof select === 'boolean' ? {} : select}
417
- {#if show !== 'never' || reorderable}
606
+ {#if show !== 'never' || reorderable || expandable?.options.chevron !== 'never'}
418
607
  <Column
419
- id="__fixed"
608
+ id='__fixed'
420
609
  {table}
421
610
  fixed
422
- width={Math.max(56, (select && show !== 'never' ? 34 : 0) + (reorderable ? 34 : 0))}
611
+ width={Math.max(56, 0
612
+ + (select && show !== 'never' ? 34 : 0)
613
+ + (reorderable ? 34 : 0)
614
+ + (expandable?.options.chevron !== 'never' ? 34 : 0)
615
+ )}
423
616
  resizeable={false}
424
617
  >
425
618
  {#snippet header()}
426
- <div class="__fixed">
619
+ <div class='__fixed'>
427
620
  {#if reorderable}
428
- <span style="width: 16px; display: flex; align-items: center;"></span>
621
+ <span style='width: 16px; display: flex; align-items: center;'></span>
429
622
  {/if}
430
623
  {#if select}
431
624
  {@render headerSnippet({
@@ -453,15 +646,15 @@
453
646
  </div>
454
647
  {/snippet}
455
648
  {#snippet row(item, row)}
456
- <div class="__fixed">
457
- {#if reorderable}
458
- <span style="width: 16px; display: flex; align-items: center;" use:row.itemState.handle>
459
- {#if (row.isHovered && !row.itemState?.area.isTarget) || row.itemState.dragging}
649
+ <div class='__fixed'>
650
+ {#if reorderable && row.itemState}
651
+ <span style='width: 16px; display: flex; align-items: center;' use:row.itemState.handle>
652
+ {#if (row.isHovered && !row.itemState.area.isTarget) || row.itemState.dragging}
460
653
  {@render dragSnippet()}
461
654
  {/if}
462
655
  </span>
463
656
  {/if}
464
- {#if select && (row.selected || show === 'always' || (row.isHovered && show === 'hover'))}
657
+ {#if select && (row.selected || show === 'always' || (row.isHovered && show === 'hover') || row.expanded)}
465
658
  {@render rowSnippet({
466
659
  get isSelected() {
467
660
  return row.selected
@@ -480,6 +673,11 @@
480
673
  }
481
674
  })}
482
675
  {/if}
676
+ {#if expandable && (row.expanded || expandable.options.chevron === 'always' || (row.isHovered && expandable.options.chevron === 'hover'))}
677
+ <button class='expand-row' onclick={() => row.expanded = !row.expanded}>
678
+ {@render chevronSnippet(row.expanded ? 180 : 90)}
679
+ </button>
680
+ {/if}
483
681
  </div>
484
682
  {/snippet}
485
683
  </Column>
@@ -489,6 +687,7 @@
489
687
  {@render content?.({
490
688
  Column,
491
689
  Panel,
690
+ Expandable,
492
691
  get table() {
493
692
  return table
494
693
  },
@@ -510,6 +709,39 @@
510
709
  overflow: visible;
511
710
  }
512
711
 
712
+ .expandable {
713
+ position: relative;
714
+
715
+ & > td {
716
+ position: sticky;
717
+ left: 1px;
718
+ > div {
719
+ position: absolute;
720
+ overflow: auto;
721
+ top: -1.5px;
722
+ left: 0;
723
+ }
724
+ }
725
+ }
726
+
727
+ .expand-row {
728
+ display: flex;
729
+ justify-content: center;
730
+ align-items: center;
731
+ padding: 0;
732
+ outline: none;
733
+ border: none;
734
+ cursor: pointer;
735
+ background-color: transparent;
736
+ color: inherit;
737
+ width: 22px;
738
+ height: 100%;
739
+
740
+ > svg {
741
+ transition: transform 0.15s ease;
742
+ }
743
+ }
744
+
513
745
  caption {
514
746
  all: unset;
515
747
  }
@@ -527,10 +759,13 @@
527
759
  }
528
760
 
529
761
  .sorting-icon {
530
- transition: transform 0.15s ease;
531
- transform: rotateZ(0deg);
532
- &.reversed {
533
- transform: rotateZ(-180deg);
762
+ align-items: center;
763
+ justify-items: end;
764
+ margin: 0;
765
+ margin-left: auto;
766
+ margin-right: var(--tably-padding-x, 1rem);
767
+ > svg {
768
+ transition: transform 0.15s ease;
534
769
  }
535
770
  }
536
771
 
@@ -572,7 +807,7 @@
572
807
  height: var(--b);
573
808
  }
574
809
 
575
- a.row {
810
+ .row:global(:is(a)) {
576
811
  color: inherit;
577
812
  text-decoration: inherit;
578
813
  }
@@ -728,18 +963,19 @@
728
963
  position: relative;
729
964
  grid-area: panel;
730
965
  height: 100%;
731
-
966
+ overflow: hidden;
732
967
  border-left: 1px solid var(--tably-border, hsl(0, 0%, 90%));
733
- scrollbar-gutter: stable both-edges;
734
- scrollbar-width: thin;
968
+
735
969
  z-index: 4;
736
970
 
737
971
  > .panel-content {
738
972
  position: absolute;
739
973
  top: 0;
740
974
  right: 0;
975
+ bottom: 0;
741
976
  width: min-content;
742
977
  overflow: auto;
978
+ scrollbar-width: thin;
743
979
  padding: var(--tably-padding-y, 0.5rem) 0;
744
980
  }
745
981
  }
@@ -3,7 +3,14 @@ declare class __sveltets_Render<T extends Record<PropertyKey, unknown>> {
3
3
  events(): {};
4
4
  slots(): {};
5
5
  bindings(): "selected" | "panel" | "data";
6
- exports(): {};
6
+ exports(): {
7
+ toCSV: (opts?: {
8
+ /** Semi-colons as separator? */
9
+ semicolon?: boolean;
10
+ /** Only selected rows */
11
+ selected?: boolean;
12
+ }) => Promise<string>;
13
+ };
7
14
  }
8
15
  interface $$IsomorphicComponent {
9
16
  new <T extends Record<PropertyKey, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
@@ -9,6 +9,7 @@ export class TableState {
9
9
  data;
10
10
  columns = $state({});
11
11
  panels = $state({});
12
+ expandable = $state();
12
13
  /** Currently selected items */
13
14
  get selected() { return this.#props.selected ??= []; }
14
15
  set selected(items) { this.#props.selected = items; }
@@ -13,4 +13,5 @@ export declare function getters<T extends AnyRecord>(obj: T): { readonly [K in k
13
13
  type SetterRecord = Record<PropertyKey, [() => any, (v: any) => void]>;
14
14
  export declare function withSetters<T extends SetterRecord>(obj: T): T;
15
15
  export declare function fromProps<T extends AnyRecord, B extends SetterRecord>(props: T, boundProps?: B): Simplify<{ [K in keyof B]: ReturnType<B[K][0]>; } & { readonly [K in keyof T]: T[K]; }>;
16
+ export declare function assignDescriptors<T extends AnyRecord, B extends AnyRecord>(target: T, source: B): T & B;
16
17
  export {};
@@ -66,3 +66,15 @@ export function fromProps(props, boundProps) {
66
66
  ...withSetters(boundProps ?? {})
67
67
  });
68
68
  }
69
+ export function assignDescriptors(target, source) {
70
+ for (const key of Object.keys(source)) {
71
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
72
+ if (descriptor) {
73
+ Object.defineProperty(target, key, descriptor);
74
+ }
75
+ else {
76
+ target[key] = source[key]; // Copy regular values if descriptor is missing
77
+ }
78
+ }
79
+ return target;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-tably",
3
- "version": "1.0.0-next.12",
3
+ "version": "1.0.0-next.14",
4
4
  "repository": "github:refzlund/svelte-tably",
5
5
  "homepage": "https://github.com/Refzlund/svelte-tably",
6
6
  "bugs": {
@@ -13,7 +13,7 @@
13
13
  "@sveltejs/vite-plugin-svelte": "^5.0.0",
14
14
  "floating-runes": "^1.0.0",
15
15
  "publint": "^0.2.0",
16
- "runic-reorder": "next",
16
+ "runic-reorder": "^1.0.0",
17
17
  "svelte": "^5.0.0",
18
18
  "svelte-check": "^4.0.0",
19
19
  "typescript": "^5.0.0",