@svgrid/grid 3.0.5 → 3.0.6

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 (148) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +21 -3
  4. package/dist/SvGrid.controller.svelte.js +205 -50
  5. package/dist/SvGrid.css +127 -2
  6. package/dist/SvGrid.svelte +223 -38
  7. package/dist/SvGrid.types.d.ts +77 -4
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +81 -31
  15. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-C-NtjlrJ.js} +150 -141
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  45. package/dist/cdn/{src-jcFMkBnN.js → src-CgoeSM8k.js} +4697 -4131
  46. package/dist/cdn/{src-Bklofsad.js → src-DfwxB1TH.js} +4727 -4161
  47. package/dist/cdn/svgrid.js +17 -17
  48. package/dist/cdn/svgrid.svelte-external.js +17 -17
  49. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +365 -25
  59. package/dist/editing.js +24 -0
  60. package/dist/grid-messages.d.ts +4 -0
  61. package/dist/grid-messages.js +4 -0
  62. package/dist/headless.d.ts +5 -0
  63. package/dist/headless.js +12 -0
  64. package/dist/history.d.ts +21 -0
  65. package/dist/history.js +34 -4
  66. package/dist/index.d.ts +1 -1
  67. package/dist/keyboard-handlers.js +24 -1
  68. package/dist/keyboard.js +4 -0
  69. package/dist/row-drag-touch.d.ts +1 -1
  70. package/dist/row-drag.js +35 -1
  71. package/dist/row-model.d.ts +6 -0
  72. package/dist/row-resize.js +22 -1
  73. package/dist/selection.js +1 -1
  74. package/dist/server-block-cache.d.ts +5 -0
  75. package/dist/server-block-cache.js +8 -0
  76. package/dist/server-data-source.d.ts +35 -3
  77. package/dist/server-data-source.js +11 -2
  78. package/dist/server.d.ts +1 -1
  79. package/dist/sparkline.js +7 -5
  80. package/dist/svgrid-wrapper.types.d.ts +33 -0
  81. package/dist/validate.js +9 -1
  82. package/package.json +6 -1
  83. package/src/GridMenus.svelte +34 -4
  84. package/src/SvGrid.controller.svelte.ts +206 -53
  85. package/src/SvGrid.css +127 -2
  86. package/src/SvGrid.svelte +223 -38
  87. package/src/SvGrid.types.ts +77 -4
  88. package/src/SvGridDropdown.svelte +20 -0
  89. package/src/SvModal.svelte +22 -0
  90. package/src/SvModal.test.ts +30 -1
  91. package/src/a11y/dismissable.test.ts +10 -1
  92. package/src/a11y/dismissable.ts +22 -2
  93. package/src/build-api.ts +85 -27
  94. package/src/cell-formatting.test.ts +17 -0
  95. package/src/cell-formatting.ts +18 -1
  96. package/src/cell-render.test.ts +24 -2
  97. package/src/cell-render.ts +24 -8
  98. package/src/clipboard.test.ts +107 -0
  99. package/src/clipboard.ts +62 -23
  100. package/src/columns.test.ts +4 -4
  101. package/src/columns.ts +7 -2
  102. package/src/conditional-formatting.test.ts +8 -0
  103. package/src/conditional-formatting.ts +3 -0
  104. package/src/core.tick-repair.test.ts +287 -0
  105. package/src/core.ts +374 -26
  106. package/src/editing.test.ts +52 -0
  107. package/src/editing.ts +25 -0
  108. package/src/grid-messages.test.ts +7 -0
  109. package/src/grid-messages.ts +10 -0
  110. package/src/headless.ts +24 -0
  111. package/src/history.test.ts +49 -1
  112. package/src/history.ts +48 -2
  113. package/src/index.ts +2 -0
  114. package/src/keyboard-handlers.coverage.test.ts +65 -0
  115. package/src/keyboard-handlers.ts +26 -1
  116. package/src/keyboard.ts +3 -0
  117. package/src/row-drag-touch.ts +2 -2
  118. package/src/row-drag.test.ts +51 -0
  119. package/src/row-drag.ts +35 -2
  120. package/src/row-model.ts +6 -0
  121. package/src/row-resize.ts +24 -1
  122. package/src/selection.ts +1 -1
  123. package/src/server-block-cache.test.ts +27 -0
  124. package/src/server-block-cache.ts +13 -0
  125. package/src/server-data-source.infinite.test.ts +62 -0
  126. package/src/server-data-source.test.ts +19 -0
  127. package/src/server-data-source.ts +48 -4
  128. package/src/server.ts +2 -0
  129. package/src/sparkline.test.ts +7 -0
  130. package/src/sparkline.ts +6 -4
  131. package/src/svgrid-wrapper.types.ts +32 -0
  132. package/src/svgrid.api.test.ts +21 -0
  133. package/src/svgrid.charting.test.ts +12 -4
  134. package/src/svgrid.context-menu.test.ts +12 -0
  135. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  136. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  137. package/src/svgrid.gantt-seam.test.ts +1 -1
  138. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  139. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  140. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  141. package/src/svgrid.row-pinning.test.ts +15 -1
  142. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  143. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  144. package/src/svgrid.upsell-license.test.ts +1 -1
  145. package/src/test-setup.ts +12 -0
  146. package/src/transaction.test.ts +58 -0
  147. package/src/validate.ts +7 -1
  148. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
