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

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>
@@ -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,20 @@
1
+ import { TableState } from '../table/table.svelte.js';
2
+ export class ExpandableState {
3
+ #table;
4
+ #props = $state({});
5
+ snippets = $derived({
6
+ content: this.#props.content
7
+ });
8
+ options = $derived({
9
+ slide: this.#props.slide ?? 200
10
+ });
11
+ constructor(props) {
12
+ this.#props = props;
13
+ this.#table = TableState.getContext();
14
+ if (!this.#table) {
15
+ throw new Error('svelte-tably: Expandable must be associated with a Table');
16
+ }
17
+ this.#table.expandable = this;
18
+ $effect(() => () => this.#table.expandable === this && (this.#table.expandable = undefined));
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ interface SizeOptions {
2
+ min?: number;
3
+ duration?: number;
4
+ }
5
+ export declare class SizeTween {
6
+ #private;
7
+ current: number;
8
+ transitioning: boolean;
9
+ /** bind:offsetWidth bind:offsetHeight */
10
+ size: number;
11
+ set target(value: number);
12
+ constructor(cb: () => boolean | undefined, opts?: SizeOptions);
13
+ }
14
+ export {};
@@ -0,0 +1,30 @@
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
+ untrack(() => {
20
+ if (cb()) {
21
+ requestAnimationFrame(() => {
22
+ this.#tween.set(Math.max(this.size, opts.min ?? 0), { duration: 0 });
23
+ });
24
+ }
25
+ });
26
+ $effect.pre(() => {
27
+ this.target = cb() ? Math.max(this.size, opts.min ?? 0) : 0;
28
+ });
29
+ }
30
+ }
@@ -24,6 +24,8 @@
24
24
  import { fromProps, mounted } from '../utility.svelte.js'
25
25
  import { conditional } from '../conditional.svelte.js'
26
26
  import { ColumnState } from '../column/column.svelte.js'
27
+ import Expandable from '../expandable/Expandable.svelte'
28
+ import { SizeTween } from '../size-tween.svelte.js'
27
29
 
28
30
  type T = $$Generic<Record<PropertyKey, unknown>>
29
31
 
@@ -35,7 +37,8 @@
35
37
  new <V>(...args: ConstructorParams<typeof Column<T, V>>): ConstructorReturnType<typeof Column<T, V>>
36
38
  <V>(...args: Parameters<typeof Column<T, V>>): ReturnType<typeof Column<T, V>>
37
39
  }
38
- Panel: typeof Panel
40
+ Panel: typeof Panel<T>
41
+ Expandable: typeof Expandable<T>
39
42
  readonly table: TableState<T>
40
43
  readonly data: T[]
41
44
  }
@@ -70,7 +73,7 @@
70
73
 
71
74
  const virtualization = new Virtualization(table)
72
75
 
73
- const panelTween = new PanelTween(() => properties.panel, 24)
76
+ const panelTween = new SizeTween(() => !!properties.panel)
74
77
 
75
78
  let hoveredRow: T | null = $state(null)
76
79
 
