@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
package/src/editing.ts CHANGED
@@ -158,6 +158,24 @@ export function createEditing<
158
158
  runHistoryGroup(ctx, () => commitEditingCell());
159
159
  }
160
160
 
161
+ /**
162
+ * True when a column with `rejectInvalid` refuses `value`: its `validate`
163
+ * hook returns `false` or a message. Without the flag nothing is refused.
164
+ */
165
+ function editRejected(column: any, value: unknown, rowData: unknown): boolean {
166
+ const def = column?.columnDef as
167
+ | { rejectInvalid?: boolean; validate?: (p: unknown) => string | boolean | null | undefined }
168
+ | undefined;
169
+ if (!def?.rejectInvalid || typeof def.validate !== "function") return false;
170
+ const verdict = def.validate({
171
+ value,
172
+ row: rowData,
173
+ rowIndex: ctx.internalData.indexOf(rowData as TData),
174
+ column,
175
+ });
176
+ return verdict === false || (typeof verdict === "string" && verdict.length > 0);
177
+ }
178
+
161
179
  function commitEditingCell() {
162
180
  if (!ctx.editingCell) return;
163
181
  const editing = ctx.editingCell;
@@ -200,6 +218,12 @@ export function createEditing<
200
218
  columnId: ctx.editingCell.columnId,
201
219
  });
202
220
  }
221
+ // A refused edit closes the editor and leaves the row, the overlay,
222
+ // the history and the consumer untouched.
223
+ if (editRejected(column, finalValue, rowData)) {
224
+ ctx.editingCell = null;
225
+ return;
226
+ }
203
227
  rowData[column.columnDef.field] = finalValue;
204
228
  }
205
229
  const key = getCellKey(ctx.editingCell.rowId, ctx.editingCell.columnId);
@@ -778,6 +802,7 @@ export function createEditing<
778
802
  columnId: column.id,
779
803
  })
780
804
  : parsed;
