@svgrid/grid 1.0.2 → 1.1.1

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 (216) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +648 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +472 -0
  8. package/dist/SvGrid.controller.svelte.js +1917 -0
  9. package/dist/SvGrid.css +2012 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.helpers.test.d.ts +1 -0
  13. package/dist/SvGrid.helpers.test.js +298 -0
  14. package/dist/SvGrid.svelte +2346 -8742
  15. package/dist/SvGrid.svelte.d.ts +4 -357
  16. package/dist/SvGrid.types.d.ts +523 -0
  17. package/dist/SvGrid.types.js +1 -0
  18. package/dist/SvGridChart.svelte +1060 -23
  19. package/dist/SvGridChart.svelte.d.ts +17 -0
  20. package/dist/aligned-grids.d.ts +6 -0
  21. package/dist/aligned-grids.js +84 -0
  22. package/dist/aligned-grids.test.d.ts +1 -0
  23. package/dist/aligned-grids.test.js +75 -0
  24. package/dist/build-api.coverage.test.d.ts +20 -0
  25. package/dist/build-api.coverage.test.js +505 -0
  26. package/dist/build-api.d.ts +5 -0
  27. package/dist/build-api.js +557 -0
  28. package/dist/cell-render.d.ts +15 -0
  29. package/dist/cell-render.js +246 -0
  30. package/dist/cell-render.test.d.ts +1 -0
  31. package/dist/cell-render.test.js +338 -0
  32. package/dist/cell-values.d.ts +28 -0
  33. package/dist/cell-values.js +89 -0
  34. package/dist/chart-export.test.d.ts +1 -0
  35. package/dist/chart-export.test.js +302 -0
  36. package/dist/chart.coverage.test.d.ts +1 -0
  37. package/dist/chart.coverage.test.js +748 -0
  38. package/dist/chart.d.ts +370 -3
  39. package/dist/chart.js +1135 -42
  40. package/dist/clipboard.d.ts +15 -0
  41. package/dist/clipboard.js +420 -0
  42. package/dist/clipboard.test.d.ts +1 -0
  43. package/dist/clipboard.test.js +700 -0
  44. package/dist/collaboration.coverage.test.d.ts +1 -0
  45. package/dist/collaboration.coverage.test.js +200 -0
  46. package/dist/column-groups.d.ts +19 -0
  47. package/dist/column-groups.js +62 -0
  48. package/dist/column-groups.test.d.ts +1 -0
  49. package/dist/column-groups.test.js +56 -0
  50. package/dist/column-types.d.ts +10 -0
  51. package/dist/column-types.js +63 -0
  52. package/dist/column-types.test.d.ts +1 -0
  53. package/dist/column-types.test.js +62 -0
  54. package/dist/columns.d.ts +30 -0
  55. package/dist/columns.js +277 -0
  56. package/dist/columns.test.d.ts +1 -0
  57. package/dist/columns.test.js +625 -0
  58. package/dist/core.d.ts +86 -1
  59. package/dist/css.d.ts +3 -0
  60. package/dist/editing.d.ts +31 -0
  61. package/dist/editing.js +529 -0
  62. package/dist/editing.test.d.ts +1 -0
  63. package/dist/editing.test.js +732 -0
  64. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  65. package/dist/editors/cell-editors.coverage.test.js +139 -0
  66. package/dist/editors/cell-editors.d.ts +1 -1
  67. package/dist/facet-buckets.d.ts +13 -0
  68. package/dist/facet-buckets.js +54 -0
  69. package/dist/facet-buckets.test.d.ts +1 -0
  70. package/dist/facet-buckets.test.js +296 -0
  71. package/dist/features.d.ts +5 -0
  72. package/dist/features.js +30 -0
  73. package/dist/filter-operators.d.ts +16 -0
  74. package/dist/filter-operators.js +69 -0
  75. package/dist/filter-operators.test.d.ts +1 -0
  76. package/dist/filter-operators.test.js +135 -0
  77. package/dist/hyperformula-adapter.d.ts +82 -0
  78. package/dist/hyperformula-adapter.js +73 -0
  79. package/dist/hyperformula-adapter.test.d.ts +1 -0
  80. package/dist/hyperformula-adapter.test.js +205 -0
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.js +8 -2
  83. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  84. package/dist/keyboard-handlers.coverage.test.js +495 -0
  85. package/dist/keyboard-handlers.d.ts +7 -0
  86. package/dist/keyboard-handlers.js +202 -0
  87. package/dist/menus.d.ts +40 -0
  88. package/dist/menus.js +390 -0
  89. package/dist/menus.test.d.ts +1 -0
  90. package/dist/menus.test.js +560 -0
  91. package/dist/named-views.coverage.test.d.ts +1 -0
  92. package/dist/named-views.coverage.test.js +180 -0
  93. package/dist/named-views.d.ts +27 -0
  94. package/dist/named-views.js +39 -0
  95. package/dist/row-drag.d.ts +49 -0
  96. package/dist/row-drag.js +221 -0
  97. package/dist/row-drag.test.d.ts +1 -0
  98. package/dist/row-drag.test.js +142 -0
  99. package/dist/row-resize.d.ts +43 -0
  100. package/dist/row-resize.js +158 -0
  101. package/dist/row-resize.test.d.ts +1 -0
  102. package/dist/row-resize.test.js +329 -0
  103. package/dist/scroll-sync.d.ts +9 -0
  104. package/dist/scroll-sync.js +89 -0
  105. package/dist/scroll-sync.test.d.ts +1 -0
  106. package/dist/scroll-sync.test.js +290 -0
  107. package/dist/selection.d.ts +32 -0
  108. package/dist/selection.js +427 -0
  109. package/dist/selection.multi-range.test.d.ts +1 -0
  110. package/dist/selection.multi-range.test.js +55 -0
  111. package/dist/selection.test.d.ts +1 -0
  112. package/dist/selection.test.js +647 -0
  113. package/dist/server-data-source.coverage.test.d.ts +1 -0
  114. package/dist/server-data-source.coverage.test.js +154 -0
  115. package/dist/spreadsheet.d.ts +110 -0
  116. package/dist/spreadsheet.js +242 -0
  117. package/dist/spreadsheet.test.d.ts +1 -0
  118. package/dist/spreadsheet.test.js +446 -0
  119. package/dist/summaries.d.ts +12 -0
  120. package/dist/summaries.js +65 -0
  121. package/dist/sv-grid-scrollbar.js +13 -1
  122. package/dist/svgrid-wrapper.types.d.ts +31 -1
  123. package/dist/svgrid.behavior.test.js +20 -0
  124. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  125. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  126. package/dist/svgrid.context-menu.test.d.ts +1 -0
  127. package/dist/svgrid.context-menu.test.js +102 -0
  128. package/dist/svgrid.interaction.test.js +31 -0
  129. package/dist/svgrid.new-features.wrapper.test.js +64 -6
  130. package/dist/svgrid.wrapper.test.js +27 -1
  131. package/dist/test-setup.js +9 -3
  132. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  133. package/dist/virtualization/scroll-scaling.d.ts +45 -0
  134. package/dist/virtualization/scroll-scaling.js +99 -0
  135. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  136. package/dist/virtualization/scroll-scaling.test.js +127 -0
  137. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  138. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  139. package/dist/virtualization/virtualizer.d.ts +7 -0
  140. package/dist/virtualization/virtualizer.js +30 -0
  141. package/package.json +2 -1
  142. package/src/GridFooter.svelte +164 -0
  143. package/src/GridMenus.svelte +648 -0
  144. package/src/SvGrid.controller.svelte.ts +2352 -0
  145. package/src/SvGrid.css +2012 -0
  146. package/src/SvGrid.helpers.test.ts +415 -0
  147. package/src/SvGrid.helpers.ts +185 -0
  148. package/src/SvGrid.svelte +2346 -8742
  149. package/src/SvGrid.types.ts +537 -0
  150. package/src/SvGridChart.svelte +1060 -23
  151. package/src/aligned-grids.test.ts +80 -0
  152. package/src/aligned-grids.ts +87 -0
  153. package/src/build-api.coverage.test.ts +532 -0
  154. package/src/build-api.ts +683 -0
  155. package/src/cell-render.test.ts +451 -0
  156. package/src/cell-render.ts +426 -0
  157. package/src/cell-values.ts +114 -0
  158. package/src/chart-export.test.ts +370 -0
  159. package/src/chart.coverage.test.ts +814 -0
  160. package/src/chart.ts +1352 -47
  161. package/src/clipboard.test.ts +780 -0
  162. package/src/clipboard.ts +552 -0
  163. package/src/collaboration.coverage.test.ts +220 -0
  164. package/src/column-groups.test.ts +59 -0
  165. package/src/column-groups.ts +80 -0
  166. package/src/column-types.test.ts +68 -0
  167. package/src/column-types.ts +82 -0
  168. package/src/columns.test.ts +702 -0
  169. package/src/columns.ts +419 -0
  170. package/src/core.ts +86 -0
  171. package/src/css.d.ts +3 -0
  172. package/src/editing.test.ts +837 -0
  173. package/src/editing.ts +669 -0
  174. package/src/editors/cell-editors.coverage.test.ts +156 -0
  175. package/src/editors/cell-editors.ts +1 -0
  176. package/src/facet-buckets.test.ts +353 -0
  177. package/src/facet-buckets.ts +67 -0
  178. package/src/features.ts +128 -0
  179. package/src/filter-operators.test.ts +174 -0
  180. package/src/filter-operators.ts +87 -0
  181. package/src/hyperformula-adapter.test.ts +256 -0
  182. package/src/hyperformula-adapter.ts +124 -0
  183. package/src/index.ts +49 -0
  184. package/src/keyboard-handlers.coverage.test.ts +560 -0
  185. package/src/keyboard-handlers.ts +353 -0
  186. package/src/keyboard.ts +97 -97
  187. package/src/menus.test.ts +620 -0
  188. package/src/menus.ts +555 -0
  189. package/src/named-views.coverage.test.ts +210 -0
  190. package/src/named-views.ts +48 -0
  191. package/src/row-drag.test.ts +168 -0
  192. package/src/row-drag.ts +255 -0
  193. package/src/row-resize.test.ts +369 -0
  194. package/src/row-resize.ts +171 -0
  195. package/src/scroll-sync.test.ts +330 -0
  196. package/src/scroll-sync.ts +218 -0
  197. package/src/selection.multi-range.test.ts +61 -0
  198. package/src/selection.test.ts +722 -0
  199. package/src/selection.ts +579 -0
  200. package/src/server-data-source.coverage.test.ts +180 -0
  201. package/src/spreadsheet.test.ts +489 -0
  202. package/src/spreadsheet.ts +304 -0
  203. package/src/summaries.ts +204 -0
  204. package/src/sv-grid-scrollbar.ts +13 -1
  205. package/src/svgrid-wrapper.types.ts +31 -1
  206. package/src/svgrid.behavior.test.ts +22 -0
  207. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  208. package/src/svgrid.context-menu.test.ts +126 -0
  209. package/src/svgrid.interaction.test.ts +30 -0
  210. package/src/svgrid.new-features.wrapper.test.ts +67 -6
  211. package/src/svgrid.wrapper.test.ts +27 -1
  212. package/src/test-setup.ts +9 -6
  213. package/src/virtualization/scroll-scaling.test.ts +148 -0
  214. package/src/virtualization/scroll-scaling.ts +121 -0
  215. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  216. package/src/virtualization/virtualizer.ts +26 -0
