@svgrid/grid 3.0.2 → 3.0.4

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 (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -54,6 +54,10 @@ function makeCtx(overrides: Partial<any> = {}) {
54
54
  extendSelection: vi.fn(),
55
55
  setSelection: vi.fn(),
56
56
  closeMenus: vi.fn(),
57
+ tabRunOrigin: null,
58
+ activeRangeRect: () => null,
59
+ isRowCollapsed: () => false,
60
+ collapsedColumns: {},
57
61
  ...overrides,
58
62
  }
59
63
  ctx._state = state
@@ -344,12 +348,32 @@ describe('onGridKeyDown / navigation', () => {
344
348
  expect(ctx.extendSelection).not.toHaveBeenCalled()
345
349
  })
346
350
 
347
- it('Shift+ArrowRight extends the selection rectangle', () => {
348
- const ctx = navCtx()
351
+ it('Shift+ArrowRight extends the selection rectangle and leaves the active cell', () => {
352
+ const ctx = navCtx({
353
+ selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 1, colIndex: 1 } },
354
+ })
349
355
  const { onGridKeyDown } = createKeyboard(ctx)
350
356
  onGridKeyDown(rootKeyEvent({ key: 'ArrowRight', shiftKey: true }))
351
- expect(ctx.setActiveCell).toHaveBeenCalledWith(1, 2)
357
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
352
358
  expect(ctx.extendSelection).toHaveBeenCalledWith(1, 2)
359
+ expect(ctx.scrollActiveCellIntoView).toHaveBeenCalledWith(1, 2)
360
+ expect(ctx.setSelection).not.toHaveBeenCalled()
361
+ })
362
+
363
+ it('Shift+Home, Shift+PageDown and Ctrl+Shift+Home grow the range to where the plain key goes', () => {
364
+ const ctx = navCtx({
365
+ selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 3, colIndex: 2 } },
366
+ })
367
+ const { onGridKeyDown } = createKeyboard(ctx)
368
+ onGridKeyDown(rootKeyEvent({ key: 'Home', shiftKey: true }))
369
+ expect(ctx.extendSelection).toHaveBeenLastCalledWith(3, 0)
370
+ onGridKeyDown(rootKeyEvent({ key: 'Home', shiftKey: true, ctrlKey: true }))
371
+ expect(ctx.extendSelection).toHaveBeenLastCalledWith(0, 0)
372
+ onGridKeyDown(rootKeyEvent({ key: 'PageDown', shiftKey: true }))
373
+ const [row, col] = ctx.extendSelection.mock.lastCall
374
+ expect(col).toBe(2)
375
+ expect(row).toBeGreaterThan(3)
376
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
353
377
  expect(ctx.setSelection).not.toHaveBeenCalled()
354
378
  })
355
379
 
@@ -364,13 +388,109 @@ describe('onGridKeyDown / navigation', () => {
364
388
  expect(ctx.setSelection).toHaveBeenCalledWith(1, 0)
365
389
  })
366
390
 