805
+ if (editRejected(column, finalValue, row.original)) continue;
781
806
  if (oldValue !== finalValue) {
782
807
  (row.original as Record<string, unknown>)[field] = finalValue;
783
808
  const key = getCellKey(fr.rowId, column.id);
@@ -8,6 +8,13 @@ describe('resolveGridMessages', () => {
8
8
  expect(resolveGridMessages({})).toEqual(defaultGridMessages)
9
9
  })
10
10
 
11
+ it('names the client group expander, so a localized grid has no English left in it', () => {
12
+ const m = resolveGridMessages({ expandGroup: 'Gruppe aufklappen', rowsSuffix: 'Zeilen' })
13
+ expect(m.expandGroup).toBe('Gruppe aufklappen')
14
+ expect(m.collapseGroup).toBe('Collapse group')
15
+ expect(m.rowsSuffix).toBe('Zeilen')
16
+ })
17
+
11
18
  it('replaces only the provided keys, keeping English for the rest', () => {
12
19
  const m = resolveGridMessages({ noRows: 'Aucune ligne', pageSize: 'Taille :' })
13
20
  expect(m.noRows).toBe('Aucune ligne')
@@ -109,6 +109,12 @@ export type GridChromeMessages = {
109
109
  // Rows whose data has not arrived (server-side / infinite loading)
110
110
  rowLoadFailed: string
111
111
  rowLoadRetry: string
112
+ // The expander on a client-side group row
113
+ expandGroup: string
114
+ collapseGroup: string
115
+ // The chevron of the detail-toggle column (`showDetailToggle`)
116
+ openDetail: string
117
+ closeDetail: string
112
118
  // Upsell notes (enterprise views not installed)
113
119
  pivotUpsellTitle: string
114
120
  pivotUpsellBody: string
@@ -194,6 +200,10 @@ export const defaultGridMessages: GridChromeMessages = {
194
200
  chartRange: 'Chart selected range',
195
201
  rowLoadFailed: 'Could not load these rows.',
196
202
  rowLoadRetry: 'Retry',
203
+ expandGroup: 'Expand group',
204
+ collapseGroup: 'Collapse group',
205
+ openDetail: 'Open details',
206
+ closeDetail: 'Close details',
197
207
  pivotUpsellTitle: 'Pivot mode',
198
208
  pivotUpsellBody: 'Pivot mode is an Enterprise feature. Install @svgrid/enterprise and call enablePivot() to use it.',
199
209
  ganttUpsellTitle: 'Gantt view',
package/src/headless.ts CHANGED
@@ -85,3 +85,27 @@ export {
85
85
  export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
86
86
  export { createGridState, createSvGridState } from './createGridState.svelte'
87
87
  export { subscribeGrid, subscribeSvGrid } from './subscribe'
88
+
89
+ // The virtualizers are pure window arithmetic (scroll offset in, visible
90
+ // range out) with no DOM of their own, and a custom renderer over 50k rows
91
+ // needs one. They were only on the barrel, so the Headless demos imported
92
+ // the barrel to reach them and paid for the whole thing.
93
+ export { createVirtualizer } from './virtualization/virtualizer'
94
+ export { createSvelteVirtualizer } from './virtualization/svelte-virtualizer.svelte'
95
+ export { createColumnVirtualizer } from './virtualization/column-virtualizer'
96
+ export type { VirtualItem, VirtualizerOptions, VirtualizerState } from './virtualization/types'
97
+
98
+ // The WAI-ARIA attribute factories: plain objects to spread onto your own
99
+ // markup (`role`, `aria-sort`, `aria-rowindex`, ...). No DOM is touched here
100
+ // either; a custom renderer that wants keyboard navigation reads
101
+ // `activeCell` from the store and spreads these.
102
+ export {
103
+ getGridCellDomId,
104
+ getGridCellA11yProps,
105
+ getGridHeaderA11yProps,
106
+ getGridRootA11yProps,
107
+ getGridRowA11yProps,
108
+ type GridCellA11yInput,
109
+ type GridColumnA11yInput,
110
+ type GridSortDirection,
111
+ } from './a11y'
@@ -5,6 +5,8 @@ import {
5
5
  pushHistory,
6
6
  runHistoryGroup,
7
7
  nextGroupId,
8
+ peekUndoHistory,
9
+ peekRedoHistory,
8
10
  type HistoryStep,
9
11
  } from './history'
10
12
 
@@ -162,7 +164,7 @@ describe('runHistoryGroup', () => {
162
164
  // This is why the group id is ambient rather than stamped afterwards: the
163
165
  // cap drops entries from the front, so any index captured before the call
164
166
  // would point at the wrong steps by the end of it.
165
- const { ctx } = makeCtx([step(0)], 3)
167
+ const { ctx } = makeCtx([step(0)], 1)
166
168
  runHistoryGroup(ctx, () => {
167
169
  pushHistory(ctx, [step(1)])
168
170
  pushHistory(ctx, [step(2)])
@@ -172,6 +174,21 @@ describe('runHistoryGroup', () => {
172
174
  expect(new Set(ctx.history.map((s) => s.groupId)).size).toBe(1)
173
175
  })
174
176
 
177
+ it('caps by actions, so a group larger than the buffer stays whole', () => {
178
+ // A sort of 5,000 rows is one Ctrl+Z of 20,000 steps; a cap that
179
+ // counted steps cut it to its tail and undid a fifth of it.
180
+ const { ctx } = makeCtx([step(0)], 2)
181
+ runHistoryGroup(ctx, () => { for (let i = 1; i <= 5; i += 1) pushHistory(ctx, [step(i)]) })
182
+ expect(ctx.history.map((s) => s.rowId)).toEqual(['r0', 'r1', 'r2', 'r3', 'r4', 'r5'])
183
+ pushHistory(ctx, [step(9)])
184
+ // Three actions now; the oldest one goes and the group survives whole.
185
+ expect(ctx.history.map((s) => s.rowId)).toEqual(['r1', 'r2', 'r3', 'r4', 'r5', 'r9'])
186
+ expect(ctx.historyPtr).toBe(5)
187
+ undoHistory(ctx)
188
+ undoHistory(ctx)
189
+ expect(ctx.historyPtr).toBe(-1)
190
+ })
191
+
175
192
  it('nests into the outer group so one action is one undo', () => {
176
193
  const { ctx } = makeCtx()
177
194
  runHistoryGroup(ctx, () => {
@@ -194,3 +211,34 @@ describe('runHistoryGroup', () => {
194
211
  expect(runHistoryGroup(ctx, () => 42)).toBe(42)
195
212
  })
196
213
  })
214
+
215
+ describe('tags', () => {
216
+ it('stamps the ambient tag on every step recorded while it is set, grouped or not', () => {
217
+ const { ctx } = makeCtx()
218
+ const sheet = { name: 'Orders' }
219
+ pushHistory(ctx, [step(1)])
220
+ ;(ctx as { historyTag?: unknown }).historyTag = sheet
221
+ pushHistory(ctx, [step(2)])
222
+ runHistoryGroup(ctx, () => { pushHistory(ctx, [step(3)]); pushHistory(ctx, [step(4)]) })
223
+ ;(ctx as { historyTag?: unknown }).historyTag = undefined
224
+ pushHistory(ctx, [step(5)])
225
+ expect(ctx.history.map((s) => s.tag)).toEqual([undefined, sheet, sheet, sheet, undefined])
226
+ expect(ctx.history[2]!.groupId).toBe(ctx.history[3]!.groupId)
227
+ })
228
+
229
+ it('peeks at the step the next undo or redo would take', () => {
230
+ const { ctx } = makeCtx()
231
+ ;(ctx as { historyTag?: unknown }).historyTag = 'Summary'
232
+ pushHistory(ctx, [step(1)])
233
+ ;(ctx as { historyTag?: unknown }).historyTag = 'Orders'
234
+ runHistoryGroup(ctx, () => { pushHistory(ctx, [step(2)]); pushHistory(ctx, [step(3)]) })
235
+ expect(peekUndoHistory(ctx)).toEqual({ tag: 'Orders' })
236
+ expect(peekRedoHistory(ctx)).toBeNull()
237
+ undoHistory(ctx)
238
+ expect(peekUndoHistory(ctx)).toEqual({ tag: 'Summary' })
239
+ expect(peekRedoHistory(ctx)).toEqual({ tag: 'Orders' })
240
+ undoHistory(ctx)
241
+ expect(peekUndoHistory(ctx)).toBeNull()
242
+ expect(peekRedoHistory(ctx)).toEqual({ tag: 'Summary' })
243
+ })
244
+ })
package/src/history.ts CHANGED
@@ -36,6 +36,15 @@ export type HistoryStep = {
36
36
  * functions do the work. Never serialised, since a closure cannot be.
37
37
  */
38
38
  custom?: { undo: () => void; redo: () => void }
39
+ /**
40
+ * The consumer's own mark on the step, copied from `api.setHistoryTag` at
41
+ * the moment it is recorded. The grid never reads it: it is there for a
42
+ * consumer that shows one of several data sets through the grid and has
43
+ * to know which one a step belongs to before it lets Ctrl+Z apply it (a
44
+ * workbook with several sheets, say). Kept as given, so an object tag
45
+ * compares by identity. Not serialised.
46
+ */
47
+ tag?: unknown
39
48
  }
40
49
 
41
50
  /** Ctx surface these helpers touch. The controller satisfies it structurally. */
@@ -48,6 +57,8 @@ type HistoryCtx = {
48
57
  * inside it joins that group, so a command does not have to thread a group
49
58
  * id down through the writers it calls. */
50
59
  historyGroupId: string | undefined
60
+ /** Stamped on every step recorded while it is set; see `HistoryStep.tag`. */
61
+ historyTag?: unknown
51
62
  applyHistoryStep(step: HistoryStep, direction: 'undo' | 'redo'): void
52
63
  }
53
64
 
@@ -71,6 +82,37 @@ function groupEnd(history: ReadonlyArray<HistoryStep>, start: number): number {
71
82
  return i
72
83
  }
73
84
 
85
+ /**
86
+ * The last `limit` ACTIONS of a history, whole groups kept. The cap used
87
+ * to count steps, and a sort of 5,000 rows is 20,000 steps in one group:
88
+ * it cut the group to its last 200 cells, so Ctrl+Z put a fifth of a
89
+ * column back and left the rest sorted. An action is one press of Ctrl+Z,
90
+ * so that is what the buffer counts; the steps inside are the cost of it.
91
+ */
92
+ function lastActions(history: HistoryStep[], limit: number): HistoryStep[] {
93
+ let i = history.length
94
+ let actions = 0
95
+ while (i > 0 && actions < limit) {
96
+ i = groupStart(history, i - 1)
97
+ actions += 1
98
+ }
99
+ return i === 0 ? history : history.slice(i)
100
+ }
101
+
102
+ /** What the next undo would apply: the tag of the step Ctrl+Z takes next
103
+ * (a group carries one tag, so its last step's is the group's). Null when
104
+ * there is nothing to undo. */
105
+ export function peekUndoHistory(ctx: HistoryCtx): { tag: unknown } | null {
106
+ const step = ctx.historyPtr >= 0 ? ctx.history[ctx.historyPtr] : undefined
107
+ return step ? { tag: step.tag } : null
108
+ }
109
+
110
+ /** The redo counterpart of `peekUndoHistory`. */
111
+ export function peekRedoHistory(ctx: HistoryCtx): { tag: unknown } | null {
112
+ const step = ctx.historyPtr < ctx.history.length - 1 ? ctx.history[ctx.historyPtr + 1] : undefined
113
+ return step ? { tag: step.tag } : null
114
+ }
115
+
74
116
  /** Undo one action. Returns false when there is nothing left to undo. */
75
117
  export function undoHistory(ctx: HistoryCtx): boolean {
76
118
  if (ctx.historyPtr < 0) return false
@@ -123,9 +165,13 @@ export function pushHistory(
123
165
  // Outside one, a group of one is just a step. Tagging it would make undo do
124
166
  // the same work through a slower path and would show up in serialized state.
125
167
  const id = ctx.historyGroupId ?? (groupId && steps.length > 1 ? groupId : undefined)
168
+ const tag = ctx.historyTag
126
169
  let next = ctx.history.slice(0, ctx.historyPtr + 1)
127
- for (const step of steps) next.push(id ? { ...step, groupId: id } : step)
128
- if (next.length > ctx.UNDO_LIMIT) next = next.slice(next.length - ctx.UNDO_LIMIT)
170
+ for (const step of steps) {
171
+ const stamped = tag === undefined ? step : { ...step, tag }
172
+ next.push(id ? { ...stamped, groupId: id } : stamped)
173
+ }
174
+ next = lastActions(next, ctx.UNDO_LIMIT)
129
175
  ctx.history = next
130
176
  ctx.historyPtr = ctx.history.length - 1
131
177
  ctx.historyVersion += 1
package/src/index.ts CHANGED
@@ -779,6 +779,7 @@ export {
779
779
  type ServerState,
780
780
  type ServerSortModel,
781
781
  type ServerFilterModel,
782
+ type ServerAggFn,
782
783
  type ServerAggregation,
783
784
  type ServerGroupRow,
784
785
  type ServerLeafRow,
@@ -788,6 +789,7 @@ export {
788
789
  type ServerGrandTotalRow,
789
790
  type ServerPlaceholderRow,
790
791
  type ServerDisplayRow,
792
+ type ServerDetailRow,
791
793
  } from './server-data-source'
792
794
  export {
793
795
  createBlockCache,
@@ -576,6 +576,17 @@ describe('onWindowKeydown', () => {
576
576
  expect(ctx.closeMenus).toHaveBeenCalled()
577
577
  })
578
578
 
579
+ it('Escape closes the funnel filter popover, the value suggestions and the column chooser too', () => {
580
+ // The filter popover focuses its condition input, so Escape from there
581
+ // used to go unanswered while the column menu closed as expected.
582
+ for (const open of [{ filterMenuFor: 'col1' }, { inSuggestFor: 'col1' }, { chooseColumnsPos: { x: 1, y: 1 } }]) {
583
+ const ctx = makeCtx(open)
584
+ const { onWindowKeydown } = createKeyboard(ctx)
585
+ onWindowKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
586
+ expect(ctx.closeMenus).toHaveBeenCalled()
587
+ }
588
+ })
589
+
579
590
  it('does nothing when no menu is open', () => {
580
591
  const ctx = makeCtx()
581
592
  const { onWindowKeydown } = createKeyboard(ctx)
@@ -678,3 +689,57 @@ describe('onHeaderSortClick', () => {
678
689
  expect(ctx._state.sorting).toEqual([{ id: 'name', desc: false }])
679
690
  })
680
691
  })
692
+
693
+ describe('onGridKeyDown / Ctrl+Enter on a server-side tree', () => {
694
+ const tree = () => {
695
+ const toggle = vi.fn()
696
+ const toggleDetail = vi.fn()
697
+ const rows = [
698
+ { original: { id: 'g', kind: 'group' } },
699
+ { original: { id: 'a', kind: 'leaf' } },
700
+ ]
701
+ const serverGroup = {
702
+ isGroup: (r: { kind: string }) => r.kind === 'group',
703
+ level: () => 0,
704
+ onToggle: toggle,
705
+ toggleDetail,
706
+ }
707
+ return { toggle, toggleDetail, rows, serverGroup }
708
+ }
709
+
710
+ it('opens or closes the group under the active cell', () => {
711
+ const t = tree()
712
+ const ctx = makeCtx({ allRows: t.rows, props: { rowHeight: 30, serverGroup: t.serverGroup, renderDetailRow: () => {} }, state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } } })
713
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
714
+ expect(t.toggle).toHaveBeenCalledWith(t.rows[0]!.original)
715
+ expect(t.toggleDetail).not.toHaveBeenCalled()
716
+ expect(ctx.onCellDoubleClick).not.toHaveBeenCalled()
717
+ })
718
+
719
+ it('opens or closes the detail panel under a leaf, when the grid draws one', () => {
720
+ const t = tree()
721
+ const ctx = makeCtx({ allRows: t.rows, props: { rowHeight: 30, serverGroup: t.serverGroup, renderDetailRow: () => {} }, state: { activeCell: { rowIndex: 1, colIndex: 0, cellId: null } } })
722
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
723
+ expect(t.toggleDetail).toHaveBeenCalledWith(t.rows[1]!.original)
724
+ expect(t.toggle).not.toHaveBeenCalled()
725
+ // Without a renderer there is no panel to open: Enter falls through to editing.
726
+ const plain = makeCtx({ allRows: t.rows, props: { rowHeight: 30, serverGroup: t.serverGroup }, state: { activeCell: { rowIndex: 1, colIndex: 0, cellId: null } } })
727
+ createKeyboard(plain).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
728
+ expect(t.toggleDetail).toHaveBeenCalledTimes(1)
729
+ expect(plain.onCellDoubleClick).toHaveBeenCalled()
730
+ })
731
+
732
+ it('on a client master-detail calls onDetailToggle with the row, never on the detail row itself', () => {
733
+ const onDetailToggle = vi.fn()
734
+ const rows = [{ original: { id: 'a', kind: 'data' } }, { original: { id: 'a-detail', kind: 'detail' } }]
735
+ const props = { rowHeight: 30, renderDetailRow: () => {}, onDetailToggle, isDetailRow: (r: { kind: string }) => r.kind === 'detail' }
736
+ const ctx = makeCtx({ allRows: rows, props, state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } } })
737
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
738
+ expect(onDetailToggle).toHaveBeenCalledWith(rows[0]!.original, 0)
739
+ expect(ctx.onCellDoubleClick).not.toHaveBeenCalled()
740
+ const onDetail = makeCtx({ allRows: rows, props, state: { activeCell: { rowIndex: 1, colIndex: 0, cellId: null } } })
741
+ createKeyboard(onDetail).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
742
+ expect(onDetailToggle).toHaveBeenCalledTimes(1)
743
+ expect(onDetail.onCellDoubleClick).toHaveBeenCalled()
744
+ })
745
+ })
@@ -193,6 +193,28 @@ export function createKeyboard<
193
193
  row.toggleExpanded?.();
194
194
  return;
195
195
  }
196
+ // The same key on a server-side tree: a group opens or closes, a leaf
197
+ // opens or closes its detail panel when the grid draws one.
198
+ const sg = ctx.props.serverGroup;
199
+ if (event.ctrlKey && sg && row) {
200
+ const data = row.original;
201
+ if (sg.isGroup(data)) {
202
+ sg.onToggle(data);
203
+ return;
204
+ }
205
+ if (sg.toggleDetail && ctx.props.renderDetailRow && (sg.hasDetail?.(data) ?? true)) {
206
+ sg.toggleDetail(data);
207
+ return;
208
+ }
209
+ }
210
+ // ...and on a client-side master-detail: the app's own toggle.
211
+ if (event.ctrlKey && row && ctx.props.onDetailToggle && ctx.props.renderDetailRow) {
212
+ const data = row.original;
213
+ if (!ctx.props.isDetailRow?.(data, current.rowIndex) && (ctx.props.hasDetail?.(data) ?? true)) {
214
+ ctx.props.onDetailToggle(data, current.rowIndex);
215
+ return;
216
+ }
217
+ }
196
218
  if (column?.columnDef.editorType === "checkbox") {
197
219
  ctx.toggleBooleanCell(current.rowIndex, current.colIndex);
198
220
  return;
@@ -287,7 +309,10 @@ export function createKeyboard<
287
309
  }
288
310
 
289
311
  function onWindowKeydown(event: KeyboardEvent) {
290
- if (event.key === "Escape" && (ctx.columnMenuFor || ctx.operatorMenuFor)) {
312
+ if (
313
+ event.key === "Escape" &&
314
+ (ctx.columnMenuFor || ctx.operatorMenuFor || ctx.filterMenuFor || ctx.inSuggestFor || ctx.chooseColumnsPos)
315
+ ) {
291
316
  ctx.closeMenus();
292
317
  }
293
318
  }
package/src/keyboard.ts CHANGED
@@ -50,6 +50,9 @@ export function getKeyboardIntent(event: KeyboardEvent): GridKeyboardIntent {
50
50
  // Tab → move right (Shift+Tab → left), wraps at row boundaries
51
51
  // Delete → clear contents of the selected cells
52
52
  // F2 / Space → start editing the active cell
53
+ // Ctrl+Enter acts on the row rather than moving: a group opens or closes,
54
+ // a row with a detail panel shows or hides it (see the activate branch).
55
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) return 'activate'
53
56
  if (event.key === 'Enter') return event.shiftKey ? 'moveUp' : 'moveDown'
54
57
  if (event.key === 'Tab') return event.shiftKey ? 'tabPrev' : 'tabNext'
55
58
  if (event.key === 'F2') return 'activate'
@@ -23,7 +23,7 @@ export type TouchDragDeps = {
23
23
  ctx: any;
24
24
  managed: () => boolean;
25
25
  originalAt: (rowIndex: number) => unknown;
26
- commitDropOnRow: (rowIndex: number, side: "before" | "after") => void;
26
+ commitDropOnRow: (rowIndex: number, side: "before" | "after" | "into") => void;
27
27
  clearIndicators: () => void;
28
28
  cancelPendingLeave: () => void;
29
29
  /** Publish the dragged row on the shared cross-grid bus. */
@@ -135,7 +135,7 @@ export function createTouchDrag(deps: TouchDragDeps): TouchDragHandle {
135
135
  if (commit && ctx.rowDropIndex != null) {
136
136
  deps.commitDropOnRow(
137
137
  ctx.rowDropIndex as number,
138
- (ctx.rowDropSide ?? "before") as "before" | "after",
138
+ (ctx.rowDropSide ?? "before") as "before" | "after" | "into",
139
139
  );
140
140
  } else {
141
141
  deps.clearIndicators();
@@ -73,6 +73,57 @@ describe('createRowDrag - same-grid reorder', () => {
73
73
  })
74
74
  })
75
75
 
76
+ describe('createRowDrag - a custom drop (onRowDrop)', () => {
77
+ it('reports the drop with its side and leaves the data alone', () => {
78
+ const rows: Task[] = [
79
+ { id: 1, title: 'a' },
80
+ { id: 2, title: 'b' },
81
+ { id: 3, title: 'c' },
82
+ ]
83
+ const onRowDrop = vi.fn()
84
+ const ctx = makeCtx(rows, { ...P, onRowDrop })
85
+ const h = createRowDrag(ctx)
86
+ h.onRowDragStart(fakeEvent(), 0) // drag 'a'
87
+ h.onRowDragOver(fakeEvent(35, 0, 40), 2) // low on c: after
88
+ expect(ctx.rowDropSide).toBe('after')
89
+ h.onRowDrop(fakeEvent(), 2)
90
+ expect(onRowDrop).toHaveBeenCalledWith({ row: rows[0], target: rows[2], targetIndex: 2, side: 'after' })
91
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['a', 'b', 'c'])
92
+ // The empty space below the rows reports no target.
93
+ h.onRowDragStart(fakeEvent(), 1)
94
+ h.onRowsContainerDrop(fakeEvent())
95
+ expect(onRowDrop).toHaveBeenLastCalledWith({ row: rows[1], target: null, targetIndex: null, side: 'after' })
96
+ })
97
+
98
+ it('offers "into" over the middle of a group row, and only then', () => {
99
+ const rows: Task[] = [
100
+ { id: 1, title: 'folder' },
101
+ { id: 2, title: 'file' },
102
+ ]
103
+ const ctx = makeCtx(rows, {
104
+ ...P,
105
+ onRowDrop: vi.fn(),
106
+ serverGroup: { isGroup: (r: Task) => r.title === 'folder', level: () => 0, onToggle: () => {} },
107
+ })
108
+ const h = createRowDrag(ctx)
109
+ h.onRowDragStart(fakeEvent(), 1)
110
+ h.onRowDragOver(fakeEvent(20, 0, 40), 0) // the middle of the folder
111
+ expect(ctx.rowDropSide).toBe('into')
112
+ h.onRowDragOver(fakeEvent(3, 0, 40), 0) // its top edge
113
+ expect(ctx.rowDropSide).toBe('before')
114
+ h.onRowDragOver(fakeEvent(19, 0, 40), 1) // the middle of a file: no nesting, just the halves
115
+ expect(ctx.rowDropSide).toBe('before')
116
+ h.onRowDragOver(fakeEvent(21, 0, 40), 1)
117
+ expect(ctx.rowDropSide).toBe('after')
118
+ // Without a custom handler the middle band does not exist.
119
+ const managed = makeCtx(rows, { ...P, serverGroup: ctx.props.serverGroup })
120
+ const hm = createRowDrag(managed)
121
+ hm.onRowDragStart(fakeEvent(), 1)
122
+ hm.onRowDragOver(fakeEvent(20, 0, 40), 0)
123
+ expect(managed.rowDropSide).toBe('after')
124
+ })
125
+ })
126
+
76
127
  describe('createRowDrag - grid-to-grid', () => {
77
128
  it('moves a row from source into target when groups match', () => {
78
129
  const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
package/src/row-drag.ts CHANGED
@@ -116,7 +116,25 @@ export function createRowDrag<TFeatures, TData>(ctx: any) {
116
116
  if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
117
117
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
118
118
  ctx.rowDropIndex = rowIndex;
119
- ctx.rowDropSide = e.clientY < rect.top + rect.height / 2 ? "before" : "after";
119
+ ctx.rowDropSide = sideAt(rowIndex, rect, e.clientY);
120
+ }
121
+
122
+ /**
123
+ * Which edge the pointer is on. A custom drop handler also gets the middle
124
+ * of a group or tree row as "into" (the row becomes its child); the
125
+ * managed reorder only knows before and after.
126
+ */
127
+ function sideAt(rowIndex: number, rect: DOMRect, y: number): "before" | "after" | "into" {
128
+ const rel = (y - rect.top) / Math.max(rect.height, 1);
129
+ if (ctx.props.onRowDrop && rel >= 0.25 && rel <= 0.75 && canNest(rowIndex)) return "into";
130
+ return rel < 0.5 ? "before" : "after";
131
+ }
132
+ function canNest(rowIndex: number): boolean {
133
+ const row = ctx.allRows[rowIndex];
134
+ if (!row) return false;
135
+ const sg = ctx.props.serverGroup;
136
+ if (sg) return !!sg.isGroup(row.original);
137
+ return typeof row.getCanExpand === "function" && row.getCanExpand();
120
138
  }
121
139
 
122
140
  function onRowDragLeave(rowIndex: number) {
@@ -147,7 +165,7 @@ export function createRowDrag<TFeatures, TData>(ctx: any) {
147
165
  * both land here, so the reorder, the cross-grid hand-off and the
148
166
  * `onRowDragEnd` payload have exactly one implementation.
149
167
  */
150
- function commitDropOnRow(rowIndex: number, side: "before" | "after") {
168
+ function commitDropOnRow(rowIndex: number, side: "before" | "after" | "into") {
151
169
  if (!canAccept() || !bus) return;
152
170
  const dragged = bus.row;
153
171
  const target = originalAt(rowIndex);
@@ -155,6 +173,15 @@ export function createRowDrag<TFeatures, TData>(ctx: any) {
155
173
  const sameGrid = sourceGridId === gridId;
156
174
  clearIndicators();
157
175
 
176
+ // A custom handler owns the drop: the grid reports it and leaves its
177
+ // data alone (a row model's rows are not the grid's to splice).
178
+ if (sameGrid && ctx.props.onRowDrop) {
179
+ bus = null;
180
+ ctx.props.onRowDrop({ row: dragged, target, targetIndex: rowIndex, side });
181
+ return;
182
+ }
183
+ if (side === "into") side = "after";
184
+
158
185
  const data = (ctx.internalData as unknown[]).slice();
159
186
  if (sameGrid) {
160
187
  const from = data.indexOf(dragged);
@@ -198,6 +225,12 @@ export function createRowDrag<TFeatures, TData>(ctx: any) {
198
225
  const sameGrid = sourceGridId === gridId;
199
226
  clearIndicators();
200
227
 
228
+ if (sameGrid && ctx.props.onRowDrop) {
229
+ bus = null;
230
+ ctx.props.onRowDrop({ row: dragged, target: null, targetIndex: null, side: "after" });
231
+ return;
232
+ }
233
+
201
234
  const data = (ctx.internalData as unknown[]).slice();
202
235
  if (sameGrid) {
203
236
  const from = data.indexOf(dragged);
package/src/row-model.ts CHANGED
@@ -84,6 +84,12 @@ export type GridRowModel<TData> = {
84
84
  level: (row: TData) => number
85
85
  expanded?: (row: TData) => boolean
86
86
  onToggle: (row: TData) => void
87
+ /** Open or close the detail panel under a leaf. Ctrl+Enter on a row calls it when `renderDetailRow` is set, and so does the `showDetailToggle` chevron. */
88
+ toggleDetail?: (row: TData) => void
89
+ /** Whether the detail under a leaf is open: the chevron's direction. */
90
+ detailOpen?: (row: TData) => boolean
91
+ /** Whether a row can open a detail; the chevron is left out where this is false. */
92
+ hasDetail?: (row: TData) => boolean
87
93
  }
88
94
  /** Selection the grid does not own - see the `rowSelectionModel` prop. */
89
95
  selection?: {
package/src/row-resize.ts CHANGED
@@ -69,6 +69,20 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
69
69
  /** The last press on a strip, for telling a double-click from two drags. */
70
70
  let lastPress: { rowIndex: number; at: number } | null = null
71
71
 
72
+ /**
73
+ * A focusable `role="separator"` is a splitter, and a splitter carries its
74
+ * position: axe's `aria-required-attr` fails one without `aria-valuenow`,
75
+ * and a screen reader reading the strip says nothing useful without it. The
76
+ * value is the row's height in pixels, between the action's min and max.
77
+ */
78
+ function setStripValue(strip: HTMLElement, height: number): void {
79
+ const min = current.min ?? 20
80
+ const max = current.max ?? 320
81
+ strip.setAttribute('aria-valuemin', String(min))
82
+ strip.setAttribute('aria-valuemax', String(max))
83
+ strip.setAttribute('aria-valuenow', String(Math.round(Math.max(min, Math.min(max, height || min)))))
84
+ }
85
+
72
86
  function rowIndexOf(tr: HTMLTableRowElement): number {
73
87
  const dataRow = tr.querySelector<HTMLElement>('[data-svgrid-row]')
74
88
  const val = dataRow?.dataset.svgridRow
@@ -81,6 +95,7 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
81
95
  const max = current.max ?? 320
82
96
  const next = Math.max(min, Math.min(max, drag.startHeight + (e.clientY - drag.startY)))
83
97
  drag.trEl.style.height = `${Math.round(next)}px`
98
+ setStripValue(drag.strip, next)
84
99
  current.onResizeMove?.(drag.rowIndex, Math.round(next))
85
100
  }
86
101
  function onPointerUp(e: PointerEvent) {
@@ -124,6 +139,7 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
124
139
  Math.max(min, Math.min(max, tr.getBoundingClientRect().height + delta)),
125
140
  )
126
141
  tr.style.height = `${next}px`
142
+ setStripValue(t, next)
127
143
  current.onResizeMove?.(rowIndex, next)
128
144
  current.onResize(rowIndex, next)
129
145
  }
@@ -189,7 +205,13 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
189
205
  ? tr.querySelector<HTMLTableCellElement>('.sv-grid-cell')
190
206
  : null)
191
207
  if (!gutter) continue
192
- if (gutter.querySelector(`:scope > .${STRIP_CLASS}`)) continue
208
+ const already = gutter.querySelector<HTMLElement>(`:scope > .${STRIP_CLASS}`)
209
+ if (already) {
210
+ // The row may have been resized from outside the action (a saved
211
+ // height, a double-click autofit); keep the separator's value honest.
212
+ setStripValue(already, tr.getBoundingClientRect().height)
213
+ continue
214
+ }
193
215
  // Anchor the strip to the gutter cell + allow it to overflow
194
216
  // downward into the row gap so the hit-area straddles the
195
217
  // row boundary the way Excel does.
@@ -203,6 +225,7 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
203
225
  // A separator used as an interactive splitter has to be focusable so
204
226
  // keyboard-only users can reach it and drive `onKeyDown` (#79).
205
227
  strip.tabIndex = 0
228
+ setStripValue(strip, tr.getBoundingClientRect().height)
206
229
  // Inline the geometry + pointer-events so the strip works even
207
230
  // before SvGrid.css is parsed; the hover tint still comes from
208
231
  // the stylesheet. We keep the strip fully inside the gutter
package/src/selection.ts CHANGED
@@ -548,7 +548,7 @@ export function createSelection<
548
548
  ctx.stopEdgeScroll();
549
549
  // Also commit a fill-handle drag if one was in progress - the user
550
550
  // released the mouse, time to apply the pattern.
551
- if (ctx.fillDrag) ctx.onFillPointerUp();
551
+ if (ctx.fillDrag) ctx.onFillPointerUp(event);
552
552
  if (ctx.moveDrag) ctx.onMovePointerUp(event);
553
553
  }
554
554