@happyvertical/smrt-ui 0.42.3 → 0.42.5

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.
Files changed (52) hide show
  1. package/README.md +275 -0
  2. package/dist/components/data/DataTable.svelte +1444 -172
  3. package/dist/components/data/DataTable.svelte.d.ts +1 -1
  4. package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
  5. package/dist/components/data/DataTableController.d.ts +235 -0
  6. package/dist/components/data/DataTableController.d.ts.map +1 -0
  7. package/dist/components/data/DataTableController.js +792 -0
  8. package/dist/components/data/DataTableIdentity.d.ts +21 -0
  9. package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
  10. package/dist/components/data/DataTableIdentity.js +28 -0
  11. package/dist/components/data/DataTableLayout.d.ts +37 -0
  12. package/dist/components/data/DataTableLayout.d.ts.map +1 -0
  13. package/dist/components/data/DataTableLayout.js +135 -0
  14. package/dist/components/data/DataTablePerformance.d.ts +31 -0
  15. package/dist/components/data/DataTablePerformance.d.ts.map +1 -0
  16. package/dist/components/data/DataTablePerformance.js +46 -0
  17. package/dist/components/data/DataTableVirtualization.d.ts +71 -0
  18. package/dist/components/data/DataTableVirtualization.d.ts.map +1 -0
  19. package/dist/components/data/DataTableVirtualization.js +100 -0
  20. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts +2 -0
  21. package/dist/components/data/__benchmarks__/DataTable.bench.d.ts.map +1 -0
  22. package/dist/components/data/__benchmarks__/DataTable.bench.js +53 -0
  23. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts +27 -0
  24. package/dist/components/data/__fixtures__/DataTableConformanceFixture.d.ts.map +1 -0
  25. package/dist/components/data/__fixtures__/DataTableConformanceFixture.js +141 -0
  26. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts +10 -0
  27. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.d.ts.map +1 -0
  28. package/dist/components/data/__fixtures__/DataTablePerformanceFixture.js +23 -0
  29. package/dist/components/data/__tests__/DataTable.test.js +644 -21
  30. package/dist/components/data/__tests__/DataTableConformance.test.js +212 -0
  31. package/dist/components/data/__tests__/DataTableController.test.js +336 -0
  32. package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
  33. package/dist/components/data/__tests__/DataTableLayout.test.js +195 -0
  34. package/dist/components/data/__tests__/DataTablePerformance.test.js +21 -0
  35. package/dist/components/data/__tests__/DataTableVirtualization.test.js +80 -0
  36. package/dist/components/data/__tests__/DataTableVirtualizationComponent.test.js +255 -0
  37. package/dist/components/data/__tests__/data-surface.test.js +675 -0
  38. package/dist/components/data/data-surface.d.ts +246 -0
  39. package/dist/components/data/data-surface.d.ts.map +1 -0
  40. package/dist/components/data/data-surface.js +1102 -0
  41. package/dist/components/data/index.d.ts +5 -0
  42. package/dist/components/data/index.d.ts.map +1 -1
  43. package/dist/components/data/index.js +5 -0
  44. package/dist/components/data/types.d.ts +110 -2
  45. package/dist/components/data/types.d.ts.map +1 -1
  46. package/dist/i18n/strings.d.ts +26 -0
  47. package/dist/i18n/strings.d.ts.map +1 -1
  48. package/dist/i18n/strings.js +28 -2
  49. package/dist/svelte/playground/DataTablePreview.svelte +400 -10
  50. package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
  51. package/dist/svelte/playground.js +2 -2
  52. package/package.json +3 -2
@@ -1,8 +1,16 @@
1
1
  <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import {
4
+ createDataTableConformanceRows,
5
+ dataTableConformanceColumns,
6
+ dataTableConformanceRows,
7
+ dataTableConformanceScenarios,
8
+ } from '../../components/data/__fixtures__/DataTableConformanceFixture.js';
2
9
  import DataTable from '../../components/data/DataTable.svelte';