367
- it('Shift+Enter (moveUp) DOES extend the selection (arrow-family intent)', () => {
391
+ it('Shift+Arrow grows from the far corner of the range, not from the active cell', () => {
392
+ const ctx = navCtx({
393
+ selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 3, colIndex: 2 } },
394
+ })
395
+ const { onGridKeyDown } = createKeyboard(ctx)
396
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown', shiftKey: true }))
397
+ expect(ctx.extendSelection).toHaveBeenCalledWith(4, 2)
398
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
399
+ })
400
+
401
+ it('Shift+Arrow with nothing anchored anchors at the active cell first', () => {
402
+ const ctx = navCtx({ selectionRange: { anchor: null, focus: null } })
403
+ const { onGridKeyDown } = createKeyboard(ctx)
404
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown', shiftKey: true }))
405
+ expect(ctx.setSelection).toHaveBeenCalledWith(1, 1)
406
+ expect(ctx.extendSelection).toHaveBeenCalledWith(2, 1)
407
+ })
408
+
409
+ it('Shift+Arrow moves the active cell when cell selection is off', () => {
410
+ const ctx = navCtx({ enableCellSelectionEffective: false })
411
+ const { onGridKeyDown } = createKeyboard(ctx)
412
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown', shiftKey: true }))
413
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(2, 1)
414
+ expect(ctx.extendSelection).not.toHaveBeenCalled()
415
+ })
416
+
417
+ it('Ctrl+V with onPasteClipboard leaves the key to the browser and arms the async fallback', () => {
418
+ const arm = vi.fn()
419
+ const ctx = navCtx({ props: { rowHeight: 30, onPasteClipboard: () => true }, armPasteFallback: arm, pasteFromClipboard: vi.fn() })
420
+ const { onGridKeyDown } = createKeyboard(ctx)
421
+ const ev = rootKeyEvent({ key: 'v', ctrlKey: true })
422
+ onGridKeyDown(ev)
423
+ expect(ev.defaultPrevented).toBe(false)
424
+ expect(arm).toHaveBeenCalledTimes(1)
425
+ expect(ctx.pasteFromClipboard).not.toHaveBeenCalled()
426
+ })
427
+
428
+ it('Shift+Enter moves up without extending the selection', () => {
429
+ // Shift+Enter shares the moveUp intent with Shift+ArrowUp, and used to
430
+ // grow the range like the arrow does. In Excel it only reverses Enter.
368
431
  const ctx = navCtx()
369
432
  const { onGridKeyDown } = createKeyboard(ctx)
370
433
  onGridKeyDown(rootKeyEvent({ key: 'Enter', shiftKey: true }))
371
- // Enter+shift -> moveUp, which is in the extend set.
372
434
  expect(ctx.setActiveCell).toHaveBeenCalledWith(0, 1)
373
- expect(ctx.extendSelection).toHaveBeenCalledWith(0, 1)
435
+ expect(ctx.setSelection).toHaveBeenCalledWith(0, 1)
436
+ expect(ctx.extendSelection).not.toHaveBeenCalled()
437
+ })
438
+
439
+ it('Enter after a run of Tabs goes down from the column the run began in', () => {
440
+ const ctx = navCtx({ state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } } })
441
+ // The fake setActiveCell does what the real one does: forgets the run.
442
+ ctx.setActiveCell = vi.fn((r: number, c: number) => {
443
+ const prev = ctx._state.activeCell
444
+ if (!prev || prev.rowIndex !== r || prev.colIndex !== c) ctx.tabRunOrigin = null
445
+ ctx._state.activeCell = { rowIndex: r, colIndex: c, cellId: null }
446
+ })
447
+ const { onGridKeyDown } = createKeyboard(ctx)
448
+ onGridKeyDown(rootKeyEvent({ key: 'Tab' }))
449
+ onGridKeyDown(rootKeyEvent({ key: 'Tab' }))
450
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 0, colIndex: 2 })
451
+ expect(ctx.tabRunOrigin).toBe(0)
452
+ onGridKeyDown(rootKeyEvent({ key: 'Enter' }))
453
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 1, colIndex: 0 })
454
+ expect(ctx.tabRunOrigin).toBe(null)
455
+ expect(ctx.setSelection).toHaveBeenLastCalledWith(1, 0)
456
+ })
457
+
458
+ it('an arrow between the Tabs ends the run', () => {
459
+ const ctx = navCtx({ state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } } })
460
+ ctx.setActiveCell = vi.fn((r: number, c: number) => {
461
+ const prev = ctx._state.activeCell
462
+ if (!prev || prev.rowIndex !== r || prev.colIndex !== c) ctx.tabRunOrigin = null
463
+ ctx._state.activeCell = { rowIndex: r, colIndex: c, cellId: null }
464
+ })
465
+ const { onGridKeyDown } = createKeyboard(ctx)
466
+ onGridKeyDown(rootKeyEvent({ key: 'Tab' }))
467
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowRight' }))
468
+ expect(ctx.tabRunOrigin).toBe(null)
469
+ onGridKeyDown(rootKeyEvent({ key: 'Enter' }))
470
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 1, colIndex: 2 })
471
+ })
472
+
473
+ it('Enter and Tab stay inside a selected block and keep the selection', () => {
474
+ const ctx = navCtx({
475
+ state: { activeCell: { rowIndex: 1, colIndex: 1, cellId: null } },
476
+ activeRangeRect: () => ({ minRow: 1, maxRow: 2, minCol: 1, maxCol: 2 }),
477
+ })
478
+ ctx.setActiveCell = vi.fn((r: number, c: number) => {
479
+ const prev = ctx._state.activeCell
480
+ if (!prev || prev.rowIndex !== r || prev.colIndex !== c) ctx.tabRunOrigin = null
481
+ ctx._state.activeCell = { rowIndex: r, colIndex: c, cellId: null }
482
+ })
483
+ const { onGridKeyDown } = createKeyboard(ctx)
484
+ onGridKeyDown(rootKeyEvent({ key: 'Enter' }))
485
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 2, colIndex: 1 })
486
+ onGridKeyDown(rootKeyEvent({ key: 'Enter' }))
487
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 1, colIndex: 2 })
488
+ onGridKeyDown(rootKeyEvent({ key: 'Tab' }))
489
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 2, colIndex: 1 })
490
+ onGridKeyDown(rootKeyEvent({ key: 'Tab', shiftKey: true }))
491
+ expect(ctx._state.activeCell).toMatchObject({ rowIndex: 1, colIndex: 2 })
492
+ expect(ctx.setSelection).not.toHaveBeenCalled()
493
+ expect(ctx.extendSelection).not.toHaveBeenCalled()
374
494
  })
