svelte-tably 1.2.0 → 1.3.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Via the amazing capabilities braught to us by Svelte 5 — a performant, dynamic, flexible, feature rich table. It's as simple, or as flexible as you need it to be.
4
4
 
5
- Simple example on [Svelte 5 Playground](https://svelte.dev/playground/f79124e8473546d29433a95a68440d6d?version=5.16.0)
5
+ Simple example on [Svelte 5 Playground](https://svelte.dev/playground/f79124e8473546d29433a95a68440d6d?version=5)
6
6
  <br>
7
7
  Fledged out example on [Svelte 5 Playground](https://svelte.dev/playground/a16d71c97445455e80a55b77ec1cf915?version=5)
8
8
 
@@ -255,6 +255,7 @@ This component can add a context-menu on the side of each row, as well as provid
255
255
  | - | - | - |
256
256
  | hover? | Only show when hovering? | `boolean` |
257
257
  | width? | The width for the context-column | `string` |
258
+ | alignHeaderToRows? | If enabled, the header and row context cells share the same measured width | `boolean` |
258
259
 
259
260
  <br>
260
261
 
@@ -10,9 +10,17 @@ type ContextOptions<T> = {
10
10
  */
11
11
  hover?: boolean;
12
12
  /**
13
- * @defualt 'max-content'
13
+ * @default 'max-content'
14
14
  */
15
15
  width?: string;
16
+ /**
17
+ * Align the header context cell (if any) with the row context cell.
18
+ *
19
+ * When enabled, the table measures the rendered context cell width
20
+ * (from header and rows) and uses a shared fixed width so they line up.
21
+ * @default false
22
+ */
23
+ alignHeaderToRows?: boolean;
16
24
  };
17
25
  export interface RowProps<T> {
18
26
  /**
@@ -38,6 +46,7 @@ export declare class RowState<T> {
38
46
  context: {
39
47
  hover: boolean;
40
48
  width: string;
49
+ alignHeaderToRows: boolean;
41
50
  };
42
51
  };
43
52
  constructor(props: RowProps<T>);
@@ -13,14 +13,15 @@ export class RowState {
13
13
  options = $derived({
14
14
  context: {
15
15
  hover: this.#props.contextOptions?.hover ?? true,
16
- width: this.#props.contextOptions?.width ?? 'max-content'
16
+ width: this.#props.contextOptions?.width ?? 'max-content',
17
+ alignHeaderToRows: this.#props.contextOptions?.alignHeaderToRows ?? false
17
18
  }
18
19
  });
19
20
  constructor(props) {
20
21
  this.#props = props;
21
22
  this.#table = TableState.getContext();
22
23
  if (!this.#table) {
23
- throw new Error('svelte-tably: Expandable must be associated with a Table');
24
+ throw new Error('svelte-tably: Row must be associated with a Table');
24
25
  }
25
26
  this.#table.row = this;
26
27
  $effect(() => () => this.#table.row === this && (this.#table.row = undefined));
@@ -149,6 +149,65 @@
149
149
  const getWidth = (key: string, def: number = 150) =>
150
150
  table.columnWidths[key] ??= table.columns[key]?.defaults.width ?? def
151
151
 
152
+ const measureContextCellWidth = (cell: HTMLElement | null) => {
153
+ if (!cell) return 0
154
+ const inner = cell.querySelector(':scope > .context-inner') as HTMLElement | null
155
+ const content = inner?.firstElementChild as HTMLElement | null
156
+ const candidates = [cell, inner, content].filter(Boolean) as HTMLElement[]
157
+ let width = 0
158
+ for (const el of candidates) {
159
+ width = Math.max(
160
+ width,
161
+ Math.ceil(el.getBoundingClientRect().width),
162
+ Math.ceil(el.scrollWidth)
163
+ )
164
+ }
165
+ return width
166
+ }
167
+
168
+ let contextWidth = $state(0)
169
+ let contextWidthRaf = 0
170
+ $effect(() => {
171
+ if (!mount.isMounted) {
172
+ contextWidth = 0
173
+ if (contextWidthRaf) cancelAnimationFrame(contextWidthRaf)
174
+ return
175
+ }
176
+ if (!table.row?.snippets.context) {
177
+ contextWidth = 0
178
+ if (contextWidthRaf) cancelAnimationFrame(contextWidthRaf)
179
+ return
180
+ }
181
+ if (!table.row?.options.context.alignHeaderToRows) {
182
+ contextWidth = 0
183
+ if (contextWidthRaf) cancelAnimationFrame(contextWidthRaf)
184
+ return
185
+ }
186
+
187
+ virtualization.topIndex
188
+ if (contextWidthRaf) cancelAnimationFrame(contextWidthRaf)
189
+ contextWidthRaf = requestAnimationFrame(() => {
190
+ const headerCell = elements.headers?.querySelector(
191
+ '[data-tably-context-measure="header"]'
192
+ ) as HTMLElement | null
193
+ const rowCell = virtualization.viewport.element?.querySelector(
194
+ '[data-tably-context-measure="row"]'
195
+ ) as HTMLElement | null
196
+
197
+ const width = Math.max(
198
+ measureContextCellWidth(headerCell),
199
+ measureContextCellWidth(rowCell)
200
+ )
201
+ if (width > 0 && width !== contextWidth) {
202
+ contextWidth = width
203
+ }
204
+ })
205
+
206
+ return () => {
207
+ if (contextWidthRaf) cancelAnimationFrame(contextWidthRaf)
208
+ }
209
+ })
210
+
152
211
  /** grid-template-columns for widths */
153
212
  let style = $state('')
154
213
  $effect(() => {
@@ -157,7 +216,7 @@
157
216
  return
158
217
  }
159
218
 
160
- const context = table.row?.snippets.context ? ` ${table.row?.options.context.width}` : ''
219
+ const context = table.row?.snippets.context ? ' var(--tably-context-width)' : ''
161
220
 
162
221
  const templateColumns =
163
222
  columns
@@ -177,6 +236,7 @@
177
236
 
178
237
  const tbodyTemplateColumns = `
179
238
  [data-area-class='${table.cssId}'] tr.row,
239
+ [data-area-class='${table.cssId}'] tr.expandable,
180
240
  [data-area-class='${table.cssId}'] tr.filler,
181
241
  [data-svelte-tably="${table.cssId}"] > tbody::after {
182
242
  grid-template-columns: ${templateColumns};
@@ -242,7 +302,8 @@
242
302
  }
243
303
 
244
304
  let tbody = $state({
245
- scrollbar: 0
305
+ scrollbar: 0,
306
+ viewportWidth: 0
246
307
  })
247
308
 
248
309
  function observeScrollbar(node: HTMLElement) {
@@ -251,6 +312,7 @@
251
312
  const update = () => {
252
313
  // Reserve the same gutter in header/footer as the scrollable body
253
314
  tbody.scrollbar = Math.max(0, node.offsetWidth - node.clientWidth)
315
+ tbody.viewportWidth = node.clientWidth
254
316
  }
255
317
 
256
318
  update()
@@ -590,9 +652,16 @@
590
652
  return table.selected?.includes(item)
591
653
  },
592
654
  set selected(value) {
593
- value ?
594
- table.selected!.push(item)
595
- : table.selected!.splice(table.selected!.indexOf(item), 1)
655
+ const current = table.selected
656
+ if (value) {
657
+ if (!current.includes(item)) {
658
+ table.selected = [...current, item]
659
+ }
660
+ return
661
+ }
662
+ if (current.includes(item)) {
663
+ table.selected = current.filter((v) => v !== item)
664
+ }
596
665
  },
597
666
  get itemState() {
598
667
  return itemState
@@ -619,10 +688,8 @@
619
688
  use:addRowEvents={ctx}
620
689
  onclick={(e) => {
621
690
  if (table.expandable?.options.click === true) {
622
- let target = e.target as HTMLElement
623
- if (['INPUT', 'TEXTAREA', 'BUTTON', 'A'].includes(target.tagName)) {
624
- return
625
- }
691
+ const target = e.target
692
+ if (target instanceof Element && target.closest('input, textarea, button, a')) return
626
693
  ctx.expanded = !ctx.expanded
627
694
  }
628
695
  }}
@@ -651,8 +718,16 @@
651
718
  <td
652
719
  class="context-col"
653
720
  class:hidden={table.row?.options.context.hover && hoveredRow !== item}
721
+ data-tably-context-measure={
722
+ table.row?.options.context.alignHeaderToRows &&
723
+ index === virtualization.topIndex ?
724
+ 'row'
725
+ : undefined
726
+ }
654
727
  >
655
- {@render table.row?.snippets.context?.(item, ctx)}
728
+ <div class="context-inner">
729
+ {@render table.row?.snippets.context?.(item, ctx)}
730
+ </div>
656
731
  </td>
657
732
  {/if}
658
733
  </tr>
@@ -668,25 +743,28 @@
668
743
  {@const expandLabelId = `${expandId}-label`}
669
744
  <tr class="expandable">
670
745
  <td
746
+ class="expandable-cell"
671
747
  colspan={columns.length + (table.row?.snippets.context ? 1 : 0)}
672
748
  style="padding: 0"
673
749
  >
674
- <div
675
- class="expandable-clip"
676
- style="height: {Math.round(expandableTween.current)}px"
677
- id={expandId}
678
- role="region"
679
- aria-labelledby={expandLabelId}
680
- aria-hidden={!expanded}
681
- >
682
- <span class="sr-only" id={expandLabelId}>
683
- Expanded content for {getRowLabel(item, index)}
684
- </span>
750
+ <div class="expandable-sticky">
685
751
  <div
686
- class="expandable-content"
687
- bind:offsetHeight={expandableTween.size}
752
+ class="expandable-clip"
753
+ style="height: {Math.round(expandableTween.current)}px"
754
+ id={expandId}
755
+ role="region"
756
+ aria-labelledby={expandLabelId}
757
+ aria-hidden={!expanded}
688
758
  >
689
- {@render table.expandable?.snippets.content?.(item, ctx)}
759
+ <span class="sr-only" id={expandLabelId}>
760
+ Expanded content for {getRowLabel(item, index)}
761
+ </span>
762
+ <div
763
+ class="expandable-content"
764
+ bind:offsetHeight={expandableTween.size}
765
+ >
766
+ {@render table.expandable?.snippets.content?.(item, ctx)}
767
+ </div>
690
768
  </div>
691
769
  </div>
692
770
  </td>
@@ -698,7 +776,7 @@
698
776
  id={table.id}
699
777
  data-svelte-tably={table.cssId}
700
778
  class="table svelte-tably"
701
- style="--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px; --scrollbar: {tbody.scrollbar}px;"
779
+ style="--t: {virtualization.virtualTop}px; --b: {virtualization.virtualBottom}px; --scrollbar: {tbody.scrollbar}px; --viewport-width: {tbody.viewportWidth}px; --tably-context-width: {table.row?.options.context.alignHeaderToRows && contextWidth > 0 ? `${contextWidth}px` : (table.row?.options.context.width ?? 'max-content')};"
702
780
  aria-rowcount={table.data.length}
703
781
  >
704
782
  {#if columns.some((v) => v.snippets.header)}
@@ -721,11 +799,14 @@
721
799
  {#if table.row?.snippets.context}
722
800
  <th
723
801
  class="context-col"
802
+ data-tably-context-measure={table.row?.options.context.alignHeaderToRows ? 'header' : undefined}
724
803
  aria-hidden={table.row?.snippets.contextHeader ? undefined : true}
725
804
  role={table.row?.snippets.contextHeader ? undefined : 'presentation'}
726
805
  >
727
806
  {#if table.row?.snippets.contextHeader}
728
- {@render table.row?.snippets.contextHeader()}
807
+ <div class="context-inner">
808
+ {@render table.row?.snippets.contextHeader()}
809
+ </div>
729
810
  {/if}
730
811
  </th>
731
812
  {/if}
@@ -963,7 +1044,7 @@
963
1044
  {#each autoSchema.keys as key}
964
1045
  <Column
965
1046
  id={key}
966
- value={(r) => (r as any)?.[key]}
1047
+ value={(r) => (r as any)?.[key]}
967
1048
  header={capitalize(segmentize(key))}
968
1049
  sort={typeof autoSchema.sample?.[key] === 'number' ?
969
1050
  (a, b) => a - b
@@ -996,19 +1077,28 @@
996
1077
  justify-content: center;
997
1078
  position: sticky;
998
1079
  right: 0;
999
- height: 100%;
1000
1080
  z-index: 3;
1001
1081
  padding: 0;
1082
+ border-left: 1px solid var(--tably-border);
1002
1083
  &.hidden {
1003
1084
  pointer-events: none;
1004
1085
  user-select: none;
1005
- background: none;
1006
- > :global(*) {
1007
- opacity: 0;
1086
+ border-left: none;
1087
+ > .context-inner {
1088
+ visibility: hidden;
1008
1089
  }
1009
1090
  }
1010
1091
  }
1011
1092
 
1093
+ .context-inner {
1094
+ display: flex;
1095
+ align-items: center;
1096
+ justify-content: center;
1097
+ padding: calc(var(--tably-padding-y) / 2) 0;
1098
+ overflow: clip;
1099
+ width: 100%;
1100
+ }
1101
+
1012
1102
  .table::before {
1013
1103
  content: '';
1014
1104
  grid-area: headers;
@@ -1016,6 +1106,9 @@
1016
1106
  align-self: stretch;
1017
1107
  width: var(--scrollbar, 0px);
1018
1108
  background-color: var(--tably-bg);
1109
+ border-bottom: 1px solid var(--tably-border);
1110
+ border-right: 1px solid var(--tably-border);
1111
+ margin-right: -1px;
1019
1112
  pointer-events: none;
1020
1113
  position: relative;
1021
1114
  z-index: 4;
@@ -1028,6 +1121,8 @@
1028
1121
  align-self: stretch;
1029
1122
  width: var(--scrollbar, 0px);
1030
1123
  background-color: var(--tably-statusbar);
1124
+ border-right: 1px solid var(--tably-border);
1125
+ margin-right: -1px;
1031
1126
  pointer-events: none;
1032
1127
  position: relative;
1033
1128
  z-index: 4;
@@ -1061,11 +1156,28 @@
1061
1156
  }
1062
1157
  }
1063
1158
 
1159
+ .expandable-cell {
1160
+ grid-column: 1 / -1;
1161
+ display: block;
1162
+ min-width: 0;
1163
+ width: 100%;
1164
+ }
1165
+
1166
+ .expandable-sticky {
1167
+ position: sticky;
1168
+ left: 0;
1169
+ width: var(--viewport-width, 100%);
1170
+ min-width: 0;
1171
+ display: block;
1172
+ background-color: var(--tably-bg);
1173
+ z-index: 1;
1174
+ }
1175
+
1064
1176
  .expandable-clip {
1065
1177
  overflow: hidden;
1066
1178
  width: 100%;
1067
1179
  background-color: var(--tably-bg);
1068
- box-shadow: inset 0 -1px 0 var(--tably-border-grid);
1180
+ border-bottom: 1px solid var(--tably-border-grid);
1069
1181
  }
1070
1182
 
1071
1183
  .expandable-content {
@@ -1073,6 +1185,7 @@
1073
1185
  width: 100%;
1074
1186
  background-color: var(--tably-bg);
1075
1187
  box-sizing: border-box;
1188
+ min-width: 0;
1076
1189
  }
1077
1190
 
1078
1191
  .expand-row {
@@ -1128,6 +1241,7 @@
1128
1241
  align-items: center;
1129
1242
  justify-content: center;
1130
1243
  gap: 0.25rem;
1244
+ background-color: transparent;
1131
1245
  position: absolute;
1132
1246
  top: 0;
1133
1247
  left: 0;
@@ -1136,6 +1250,10 @@
1136
1250
  width: 100%;
1137
1251
  }
1138
1252
 
1253
+ .__fixed > * {
1254
+ background-color: transparent;
1255
+ }
1256
+
1139
1257
  thead {
1140
1258
  position: relative;
1141
1259
  }
@@ -1247,19 +1365,18 @@
1247
1365
  overflow: hidden;
1248
1366
  min-width: 0;
1249
1367
  padding-right: var(--scrollbar, 0px);
1368
+ border-bottom: 1px solid var(--tably-border);
1250
1369
  }
1251
1370
 
1252
1371
  .headers > tr > .column {
1253
1372
  width: auto !important;
1254
- border-bottom: 1px solid var(--tably-border);
1255
1373
  }
1256
- .headers > tr > .column,
1257
- .headers > tr > .context-col {
1258
- border-bottom: 1px solid var(--tably-border);
1374
+ .headers > tr > .column:not(:first-child) {
1259
1375
  border-left: 1px solid var(--tably-border-grid);
1260
1376
  }
1261
1377
 
1262
1378
  .headers > tr > .context-col {
1379
+ border-left: 1px solid var(--tably-border);
1263
1380
  background-color: var(--tably-bg);
1264
1381
  }
1265
1382
 
@@ -1300,7 +1417,7 @@
1300
1417
  }
1301
1418
  .statusbar > tr > .context-col {
1302
1419
  border-top: 1px solid var(--tably-border);
1303
- border-left: 1px solid var(--tably-border-grid);
1420
+ border-left: 1px solid var(--tably-border);
1304
1421
  }
1305
1422
 
1306
1423
  .statusbar > tr > .context-col {
@@ -1345,14 +1462,28 @@
1345
1462
  }
1346
1463
  }
1347
1464
 
1465
+ .row > .context-col {
1466
+ background-color: var(--tably-bg);
1467
+ }
1468
+
1469
+ .row > .context-col.hidden {
1470
+ background-color: transparent;
1471
+ }
1472
+
1348
1473
  :global(#runic-drag .row) {
1349
1474
  border: 1px solid var(--tably-border-grid);
1350
1475
  border-top: 2px solid var(--tably-border-grid);
1351
1476
  }
1352
1477
 
1353
- .row > *,
1354
- .filler > * {
1478
+ .headers > tr > .column:not(:first-child),
1479
+ .row > .column:not(:first-child),
1480
+ .filler > .column:not(:first-child),
1481
+ .statusbar > tr > .column:not(:first-child) {
1355
1482
  border-left: 1px solid var(--tably-border-grid);
1483
+ }
1484
+
1485
+ .row,
1486
+ .filler {
1356
1487
  border-bottom: 1px solid var(--tably-border-grid);
1357
1488
  }
1358
1489
 
@@ -104,7 +104,23 @@ export class TableState {
104
104
  }
105
105
  /** Width of each column */
106
106
  columnWidths = $state({});
107
+ #storageKey() {
108
+ if (!this.id)
109
+ return null;
110
+ return `svelte-tably:${this.id}`;
111
+ }
112
+ #getStorage() {
113
+ try {
114
+ return localStorage;
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
107
120
  #save() {
121
+ const key = this.#storageKey();
122
+ if (!key)
123
+ return;
108
124
  const content = {
109
125
  columnWidths: this.columnWidths,
110
126
  positions: {
@@ -116,24 +132,56 @@ export class TableState {
116
132
  sortby: this.dataState.sortby,
117
133
  sortReverse: this.dataState.sortReverse
118
134
  };
119
- localStorage.setItem(`svelte-tably:${this.id}`, JSON.stringify(content));
135
+ const storage = this.#getStorage();
136
+ if (!storage)
137
+ return;
138
+ try {
139
+ storage.setItem(key, JSON.stringify(content));
140
+ }
141
+ catch {
142
+ return;
143
+ }
120
144
  }
121
145
  #saving = false;
146
+ #saveTimeout = null;
122
147
  #scheduleSave() {
123
148
  if (this.#saving)
124
149
  return;
125
- if (typeof localStorage === 'undefined')
150
+ if (!this.#storageKey())
151
+ return;
152
+ if (!this.#getStorage())
126
153
  return;
127
154
  this.#saving = true;
128
- setTimeout(() => {
155
+ if (this.#saveTimeout)
156
+ clearTimeout(this.#saveTimeout);
157
+ this.#saveTimeout = setTimeout(() => {
129
158
  this.#saving = false;
130
159
  this.#save();
131
160
  }, 1000);
132
161
  }
133
162
  #load() {
134
- if (typeof localStorage === 'undefined')
163
+ const key = this.#storageKey();
164
+ if (!key)
165
+ return null;
166
+ const storage = this.#getStorage();
167
+ if (!storage)
135
168
  return null;
136
- const item = JSON.parse(localStorage.getItem(`svelte-tably:${this.id}`) || '{}');
169
+ let raw = null;
170
+ try {
171
+ raw = storage.getItem(key);
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ let item;
177
+ try {
178
+ item = JSON.parse(raw || '{}');
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ if (!item || typeof item !== 'object')
184
+ item = {};
137
185
  item.columnWidths ??= {};
138
186
  item.positions ??= {};
139
187
  item.positions.fixed ??= [];
@@ -163,9 +211,20 @@ export class TableState {
163
211
  this.dataState.sortReverse = saved.sortReverse;
164
212
  }
165
213
  }
166
- if (typeof window !== 'undefined') {
167
- window.addEventListener('beforeunload', () => this.#save());
168
- }
214
+ $effect(() => {
215
+ if (typeof window === 'undefined')
216
+ return;
217
+ const handler = () => this.#save();
218
+ window.addEventListener('beforeunload', handler);
219
+ return () => window.removeEventListener('beforeunload', handler);
220
+ });
221
+ $effect(() => {
222
+ return () => {
223
+ if (this.#saveTimeout)
224
+ clearTimeout(this.#saveTimeout);
225
+ this.#saveTimeout = null;
226
+ };
227
+ });
169
228
  $effect(() => {
170
229
  Object.keys(this.columnWidths);
171
230
  // Track order changes by observing the id sequences
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-tably",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A high performant dynamic table for Svelte 5",
5
5
  "license": "MIT",
6
6
  "repository": {