@@ -111,6 +114,7 @@
111
114
  .map((column, i, arr) => {
112
115
  sum += getWidth(arr[i - 1]?.id, i === 0 ? 0 : undefined)
113
116
  return `
117
+ #${table.id} .column.sticky[data-column='${column.id}'],
114
118
  [data-svelte-tably='${table.id}'] .column.sticky[data-column='${column.id}'] {
115
119
  left: ${sum}px;
116
120
  }
@@ -147,6 +151,9 @@
147
151
  return { destroy: () => observer.disconnect() }
148
152
  }
149
153
 
154
+ let tbody = $state({
155
+ width: 0
156
+ })
150
157
  async function onscroll() {
151
158
  const target = virtualization.viewport.element!
152
159
  if (target.scrollTop !== virtualization.scrollTop) {
@@ -161,10 +168,100 @@
161
168
  elements.headers.scrollLeft = target.scrollLeft
162
169
  elements.statusbar.scrollLeft = target.scrollLeft
163
170
  }
171
+
172
+
173
+ // * --- CSV --- *
174
+ let csv = $state(false) as false | { selected?: boolean }
175
+ let csvElement = $state() as undefined | HTMLTableElement
176
+ interface CSVOptions {
177
+ /** Semi-colons as separator? */
178
+ semicolon?: boolean
179
+ /** Only selected rows */
180
+ selected?: boolean
181
+ }
182
+ export async function toCSV(opts: CSVOptions = {}) {
183
+ csv = { selected: !!opts.selected }
184
+ let resolve: (value: HTMLTableElement) => void
185
+ const promise = new Promise<HTMLTableElement>(r => resolve = r)
186
+
187
+ const clean = $effect.root(() => {
188
+ $effect(() => {
189
+ if(csvElement) {
190
+ resolve(csvElement)
191
+ }
192
+ })
193
+ })
194
+
195
+ let table = await promise
196
+ clean()
197
+
198
+ const separator = opts.semicolon ? ";" : ","
199
+ const rows = Array.from(table.rows)
200
+ const csvRows = []
201
+
202
+ for (const row of rows) {
203
+ const cells = Array.from(row.cells)
204
+ const csvCells = cells.map(cell => {
205
+ let text = cell.textContent?.trim() || ''
206
+
207
+ // Escape double quotes and wrap in quotes if needed
208
+ if(text.includes('"')) {
209
+ text = text.replace(/"/g, '""')
210
+ }
211
+ if(text.includes(separator) || text.includes('"') || text.includes('\n')) {
212
+ text = `"${text}"`
213
+ }
214
+
215
+ return text
216
+ })
217
+ csvRows.push(csvCells.join(separator))
218
+ }
219
+
220
+ csv = false
221
+ return csvRows.join("\n")
222
+ }
223
+ // * --- CSV --- *
224
+
225
+
226
+ // * --- Expandable --- *
227
+ let expandedRow = $state() as undefined | T
228
+
164
229
  </script>
165
230
 
166
231
  <!---------------------------------------------------->
167
232
 
233
+ {#if csv !== false}
234
+ {@const renderedColumns = columns.filter(v => v.id !== '__fixed')}
235
+ <table bind:this={csvElement} hidden>
236
+ <thead>
237
+ <tr>
238
+ {#each renderedColumns as column}
239
+ <th>{@render column.snippets.title()}</th>
240
+ {/each}
241
+ </tr>
242
+ </thead>
243
+ <tbody>
244
+ {#each data.current as row, i}
245
+ {#if (csv.selected && table.selected.includes(row)) || !csv.selected}
246
+ <tr>
247
+ {#each renderedColumns as column}
248
+ <td>
249
+ {@render column.snippets.row?.(row, {
250
+ index: i,
251
+ value: column.options.value?.(row),
252
+ isHovered: false,
253
+ itemState: { index: i, dragging: false, positioning: false } as ItemState<any>,
254
+ selected: false
255
+ })}
256
+ </td>
257
+ {/each}
258
+ </tr>
259
+ {/if}
260
+ {/each}
261
+ </tbody>
262
+ </table>
263
+ {/if}
264
+
168
265
  <svelte:head>
169
266
  {@html `<style>${style}</style>`}
170
267
  </svelte:head>
@@ -264,23 +361,31 @@
264
361
  {/snippet}
265
362
 
266
363
  {#snippet rowSnippet(item: T, itemState?: ItemState<T>)}
267
- {@const props = table.options.href ? { href: table.options.href(item) } : {}}
268
364
  {@const i = itemState?.index ?? 0}
269
365
  {@const index = (itemState?.index ?? 0)}
270
- <svelte:element
271
- this={table.options.href ? 'a' : 'tr'}
366
+ <tr
272
367
  aria-rowindex={index + 1}
273
368
  data-svelte-tably={table.id}
274
369
  style:opacity={itemState?.positioning ? 0 : 1}
275
- class="row"
370
+ class='row'
276
371
  class:hover={hoveredRow === item}
277
372
  class:dragging={itemState?.dragging}
278
373
  class:selected={table.selected?.includes(item)}
279
374
  class:first={i === 0}
280
375
  class:last={i === virtualization.area.length - 1}
281
- {...props}
376
+ {...(table.options.href ? { href: table.options.href(item) } : {})}
377
+ {...(itemState?.dragging ? { 'data-svelte-tably': table.id } : {})}
282
378
  onpointerenter={() => (hoveredRow = item)}
283
379
  onpointerleave={() => (hoveredRow = null)}
380
+ onclick={(e) => {
381
+ if (table.expandable) {
382
+ let target = e.target as HTMLElement
383
+ if(['INPUT', 'TEXTAREA', 'BUTTON', 'A'].includes(target.tagName)) {
384
+ return
385
+ }
386
+ expandedRow = expandedRow === item ? undefined : item
387
+ }
388
+ }}
284
389
  >
285
390
  {@render columnsSnippet(
286
391
  (column) => column.snippets.row,
@@ -312,20 +417,41 @@
312
417
  ]
313
418
  }
314
419
  )}
315
- </svelte:element>
420
+ </tr>
421
+
422
+ {@const expandableTween = new SizeTween(() => table.expandable && expandedRow === item, { min: 1, duration: 150 })}
423
+ {#if expandableTween.current > 0}
424
+ <tr class='expandable' style='height: {expandableTween.current}px'>
425
+ <td
426
+ colspan={columns.length}
427
+ style='height: {expandableTween.current}px'
428
+ >
429
+ <div
430
+ bind:offsetHeight={expandableTween.size}
431
+ style='width: {tbody.width - 2}px'
432
+ >
433
+ {@render table.expandable!.snippets.content?.(item, {
434
+ close() {
435
+ expandedRow = undefined
436
+ }
437
+ })}
438
+ </div>
439
+ </td>
440
+ </tr>
441
+ {/if}
316
442
  {/snippet}
317
443
 
318
444
  <table
319
445
  id={table.id}
320
- class="table svelte-tably"
321
- style="--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px;"
446
+ class='table svelte-tably'
447
+ style='--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px;'
322
448
  aria-rowcount={data.current.length}
323
449
  >
324
- <thead class="headers" bind:this={elements.headers}>
450
+ <thead class='headers' bind:this={elements.headers}>
325
451
  {@render columnsSnippet(
326
452
  (column) => column.snippets.header,
327
453
  () => [{
328
- get header() { return true } ,
454
+ get header() { return true },
329
455
  get data() { return data.current }
330
456
  }],
331
457
  true
@@ -333,11 +459,12 @@
333
459
  </thead>
334
460
 
335
461
  <tbody
336
- class="content"
462
+ class='content'
337
463
  use:reorderArea={{ axis: 'y' }}
338
464
  bind:this={virtualization.viewport.element}
339
465
  onscrollcapture={onscroll}
340
466
  bind:clientHeight={virtualization.viewport.height}
467
+ bind:clientWidth={tbody.width}
341
468
  >
342
469
  {#if table.options.reorderable}
343
470
  {@render reorderArea({
@@ -358,7 +485,7 @@
358
485
  {/if}
359
486
  </tbody>
360
487
 
361
- <tfoot class="statusbar" bind:this={elements.statusbar}>
488
+ <tfoot class='statusbar' bind:this={elements.statusbar}>
362
489
  <tr>
363
490
  {@render columnsSnippet(
364
491
  (column) => column.snippets.statusbar,
@@ -370,14 +497,13 @@
370
497
  </tfoot>
371
498
 
372
499
  <caption
373
- class="panel"
374
- style="width: {panelTween.current}px;"
375
- style:overflow={panelTween.transitioning ? 'hidden' : 'auto'}
500
+ class='panel'
501
+ style='width: {panelTween.current}px;'
376
502
  >
377
503
  {#if properties.panel && properties.panel in table.panels}
378
504
  <div
379
- class="panel-content"
380
- bind:clientWidth={panelTween.width}
505
+ class='panel-content'
506
+ bind:offsetWidth={panelTween.size}
381
507
  in:fly={{ x: 100, easing: sineInOut, duration: 300 }}
382
508
  out:fly={{ x: 100, duration: 200, easing: sineInOut }}
383
509
  >
@@ -392,18 +518,18 @@
392
518
  </div>
393
519
  {/if}
394
520
  </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)}
521
+ <caption class='backdrop' aria-hidden={properties.panel && table.panels[properties.panel]?.backdrop ? false : true}>
522
+ <button aria-label='Panel backdrop' class='btn-backdrop' tabindex='-1' onclick={() => (properties.panel = undefined)}
397
523
  ></button>
398
524
  </caption>
399
525
  </table>
400
526
 
401
527
  {#snippet headerSelected(ctx: HeaderSelectCtx<T>)}
402
- <input type="checkbox" indeterminate={ctx.indeterminate} bind:checked={ctx.isSelected} />
528
+ <input type='checkbox' indeterminate={ctx.indeterminate} bind:checked={ctx.isSelected} />
403
529
  {/snippet}
404
530
 
405
531
  {#snippet rowSelected(ctx: RowSelectCtx<T>)}
406
- <input type="checkbox" bind:checked={ctx.isSelected} />
532
+ <input type='checkbox' bind:checked={ctx.isSelected} />
407
533
  {/snippet}
408
534
 
409
535
  {#if table.options.select || table.options.reorderable}
@@ -416,16 +542,16 @@
416
542
  } = typeof select === 'boolean' ? {} : select}
417
543
  {#if show !== 'never' || reorderable}
418
544
  <Column
419
- id="__fixed"
545
+ id='__fixed'
420
546
  {table}
421
547
  fixed
422
548
  width={Math.max(56, (select && show !== 'never' ? 34 : 0) + (reorderable ? 34 : 0))}
423
549
  resizeable={false}
424
550
  >
425
551
  {#snippet header()}
426
- <div class="__fixed">
552
+ <div class='__fixed'>
427
553
  {#if reorderable}
428
- <span style="width: 16px; display: flex; align-items: center;"></span>
554
+ <span style='width: 16px; display: flex; align-items: center;'></span>
429
555
  {/if}
430
556
  {#if select}
431
557
  {@render headerSnippet({
@@ -453,9 +579,9 @@
453
579
  </div>
454
580
  {/snippet}
455
581
  {#snippet row(item, row)}
456
- <div class="__fixed">
582
+ <div class='__fixed'>
457
583
  {#if reorderable}
458
- <span style="width: 16px; display: flex; align-items: center;" use:row.itemState.handle>
584
+ <span style='width: 16px; display: flex; align-items: center;' use:row.itemState.handle>
459
585
  {#if (row.isHovered && !row.itemState?.area.isTarget) || row.itemState.dragging}
460
586
  {@render dragSnippet()}
461
587
  {/if}
@@ -489,6 +615,7 @@
489
615
  {@render content?.({
490
616
  Column,
491
617
  Panel,
618
+ Expandable,
492
619
  get table() {
493
620
  return table
494
621
  },
@@ -510,6 +637,21 @@
510
637
  overflow: visible;
511
638
  }
512
639
 
640
+ .expandable {
641
+ position: relative;
642
+
643
+ & > td {
644
+ position: sticky;
645
+ left: 1px;
646
+ > div {
647
+ position: absolute;
648
+ overflow: auto;
649
+ top: 0;
650
+ left: 0;
651
+ }
652
+ }
653
+ }
654
+
513
655
  caption {
514
656
  all: unset;
515
657
  }
@@ -572,7 +714,7 @@
572
714
  height: var(--b);
573
715
  }
574
716
 
575
- a.row {
717
+ .row:global(:is(a)) {
576
718
  color: inherit;
577
719
  text-decoration: inherit;
578
720
  }
@@ -728,18 +870,19 @@
728
870
  position: relative;
729
871
  grid-area: panel;
730
872
  height: 100%;
731
-
873
+ overflow: hidden;
732
874
  border-left: 1px solid var(--tably-border, hsl(0, 0%, 90%));
733
- scrollbar-gutter: stable both-edges;
734
- scrollbar-width: thin;
875
+
735
876
  z-index: 4;
736
877
 
737
878
  > .panel-content {
738
879
  position: absolute;
739
880
  top: 0;
740
881
  right: 0;
882
+ bottom: 0;
741
883
  width: min-content;
742
884
  overflow: auto;
885
+ scrollbar-width: thin;
743
886
  padding: var(--tably-padding-y, 0.5rem) 0;
744
887
  }
745
888
  }
@@ -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; }
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.13",
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",