10
+ import { createDataTableController } from '../../components/data/DataTableController.js';
3
11
  import type {
4
12
  DataTableColumn,
5
- SortState,
13
+ DataTableStructuralRow,
6
14
  } from '../../components/data/types.js';
7
15
  import Toggle from '../../components/forms/Toggle.svelte';
8
16
  import Badge from '../../components/ui/Badge.svelte';
@@ -17,6 +25,16 @@ interface WorkspaceUser {
17
25
  lastSeen: string;
18
26
  }
19
27
 
28
+ interface ReportLine {
29
+ id: string;
30
+ account: string;
31
+ actual: number;
32
+ forecast: number;
33
+ variance: number;
34
+ status: string;
35
+ action: string;
36
+ }
37
+
20
38
  const users: WorkspaceUser[] = [
21
39
  {
22
40
  id: 'user-ada',
@@ -68,13 +86,224 @@ const columns: DataTableColumn<WorkspaceUser>[] = [
68
86
  { id: 'lastSeen', label: 'Last seen', sortable: true, align: 'right' },
69
87
  ];
70
88
 
71
- let selected = $state<Set<string | number>>(new Set());
72
- let sort = $state<SortState>({ columnId: null, direction: null });
89
+ const reportRows: ReportLine[] = [
90
+ {
91
+ id: 'revenue',
92
+ account: 'Subscription revenue',
93
+ actual: 182400,
94
+ forecast: 176000,
95
+ variance: 6400,
96
+ status: 'On track',
97
+ action: 'Review',
98
+ },
99
+ {
100
+ id: 'services',
101
+ account: 'Professional services',
102
+ actual: 48300,
103
+ forecast: 52000,
104
+ variance: -3700,
105
+ status: 'Watch',
106
+ action: 'Review',
107
+ },
108
+ ];
109
+
110
+ const reportColumns: DataTableColumn<ReportLine>[] = [
111
+ {
112
+ id: 'account',
113
+ label: 'Account',
114
+ headerPath: [{ id: 'dimension', label: 'Dimensions' }],
115
+ minWidth: '13rem',
116
+ resizable: true,
117
+ },
118
+ {
119
+ id: 'actual',
120
+ label: 'Actual',
121
+ headerPath: [{ id: 'performance', label: 'Performance' }],
122
+ align: 'right',
123
+ resizable: true,
124
+ },
125
+ {
126
+ id: 'forecast',
127
+ label: 'Forecast',
128
+ headerPath: [{ id: 'performance', label: 'Performance' }],
129
+ align: 'right',
130
+ resizable: true,
131
+ },
132
+ {
133
+ id: 'variance',
134
+ label: 'Variance',
135
+ headerPath: [{ id: 'performance', label: 'Performance' }],
136
+ align: 'right',
137
+ },
138
+ {
139
+ id: 'status',
140
+ label: 'Status',
141
+ role: 'status',
142
+ responsive: { keepVisible: true, priority: 10 },
143
+ },
144
+ {
145
+ id: 'action',
146
+ label: 'Action',
147
+ role: 'action',
148
+ responsive: { keepVisible: true, priority: 10 },
149
+ },
150
+ ];
151
+
152
+ const reportStructuralRows: DataTableStructuralRow<ReportLine>[] = [
153
+ {
154
+ id: 'forecast-subtotal',
155
+ kind: 'subtotal',
156
+ label: 'Current forecast',
157
+ values: { actual: 230700, forecast: 228000, variance: 2700 },
158
+ },
159
+ {
160
+ id: 'forecast-total',
161
+ kind: 'footer',
162
+ label: 'All accounts',
163
+ values: { actual: 230700, forecast: 228000, variance: 2700 },
164
+ },
165
+ ];
166
+
73
167
  let loading = $state(false);
74
168
  let dense = $state(false);
169
+ const tableController = createDataTableController({
170
+ columnIds: columns.map((column) => column.id),
171
+ initialState: { pageSize: 3 },
172
+ });
173
+ const reportController = createDataTableController({
174
+ columnIds: reportColumns.map((column) => column.id),
175
+ initialState: {
176
+ columnOrder: reportColumns.map((column) => column.id),
177
+ columnWidths: [
178
+ { columnId: 'account', width: 240 },
179
+ { columnId: 'actual', width: 112 },
180
+ { columnId: 'forecast', width: 112 },
181
+ ],
182
+ columnPinning: [
183
+ { columnId: 'account', position: 'start' },
184
+ { columnId: 'action', position: 'end' },
185
+ ],
186
+ },
187
+ });
188
+ const manualController = createDataTableController({
189
+ columnIds: dataTableConformanceColumns.map((column) => column.id),
190
+ modes: { filtering: 'manual', sorting: 'manual', pagination: 'manual' },
191
+ initialState: { page: 2, pageSize: 25 },
192
+ });
193
+ const virtualRows = createDataTableConformanceRows(120);
194
+ const virtualColumns = dataTableConformanceColumns.slice(0, 2);
195
+ const virtualization = { rowHeight: 32, viewportHeight: 176, overscan: 3 };
196
+ const manualQueryCommands = new Set([
197
+ 'setSearch',
198
+ 'setFilters',
199
+ 'setSorting',
200
+ 'toggleSorting',
201
+ 'setPage',
202
+ 'setPageSize',
203
+ ]);
204
+ let tableState = $state(tableController.getState());
205
+ let manualState = $state(manualController.getState());
206
+ let manualLifecycle = $state<'ready' | 'refreshing' | 'stale' | 'error'>(
207
+ 'ready',
208
+ );
209
+ let manualQueryRevision = $state(1);
210
+ const manualQueryFingerprint = $derived(
211
+ JSON.stringify({
212
+ search: manualState.search,
213
+ filters: manualState.filters,
214
+ sorting: manualState.sorting,
215
+ page: manualState.page,
216
+ pageSize: manualState.pageSize,
217
+ }),
218
+ );
219
+
220
+ onMount(() => {
221
+ const unsubscribeTable = tableController.subscribe((transition) => {
222
+ tableState = transition.next.state;
223
+ });
224
+ const unsubscribeManual = manualController.subscribe((transition) => {
225
+ manualState = transition.next.state;
226
+ if (
227
+ transition.command &&
228
+ manualQueryCommands.has(transition.command.type)
229
+ ) {
230
+ manualQueryRevision += 1;
231
+ manualLifecycle = 'refreshing';
232
+ }
233
+ });
234
+ return () => {
235
+ unsubscribeTable();
236
+ unsubscribeManual();
237
+ };
238
+ });
75
239
 
76
240
  function clearSelection() {
77
- selected = new Set();
241
+ tableController.dispatch({ type: 'setSelectedRows', rowIds: [] });
242
+ }
243
+
244
+ function toggleActiveFilter() {
245
+ tableController.dispatch({
246
+ type: 'setFilters',
247
+ filters: tableState.filters.length
248
+ ? []
249
+ : [{ columnId: 'status', operator: 'equals', value: 'Active' }],
250
+ });
251
+ }
252
+
253
+ function restoreReportLayout() {
254
+ reportController.replaceState({
255
+ ...reportController.getState(),
256
+ columnOrder: reportColumns.map((column) => column.id),
257
+ columnVisibility: reportColumns.map((column) => ({
258
+ columnId: column.id,
259
+ visible: true,
260
+ })),
261
+ columnWidths: [
262
+ { columnId: 'account', width: 240 },
263
+ { columnId: 'actual', width: 112 },
264
+ { columnId: 'forecast', width: 112 },
265
+ ],
266
+ columnPinning: [
267
+ { columnId: 'account', position: 'start' },
268
+ { columnId: 'action', position: 'end' },
269
+ ],
270
+ });
271
+ }
272
+
273
+ function showStaleManualResult() {
274
+ manualQueryRevision += 1;
275
+ manualLifecycle = 'stale';
276
+ }
277
+
278
+ function resolveManualQuery() {
279
+ manualLifecycle = 'ready';
280
+ }
281
+
282
+ function retryManualQuery() {
283
+ manualLifecycle = 'refreshing';
284
+ manualQueryRevision += 1;
285
+ }
286
+
287
+ function showManualFailure() {
288
+ manualLifecycle = 'error';
289
+ }
290
+
291
+ function toggleManualFilter() {
292
+ manualController.dispatch({
293
+ type: 'setFilters',
294
+ filters: manualState.filters.length
295
+ ? []
296
+ : [{ columnId: 'status', operator: 'equals', value: 'On track' }],
297
+ });
298
+ }
299
+
300
+ function selectAllMatching() {
301
+ manualController.dispatch({
302
+ type: 'selectAllMatching',
303
+ queryFingerprint: manualQueryFingerprint,
304
+ queryRevision: `revision-${manualQueryRevision}`,
305
+ expectedCount: 120,
306
+ });
78
307
  }
79
308
  </script>
80
309
 
@@ -88,13 +317,13 @@ function clearSelection() {
88
317
  </p>
89
318
  </div>
90
319
  <div class="summary" aria-live="polite">
91
- <Badge variant={selected.size > 0 ? 'primary' : 'default'}>
92
- {selected.size} selected
320
+ <Badge variant={tableState.selectedRowIds.length > 0 ? 'primary' : 'default'}>
321
+ {tableState.selectedRowIds.length} selected
93
322
  </Badge>
94
323
  <Button
95
324
  variant="ghost"
96
325
  size="sm"
97
- disabled={selected.size === 0}
326
+ disabled={tableState.selectedRowIds.length === 0}
98
327
  onclick={clearSelection}
99
328
  >
100
329
  Clear
@@ -105,8 +334,13 @@ function clearSelection() {
105
334
  <div class="table-controls" aria-label="Table display controls">
106
335
  <Toggle label="Dense rows" size="sm" bind:checked={dense} />
107
336
  <Toggle label="Loading state" size="sm" bind:checked={loading} />
337
+ <Button variant="ghost" size="sm" onclick={toggleActiveFilter}>
338
+ {tableState.filters.length ? 'Clear active filter' : 'Show active'}
339
+ </Button>
108
340
  <span>
109
- Sort: {sort.columnId ? `${sort.columnId} ${sort.direction}` : 'none'}
341
+ Sort: {tableState.sorting.length
342
+ ? tableState.sorting.map((rule) => `${rule.columnId} ${rule.direction}`).join(', ')
343
+ : 'none'}
110
344
  </span>
111
345
  </div>
112
346
 
@@ -122,12 +356,126 @@ function clearSelection() {
122
356
  stickyHeader
123
357
  {loading}
124
358
  {dense}
125
- bind:selected
126
- bind:sort
359
+ controller={tableController}
127
360
  caption="Workspace users"
128
361
  />
129
362
  </div>
130
363
 
364
+ <section class="report-fixture" aria-labelledby="report-fixture-title">
365
+ <div class="report-fixture__header">
366
+ <div>
367
+ <p class="eyebrow">Report structure</p>
368
+ <h4 id="report-fixture-title">Forecast report</h4>
369
+ <p class="supporting">
370
+ Grouped headers, structural totals, restored widths, and pinned status/action columns.
371
+ </p>
372
+ </div>
373
+ <Button variant="ghost" size="sm" onclick={restoreReportLayout}>
374
+ Restore saved layout
375
+ </Button>
376
+ </div>
377
+ <div class="table-frame">
378
+ <DataTable
379
+ data={reportRows}
380
+ columns={reportColumns}
381
+ rowKey="id"
382
+ structuralRows={reportStructuralRows}
383
+ controller={reportController}
384
+ caption="Forecast report"
385
+ hoverable
386
+ stickyHeader
387
+ />
388
+ </div>
389
+ </section>
390
+
391
+ <section class="manual-fixture" aria-labelledby="manual-fixture-title">
392
+ <div class="report-fixture__header">
393
+ <div>
394
+ <p class="eyebrow">Manual query and async states</p>
395
+ <h4 id="manual-fixture-title">Server-owned results</h4>
396
+ <p class="supporting">
397
+ Supplied rows remain usable through refresh, stale, and error states. The caller owns
398
+ query revisions and discards late responses.
399
+ </p>
400
+ </div>
401
+ <Badge variant={manualLifecycle === 'error' ? 'error' : 'default'}>
402
+ Revision {manualQueryRevision}
403
+ </Badge>
404
+ </div>
405
+ <div class="table-controls" aria-label="Manual query controls">
406
+ <Button variant="ghost" size="sm" onclick={showStaleManualResult}>
407
+ Show stale result
408
+ </Button>
409
+ <Button variant="ghost" size="sm" onclick={resolveManualQuery}>
410
+ Accept newer result
411
+ </Button>
412
+ <Button variant="ghost" size="sm" onclick={showManualFailure}>
413
+ Show failure
414
+ </Button>
415
+ <Button variant="ghost" size="sm" onclick={toggleManualFilter}>
416
+ {manualState.filters.length ? 'Clear manual filter' : 'Request manual filter'}
417
+ </Button>
418
+ <Button variant="ghost" size="sm" onclick={selectAllMatching}>
419
+ Select all 120 matching
420
+ </Button>
421
+ {#if manualState.selection.scope === 'allMatching'}
422
+ <span>All matching selection is bound to {manualState.selection.queryRevision}.</span>
423
+ {/if}
424
+ </div>
425
+ <div class="table-frame">
426
+ <DataTable
427
+ data={dataTableConformanceRows}
428
+ columns={dataTableConformanceColumns}
429
+ rowKey="id"
430
+ selectable
431
+ controller={manualController}
432
+ sortable
433
+ totalRows={120}
434
+ refreshing={manualLifecycle === 'refreshing'}
435
+ stale={manualLifecycle === 'stale'}
436
+ partialResults={manualLifecycle === 'stale'}
437
+ error={manualLifecycle === 'error' ? 'The query failed. Retry to request the current revision.' : null}
438
+ onRetry={retryManualQuery}
439
+ caption="Manual workspace report"
440
+ stickyHeader
441
+ />
442
+ </div>
443
+ </section>
444
+
445
+ <section class="scale-fixture" aria-labelledby="scale-fixture-title">
446
+ <div>
447
+ <p class="eyebrow">Scale boundary</p>
448
+ <h4 id="scale-fixture-title">Virtualized result window</h4>
449
+ <p class="supporting">
450
+ A fixed-height, keyboard-scrollable body renders a stable row-key window while its
451
+ semantic header remains visible.
452
+ </p>
453
+ </div>
454
+ <div class="table-frame table-frame--virtual">
455
+ <DataTable
456
+ data={virtualRows}
457
+ columns={virtualColumns}
458
+ rowKey="id"
459
+ {virtualization}
460
+ caption="Virtual workspace report"
461
+ striped
462
+ stickyHeader
463
+ />
464
+ </div>
465
+ </section>
466
+
467
+ <section class="coverage-fixture" aria-labelledby="coverage-fixture-title">
468
+ <div>
469
+ <p class="eyebrow">Release conformance</p>
470
+ <h4 id="coverage-fixture-title">Every supported DataTable surface</h4>
471
+ </div>
472
+ <ul>
473
+ {#each dataTableConformanceScenarios as scenario}
474
+ <li><strong>{scenario.title}:</strong> {scenario.contracts.join(', ')}</li>
475
+ {/each}
476
+ </ul>
477
+ </section>
478
+
131
479
  <div class="empty-state">
132
480
  <div>
133
481
  <strong>Empty state</strong>
@@ -222,6 +570,47 @@ function clearSelection() {
222
570
  border-top: 1px solid var(--smrt-color-outline-variant);
223
571
  }
224
572
 
573
+ .report-fixture {
574
+ display: grid;
575
+ gap: var(--smrt-spacing-3);
576
+ padding-top: var(--smrt-spacing-4);
577
+ border-top: 1px solid var(--smrt-color-outline-variant);
578
+ }
579
+
580
+ .manual-fixture,
581
+ .scale-fixture,
582
+ .coverage-fixture {
583
+ display: grid;
584
+ gap: var(--smrt-spacing-3);
585
+ padding-top: var(--smrt-spacing-4);
586
+ border-top: 1px solid var(--smrt-color-outline-variant);
587
+ }
588
+
589
+ .table-frame--virtual {
590
+ max-height: 20rem;
591
+ }
592
+
593
+ .coverage-fixture ul {
594
+ display: grid;
595
+ gap: var(--smrt-spacing-2);
596
+ padding: 0;
597
+ margin: 0;
598
+ color: var(--smrt-color-on-surface-variant);
599
+ font: var(--smrt-typography-body-small-font);
600
+ list-style: none;
601
+ }
602
+
603
+ .coverage-fixture strong {
604
+ color: var(--smrt-color-on-surface);
605
+ }
606
+
607
+ .report-fixture__header {
608
+ display: flex;
609
+ align-items: flex-start;
610
+ justify-content: space-between;
611
+ gap: var(--smrt-spacing-4);
612
+ }
613
+
225
614
  .empty-state > div:first-child {
226
615
  justify-content: space-between;
227
616
  gap: var(--smrt-spacing-4);
@@ -234,6 +623,7 @@ function clearSelection() {
234
623
 
235
624
  @media (max-width: 760px) {
236
625
  .workbench__header,
626
+ .report-fixture__header,
237
627
  .empty-state > div:first-child {
238
628
  align-items: flex-start;
239
629
  flex-direction: column;
@@ -1 +1 @@
1
- {"version":3,"file":"DataTablePreview.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/playground/DataTablePreview.svelte.ts"],"names":[],"mappings":"AAuIA,QAAA,MAAM,gBAAgB,2DAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"DataTablePreview.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/playground/DataTablePreview.svelte.ts"],"names":[],"mappings":"AAucA,QAAA,MAAM,gBAAgB,2DAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
@@ -51,10 +51,10 @@ export default {
51
51
  {
52
52
  id: 'data-table',
53
53
  title: 'Data Table',
54
- description: 'Sortable and selectable tabular data with loading, density, and empty states.',
54
+ description: 'Accessible local and manual tables with query lifecycle states, grouped reports, saved layout, responsive overflow, and virtualization.',
55
55
  loadComponent: loadDataTable,
56
56
  order: 5,
57
- tags: ['data', 'table', 'grid'],
57
+ tags: ['data', 'table', 'manual-query', 'reporting', 'accessibility'],
58
58
  modes: {
59
59
  mock: {
60
60
  label: 'Interactive',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.42.3",
3
+ "version": "0.42.5",
4
4
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -124,7 +124,7 @@
124
124
  },
125
125
  "dependencies": {
126
126
  "esm-env": "^1.2.2",
127
- "@happyvertical/smrt-types": "0.42.3"
127
+ "@happyvertical/smrt-types": "0.42.5"
128
128
  },
129
129
  "peerDependencies": {
130
130
  "svelte": "^5.56.4"
@@ -152,6 +152,7 @@
152
152
  "build": "svelte-package -i src --tsconfig tsconfig.svelte.json",
153
153
  "dev": "svelte-package -i src --watch",
154
154
  "check": "pnpm exec svelte-check --tsconfig ./tsconfig.svelte.json",
155
+ "bench:data-table": "vitest bench src/components/data/__benchmarks__/DataTable.bench.ts",
155
156
  "generate:themes": "tsx scripts/generate-theme-styles.ts",
156
157
  "test": "vitest run",
157
158
  "typecheck": "tsc --noEmit && node ../../scripts/svelte-check-a11y.mjs --tsconfig ./tsconfig.svelte.json",