375
495
 
376
496
  it('Tab moves right and wraps using getNextActiveCell', () => {
@@ -7,7 +7,11 @@ import {
7
7
  } from "./index";
8
8
  import "./sv-grid-scrollbar";
9
9
 
10
- import { getKeyboardIntent, getNextActiveCell } from "./keyboard";
10
+ import { getKeyboardIntent, getNextActiveCell, getEntryStep, pastCollapsed } from "./keyboard";
11
+ import { stepPastMerge } from "./merges";
12
+ import { hasGridShortcuts, runGridShortcuts } from "./shortcut-registry";
13
+ import { undoHistory, redoHistory } from "./history";
14
+ import { buildCommandContext } from "./command-context";
11
15
 
12
16
  export function createKeyboard<
13
17
  TFeatures extends TableFeatures = TableFeatures,
@@ -16,7 +20,21 @@ export function createKeyboard<
16
20
  function onGridKeyDown(event: KeyboardEvent) {
17
21
  // Only the grid root drives navigation - keys on header buttons, menus,
18
22
  // or the cell editor are handled by those controls themselves.
23
+ //
24
+ // The command chain runs AFTER this guard, not before. Running it first
25
+ // meant a key typed into a filter-row input reached the sheet commands,
26
+ // so Ctrl+A or Ctrl+D while filtering mutated grid cells. The editor has
27
+ // its own chain call in editing.ts, which is what Alt+Enter needs.
19
28
  if (event.target !== event.currentTarget) return;
29
+
30
+ // Registered commands get the key BEFORE the grid interprets it. That is
31
+ // how @svgrid/enterprise binds Ctrl+Arrow, Ctrl+D and the rest without
32
+ // widening the closed GridKeyboardIntent union, which is public API. A
33
+ // grid with nothing registered pays one array-length read.
34
+ if (hasGridShortcuts() && runGridShortcuts(event, buildCommandContext(ctx, false))) {
35
+ return;
36
+ }
37
+
20
38
  if (ctx.editingCell) return;
21
39
 
22
40
  if ((event.ctrlKey || event.metaKey) && !event.altKey) {
@@ -27,6 +45,14 @@ export function createKeyboard<
27
45
  return;
28
46
  }
29
47
  if (lower === "v") {
48
+ // A consumer taking the paste over wants the browser's own `paste`
49
+ // event, which carries the clipboard's HTML unsanitised; the key is
50
+ // left alone and the async API is armed as the fallback for a
51
+ // browser that never delivers the event to a non-editable element.
52
+ if (ctx.props.onPasteClipboard) {
53
+ ctx.armPasteFallback();
54
+ return;
55
+ }
30
56
  // Secure context: read via the async Clipboard API and swallow the
31
57
  // key. Insecure context (no navigator.clipboard): DON'T preventDefault
32
58
  // so the browser delivers a native `paste` event to `onGridPaste`.
@@ -45,26 +71,12 @@ export function createKeyboard<
45
71
  // and Ctrl+Y both redo. Mirrors VSCode / Sheets / Excel.
46
72
  if (lower === "z" && !event.shiftKey) {
47
73
  event.preventDefault()
48
- if (ctx.historyPtr >= 0) {
49
- const step = ctx.history[ctx.historyPtr]
50
- if (step) {
51
- ctx.applyHistoryStep(step, 'undo')
52
- ctx.historyPtr -= 1
53
- ctx.historyVersion += 1
54
- }
55
- }
74
+ undoHistory(ctx)
56
75
  return
57
76
  }
58
77
  if ((lower === "z" && event.shiftKey) || lower === "y") {
59
78
  event.preventDefault()
60
- if (ctx.historyPtr < ctx.history.length - 1) {
61
- const step = ctx.history[ctx.historyPtr + 1]
62
- if (step) {
63
- ctx.applyHistoryStep(step, 'redo')
64
- ctx.historyPtr += 1
65
- ctx.historyVersion += 1
66
- }
67
- }
79
+ redoHistory(ctx)
68
80
  return
69
81
  }
70
82
  // Ctrl+F opens the find overlay.
@@ -204,25 +216,74 @@ export function createKeyboard<
204
216
  const usable = Math.max(0, clientHeight - headerHeight)
205
217
  return Math.max(1, Math.floor(usable / Math.max(rowHeight, 1)) - 1)
206
218
  }
207
- const next = getNextActiveCell(current, intent, {
219
+ const bounds = {
208
220
  maxRow: Math.max(ctx.allRows.length - 1, 0),
209
221
  maxCol: Math.max(ctx.allColumns.length - 1, 0),
210
- pageSize: pageStep(),
211
- });
212
- ctx.setActiveCell(next.rowIndex, next.colIndex);
213
- ctx.scrollActiveCellIntoView(next.rowIndex, next.colIndex);
214
- // Shift extends the selection ONLY for arrow keys (Excel-style).
215
- // Shift+Enter / Shift+Tab just change direction; they don't grow the
216
- // range, otherwise hammering Shift+Tab to backspace through a row
217
- // would paint a creeping rectangle behind the cursor.
218
- const shouldExtend =
222
+ };
223
+ // Collapsed rows and columns are stepped over, as a sheet's hidden ones.
224
+ const collapsed = {
225
+ isRowCollapsed: (i: number) => ctx.isRowCollapsed(i) as boolean,
226
+ isColumnCollapsed: (i: number) => !!ctx.collapsedColumns[ctx.allColumns[i]?.id],
227
+ };
228
+ // Enter and Tab are entry keys: they remember a Tab run and stay inside
229
+ // a selected block (getEntryStep). ArrowDown shares the moveDown intent
230
+ // and is a plain move, hence the key check.
231
+ if (
232
+ (event.key === "Enter" || event.key === "Tab") &&
233
+ (intent === "moveDown" || intent === "moveUp" || intent === "tabNext" || intent === "tabPrev")
234
+ ) {
235
+ const step = getEntryStep(current, intent, {
236
+ ...bounds,
237
+ tabOrigin: ctx.tabRunOrigin,
238
+ range: ctx.activeRangeRect(),
239
+ collapsed,
240
+ });
241
+ const landed = stepPastMerge(ctx.mergeIndex, current, step.cell, bounds);
242
+ ctx.setActiveCell(landed.rowIndex, landed.colIndex);
243
+ ctx.tabRunOrigin = step.tabOrigin;
244
+ ctx.scrollActiveCellIntoView(landed.rowIndex, landed.colIndex);
245
+ if (!step.withinRange) ctx.setSelection(landed.rowIndex, landed.colIndex);
246
+ return;
247
+ }
248
+ // Shift+Arrow grows the range from its far corner, and the active cell
249
+ // stays where it is, as in Excel: it is the cell the formula bar shows
250
+ // and the one the next keystroke edits. Shift+Home, Shift+End,
251
+ // Shift+PageUp / PageDown and Ctrl+Shift+Home / End grow it the same
252
+ // way, to where the plain key would have moved. Shift+Enter and
253
+ // Shift+Tab never come this way (they are entry keys, above), so
254
+ // hammering Shift+Tab back through a row cannot paint a creeping
255
+ // rectangle. A grid without cell selection has no range to grow and
256
+ // moves the active cell instead.
257
+ const extending =
219
258
  event.shiftKey &&
259
+ ctx.enableCellSelectionEffective !== false &&
220
260
  (intent === "moveLeft" ||
221
261
  intent === "moveRight" ||
222
262
  intent === "moveUp" ||
223
- intent === "moveDown");
224
- if (shouldExtend) ctx.extendSelection(next.rowIndex, next.colIndex);
225
- else ctx.setSelection(next.rowIndex, next.colIndex);
263
+ intent === "moveDown" ||
264
+ intent === "rowStart" ||
265
+ intent === "rowEnd" ||
266
+ intent === "pageUp" ||
267
+ intent === "pageDown" ||
268
+ intent === "gridStart" ||
269
+ intent === "gridEnd");
270
+ if (extending) {
271
+ const anchored = !!ctx.selectionRange?.anchor;
272
+ if (!anchored) ctx.setSelection(current.rowIndex, current.colIndex);
273
+ const from = anchored ? ctx.selectionRange.focus ?? current : current;
274
+ const next = stepPastMerge(ctx.mergeIndex, from, pastCollapsed(from, getNextActiveCell(from, intent, { ...bounds, pageSize: pageStep() }), bounds, collapsed), bounds);
275
+ ctx.extendSelection(next.rowIndex, next.colIndex);
276
+ // Scroll along the axis of the key only, as Excel does: Shift+Right
277
+ // brings the new column into view without moving the rows, so a
278
+ // whole-column selection (its far corner on the last row) stays put.
279
+ const sideways = intent === "moveLeft" || intent === "moveRight" || intent === "rowStart" || intent === "rowEnd";
280
+ ctx.scrollActiveCellIntoView(sideways ? current.rowIndex : next.rowIndex, sideways ? next.colIndex : current.colIndex);
281
+ return;
282
+ }
283
+ const next = stepPastMerge(ctx.mergeIndex, current, pastCollapsed(current, getNextActiveCell(current, intent, { ...bounds, pageSize: pageStep() }), bounds, collapsed), bounds);
284
+ ctx.setActiveCell(next.rowIndex, next.colIndex);
285
+ ctx.scrollActiveCellIntoView(next.rowIndex, next.colIndex);
286
+ ctx.setSelection(next.rowIndex, next.colIndex);
226
287
  }
227
288
 
228
289
  function onWindowKeydown(event: KeyboardEvent) {
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it, beforeEach, vi } from 'vitest'
2
+ import { createKeyboard } from './keyboard-handlers'
3
+ import { getKeyboardIntent } from './keyboard'
4
+ import { registerGridShortcuts, clearGridShortcuts } from './shortcut-registry'
5
+
6
+ // Same harness shape as keyboard-handlers.coverage.test.ts: a fake controller
7
+ // ctx that records side effects, and real jsdom KeyboardEvents so modifier
8
+ // flags and target/currentTarget behave.
9
+ function makeCtx(overrides: Partial<any> = {}) {
10
+ const state: any = { activeCell: { rowIndex: 2, colIndex: 1, cellId: null }, sorting: [] }
11
+ const ctx: any = {
12
+ editingCell: null,
13
+ findOpen: false,
14
+ history: [],
15
+ historyPtr: -1,
16
+ historyVersion: 0,
17
+ historyGroupId: undefined,
18
+ UNDO_LIMIT: 200,
19
+ headerHeight: 30,
20
+ scrollContainer: { clientHeight: 330 },
21
+ props: { rowHeight: 30 },
22
+ allRows: [{}, {}, {}, {}],
23
+ allColumns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
24
+ grid: { getState: () => state, store: { setState: vi.fn() } },
25
+ buildApi: () => ({}),
26
+ getSelectionRects: () => [{ minRow: 2, minCol: 1, maxRow: 2, maxCol: 1 }],
27
+ readCellRaw: vi.fn(() => 'v'),
28
+ writeCellRaw: vi.fn(),
29
+ copySelectionToClipboard: vi.fn(),
30
+ pasteFromClipboard: vi.fn(() => Promise.resolve()),
31
+ cutSelectionToClipboard: vi.fn(() => Promise.resolve()),
32
+ applyHistoryStep: vi.fn(),
33
+ clearSelectedCells: vi.fn(() => false),
34
+ clearSelectedCellValues: vi.fn(),
35
+ onCellDoubleClick: vi.fn(),
36
+ startEditingWithChar: vi.fn(() => true),
37
+ toggleBooleanCell: vi.fn(),
38
+ setActiveCell: vi.fn(),
39
+ scrollActiveCellIntoView: vi.fn(),
40
+ extendSelection: vi.fn(),
41
+ setSelection: vi.fn(),
42
+ closeMenus: vi.fn(),
43
+ tabRunOrigin: null,
44
+ activeRangeRect: () => null,
45
+ isRowCollapsed: () => false,
46
+ collapsedColumns: {},
47
+ ...overrides,
48
+ }
49
+ return ctx
50
+ }
51
+
52
+ function rootKeyEvent(init: KeyboardEventInit) {
53
+ const el = document.createElement('div')
54
+ const event = new KeyboardEvent('keydown', { cancelable: true, ...init })
55
+ Object.defineProperty(event, 'target', { value: el })
56
+ Object.defineProperty(event, 'currentTarget', { value: el })
57
+ return event
58
+ }
59
+
60
+ beforeEach(() => clearGridShortcuts())
61
+
62
+ describe('the shortcut chain inside onGridKeyDown', () => {
63
+ it('lets a handler claim a key the grid would otherwise interpret', () => {
64
+ registerGridShortcuts((e) => e.key === 'ArrowDown' && e.ctrlKey)
65
+ const ctx = makeCtx()
66
+ const { onGridKeyDown } = createKeyboard(ctx)
67
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown', ctrlKey: true }))
68
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
69
+ })
70
+
71
+ it('claims a key the grid DOES implement, so enterprise can reclaim it', () => {
72
+ // Ctrl+C is copy in the free grid. A registered handler taking it first is
73
+ // what lets a sheet put formulas and formats on the clipboard instead.
74
+ registerGridShortcuts(() => true)
75
+ const ctx = makeCtx()
76
+ const { onGridKeyDown } = createKeyboard(ctx)
77
+ onGridKeyDown(rootKeyEvent({ key: 'c', ctrlKey: true }))
78
+ expect(ctx.copySelectionToClipboard).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('falls through to normal navigation when the handler declines', () => {
82
+ registerGridShortcuts(() => false)
83
+ const ctx = makeCtx()
84
+ const { onGridKeyDown } = createKeyboard(ctx)
85
+ onGridKeyDown(rootKeyEvent({ key: 'ArrowDown' }))
86
+ expect(ctx.setActiveCell).toHaveBeenCalledWith(3, 1)
87
+ })
88
+
89
+ it('does NOT run for a key typed into a child element', () => {
90
+ // The chain used to run before the grid-root guard, so a Ctrl+A or Ctrl+D
91
+ // typed into a filter-row input reached the sheet commands and mutated
92
+ // grid cells.
93
+ const handler = vi.fn(() => true)
94
+ registerGridShortcuts(handler)
95
+ const ctx = makeCtx()
96
+ const root = document.createElement('div')
97
+ const input = document.createElement('input')
98
+ const event = new KeyboardEvent('keydown', { key: 'd', ctrlKey: true, cancelable: true })
99
+ Object.defineProperty(event, 'target', { value: input })
100
+ Object.defineProperty(event, 'currentTarget', { value: root })
101
+ createKeyboard(ctx).onGridKeyDown(event)
102
+ expect(handler).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('runs before the editing bail, so a mid-edit key still reaches a handler', () => {
106
+ const handler = vi.fn(() => true)
107
+ registerGridShortcuts(handler)
108
+ const ctx = makeCtx({ editingCell: { rowId: 'r1', columnId: 'a' } })
109
+ const { onGridKeyDown } = createKeyboard(ctx)
110
+ onGridKeyDown(rootKeyEvent({ key: 'Enter', altKey: true }))
111
+ expect(handler).toHaveBeenCalledOnce()
112
+ })
113
+
114
+ it('hands the handler a context describing the active cell and selection', () => {
115
+ let seen: any = null
116
+ registerGridShortcuts((_e, cmd) => {
117
+ seen = {
118
+ active: cmd.activeCell,
119
+ ranges: cmd.ranges,
120
+ rowCount: cmd.rowCount,
121
+ colCount: cmd.colCount,
122
+ editing: cmd.editing,
123
+ }
124
+ return true
125
+ })
126
+ const ctx = makeCtx()
127
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'd', ctrlKey: true }))
128
+ expect(seen.active).toEqual({ rowIndex: 2, colIndex: 1, columnId: 'b' })
129
+ expect(seen.ranges).toEqual([[2, 1, 2, 1]])
130
+ expect(seen.rowCount).toBe(4)
131
+ expect(seen.colCount).toBe(3)
132
+ expect(seen.editing).toBe(false)
133
+ })
134
+
135
+ it('reads and writes cells by display index through the context', () => {
136
+ registerGridShortcuts((_e, cmd) => {
137
+ cmd.setCellValue(0, 2, cmd.getCellValue(1, 0))
138
+ return true
139
+ })
140
+ const ctx = makeCtx()
141
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'd', ctrlKey: true }))
142
+ expect(ctx.readCellRaw).toHaveBeenCalledWith(1, 'a')
143
+ expect(ctx.writeCellRaw).toHaveBeenCalledWith(0, 'c', 'v')
144
+ })
145
+
146
+ it('does not build a context when nothing is registered', () => {
147
+ const ctx = makeCtx()
148
+ // getSelectionRects is only reached through the command context.
149
+ ctx.getSelectionRects = vi.fn(() => [])
150
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'ArrowDown' }))
151
+ expect(ctx.getSelectionRects).not.toHaveBeenCalled()
152
+ })
153
+ })
154
+
155
+ describe('Ctrl / Cmd + arrow', () => {
156
+ it('is a noop intent rather than a single-cell move', () => {
157
+ for (const key of ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) {
158
+ expect(getKeyboardIntent(new KeyboardEvent('keydown', { key, ctrlKey: true }))).toBe('noop')
159
+ expect(getKeyboardIntent(new KeyboardEvent('keydown', { key, metaKey: true }))).toBe('noop')
160
+ }
161
+ })
162
+
163
+ it('leaves a plain arrow alone', () => {
164
+ expect(getKeyboardIntent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))).toBe('moveDown')
165
+ })
166
+
167
+ it('leaves Alt combinations to the browser', () => {
168
+ expect(
169
+ getKeyboardIntent(new KeyboardEvent('keydown', { key: 'ArrowLeft', ctrlKey: true, altKey: true })),
170
+ ).toBe('moveLeft')
171
+ })
172
+
173
+ it('does not move the active cell when unclaimed', () => {
174
+ const ctx = makeCtx()
175
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'ArrowDown', ctrlKey: true }))
176
+ expect(ctx.setActiveCell).not.toHaveBeenCalled()
177
+ })
178
+
179
+ it('still does not start an edit, since the key is not printable', () => {
180
+ const ctx = makeCtx()
181
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'ArrowDown', ctrlKey: true }))
182
+ expect(ctx.startEditingWithChar).not.toHaveBeenCalled()
183
+ })
184
+ })
185
+
186
+ describe('grouped undo through the keyboard', () => {
187
+ it('walks a whole grouped action back on one Ctrl+Z', () => {
188
+ const g = 'g1'
189
+ const ctx = makeCtx({
190
+ history: [
191
+ { rowId: 'r1', columnId: 'a', field: 'a', before: 1, after: 2, groupId: g },
192
+ { rowId: 'r2', columnId: 'a', field: 'a', before: 3, after: 4, groupId: g },
193
+ ],
194
+ historyPtr: 1,
195
+ })
196
+ createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'z', ctrlKey: true }))
197
+ expect(ctx.applyHistoryStep).toHaveBeenCalledTimes(2)
198
+ expect(ctx.historyPtr).toBe(-1)
199
+ })
200
+ })
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { getKeyboardIntent, getNextActiveCell } from './keyboard'
2
+ import { getKeyboardIntent, getNextActiveCell, getEntryStep, pastCollapsed } from './keyboard'
3
+ import type { ActiveCellState } from './core'
3
4
 