@@ -0,0 +1,560 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createKeyboard } from './keyboard-handlers'
3
+
4
+ // createKeyboard returns imperative handlers that read/write a controller
5
+ // `ctx`. We build a fake ctx that records the side-effecting calls, plus a
6
+ // fake `grid` whose state is settable. KeyboardEvents are real DOM events
7
+ // (jsdom) so `target`/`currentTarget`/modifier flags behave correctly.
8
+
9
+ type FakeState = {
10
+ activeCell?: { rowIndex: number; colIndex: number; cellId: string | null }
11
+ sorting?: Array<{ id: string; desc: boolean }>
12
+ pagination?: { pageSize: number }
13
+ }
14
+
15
+ function makeCtx(overrides: Partial<any> = {}) {
16
+ const state: FakeState = {
17
+ activeCell: { rowIndex: 1, colIndex: 1, cellId: null },
18
+ sorting: [],
19
+ ...overrides.state,
20
+ }
21
+ const setState = vi.fn((updater: any) => {
22
+ Object.assign(state, typeof updater === 'function' ? updater(state) : updater)
23
+ })
24
+
25
+ const ctx: any = {
26
+ editingCell: null,
27
+ findOpen: false,
28
+ columnMenuFor: null,
29
+ operatorMenuFor: null,
30
+ history: [],
31
+ historyPtr: -1,
32
+ historyVersion: 0,
33
+ headerHeight: 30,
34
+ scrollContainer: { clientHeight: 330 },
35
+ props: { rowHeight: 30 },
36
+ allRows: [],
37
+ allColumns: [],
38
+ grid: {
39
+ getState: () => state,
40
+ store: { setState },
41
+ },
42
+ // recorded side effects
43
+ copySelectionToClipboard: vi.fn(),
44
+ pasteFromClipboard: vi.fn(() => Promise.resolve()),
45
+ cutSelectionToClipboard: vi.fn(() => Promise.resolve()),
46
+ applyHistoryStep: vi.fn(),
47
+ clearSelectedCells: vi.fn(() => false),
48
+ clearSelectedCellValues: vi.fn(),
49
+ onCellDoubleClick: vi.fn(),
50
+ startEditingWithChar: vi.fn(() => true),
51
+ toggleBooleanCell: vi.fn(),
52
+ setActiveCell: vi.fn(),
53
+ scrollActiveCellIntoView: vi.fn(),
54
+ extendSelection: vi.fn(),
55
+ setSelection: vi.fn(),
56
+ closeMenus: vi.fn(),
57
+ ...overrides,
58
+ }
59
+ ctx._state = state
60
+ ctx._setState = setState
61
+ return ctx
62
+ }
63
+
64
+ // A keydown event whose target === currentTarget (so the grid root owns it).
65
+ function rootKeyEvent(init: KeyboardEventInit) {
66
+ const el = document.createElement('div')
67
+ const event = new KeyboardEvent('keydown', { cancelable: true, ...init })
68
+ Object.defineProperty(event, 'target', { value: el })
69
+ Object.defineProperty(event, 'currentTarget', { value: el })
70
+ vi.spyOn(event, 'preventDefault')
71
+ return event
72
+ }
73
+
74
+ describe('onGridKeyDown / guards', () => {
75
+ it('ignores events that did not originate on the grid root', () => {
76
+ const ctx = makeCtx()
77
+ const { onGridKeyDown } = createKeyboard(ctx)
78
+ const el = document.createElement('div')
79
+ const other = document.createElement('div')
80
+ const event = new KeyboardEvent('keydown', { key: 'ArrowDown' })
81
+ Object.defineProperty(event, 'target', { value: other })
82
+ Object.defineProperty(event, 'currentTarget', { value: el })
83
+ onGridKeyDown(event)
84
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
85
+ })
86
+
87
+ it('ignores all keys while a cell is being edited', () => {
88
+ const ctx = makeCtx({ editingCell: { rowIndex: 0, colIndex: 0 } })
89
+ const { onGridKeyDown } = createKeyboard(ctx)
90
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown' }))
91
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
92
+ })
93
+ })
94
+
95
+ describe('onGridKeyDown / clipboard shortcuts', () => {
96
+ it('Ctrl+C copies the selection', () => {
97
+ const ctx = makeCtx()
98
+ const { onGridKeyDown } = createKeyboard(ctx)
99
+ const e = rootKeyEvent({ key: 'c', ctrlKey: true })
100
+ onGridKeyDown(e)
101
+ expect(ctx.copySelectionToClipboard).toHaveBeenCalledTimes(1)
102
+ expect(e.preventDefault).toHaveBeenCalled()
103
+ })
104
+
105
+ it('Cmd+C (metaKey) also copies', () => {
106
+ const ctx = makeCtx()
107
+ const { onGridKeyDown } = createKeyboard(ctx)
108
+ onGridKeyDown(rootKeyEvent({ key: 'C', metaKey: true }))
109
+ expect(ctx.copySelectionToClipboard).toHaveBeenCalledTimes(1)
110
+ })
111
+
112
+ it('Ctrl+V in a secure context reads via the Clipboard API', () => {
113
+ const original = (navigator as any).clipboard
114
+ Object.defineProperty(navigator, 'clipboard', {
115
+ value: { readText: () => Promise.resolve('') },
116
+ configurable: true,
117
+ })
118
+ const ctx = makeCtx()
119
+ const { onGridKeyDown } = createKeyboard(ctx)
120
+ const e = rootKeyEvent({ key: 'v', ctrlKey: true })
121
+ onGridKeyDown(e)
122
+ expect(ctx.pasteFromClipboard).toHaveBeenCalledTimes(1)
123
+ expect(e.preventDefault).toHaveBeenCalled()
124
+ Object.defineProperty(navigator, 'clipboard', { value: original, configurable: true })
125
+ })
126
+
127
+ it('Ctrl+V in an insecure context does NOT preventDefault (lets native paste through)', () => {
128
+ const original = (navigator as any).clipboard
129
+ Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
130
+ const ctx = makeCtx()
131
+ const { onGridKeyDown } = createKeyboard(ctx)
132
+ const e = rootKeyEvent({ key: 'v', ctrlKey: true })
133
+ onGridKeyDown(e)
134
+ expect(ctx.pasteFromClipboard).not.toHaveBeenCalled()
135
+ expect(e.preventDefault).not.toHaveBeenCalled()
136
+ Object.defineProperty(navigator, 'clipboard', { value: original, configurable: true })
137
+ })
138
+
139
+ it('Ctrl+X cuts the selection', () => {
140
+ const ctx = makeCtx()
141
+ const { onGridKeyDown } = createKeyboard(ctx)
142
+ const e = rootKeyEvent({ key: 'x', ctrlKey: true })
143
+ onGridKeyDown(e)
144
+ expect(ctx.cutSelectionToClipboard).toHaveBeenCalledTimes(1)
145
+ expect(e.preventDefault).toHaveBeenCalled()
146
+ })
147
+
148
+ it('does not treat Ctrl+Alt+C as a copy shortcut', () => {
149
+ const ctx = makeCtx()
150
+ const { onGridKeyDown } = createKeyboard(ctx)
151
+ onGridKeyDown(rootKeyEvent({ key: 'c', ctrlKey: true, altKey: true }))
152
+ expect(ctx.copySelectionToClipboard).not.toHaveBeenCalled()
153
+ })
154
+ })
155
+
156
+ describe('onGridKeyDown / undo & redo', () => {
157
+ it('Ctrl+Z undoes when there is history to undo', () => {
158
+ const step = { kind: 'edit' }
159
+ const ctx = makeCtx({ history: [step], historyPtr: 0 })
160
+ const { onGridKeyDown } = createKeyboard(ctx)
161
+ onGridKeyDown(rootKeyEvent({ key: 'z', ctrlKey: true }))
162
+ expect(ctx.applyHistoryStep).toHaveBeenCalledWith(step, 'undo')
163
+ expect(ctx.historyPtr).toBe(-1)
164
+ expect(ctx.historyVersion).toBe(1)
165
+ })
166
+
167
+ it('Ctrl+Z is a no-op when nothing to undo', () => {
168
+ const ctx = makeCtx({ history: [], historyPtr: -1 })
169
+ const { onGridKeyDown } = createKeyboard(ctx)
170
+ onGridKeyDown(rootKeyEvent({ key: 'z', ctrlKey: true }))
171
+ expect(ctx.applyHistoryStep).not.toHaveBeenCalled()
172
+ })
173
+
174
+ it('Ctrl+Shift+Z redoes when there is a future step', () => {
175
+ const step = { kind: 'edit' }
176
+ const ctx = makeCtx({ history: [{}, step], historyPtr: 0 })
177
+ const { onGridKeyDown } = createKeyboard(ctx)
178
+ onGridKeyDown(rootKeyEvent({ key: 'z', ctrlKey: true, shiftKey: true }))
179
+ expect(ctx.applyHistoryStep).toHaveBeenCalledWith(step, 'redo')
180
+ expect(ctx.historyPtr).toBe(1)
181
+ expect(ctx.historyVersion).toBe(1)
182
+ })
183
+
184
+ it('Ctrl+Y also redoes', () => {
185
+ const step = { kind: 'edit' }
186
+ const ctx = makeCtx({ history: [{}, step], historyPtr: 0 })
187
+ const { onGridKeyDown } = createKeyboard(ctx)
188
+ onGridKeyDown(rootKeyEvent({ key: 'y', ctrlKey: true }))
189
+ expect(ctx.applyHistoryStep).toHaveBeenCalledWith(step, 'redo')
190
+ })
191
+
192
+ it('redo is a no-op when already at the end of history', () => {
193
+ const ctx = makeCtx({ history: [{}], historyPtr: 0 })
194
+ const { onGridKeyDown } = createKeyboard(ctx)
195
+ onGridKeyDown(rootKeyEvent({ key: 'y', ctrlKey: true }))
196
+ expect(ctx.applyHistoryStep).not.toHaveBeenCalled()
197
+ })
198
+ })
199
+
200
+ describe('onGridKeyDown / find overlay', () => {
201
+ it('Ctrl+F opens the find overlay', () => {
202
+ const ctx = makeCtx()
203
+ const { onGridKeyDown } = createKeyboard(ctx)
204
+ onGridKeyDown(rootKeyEvent({ key: 'f', ctrlKey: true }))
205
+ expect(ctx.findOpen).toBe(true)
206
+ })
207
+
208
+ it('Escape closes the find overlay when it is open', () => {
209
+ const ctx = makeCtx({ findOpen: true })
210
+ const { onGridKeyDown } = createKeyboard(ctx)
211
+ const e = rootKeyEvent({ key: 'Escape' })
212
+ onGridKeyDown(e)
213
+ expect(ctx.findOpen).toBe(false)
214
+ expect(e.preventDefault).toHaveBeenCalled()
215
+ })
216
+ })
217
+
218
+ describe('onGridKeyDown / delete & backspace clearing', () => {
219
+ it('Delete clears the selection when cells were cleared', () => {
220
+ const ctx = makeCtx({ clearSelectedCells: vi.fn(() => true), allRows: [{}], allColumns: [{}] })
221
+ const { onGridKeyDown } = createKeyboard(ctx)
222
+ const e = rootKeyEvent({ key: 'Delete' })
223
+ onGridKeyDown(e)
224
+ expect(ctx.clearSelectedCells).toHaveBeenCalled()
225
+ expect(e.preventDefault).toHaveBeenCalled()
226
+ })
227
+
228
+ it('Backspace clears the selection too', () => {
229
+ const ctx = makeCtx({ clearSelectedCells: vi.fn(() => true), allRows: [{}], allColumns: [{}] })
230
+ const { onGridKeyDown } = createKeyboard(ctx)
231
+ onGridKeyDown(rootKeyEvent({ key: 'Backspace' }))
232
+ expect(ctx.clearSelectedCells).toHaveBeenCalled()
233
+ })
234
+
235
+ it('Delete falls through to clearCells intent when nothing was cleared', () => {
236
+ const ctx = makeCtx({
237
+ clearSelectedCells: vi.fn(() => false),
238
+ allRows: [{}, {}],
239
+ allColumns: [{}, {}],
240
+ })
241
+ const { onGridKeyDown } = createKeyboard(ctx)
242
+ onGridKeyDown(rootKeyEvent({ key: 'Delete' }))
243
+ expect(ctx.clearSelectedCellValues).toHaveBeenCalledTimes(1)
244
+ })
245
+ })
246
+
247
+ describe('onGridKeyDown / F2 + activation', () => {
248
+ it('F2 starts editing the active cell', () => {
249
+ const ctx = makeCtx({ allRows: [{}, {}], allColumns: [{}, {}] })
250
+ const { onGridKeyDown } = createKeyboard(ctx)
251
+ onGridKeyDown(rootKeyEvent({ key: 'F2' }))
252
+ expect(ctx.onCellDoubleClick).toHaveBeenCalledWith(1, 1)
253
+ })
254
+
255
+ it('Space toggles row selection', () => {
256
+ const toggleSelected = vi.fn()
257
+ const ctx = makeCtx({
258
+ allRows: [{}, { toggleSelected }],
259
+ allColumns: [{}, {}],
260
+ })
261
+ const { onGridKeyDown } = createKeyboard(ctx)
262
+ onGridKeyDown(rootKeyEvent({ key: ' ' }))
263
+ expect(toggleSelected).toHaveBeenCalled()
264
+ })
265
+
266
+ it('Space on an expandable row toggles selection (space branch wins over expand)', () => {
267
+ // getKeyboardIntent(" ") === "activate". The handler checks `event.key === " "`
268
+ // FIRST and toggles row selection, returning before the ctrl/expand branch.
269
+ const toggleSelected = vi.fn()
270
+ const toggleExpanded = vi.fn()
271
+ const row = { toggleSelected, getCanExpand: () => true, toggleExpanded }
272
+ const ctx = makeCtx({ allRows: [{}, row], allColumns: [{}, {}] })
273
+ const { onGridKeyDown } = createKeyboard(ctx)
274
+ onGridKeyDown(rootKeyEvent({ key: ' ', ctrlKey: true }))
275
+ expect(toggleSelected).toHaveBeenCalled()
276
+ expect(toggleExpanded).not.toHaveBeenCalled()
277
+ })
278
+
279
+ it('F2 on a checkbox column opens editing via onCellDoubleClick', () => {
280
+ // F2 is intercepted before intent resolution and always calls
281
+ // onCellDoubleClick regardless of column editorType.
282
+ const ctx = makeCtx({
283
+ allRows: [{}, {}],
284
+ allColumns: [{}, { columnDef: { editorType: 'checkbox' } }],
285
+ })
286
+ const { onGridKeyDown } = createKeyboard(ctx)
287
+ onGridKeyDown(rootKeyEvent({ key: 'F2' }))
288
+ expect(ctx.onCellDoubleClick).toHaveBeenCalledWith(1, 1)
289
+ expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
290
+ })
291
+ })
292
+
293
+ describe('onGridKeyDown / typing to edit', () => {
294
+ it('a printable character starts editing seeded with that char', () => {
295
+ const ctx = makeCtx({
296
+ allRows: [{}, {}],
297
+ allColumns: [{}, {}],
298
+ startEditingWithChar: vi.fn(() => true),
299
+ })
300
+ const { onGridKeyDown } = createKeyboard(ctx)
301
+ const e = rootKeyEvent({ key: 'a' })
302
+ onGridKeyDown(e)
303
+ expect(ctx.startEditingWithChar).toHaveBeenCalledWith(1, 1, 'a')
304
+ expect(e.preventDefault).toHaveBeenCalled()
305
+ })
306
+
307
+ it('does not preventDefault when editing cannot start', () => {
308
+ const ctx = makeCtx({
309
+ allRows: [{}, {}],
310
+ allColumns: [{}, {}],
311
+ startEditingWithChar: vi.fn(() => false),
312
+ })
313
+ const { onGridKeyDown } = createKeyboard(ctx)
314
+ const e = rootKeyEvent({ key: 'a' })
315
+ onGridKeyDown(e)
316
+ expect(e.preventDefault).not.toHaveBeenCalled()
317
+ })
318
+
319
+ it('ignores a multi-character / modified key as a noop', () => {
320
+ const ctx = makeCtx({ allRows: [{}, {}], allColumns: [{}, {}] })
321
+ const { onGridKeyDown } = createKeyboard(ctx)
322
+ onGridKeyDown(rootKeyEvent({ key: 'Shift' }))
323
+ expect(ctx.startEditingWithChar).not.toHaveBeenCalled()
324
+ })
325
+ })
326
+
327
+ describe('onGridKeyDown / navigation', () => {
328
+ function navCtx(extra: Partial<any> = {}) {
329
+ return makeCtx({
330
+ state: { activeCell: { rowIndex: 1, colIndex: 1, cellId: null } },
331
+ allRows: [{}, {}, {}, {}, {}],
332
+ allColumns: [{}, {}, {}, {}],
333
+ ...extra,
334
+ })
335
+ }
336
+
337
+ it('ArrowDown moves the active cell down and updates selection', () => {
338
+ const ctx = navCtx()
339
+ const { onGridKeyDown } = createKeyboard(ctx)
340
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown' }))
341
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(2, 1)
342
+ expect(ctx.scrollActiveCellIntoView).toHaveBeenCalledWith(2, 1)
343
+ expect(ctx.setSelection).toHaveBeenCalledWith(2, 1)
344
+ expect(ctx.extendSelection).not.toHaveBeenCalled()
345
+ })
346
+
347
+ it('Shift+ArrowRight extends the selection rectangle', () => {
348
+ const ctx = navCtx()
349
+ const { onGridKeyDown } = createKeyboard(ctx)
350
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowRight', shiftKey: true }))
351
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(1, 2)
352
+ expect(ctx.extendSelection).toHaveBeenCalledWith(1, 2)
353
+ expect(ctx.setSelection).not.toHaveBeenCalled()
354
+ })
355
+
356
+ it('Shift+Tab changes direction without extending the range', () => {
357
+ // tabPrev is NOT in the arrow-family extend set, so even with shiftKey the
358
+ // selection is replaced rather than grown.
359
+ const ctx = navCtx({ state: { activeCell: { rowIndex: 1, colIndex: 1, cellId: null } } })
360
+ const { onGridKeyDown } = createKeyboard(ctx)
361
+ onGridKeyDown(rootKeyEvent({ key: 'Tab', shiftKey: true }))
362
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(1, 0)
363
+ expect(ctx.extendSelection).not.toHaveBeenCalled()
364
+ expect(ctx.setSelection).toHaveBeenCalledWith(1, 0)
365
+ })
366
+
367
+ it('Shift+Enter (moveUp) DOES extend the selection (arrow-family intent)', () => {
368
+ const ctx = navCtx()
369
+ const { onGridKeyDown } = createKeyboard(ctx)
370
+ onGridKeyDown(rootKeyEvent({ key: 'Enter', shiftKey: true }))
371
+ // Enter+shift -> moveUp, which is in the extend set.
372
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(0, 1)
373
+ expect(ctx.extendSelection).toHaveBeenCalledWith(0, 1)
374
+ })
375
+
376
+ it('Tab moves right and wraps using getNextActiveCell', () => {
377
+ const ctx = navCtx({ state: { activeCell: { rowIndex: 1, colIndex: 3, cellId: null } } })
378
+ const { onGridKeyDown } = createKeyboard(ctx)
379
+ onGridKeyDown(rootKeyEvent({ key: 'Tab' }))
380
+ // colIndex 3 is maxCol -> wraps to col 0 row 2
381
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(2, 0)
382
+ })
383
+
384
+ it('defaults the active cell to (0,0) when state has none', () => {
385
+ const ctx = navCtx({ state: { activeCell: undefined } })
386
+ const { onGridKeyDown } = createKeyboard(ctx)
387
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowRight' }))
388
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(0, 1)
389
+ })
390
+
391
+ it('PageDown uses the pagination pageSize when present', () => {
392
+ const ctx = navCtx({
393
+ state: {
394
+ activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
395
+ pagination: { pageSize: 2 },
396
+ },
397
+ })
398
+ const { onGridKeyDown } = createKeyboard(ctx)
399
+ onGridKeyDown(rootKeyEvent({ key: 'PageDown' }))
400
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(2, 0)
401
+ })
402
+
403
+ it('PageDown derives a page step from container height when no pagination', () => {
404
+ // clientHeight 330 - headerHeight 30 = 300 usable / 30 row - 1 = 9 step.
405
+ const ctx = navCtx({
406
+ state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } },
407
+ allRows: new Array(40).fill({}),
408
+ })
409
+ const { onGridKeyDown } = createKeyboard(ctx)
410
+ onGridKeyDown(rootKeyEvent({ key: 'PageDown' }))
411
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(9, 0)
412
+ })
413
+
414
+ it('PageDown handles a functional rowHeight prop', () => {
415
+ const ctx = navCtx({
416
+ state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } },
417
+ allRows: new Array(40).fill({}),
418
+ props: { rowHeight: () => 60 },
419
+ })
420
+ const { onGridKeyDown } = createKeyboard(ctx)
421
+ onGridKeyDown(rootKeyEvent({ key: 'PageDown' }))
422
+ // 300 usable / 60 - 1 = 4
423
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(4, 0)
424
+ })
425
+
426
+ it('Home / Ctrl+Home move to row start and grid start', () => {
427
+ const ctx = navCtx({ state: { activeCell: { rowIndex: 2, colIndex: 3, cellId: null } } })
428
+ const { onGridKeyDown } = createKeyboard(ctx)
429
+ onGridKeyDown(rootKeyEvent({ key: 'Home' }))
430
+ expect(ctx.setActiveCell).toHaveBeenLastCalledWith(2, 0)
431
+ onGridKeyDown(rootKeyEvent({ key: 'Home', ctrlKey: true }))
432
+ expect(ctx.setActiveCell).toHaveBeenLastCalledWith(0, 0)
433
+ })
434
+
435
+ it('clearCells intent (Delete with non-empty range) wipes values', () => {
436
+ const ctx = navCtx({ clearSelectedCells: vi.fn(() => false) })
437
+ const { onGridKeyDown } = createKeyboard(ctx)
438
+ onGridKeyDown(rootKeyEvent({ key: 'Delete' }))
439
+ expect(ctx.clearSelectedCellValues).toHaveBeenCalled()
440
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
441
+ })
442
+ })
443
+
444
+ describe('onWindowKeydown', () => {
445
+ it('Escape closes menus when a column menu is open', () => {
446
+ const ctx = makeCtx({ columnMenuFor: 'col1' })
447
+ const { onWindowKeydown } = createKeyboard(ctx)
448
+ onWindowKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
449
+ expect(ctx.closeMenus).toHaveBeenCalled()
450
+ })
451
+
452
+ it('Escape closes menus when an operator menu is open', () => {
453
+ const ctx = makeCtx({ operatorMenuFor: 'op1' })
454
+ const { onWindowKeydown } = createKeyboard(ctx)
455
+ onWindowKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
456
+ expect(ctx.closeMenus).toHaveBeenCalled()
457
+ })
458
+
459
+ it('does nothing when no menu is open', () => {
460
+ const ctx = makeCtx()
461
+ const { onWindowKeydown } = createKeyboard(ctx)
462
+ onWindowKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
463
+ expect(ctx.closeMenus).not.toHaveBeenCalled()
464
+ })
465
+
466
+ it('ignores non-Escape keys', () => {
467
+ const ctx = makeCtx({ columnMenuFor: 'col1' })
468
+ const { onWindowKeydown } = createKeyboard(ctx)
469
+ onWindowKeydown(new KeyboardEvent('keydown', { key: 'Enter' }))
470
+ expect(ctx.closeMenus).not.toHaveBeenCalled()
471
+ })
472
+ })
473
+
474
+ describe('onHeaderSortClick', () => {
475
+ function sortCtx(sorting: Array<{ id: string; desc: boolean }> = [], canSort = true) {
476
+ const columns = [
477
+ { id: 'name', getCanSort: () => canSort },
478
+ { id: 'age', getCanSort: () => true },
479
+ ]
480
+ return makeCtx({ allColumns: columns, state: { sorting } })
481
+ }
482
+
483
+ it('ignores a click on a non-sortable column', () => {
484
+ const ctx = sortCtx([], false)
485
+ const { onHeaderSortClick } = createKeyboard(ctx)
486
+ onHeaderSortClick(new MouseEvent('click'), 'name')
487
+ expect(ctx._setState).not.toHaveBeenCalled()
488
+ })
489
+
490
+ it('ignores a click on an unknown column id', () => {
491
+ const ctx = sortCtx()
492
+ const { onHeaderSortClick } = createKeyboard(ctx)
493
+ onHeaderSortClick(new MouseEvent('click'), 'nope')
494
+ expect(ctx._setState).not.toHaveBeenCalled()
495
+ })
496
+
497
+ it('single-click on an unsorted column sorts ascending', () => {
498
+ const ctx = sortCtx([])
499
+ const { onHeaderSortClick } = createKeyboard(ctx)
500
+ onHeaderSortClick(new MouseEvent('click'), 'name')
501
+ expect(ctx._state.sorting).toEqual([{ id: 'name', desc: false }])
502
+ })
503
+
504
+ it('single-click cycles asc -> desc', () => {
505
+ const ctx = sortCtx([{ id: 'name', desc: false }])
506
+ const { onHeaderSortClick } = createKeyboard(ctx)
507
+ onHeaderSortClick(new MouseEvent('click'), 'name')
508
+ expect(ctx._state.sorting).toEqual([{ id: 'name', desc: true }])
509
+ })
510
+
511
+ it('single-click cycles desc -> none', () => {
512
+ const ctx = sortCtx([{ id: 'name', desc: true }])
513
+ const { onHeaderSortClick } = createKeyboard(ctx)
514
+ onHeaderSortClick(new MouseEvent('click'), 'name')
515
+ expect(ctx._state.sorting).toEqual([])
516
+ })
517
+
518
+ it('shift-click appends a new column to a multi-sort', () => {
519
+ const ctx = sortCtx([{ id: 'age', desc: false }])
520
+ const { onHeaderSortClick } = createKeyboard(ctx)
521
+ onHeaderSortClick(new MouseEvent('click', { shiftKey: true }), 'name')
522
+ expect(ctx._state.sorting).toEqual([
523
+ { id: 'age', desc: false },
524
+ { id: 'name', desc: false },
525
+ ])
526
+ })
527
+
528
+ it('shift-click toggles an existing asc clause to desc', () => {
529
+ const ctx = sortCtx([
530
+ { id: 'age', desc: false },
531
+ { id: 'name', desc: false },
532
+ ])
533
+ const { onHeaderSortClick } = createKeyboard(ctx)
534
+ onHeaderSortClick(new MouseEvent('click', { shiftKey: true }), 'name')
535
+ expect(ctx._state.sorting).toEqual([
536
+ { id: 'age', desc: false },
537
+ { id: 'name', desc: true },
538
+ ])
539
+ })
540
+
541
+ it('shift-click removes a desc clause from the multi-sort', () => {
542
+ const ctx = sortCtx([
543
+ { id: 'age', desc: false },
544
+ { id: 'name', desc: true },
545
+ ])
546
+ const { onHeaderSortClick } = createKeyboard(ctx)
547
+ onHeaderSortClick(new MouseEvent('click', { shiftKey: true }), 'name')
548
+ expect(ctx._state.sorting).toEqual([{ id: 'age', desc: false }])
549
+ })
550
+
551
+ it('handles a missing sorting array (defaults to [])', () => {
552
+ const ctx = makeCtx({
553
+ allColumns: [{ id: 'name', getCanSort: () => true }],
554
+ state: { sorting: undefined },
555
+ })
556
+ const { onHeaderSortClick } = createKeyboard(ctx)
557
+ onHeaderSortClick(new MouseEvent('click'), 'name')
558
+ expect(ctx._state.sorting).toEqual([{ id: 'name', desc: false }])
559
+ })
560
+ })