@@ -294,6 +294,33 @@ describe('createBlockCache', () => {
294
294
  cache.dispose()
295
295
  })
296
296
 
297
+ it('retries one failed block by a row index, leaving the other failed blocks alone', async () => {
298
+ const server = scriptedFetch({ failBlocks: new Set([1, 3]) })
299
+ const cache = createBlockCache<Row>({
300
+ blockSize: 100,
301
+ fetch: server.fetch,
302
+ onChange: () => {},
303
+ schedule: sync,
304
+ })
305
+ cache.setViewport(100, 399)
306
+ await server.flush()
307
+ expect(cache.getCacheState().filter((b) => b.status === 'failed').map((b) => b.blockIndex)).toEqual([1, 3])
308
+
309
+ server.failBlocks.clear()
310
+ // The Retry on a row in block 3: block 1 stays failed, one request goes out.
311
+ const before = server.startsRequested().length
312
+ cache.retryFailedAt(350)
313
+ await server.flush()
314
+ expect(server.startsRequested().length).toBe(before + 1)
315
+ expect(cache.getCacheState().find((b) => b.blockIndex === 3)!.status).toBe('loaded')
316
+ expect(cache.getCacheState().find((b) => b.blockIndex === 1)!.status).toBe('failed')
317
+ // A row whose block is fine is a no-op.
318
+ cache.retryFailedAt(250)
319
+ await server.flush()
320
+ expect(server.startsRequested().length).toBe(before + 1)
321
+ cache.dispose()
322
+ })
323
+
297
324
  it('keeps the first block at its skeleton length when it fails with no count', async () => {
298
325
  // Nothing is known about the list yet, so the failed rows are the few
299
326
  // the level showed while loading, not a hundred and one of them.
@@ -177,6 +177,11 @@ export type BlockCache<TData> = {
177
177
  lastRowKnown(): boolean
178
178
  /** Re-fetch the blocks that failed. */
179
179
  retryFailed(): void
180
+ /**
181
+ * Re-fetch the failed block holding `rowIndex`, and only that one; the
182
+ * Retry on a failed row. A row whose block has not failed is a no-op.
183
+ */
184
+ retryFailedAt(rowIndex: number): void
180
185
  /**
181
186
  * Re-fetch the loaded blocks in place, keeping the row count and scroll
182
187
  * position. What you want after a mutation lands on the server.
@@ -502,6 +507,14 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
502
507
  if (any) emit()
503
508
  },
504
509
 
510
+ retryFailedAt(rowIndex) {
511
+ const index = blockOf(Math.max(0, rowIndex))
512
+ if (blocks.get(index)?.status !== 'failed') return
513
+ blocks.delete(index)
514
+ ensureBlock(index)
515
+ emit()
516
+ },
517
+
505
518
  refresh() {
506
519
  // Keep the count and the scroll position: drop the DATA, re-request the
507
520
  // same blocks. The user sees skeletons where they were, not a jump home.
@@ -167,6 +167,68 @@ describe('createServerDataSource in infinite mode', () => {
167
167
  ctl.dispose()
168
168
  })
169
169
 
170
+ it('retries only the block under the row the grid asks about', async () => {
171
+ let failing = true
172
+ const starts: number[] = []
173
+ const source: ServerDataSource<Row> = {
174
+ async getRows(req) {
175
+ starts.push(req.startRow)
176
+ if (failing && req.startRow >= 100) throw new Error('backend down')
177
+ const rows: Row[] = []
178
+ for (let i = req.startRow; i < Math.min(req.endRow, 1000); i += 1) {
179
+ rows.push({ id: i, name: `Row ${i}` })
180
+ }
181
+ return { rows, rowCount: 1000 }
182
+ },
183
+ }
184
+ let view!: ServerState<Row>
185
+ const ctl = createServerDataSource<Row>(source, {
186
+ mode: 'infinite',
187
+ blockSize: 100,
188
+ onChange: (s) => (view = s),
189
+ })
190
+ ctl.setViewport(100, 299)
191
+ await settle()
192
+ expect(view.failedBlocks).toEqual([1, 2])
193
+
194
+ failing = false
195
+ const before = starts.length
196
+ // The GridRowModel seam the failed row's Retry button goes through.
197
+ ctl.retryRow!(view.rows[250]!, 250)
198
+ await settle()
199
+ expect(starts.slice(before)).toEqual([200])
200
+ expect(view.failedBlocks).toEqual([1])
201
+ ctl.dispose()
202
+ })
203
+
204
+ it('hands each block a signal that purge, a sort change and dispose abort', async () => {
205
+ const signals: AbortSignal[] = []
206
+ const source: ServerDataSource<Row> = {
207
+ async getRows(req) {
208
+ signals.push(req.signal!)
209
+ // A server that never answers: only the abort can end these.
210
+ return new Promise(() => {})
211
+ },
212
+ }
213
+ const ctl = createServerDataSource<Row>(source, { mode: 'infinite', blockSize: 100, onChange: () => {} })
214
+ ctl.setViewport(0, 20)
215
+ await settle()
216
+ expect(signals).toHaveLength(1)
217
+ expect(signals[0]!.aborted).toBe(false)
218
+
219
+ ctl.purge()
220
+ await settle()
221
+ expect(signals[0]!.aborted).toBe(true)
222
+ expect(signals).toHaveLength(2)
223
+
224
+ ctl.setSort([{ id: 'id', desc: true }])
225
+ await settle()
226
+ expect(signals[1]!.aborted).toBe(true)
227
+
228
+ ctl.dispose()
229
+ expect(signals.at(-1)!.aborted).toBe(true)
230
+ })
231
+
170
232
  it('refresh keeps the count and the scroll position, purge does not', async () => {
171
233
  const { source, starts } = tableOf(1000)
172
234
  let view!: ServerState<Row>
@@ -86,6 +86,25 @@ describe('createServerDataSource', () => {
86
86
  expect(ctl.getState().rows[0]).toEqual({ id: 30 })
87
87
  })
88
88
 
89
+ it('aborts the page fetch a newer one supersedes, and the last one on dispose', async () => {
90
+ const signals: AbortSignal[] = []
91
+ const src: ServerDataSource<Row> = {
92
+ getRows: async (req) => {
93
+ signals.push(req.signal!)
94
+ return new Promise(() => {})
95
+ },
96
+ }
97
+ const ctl = createServerDataSource(src, { pageSize: 10, onChange: () => {} })
98
+ ctl.refresh()
99
+ ctl.setPage(3)
100
+ await new Promise((r) => setTimeout(r, 5))
101
+ expect(signals).toHaveLength(2)
102
+ expect(signals[0]!.aborted).toBe(true)
103
+ expect(signals[1]!.aborted).toBe(false)
104
+ ctl.dispose()
105
+ expect(signals[1]!.aborted).toBe(true)
106
+ })
107
+
89
108
  it('dispose stops further updates', async () => {
90
109
  const src = makeSource(50, 20)
91
110
  const onChange = vi.fn()
@@ -63,8 +63,17 @@ export type ServerFilterModel = {
63
63
  expression?: GridPredicateExpr
64
64
  }
65
65
 
66
- /** A value column to roll up per group. */
67
- export type ServerAggregation = { col: string; fn: 'sum' | 'avg' | 'min' | 'max' | 'count' }
66
+ /** The aggregate functions every backend helper understands. */
67
+ export type ServerAggFn = 'sum' | 'avg' | 'min' | 'max' | 'count'
68
+
69
+ /**
70
+ * One value column to roll up per group. `fn` is one of the five built-ins
71
+ * or any name your backend knows (`median`, `countDistinct`, a domain
72
+ * aggregate): the request carries it untouched. The SvelteKit planner emits
73
+ * only the built-ins plus the names it was told to allow, since `fn` ends
74
+ * up in SQL.
75
+ */
76
+ export type ServerAggregation = { col: string; fn: ServerAggFn | (string & {}) }
68
77
 
69
78
  /**
70
79
  * One range of rows as the grid asks for it. A flat request carries the
@@ -122,6 +131,16 @@ export type ServerRequest = {
122
131
  * transport posts the whole request to your endpoint.
123
132
  */
124
133
  context?: unknown
134
+ /**
135
+ * Aborted when the grid no longer wants the answer: the block was purged
136
+ * or evicted, the sort or filter changed, a group was collapsed, the
137
+ * controller was disposed. Hand it to `fetch` and a query the user has
138
+ * scrolled past is cancelled at the server instead of completing for
139
+ * nothing; a rejection carrying the abort is ignored by the grid. Not
140
+ * serialisable - the SvelteKit transport reads it and leaves it out of
141
+ * the body.
142
+ */
143
+ signal?: AbortSignal
125
144
  }
126
145
 
127
146
  /**
@@ -236,6 +255,21 @@ export type ServerPlaceholderRow = {
236
255
  readonly state: 'loading' | 'failed'
237
256
  }
238
257
 
258
+ /**
259
+ * The detail panel under a leaf whose detail is open: master-detail on a
260
+ * server-side row model. The grid draws it through `renderDetailRow`
261
+ * (mark it with `isDetailRow`); `master` is the leaf it belongs to.
262
+ */
263
+ export type ServerDetailRow<TData> = {
264
+ kind: 'detail'
265
+ id: string
266
+ level: number
267
+ route?: string[]
268
+ /** The leaf's id, as `getRowId` names it. */
269
+ masterId: string
270
+ master: TData
271
+ }
272
+
239
273
  /**
240
274
  * Every row a server row model can put on screen. Each carries an `id` and
241
275
  * a `level`. The block-cached model adds {@link ServerPlaceholderRow} for
@@ -244,6 +278,7 @@ export type ServerPlaceholderRow = {
244
278
  export type ServerDisplayRow<TData> =
245
279
  | ServerGroupRow<TData>
246
280
  | ServerLeafRow<TData>
281
+ | ServerDetailRow<TData>
247
282
  | ServerMoreRow
248
283
  | ServerFooterRow<TData>
249
284
  | ServerSkeletonRow
@@ -523,6 +558,8 @@ export function createServerDataSource<TData>(
523
558
  // Monotonic request id: only the latest fetch is allowed to land, so a slow
524
559
  // response for an old sort/filter can't clobber a newer one.
525
560
  let requestSeq = 0
561
+ /** The page fetch in flight; a newer one, or dispose, aborts it. */
562
+ let pageAbort: AbortController | null = null
526
563
  let disposed = false
527
564
  // Once per controller: a misconfigured backend would otherwise log on every
528
565
  // page, scroll and filter change.
@@ -559,7 +596,7 @@ export function createServerDataSource<TData>(
559
596
  maxConcurrentRequests: options.maxConcurrentRequests,
560
597
  blockLoadDebounceMs: options.blockLoadDebounceMs,
561
598
  initialRowCount: options.initialRowCount,
562
- fetch: async (startRow, endRow) => {
599
+ fetch: async (startRow, endRow, signal) => {
563
600
  const result = await source.getRows({
564
601
  startRow,
565
602
  endRow,
@@ -572,6 +609,7 @@ export function createServerDataSource<TData>(
572
609
  groupBy: [],
573
610
  groupKeys: [],
574
611
  aggregations: [],
612
+ signal,
575
613
  })
576
614
  noteExpressionApplied(result)
577
615
  return { rows: result.rows, rowCount: result.rowCount }
@@ -617,6 +655,9 @@ export function createServerDataSource<TData>(
617
655
  async function fetchPage() {
618
656
  if (disposed) return
619
657
  const id = ++requestSeq
658
+ pageAbort?.abort()
659
+ const controller = new AbortController()
660
+ pageAbort = controller
620
661
  state.loading = true
621
662
  state.error = null
622
663
  emit()
@@ -634,6 +675,7 @@ export function createServerDataSource<TData>(
634
675
  groupBy: [],
635
676
  groupKeys: [],
636
677
  aggregations: [],
678
+ signal: controller.signal,
637
679
  })
638
680
  if (disposed || id !== requestSeq) return // stale
639
681
  state.rows = result.rows
@@ -815,7 +857,7 @@ export function createServerDataSource<TData>(
815
857
  getRowId: options.getRowId ? (row: TData) => options.getRowId!(row) : undefined,
816
858
  // Only infinite mode has unloaded rows to stand in for.
817
859
  rowPlaceholder: infinite ? (row: TData) => rowPlaceholderState(row) : undefined,
818
- retryRow: infinite ? () => cache?.retryFailed() : undefined,
860
+ retryRow: infinite ? (_row: TData, rowIndex: number) => cache?.retryFailedAt(rowIndex) : undefined,
819
861
  /**
820
862
  * A getter, not a snapshot: the grid re-reads this after every change
821
863
  * notification, and a frozen object would leave the pager on page 1
@@ -878,6 +920,8 @@ export function createServerDataSource<TData>(
878
920
  getState: () => ({ ...state }),
879
921
  dispose() {
880
922
  disposed = true
923
+ pageAbort?.abort()
924
+ pageAbort = null
881
925
  cache?.dispose()
882
926
  cache = null
883
927
  // An in-flight fetch's resolution short-circuits on `disposed`, so it
package/src/server.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  type ServerState,
20
20
  type ServerSortModel,
21
21
  type ServerFilterModel,
22
+ type ServerAggFn,
22
23
  type ServerAggregation,
23
24
  type ServerGroupRow,
24
25
  type ServerLeafRow,
@@ -28,6 +29,7 @@ export {
28
29
  type ServerGrandTotalRow,
29
30
  type ServerPlaceholderRow,
30
31
  type ServerDisplayRow,
32
+ type ServerDetailRow,
31
33
  } from './server-data-source'
32
34
  export {
33
35
  createBlockCache,
@@ -59,6 +59,13 @@ describe('buildSparkline', () => {
59
59
  expect(g.bars[0]!.h).toBeCloseTo(g.bars[1]!.h)
60
60
  })
61
61
 
62
+ it('leaves a blank gap for a zero, marking only wins and losses', () => {
63
+ const g = buildSparkline([1, 0, -1, 0, 1], { type: 'winloss', height: 20 })!
64
+ // Two wins and a loss draw; the two zeros are gaps, as Excel draws them.
65
+ expect(g.bars).toHaveLength(3)
66
+ expect(g.bars.map((b) => b.negative)).toEqual([false, true, false])
67
+ })
68
+
62
69
  it('honors a fixed min/max scale', () => {
63
70
  const a = buildSparkline([5, 5, 5], { type: 'line', min: 0, max: 10 })!
64
71
  // flat series on a 0..10 scale sits at the vertical middle, not the top
package/src/sparkline.ts CHANGED
@@ -102,11 +102,13 @@ export function buildSparkline(
102
102
  const barW = Math.max(1, slot - gap)
103
103
  const mid = height / 2
104
104
  const h = innerH / 2
105
- const bars: SparklineBar[] = values.map((v, i) => {
105
+ const bars: SparklineBar[] = []
106
+ values.forEach((v, i) => {
106
107
  const x = pad + i * slot + (slot - barW) / 2
107
- if (v > 0) return { x, y: mid - h, w: barW, h, negative: false }
108
- if (v < 0) return { x, y: mid, w: barW, h, negative: true }
109
- return { x, y: mid - 1, w: barW, h: 2, negative: false }
108
+ // A win/loss sparkline marks a win up and a loss down; a zero is a
109
+ // blank gap, no mark at all, the way Excel draws one.
110
+ if (v > 0) bars.push({ x, y: mid - h, w: barW, h, negative: false })
111
+ else if (v < 0) bars.push({ x, y: mid, w: barW, h, negative: true })
110
112
  })
111
113
  return { ...base, linePath: '', areaPath: '', bars, lastPoint: null }
112
114
  }
@@ -146,6 +146,14 @@ export type SvGridApi<
146
146
  TFeatures extends TableFeatures,
147
147
  TData extends RowData,
148
148
  > = {
149
+ // ----- DOM -----
150
+ /**
151
+ * The grid's root element (`.sv-grid-root`), or null before mount and
152
+ * after unmount. For measuring, focusing, or reading the theme the grid
153
+ * is drawn with - the PDF export takes its colours from here.
154
+ */
155
+ getElement(): HTMLElement | null
156
+
149
157
  // ----- Cells -----
150
158
  /** Read a cell value from the underlying data at `rowIndex`. */
151
159
  getCellValue(rowIndex: number, columnId: string): unknown
@@ -430,6 +438,16 @@ export type SvGridApi<
430
438
  /** Snapshot of the current data array (pre-pipeline). */
431
439
  getData(): ReadonlyArray<TData>
432
440
 
441
+ /**
442
+ * The stable id the grid keys a row by: what the `getRowId` prop
443
+ * computed, or the row's position when no `getRowId` is set. Pass a
444
+ * display index (the space `getActiveCell`, `scrollToRow` and the cell
445
+ * methods use) or a row object; returns null for an unknown index or a
446
+ * row that is not in the data. Pair with `selectRows`, `toggleRowSelected`
447
+ * or an `applyTransaction` remove, all of which take these ids.
448
+ */
449
+ getRowId(row: number | TData): string | null
450
+
433
451
  /**
434
452
  * Snapshot of every column the grid currently knows about, in visual
435
453
  * order, with the human-readable header label. Use this when exporting
@@ -580,6 +598,20 @@ export type SvGridApi<
580
598
  canRedo(): boolean
581
599
  /** Wipe both stacks (e.g. after a server save commits the buffer). */
582
600
  clearHistory(): void
601
+ /**
602
+ * Mark every step recorded from now on with `tag`, until the next call;
603
+ * `undefined` stops the marking. The grid never reads the tag. It is for a
604
+ * consumer that shows one of several data sets through the grid (a
605
+ * workbook's sheets, say) and has to know which one the next Ctrl+Z would
606
+ * change before it lets the grid apply it: it tags the steps with the data
607
+ * set they were made on and asks `peekUndo` first.
608
+ */
609
+ setHistoryTag(tag: unknown): void
610
+ /** The step the next `undo()` would apply, or null when there is none. A
611
+ * multi-cell action carries one tag, so it reports as one. */
612
+ peekUndo(): { tag: unknown } | null
613
+ /** The step the next `redo()` would apply, or null when there is none. */
614
+ peekRedo(): { tag: unknown } | null
583
615
 
584
616
  // ----- Keyboard commands -----
585
617
  /**
@@ -104,6 +104,27 @@ describe('SvGridApi - getData / getDisplayedRows', () => {
104
104
  }
105
105
  })
106
106
 
107
+ it('getRowId resolves a display index and a row object, and returns null for the unknown', async () => {
108
+ const { api, destroy } = await mountGrid()
109
+ try {
110
+ // No getRowId prop: the id is the row's index as a string.
111
+ expect(api.getRowId(0)).toBe('0')
112
+ expect(api.getRowId(4)).toBe('4')
113
+ expect(api.getRowId(99)).toBeNull()
114
+ const third = api.getData()[2]!
115
+ expect(api.getRowId(third)).toBe('2')
116
+ expect(api.getRowId({ id: 999, name: 'x', team: 'x', salary: 0 })).toBeNull()
117
+ // After a sort the display index follows the new order.
118
+ api.setSort('salary', 'desc')
119
+ await Promise.resolve()
120
+ const topId = api.getRowId(0)
121
+ expect(topId).not.toBeNull()
122
+ expect(api.getData()[Number(topId)]!.salary).toBe(Math.max(...sampleRows.map((r) => r.salary)))
123
+ } finally {
124
+ destroy()
125
+ }
126
+ })
127
+
107
128
  it('getDisplayedRows returns the post-pipeline visible rows', async () => {
108
129
  const { api, destroy } = await mountGrid()
109
130
  try {
@@ -754,7 +754,15 @@ describe('SvGrid built-in charting', () => {
754
754
  })
755
755
 
756
756
 
757
- it('the builder opens from the panel with a live thumbnail per type, and a card switches the chart', { timeout: 20_000 }, async () => {
757
+ // The builder mounts a live SvGridChart per type, thirty of them, in one
758
+ // synchronous flush. Alone that is well under a second; under `--coverage`
759
+ // (instrumented chart code, every file of the suite in parallel workers)
760
+ // it took 22 s on a loaded machine and the four tests that open the
761
+ // builder timed out at 20 s, the last of them then reading a builder a
762
+ // timed-out neighbour left behind. The budget is for instrumentation,
763
+ // not for the product: `pnpm test` runs with coverage and is the gate.
764
+ const BUILDER_TIMEOUT = 90_000
765
+ it('the builder opens from the panel with a live thumbnail per type, and a card switches the chart', { timeout: BUILDER_TIMEOUT }, async () => {
758
766
  const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
759
767
  try {
760
768
  await tick()
@@ -805,7 +813,7 @@ describe('SvGrid built-in charting', () => {
805
813
  }
806
814
  })
807
815
 
808
- it('the Format tab writes series labels, crosshair pills, a compact rule, a stack group and a style through configureChart', { timeout: 20_000 }, async () => {
816
+ it('the Format tab writes series labels, crosshair pills, a compact rule, a stack group and a style through configureChart', { timeout: BUILDER_TIMEOUT }, async () => {
809
817
  const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
810
818
  try {
811
819
  await tick()
@@ -848,7 +856,7 @@ describe('SvGrid built-in charting', () => {
848
856
  }
849
857
  })
850
858
 
851
- it('the builder Data tab renders the panel pickers and both write the same tab', { timeout: 20_000 }, async () => {
859
+ it('the builder Data tab renders the panel pickers and both write the same tab', { timeout: BUILDER_TIMEOUT }, async () => {
852
860
  const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
853
861
  try {
854
862
  await tick()
@@ -883,7 +891,7 @@ describe('SvGrid built-in charting', () => {
883
891
  }
884
892
  })
885
893
 
886
- it('localization.text relabels the panel and the builder; unset keys stay English', { timeout: 20_000 }, async () => {
894
+ it('localization.text relabels the panel and the builder; unset keys stay English', { timeout: BUILDER_TIMEOUT }, async () => {
887
895
  const { target, destroy } = await mountGrid({
888
896
  charting: { defaultOpen: true },
889
897
  localization: { text: { chartPanelTitle: 'Diagramm', chartGroupBy: 'Gruppieren nach', chartTypeBar: 'Balken', chartAdd: 'Diagramm hinzufügen', chartBuilderTitle: 'Diagramm-Editor', chartBuilderTabData: 'Daten', noRows: 'Keine Zeilen' } },
@@ -137,6 +137,18 @@ describe('SvGrid context menu', () => {
137
137
  destroy()
138
138
  })
139
139
 
140
+ it('closes on Escape, so a keyboard user is not trapped under it', async () => {
141
+ const { target, destroy } = await mountGrid(true)
142
+ await tick()
143
+ await openContextMenu(target)
144
+ expect(target.querySelector('.sv-grid-context-menu')).not.toBeNull()
145
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
146
+ await vi.waitFor(() => {
147
+ expect(target.querySelector('.sv-grid-context-menu')).toBeNull()
148
+ })
149
+ destroy()
150
+ })
151
+
140
152
  it('does not open when contextMenu is omitted (native menu shows)', async () => {
141
153
  const { target, destroy } = await mountGrid(undefined)
142
154
  await tick()
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Detail rows under virtualization: `detailRowHeight` sizes a detail row for
3
+ * the virtualizer, so a master-detail grid no longer needs
4
+ * `virtualization={false}`.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount } from 'svelte'
8
+ import SvGrid from './SvGrid.svelte'
9
+ import { createCoreRowModel, tableFeatures } from './index'
10
+ import type { ColumnDef, SvGridApi } from './index'
11
+
12
+ type Row = { id: string; name: string; kind: 'data' | 'detail' }
13
+ const features = tableFeatures({})
14
+ const cols: ColumnDef<typeof features, Row>[] = [{ field: 'name', header: 'Name', width: 200 }]
15
+ const tick = () => new Promise<void>((r) => setTimeout(r))
16
+
17
+ function rows(): Row[] {
18
+ const out: Row[] = []
19
+ for (let i = 0; i < 12; i += 1) {
20
+ out.push({ id: `r${i}`, name: `row ${i}`, kind: 'data' })
21
+ if (i === 1 || i === 4) out.push({ id: `d${i}`, name: `detail of ${i}`, kind: 'detail' })
22
+ }
23
+ return out
24
+ }
25
+
26
+ function mountGrid(extra: Record<string, unknown>) {
27
+ return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>((res, rej) => {
28
+ const target = document.createElement('div')
29
+ document.body.appendChild(target)
30
+ const app = mount(SvGrid, {
31
+ target,
32
+ props: {
33
+ data: rows(),
34
+ columns: cols,
35
+ features,
36
+ _rowModels: { coreRowModel: createCoreRowModel() },
37
+ getRowId: (r: Row) => r.id,
38
+ rowHeight: 30,
39
+ containerHeight: 300,
40
+ virtualization: true,
41
+ isDetailRow: (r: Row) => r.kind === 'detail',
42
+ onApiReady(api: SvGridApi<typeof features, Row>) {
43
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
44
+ },
45
+ ...extra,
46
+ } as any,
47
+ })
48
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
49
+ })
50
+ }
51
+
52
+ describe('detailRowHeight', () => {
53
+ it('sizes detail rows for the virtualizer and leaves the others at rowHeight', async () => {
54
+ const { api, target, destroy } = await mountGrid({ detailRowHeight: 120 })
55
+ try {
56
+ await tick()
57
+ const detail = target.querySelector<HTMLElement>('tr.sv-grid-detail-row')!
58
+ expect(detail.classList.contains('sv-grid-detail-row-sized')).toBe(true)
59
+ expect(detail.style.height).toBe('120px')
60
+ // Index 2 is the detail under row 1; 3 is a plain row.
61
+ expect(api.getRowHeight(2)).toBe(120)
62
+ expect(api.getRowHeight(3)).toBe(30)
63
+ } finally {
64
+ destroy()
65
+ }
66
+ })
67
+
68
+ it('takes a function of the row', async () => {
69
+ const { api, destroy } = await mountGrid({ detailRowHeight: (r: Row) => (r.id === 'd1' ? 80 : 200) })
70
+ try {
71
+ await tick()
72
+ expect(api.getRowHeight(2)).toBe(80)
73
+ expect(api.getRowHeight(6)).toBe(200)
74
+ } finally {
75
+ destroy()
76
+ }
77
+ })
78
+
79
+ it('without one a detail row is auto height, as before', async () => {
80
+ const { target, destroy } = await mountGrid({ virtualization: false })
81
+ try {
82
+ await tick()
83
+ const detail = target.querySelector<HTMLElement>('tr.sv-grid-detail-row')!
84
+ expect(detail.classList.contains('sv-grid-detail-row-sized')).toBe(false)
85
+ expect(detail.style.height).toBe('')
86
+ } finally {
87
+ destroy()
88
+ }
89
+ })
90
+ })
91
+
92
+ describe('showDetailToggle', () => {
93
+ it('is a row-header column: sticky, no menu, no resize handle, a chevron per data row and none on a detail row', async () => {
94
+ const open = new Set<string>(['r1', 'r4'])
95
+ const toggled: string[] = []
96
+ const { target, destroy } = await mountGrid({
97
+ showDetailToggle: true,
98
+ columnResize: true,
99
+ columnMenu: true,
100
+ onDetailToggle: (r: Row) => toggled.push(r.id),
101
+ isDetailOpen: (r: Row) => open.has(r.id),
102
+ detailRowHeight: 60,
103
+ })
104
+ try {
105
+ await tick()
106
+ const head = target.querySelector<HTMLElement>('thead th.sv-grid-detail-toggle-column')!
107
+ expect(head).not.toBeNull()
108
+ expect(head.getAttribute('aria-hidden')).toBe('true')
109
+ expect(head.querySelector('.sv-grid-col-menu-btn, .sv-grid-column-resizer, [data-resize-handle]')).toBeNull()
110
+ expect(head.style.position || getComputedStyle(head).position).not.toBe('')
111
+ const first = target.querySelector<HTMLElement>('tbody tr.sv-grid-row:not(.sv-grid-detail-row)')!
112
+ const cell = first.querySelector<HTMLElement>('td.sv-grid-detail-toggle-cell')!
113
+ expect(cell).not.toBeNull()
114
+ // The gutter is not a data cell: nothing the keyboard or the selection can land on.
115
+ expect(cell.hasAttribute('data-svgrid-col')).toBe(false)
116
+ const chev = cell.querySelector<HTMLButtonElement>('button.sv-grid-detail-toggle')!
117
+ expect(chev.getAttribute('aria-expanded')).toBe('false')
118
+ expect(chev.getAttribute('aria-label')).toBe('Open details')
119
+ chev.click()
120
+ expect(toggled).toEqual(['r0'])
121
+ // Row 1 is open: its chevron says so; the detail row under it has a blank gutter cell.
122
+ const rowsAll = [...target.querySelectorAll<HTMLElement>('tbody tr.sv-grid-row')]
123
+ const r1 = rowsAll.find((tr) => tr.textContent?.includes('row 1'))!
124
+ expect(r1.querySelector('button.sv-grid-detail-toggle')!.getAttribute('aria-expanded')).toBe('true')
125
+ expect(r1.querySelector('button.sv-grid-detail-toggle')!.getAttribute('aria-label')).toBe('Close details')
126
+ const detail = target.querySelector<HTMLElement>('tr.sv-grid-detail-row')!
127
+ expect(detail.querySelector('button.sv-grid-detail-toggle')).toBeNull()
128
+ // The full-width cell spans the gutter too.
129
+ expect(detail.querySelector<HTMLTableCellElement>('td.sv-grid-detail-cell')!.colSpan).toBe(first.children.length)
130
+ } finally {
131
+ destroy()
132
+ }
133
+ })
134
+
135
+ it('leaves the chevron out where hasDetail says no, and sticks after the row numbers and the checkbox', async () => {
136
+ const { target, destroy } = await mountGrid({
137
+ showDetailToggle: true,
138
+ showRowNumbers: true,
139
+ showRowSelection: true,
140
+ onDetailToggle: () => {},
141
+ hasDetail: (r: Row) => r.id !== 'r0',
142
+ })
143
+ try {
144
+ await tick()
145
+ const rowsAll = [...target.querySelectorAll<HTMLElement>('tbody tr.sv-grid-row:not(.sv-grid-detail-row)')]
146
+ expect(rowsAll[0]!.querySelector('td.sv-grid-detail-toggle-cell button')).toBeNull()
147
+ expect(rowsAll[1]!.querySelector('td.sv-grid-detail-toggle-cell button')).not.toBeNull()
148
+ // Row number (56) + selection (44) sit before it.
149
+ const cell = rowsAll[1]!.querySelector<HTMLElement>('td.sv-grid-detail-toggle-cell')!
150
+ expect(cell.style.left).toBe('100px')
151
+ expect(cell.style.width).toBe('36px')
152
+ // The system cells come in that order, then the data.
153
+ const classes = [...rowsAll[1]!.children].map((td) => td.className.split(' ')[1])
154
+ expect(classes.slice(0, 3)).toEqual(['sv-grid-row-number-cell', 'sv-grid-selection-cell', 'sv-grid-detail-toggle-cell'])
155
+ } finally {
156
+ destroy()
157
+ }
158
+ })
159
+
160
+ it('on a server row model the seam decides: groups get no chevron, a leaf reads detailOpen and calls toggleDetail', async () => {
161
+ const calls: string[] = []
162
+ const { target, destroy } = await mountGrid({
163
+ showDetailToggle: true,
164
+ serverGroup: {
165
+ isGroup: (r: Row) => r.id === 'r0',
166
+ level: () => 0,
167
+ onToggle: () => {},
168
+ toggleDetail: (r: Row) => calls.push(r.id),
169
+ detailOpen: (r: Row) => r.id === 'r1',
170
+ hasDetail: (r: Row) => r.kind === 'data',
171
+ },
172
+ })
173
+ try {
174
+ await tick()
175
+ const rowsAll = [...target.querySelectorAll<HTMLElement>('tbody tr.sv-grid-row')]
176
+ const r0 = rowsAll.find((tr) => tr.textContent?.includes('row 0'))!
177
+ const r1 = rowsAll.find((tr) => tr.textContent?.includes('row 1'))!
178
+ expect(r0.querySelector('button.sv-grid-detail-toggle')).toBeNull()
179
+ const chev = r1.querySelector<HTMLButtonElement>('button.sv-grid-detail-toggle')!
180
+ expect(chev.getAttribute('aria-expanded')).toBe('true')
181
+ chev.click()
182
+ expect(calls).toEqual(['r1'])
183
+ } finally {
184
+ destroy()
185
+ }
186
+ })
187
+ })