4
5
  describe('keyboard navigation engine', () => {
5
6
  it('maps keys to navigation intents', () => {
@@ -57,3 +58,125 @@ describe('keyboard navigation engine', () => {
57
58
  expect(pageDown.rowIndex).toBe(20)
58
59
  })
59
60
  })
61
+
62
+ describe('getEntryStep', () => {
63
+ const at = (rowIndex: number, colIndex: number): ActiveCellState => ({ rowIndex, colIndex, cellId: null })
64
+ const open = { maxRow: 9, maxCol: 9, range: null }
65
+
66
+ it('a Tab starts a run at the column it leaves, and later Tabs keep it', () => {
67
+ const first = getEntryStep(at(0, 0), 'tabNext', { ...open, tabOrigin: null })
68
+ expect(first.cell).toMatchObject({ rowIndex: 0, colIndex: 1 })
69
+ expect(first.tabOrigin).toBe(0)
70
+ const second = getEntryStep(first.cell, 'tabNext', { ...open, tabOrigin: first.tabOrigin })
71
+ expect(second.cell).toMatchObject({ rowIndex: 0, colIndex: 2 })
72
+ expect(second.tabOrigin).toBe(0)
73
+ const back = getEntryStep(second.cell, 'tabPrev', { ...open, tabOrigin: second.tabOrigin })
74
+ expect(back.cell).toMatchObject({ rowIndex: 0, colIndex: 1 })
75
+ expect(back.tabOrigin).toBe(0)
76
+ })
77
+
78
+ it('Enter after a run goes down from the run\'s first column and ends it', () => {
79
+ const step = getEntryStep(at(0, 2), 'moveDown', { ...open, tabOrigin: 0 })
80
+ expect(step.cell).toMatchObject({ rowIndex: 1, colIndex: 0 })
81
+ expect(step.tabOrigin).toBe(null)
82
+ expect(step.withinRange).toBe(false)
83
+ const up = getEntryStep(at(3, 5), 'moveUp', { ...open, tabOrigin: 2 })
84
+ expect(up.cell).toMatchObject({ rowIndex: 2, colIndex: 2 })
85
+ })
86
+
87
+ it('Enter with no run is a plain move down', () => {
88
+ const step = getEntryStep(at(0, 2), 'moveDown', { ...open, tabOrigin: null })
89
+ expect(step.cell).toMatchObject({ rowIndex: 1, colIndex: 2 })
90
+ expect(step.tabOrigin).toBe(null)
91
+ })
92
+
93
+ it('walks a selected block: Enter down the columns, Tab along the rows, both wrapping', () => {
94
+ const range = { minRow: 1, maxRow: 2, minCol: 1, maxCol: 2 }
95
+ const opts = { maxRow: 9, maxCol: 9, tabOrigin: null, range }
96
+ const path = (start: ActiveCellState, intent: 'moveDown' | 'moveUp' | 'tabNext' | 'tabPrev', n: number) => {
97
+ const out: Array<[number, number]> = []
98
+ let cell = start
99
+ for (let i = 0; i < n; i++) {
100
+ const step = getEntryStep(cell, intent, opts)
101
+ expect(step.withinRange).toBe(true)
102
+ expect(step.tabOrigin).toBe(null)
103
+ cell = step.cell
104
+ out.push([cell.rowIndex, cell.colIndex])
105
+ }
106
+ return out
107
+ }
108
+ expect(path(at(1, 1), 'moveDown', 4)).toEqual([[2, 1], [1, 2], [2, 2], [1, 1]])
109
+ expect(path(at(1, 1), 'moveUp', 4)).toEqual([[2, 2], [1, 2], [2, 1], [1, 1]])
110
+ expect(path(at(1, 1), 'tabNext', 4)).toEqual([[1, 2], [2, 1], [2, 2], [1, 1]])
111
+ expect(path(at(1, 1), 'tabPrev', 4)).toEqual([[2, 2], [2, 1], [1, 2], [1, 1]])
112
+ })
113
+
114
+ it('a one-cell range and a cursor outside the range are not a block', () => {
115
+ const single = getEntryStep(at(1, 1), 'moveDown', { ...open, tabOrigin: null, range: { minRow: 1, maxRow: 1, minCol: 1, maxCol: 1 } })
116
+ expect(single.withinRange).toBe(false)
117
+ expect(single.cell).toMatchObject({ rowIndex: 2, colIndex: 1 })
118
+ const outside = getEntryStep(at(5, 5), 'tabNext', { ...open, tabOrigin: null, range: { minRow: 1, maxRow: 2, minCol: 1, maxCol: 2 } })
119
+ expect(outside.withinRange).toBe(false)
120
+ expect(outside.cell).toMatchObject({ rowIndex: 5, colIndex: 6 })
121
+ expect(outside.tabOrigin).toBe(5)
122
+ })
123
+
124
+ it('a Tab run inside a block does not start a run', () => {
125
+ const range = { minRow: 0, maxRow: 1, minCol: 0, maxCol: 1 }
126
+ const step = getEntryStep(at(0, 0), 'tabNext', { ...open, tabOrigin: null, range })
127
+ expect(step.tabOrigin).toBe(null)
128
+ })
129
+
130
+ it('steps over collapsed cells, on the open sheet and inside a block', () => {
131
+ const collapsed = { isRowCollapsed: (r: number) => r === 1, isColumnCollapsed: (c: number) => c === 1 }
132
+ const down = getEntryStep(at(0, 0), 'moveDown', { ...open, tabOrigin: null, collapsed })
133
+ expect(down.cell).toMatchObject({ rowIndex: 2, colIndex: 0 })
134
+ const tab = getEntryStep(at(0, 0), 'tabNext', { ...open, tabOrigin: null, collapsed })
135
+ expect(tab.cell).toMatchObject({ rowIndex: 0, colIndex: 2 })
136
+ expect(tab.tabOrigin).toBe(0)
137
+ // In a 3x3 block with its middle row and column hidden, Enter from the
138
+ // top-left goes to the bottom-left, then wraps to the top of column 3.
139
+ const range = { minRow: 0, maxRow: 2, minCol: 0, maxCol: 2 }
140
+ const first = getEntryStep(at(0, 0), 'moveDown', { ...open, tabOrigin: null, range, collapsed })
141
+ expect(first.cell).toMatchObject({ rowIndex: 2, colIndex: 0 })
142
+ expect(first.withinRange).toBe(true)
143
+ const second = getEntryStep(first.cell, 'moveDown', { ...open, tabOrigin: null, range, collapsed })
144
+ expect(second.cell).toMatchObject({ rowIndex: 0, colIndex: 2 })
145
+ // A block with nothing showing leaves the cursor where it is.
146
+ const dark = { isRowCollapsed: () => true, isColumnCollapsed: () => false }
147
+ const stuck = getEntryStep(at(0, 0), 'moveDown', { ...open, tabOrigin: null, range, collapsed: dark })
148
+ expect(stuck.cell).toMatchObject({ rowIndex: 0, colIndex: 0 })
149
+ })
150
+ })
151
+
152
+ describe('pastCollapsed', () => {
153
+ const at = (rowIndex: number, colIndex: number): ActiveCellState => ({ rowIndex, colIndex, cellId: null })
154
+ const bounds = { maxRow: 5, maxCol: 5 }
155
+ const collapsed = (rows: number[], cols: number[]) => ({
156
+ isRowCollapsed: (r: number) => rows.includes(r),
157
+ isColumnCollapsed: (c: number) => cols.includes(c),
158
+ })
159
+
160
+ it('goes on in the direction of travel to the next visible line', () => {
161
+ expect(pastCollapsed(at(0, 0), at(0, 1), bounds, collapsed([], [1, 2]))).toMatchObject({ rowIndex: 0, colIndex: 3 })
162
+ expect(pastCollapsed(at(3, 0), at(2, 0), bounds, collapsed([2, 1], []))).toMatchObject({ rowIndex: 0, colIndex: 0 })
163
+ })
164
+
165
+ it('comes back to the nearest visible line when the edge is hidden: End on a sheet whose last columns are hidden', () => {
166
+ expect(pastCollapsed(at(0, 0), at(0, 5), bounds, collapsed([], [4, 5]))).toMatchObject({ rowIndex: 0, colIndex: 3 })
167
+ })
168
+
169
+ it('stays put when everything in the direction of travel is hidden', () => {
170
+ expect(pastCollapsed(at(0, 3), at(0, 4), bounds, collapsed([], [4, 5]))).toMatchObject({ rowIndex: 0, colIndex: 3 })
171
+ })
172
+
173
+ it('leaves an axis the move did not touch alone', () => {
174
+ // The cursor sits on a row the user just hid; a sideways move keeps the row.
175
+ expect(pastCollapsed(at(2, 0), at(2, 1), bounds, collapsed([2], []))).toMatchObject({ rowIndex: 2, colIndex: 1 })
176
+ })
177
+
178
+ it('returns the landing itself when nothing is hidden', () => {
179
+ const next = at(1, 1)
180
+ expect(pastCollapsed(at(0, 0), next, bounds, collapsed([], []))).toBe(next)
181
+ })
182
+ })