@svgrid/grid 3.0.3 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/README.md +6 -5
  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 +122 -41
  8. package/dist/SvGrid.controller.svelte.js +846 -207
  9. package/dist/SvGrid.css +178 -2
  10. package/dist/SvGrid.svelte +717 -720
  11. package/dist/SvGrid.types.d.ts +566 -12
  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 +119 -28
  29. package/dist/cdn/GridMenus-CID62rlI.js +636 -0
  30. package/dist/cdn/GridMenus-DLL-qZeu.js +635 -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/server-block-cache-CGoWz-87.js +237 -0
  67. package/dist/cdn/{src-D6XvA1Ij.js → src-Bklofsad.js} +10615 -10155
  68. package/dist/cdn/{src-Bugf5XlI.js → src-jcFMkBnN.js} +7111 -6651
  69. package/dist/cdn/svgrid.js +27 -15
  70. package/dist/cdn/svgrid.svelte-external.js +27 -15
  71. package/dist/cdn/validate-C7Rn_Bns.js +76 -0
  72. package/dist/chart-axes.d.ts +53 -0
  73. package/dist/chart-axes.js +351 -0
  74. package/dist/chart-cartesian.d.ts +88 -0
  75. package/dist/chart-cartesian.js +1862 -0
  76. package/dist/chart-decimate.d.ts +51 -0
  77. package/dist/chart-decimate.js +199 -0
  78. package/dist/chart-export-pdf.d.ts +47 -0
  79. package/dist/chart-export-pdf.js +187 -0
  80. package/dist/chart-export.d.ts +2 -1
  81. package/dist/chart-export.js +18 -3
  82. package/dist/chart-financial.d.ts +91 -0
  83. package/dist/chart-financial.js +175 -0
  84. package/dist/chart-flow.d.ts +10 -0
  85. package/dist/chart-flow.js +298 -0
  86. package/dist/chart-format.d.ts +18 -0
  87. package/dist/chart-format.js +65 -0
  88. package/dist/chart-grid.d.ts +8 -0
  89. package/dist/chart-grid.js +190 -0
  90. package/dist/chart-hierarchy.d.ts +17 -0
  91. package/dist/chart-hierarchy.js +146 -0
  92. package/dist/chart-indicators.d.ts +115 -0
  93. package/dist/chart-indicators.js +389 -0
  94. package/dist/chart-messages.d.ts +95 -0
  95. package/dist/chart-messages.js +92 -0
  96. package/dist/chart-motion.d.ts +16 -0
  97. package/dist/chart-motion.js +141 -0
  98. package/dist/chart-panel-messages.d.ts +265 -0
  99. package/dist/chart-panel-messages.js +285 -0
  100. package/dist/chart-pivot.d.ts +87 -0
  101. package/dist/chart-pivot.js +156 -0
  102. package/dist/chart-polar.d.ts +25 -0
  103. package/dist/chart-polar.js +700 -0
  104. package/dist/chart-samples.d.ts +16 -0
  105. package/dist/chart-samples.js +153 -0
  106. package/dist/chart-scale.d.ts +113 -0
  107. package/dist/chart-scale.js +405 -0
  108. package/dist/chart-stats.d.ts +111 -0
  109. package/dist/chart-stats.js +419 -0
  110. package/dist/chart-stream.d.ts +45 -0
  111. package/dist/chart-stream.js +91 -0
  112. package/dist/chart-summary.d.ts +27 -0
  113. package/dist/chart-summary.js +205 -0
  114. package/dist/chart-sync.svelte.d.ts +32 -0
  115. package/dist/chart-sync.svelte.js +23 -0
  116. package/dist/chart-table.d.ts +35 -0
  117. package/dist/chart-table.js +165 -0
  118. package/dist/chart-trend.d.ts +19 -0
  119. package/dist/chart-trend.js +61 -0
  120. package/dist/chart-types.d.ts +1456 -0
  121. package/dist/chart-types.js +6 -0
  122. package/dist/chart-validate.d.ts +43 -0
  123. package/dist/chart-validate.js +272 -0
  124. package/dist/chart-zoom.d.ts +40 -0
  125. package/dist/chart-zoom.js +145 -0
  126. package/dist/chart.d.ts +186 -865
  127. package/dist/chart.js +460 -2204
  128. package/dist/clipboard.d.ts +1 -0
  129. package/dist/clipboard.js +137 -18
  130. package/dist/column-resize.d.ts +3 -0
  131. package/dist/column-resize.js +4 -1
  132. package/dist/columns.js +3 -0
  133. package/dist/command-context.d.ts +19 -0
  134. package/dist/command-context.js +116 -0
  135. package/dist/core.d.ts +7 -0
  136. package/dist/core.js +36 -13
  137. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  138. package/dist/editing.d.ts +2 -8
  139. package/dist/editing.js +272 -33
  140. package/dist/fill-patterns.d.ts +0 -5
  141. package/dist/fill-patterns.js +56 -2
  142. package/dist/gantt-view.svelte.d.ts +24 -0
  143. package/dist/gantt-view.svelte.js +13 -0
  144. package/dist/grid-icons.d.ts +2 -2
  145. package/dist/grid-icons.js +2 -2
  146. package/dist/grid-messages.d.ts +16 -3
  147. package/dist/grid-messages.js +10 -0
  148. package/dist/history.d.ts +83 -0
  149. package/dist/history.js +133 -0
  150. package/dist/index.d.ts +29 -10
  151. package/dist/index.js +23 -5
  152. package/dist/keyboard-handlers.js +88 -31
  153. package/dist/keyboard.d.ts +55 -0
  154. package/dist/keyboard.js +132 -0
  155. package/dist/menus.d.ts +3 -1
  156. package/dist/menus.js +7 -2
  157. package/dist/merges.d.ts +97 -0
  158. package/dist/merges.js +147 -0
  159. package/dist/row-model.d.ts +133 -0
  160. package/dist/row-model.js +16 -0
  161. package/dist/row-resize.d.ts +3 -0
  162. package/dist/row-resize.js +14 -0
  163. package/dist/scroll-sync.d.ts +1 -0
  164. package/dist/scroll-sync.js +24 -0
  165. package/dist/selection.d.ts +18 -1
  166. package/dist/selection.js +138 -18
  167. package/dist/server-block-cache.d.ts +214 -0
  168. package/dist/server-block-cache.js +531 -0
  169. package/dist/server-data-source.d.ts +240 -6
  170. package/dist/server-data-source.js +220 -20
  171. package/dist/server.d.ts +13 -0
  172. package/dist/server.js +13 -0
  173. package/dist/shortcut-registry.d.ts +145 -0
  174. package/dist/shortcut-registry.js +44 -0
  175. package/dist/svgrid-wrapper.types.d.ts +213 -22
  176. package/dist/validate.d.ts +4 -0
  177. package/dist/validate.js +21 -3
  178. package/dist/virtualization/virtualizer.js +38 -17
  179. package/package.json +11 -1
  180. package/src/GridMenus.svelte +44 -2
  181. package/src/SvChartMenu.svelte +69 -0
  182. package/src/SvChartPanes.svelte +113 -0
  183. package/src/SvChartPanes.test.ts +66 -0
  184. package/src/SvGrid.controller.svelte.ts +819 -179
  185. package/src/SvGrid.css +178 -2
  186. package/src/SvGrid.svelte +717 -720
  187. package/src/SvGrid.types.ts +564 -11
  188. package/src/SvGridCellEditor.svelte +17 -0
  189. package/src/SvGridChart.svelte +2257 -329
  190. package/src/SvGridChart.test.ts +1419 -1
  191. package/src/SvGridChart.types.ts +336 -0
  192. package/src/SvGridChartBuilder.svelte +323 -0
  193. package/src/SvGridChartPanel.svelte +221 -275
  194. package/src/SvGridChartPickers.svelte +417 -0
  195. package/src/SvGridChartView.svelte +13 -14
  196. package/src/SvModal.svelte +7 -1
  197. package/src/SvModal.test.ts +9 -0
  198. package/src/ai.test.ts +57 -2
  199. package/src/ai.ts +166 -22
  200. package/src/aria.d.ts +11 -0
  201. package/src/build-api.coverage.test.ts +184 -0
  202. package/src/build-api.ts +118 -22
  203. package/src/chart-axes.test.ts +573 -0
  204. package/src/chart-axes.ts +342 -0
  205. package/src/chart-cartesian.ts +1863 -0
  206. package/src/chart-decimate.test.ts +249 -0
  207. package/src/chart-decimate.ts +186 -0
  208. package/src/chart-export-pdf.test.ts +91 -0
  209. package/src/chart-export-pdf.ts +207 -0
  210. package/src/chart-export.test.ts +14 -0
  211. package/src/chart-export.ts +13 -3
  212. package/src/chart-financial.ts +208 -0
  213. package/src/chart-flow.ts +286 -0
  214. package/src/chart-format.ts +67 -0
  215. package/src/chart-grid.ts +179 -0
  216. package/src/chart-hierarchy.ts +135 -0
  217. package/src/chart-indicators.test.ts +443 -0
  218. package/src/chart-indicators.ts +384 -0
  219. package/src/chart-messages.test.ts +21 -0
  220. package/src/chart-messages.ts +184 -0
  221. package/src/chart-motion.test.ts +71 -0
  222. package/src/chart-motion.ts +148 -0
  223. package/src/chart-panel-messages.test.ts +29 -0
  224. package/src/chart-panel-messages.ts +550 -0
  225. package/src/chart-pivot.test.ts +54 -0
  226. package/src/chart-pivot.ts +225 -0
  227. package/src/chart-polar.ts +674 -0
  228. package/src/chart-samples.ts +106 -0
  229. package/src/chart-scale.ts +394 -0
  230. package/src/chart-series-types.test.ts +714 -0
  231. package/src/chart-stats.ts +409 -0
  232. package/src/chart-stream.test.ts +75 -0
  233. package/src/chart-stream.ts +114 -0
  234. package/src/chart-summary.test.ts +90 -0
  235. package/src/chart-summary.ts +197 -0
  236. package/src/chart-sync.svelte.ts +41 -0
  237. package/src/chart-table.test.ts +76 -0
  238. package/src/chart-table.ts +178 -0
  239. package/src/chart-trend.ts +47 -0
  240. package/src/chart-types.ts +1309 -0
  241. package/src/chart-validate.test.ts +100 -0
  242. package/src/chart-validate.ts +247 -0
  243. package/src/chart-zoom.test.ts +137 -0
  244. package/src/chart-zoom.ts +124 -0
  245. package/src/chart.coverage.test.ts +81 -0
  246. package/src/chart.test.ts +56 -3
  247. package/src/chart.ts +557 -2784
  248. package/src/clipboard.test.ts +151 -0
  249. package/src/clipboard.ts +145 -16
  250. package/src/column-resize.ts +7 -1
  251. package/src/columns.ts +2 -0
  252. package/src/command-context.test.ts +200 -0
  253. package/src/command-context.ts +137 -0
  254. package/src/core.rowmodel-cache.test.ts +51 -0
  255. package/src/core.ts +40 -12
  256. package/src/editing.test.ts +281 -3
  257. package/src/editing.ts +264 -38
  258. package/src/fill-patterns.test.ts +35 -0
  259. package/src/fill-patterns.ts +56 -2
  260. package/src/gantt-stub.test.svelte +38 -0
  261. package/src/gantt-view.svelte.ts +35 -0
  262. package/src/grid-icons.ts +2 -2
  263. package/src/grid-messages.ts +28 -3
  264. package/src/history.test.ts +196 -0
  265. package/src/history.ts +162 -0
  266. package/src/icon-seam.test.ts +5 -6
  267. package/src/index.ts +186 -12
  268. package/src/keyboard-handlers.coverage.test.ts +126 -6
  269. package/src/keyboard-handlers.ts +91 -30
  270. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  271. package/src/keyboard.test.ts +124 -1
  272. package/src/keyboard.ts +142 -0
  273. package/src/menus.test.ts +26 -0
  274. package/src/menus.ts +10 -3
  275. package/src/merges.test.ts +112 -0
  276. package/src/merges.ts +168 -0
  277. package/src/pivot.grid.test.ts +40 -5
  278. package/src/row-model.ts +146 -0
  279. package/src/row-resize.test.ts +44 -0
  280. package/src/row-resize.ts +17 -0
  281. package/src/scroll-sync.test.ts +42 -1
  282. package/src/scroll-sync.ts +25 -0
  283. package/src/selection.multi-range.test.ts +34 -0
  284. package/src/selection.test.ts +33 -2
  285. package/src/selection.ts +143 -21
  286. package/src/server-block-cache.test.ts +645 -0
  287. package/src/server-block-cache.ts +677 -0
  288. package/src/server-data-source.infinite.test.ts +343 -0
  289. package/src/server-data-source.ts +450 -27
  290. package/src/server.ts +47 -0
  291. package/src/shortcut-registry.test.ts +97 -0
  292. package/src/shortcut-registry.ts +181 -0
  293. package/src/svgrid-wrapper.types.ts +184 -13
  294. package/src/svgrid.behavior.test.ts +135 -0
  295. package/src/svgrid.charting.test.ts +513 -3
  296. package/src/svgrid.context-menu.test.ts +31 -0
  297. package/src/svgrid.gantt-seam.test.ts +199 -0
  298. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  299. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  300. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  301. package/src/svgrid.upsell-license.test.ts +7 -4
  302. package/src/validate.test.ts +29 -0
  303. package/src/validate.ts +28 -3
  304. package/src/virtualization/virtualizer.ts +39 -17
  305. package/dist/SvGroupCell.svelte +0 -141
  306. package/dist/SvGroupCell.svelte.d.ts +0 -49
  307. package/dist/SvRowGroupPanel.svelte +0 -186
  308. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  309. package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
  310. package/dist/cdn/GridMenus-DhdPF700.js +0 -603
  311. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  312. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  313. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  314. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  315. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  316. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  317. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  318. package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
  319. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  320. package/dist/server-group-model.d.ts +0 -98
  321. package/dist/server-group-model.js +0 -263
  322. package/src/SvGroupCell.svelte +0 -141
  323. package/src/SvRowGroupPanel.svelte +0 -186
  324. package/src/server-group-model.test.ts +0 -294
  325. package/src/server-group-model.ts +0 -370
@@ -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
+ })
package/src/keyboard.ts CHANGED
@@ -22,6 +22,20 @@ export function getKeyboardIntent(event: KeyboardEvent): GridKeyboardIntent {
22
22
  if (event.ctrlKey && event.key === 'Home') return 'gridStart'
23
23
  if (event.ctrlKey && event.key === 'End') return 'gridEnd'
24
24
 
25
+ // Ctrl/Cmd + arrow is Excel's "jump to the edge of the data region". The
26
+ // grid does not implement that itself - it is a paid command registered
27
+ // through `registerGridShortcuts`, which runs before this function is ever
28
+ // called. If we get here the key was NOT claimed, and the right answer is to
29
+ // do nothing: moving one cell is a silently wrong response to a shortcut
30
+ // every spreadsheet user presses. Alt is excluded so a browser or OS
31
+ // combination still falls through untouched.
32
+ if ((event.ctrlKey || event.metaKey) && !event.altKey) {
33
+ if (
34
+ event.key === 'ArrowLeft' || event.key === 'ArrowRight' ||
35
+ event.key === 'ArrowUp' || event.key === 'ArrowDown'
36
+ ) return 'noop'
37
+ }
38
+
25
39
  if (event.key === 'ArrowLeft') return 'moveLeft'
26
40
  if (event.key === 'ArrowRight') return 'moveRight'
27
41
  if (event.key === 'ArrowUp') return 'moveUp'
@@ -95,3 +109,131 @@ export function getNextActiveCell(
95
109
  cellId: current.cellId,
96
110
  }
97
111
  }
112
+
113
+ export type Collapsed = {
114
+ isRowCollapsed: (rowIndex: number) => boolean
115
+ isColumnCollapsed: (colIndex: number) => boolean
116
+ }
117
+
118
+ /**
119
+ * Where a move lands once collapsed rows and columns are stepped over.
120
+ *
121
+ * A sheet's arrow keys never rest on a hidden row or column: the cursor
122
+ * goes on in the direction of travel to the next one that shows, and when
123
+ * nothing shows in that direction it comes back to the nearest one before
124
+ * the landing, which for End on a sheet whose last columns are hidden is the
125
+ * last visible column. An axis the move did not touch is left alone, so a
126
+ * cursor sitting on a row the user has just hidden still moves sideways.
127
+ * With every cell in the direction of travel hidden the cursor stays put.
128
+ */
129
+ export function pastCollapsed(
130
+ from: ActiveCellState,
131
+ next: ActiveCellState,
132
+ bounds: { maxRow: number; maxCol: number },
133
+ collapsed: Collapsed,
134
+ ): ActiveCellState {
135
+ const axis = (fromIdx: number, nextIdx: number, max: number, isCollapsed: (i: number) => boolean) => {
136
+ if (nextIdx === fromIdx) return nextIdx
137
+ const dir = nextIdx > fromIdx ? 1 : -1
138
+ let i = nextIdx
139
+ while (i >= 0 && i <= max && isCollapsed(i)) i += dir
140
+ if (i >= 0 && i <= max) return i
141
+ i = nextIdx - dir
142
+ while (i !== fromIdx && isCollapsed(i)) i -= dir
143
+ return i
144
+ }
145
+ const rowIndex = axis(from.rowIndex, next.rowIndex, bounds.maxRow, collapsed.isRowCollapsed)
146
+ const colIndex = axis(from.colIndex, next.colIndex, bounds.maxCol, collapsed.isColumnCollapsed)
147
+ return rowIndex === next.rowIndex && colIndex === next.colIndex ? next : { rowIndex, colIndex, cellId: next.cellId }
148
+ }
149
+
150
+ export type EntryIntent = 'moveDown' | 'moveUp' | 'tabNext' | 'tabPrev'
151
+
152
+ export type EntryRect = { minRow: number; maxRow: number; minCol: number; maxCol: number }
153
+
154
+ export type EntryStep = {
155
+ cell: ActiveCellState
156
+ /** The move stayed inside a multi-cell selection, which is kept. */
157
+ withinRange: boolean
158
+ /** The column a run of Tabs began in, for the Enter that ends it. */
159
+ tabOrigin: number | null
160
+ }
161
+
162
+ /**
163
+ * Where Enter and Tab put the cursor, the way a sheet's data entry expects.
164
+ *
165
+ * Two rules on top of `getNextActiveCell`. A run of Tabs remembers the
166
+ * column it started in, and the Enter that ends the run goes down from
167
+ * THAT column, not from the last cell: type across A1, B1, C1 with Tab and
168
+ * Enter lands on A2, ready for the next record. Any other move clears the
169
+ * memory, which the callers do by resetting `tabOrigin` on every plain
170
+ * `setActiveCell`. And inside a selection of more than one cell the cursor
171
+ * stays inside it: Enter walks down a column and wraps to the top of the
172
+ * next, Tab walks along a row and wraps to the start of the next, both
173
+ * back to the first cell after the last, and the selection is left
174
+ * standing, so a block can be filled without ever reaching for the mouse.
175
+ * Arrow keys are not entry keys and never come here.
176
+ */
177
+ export function getEntryStep(
178
+ current: ActiveCellState,
179
+ intent: EntryIntent,
180
+ opts: { maxRow: number; maxCol: number; tabOrigin: number | null; range: EntryRect | null; collapsed?: Collapsed },
181
+ ): EntryStep {
182
+ const step = entryStep(current, intent, opts)
183
+ const collapsed = opts.collapsed
184
+ if (!collapsed) return step
185
+ const hidden = (c: ActiveCellState) => collapsed.isRowCollapsed(c.rowIndex) || collapsed.isColumnCollapsed(c.colIndex)
186
+ if (!hidden(step.cell)) return step
187
+ if (!step.withinRange) return { ...step, cell: pastCollapsed(current, step.cell, opts, collapsed) }
188
+ // Walking a block: the same step again from the hidden cell, until one
189
+ // shows. A block with nothing showing leaves the cursor where it is.
190
+ let cell = step.cell
191
+ for (let guard = (opts.range!.maxRow - opts.range!.minRow + 1) * (opts.range!.maxCol - opts.range!.minCol + 1); guard > 0 && hidden(cell); guard -= 1) {
192
+ cell = entryStep(cell, intent, opts).cell
193
+ }
194
+ return { ...step, cell: hidden(cell) ? current : cell }
195
+ }
196
+
197
+ function entryStep(
198
+ current: ActiveCellState,
199
+ intent: EntryIntent,
200
+ opts: { maxRow: number; maxCol: number; tabOrigin: number | null; range: EntryRect | null },
201
+ ): EntryStep {
202
+ const range = opts.range
203
+ const inRange =
204
+ range !== null &&
205
+ (range.maxRow > range.minRow || range.maxCol > range.minCol) &&
206
+ current.rowIndex >= range.minRow && current.rowIndex <= range.maxRow &&
207
+ current.colIndex >= range.minCol && current.colIndex <= range.maxCol
208
+ if (inRange) {
209
+ let { rowIndex, colIndex } = current
210
+ if (intent === 'moveDown') {
211
+ rowIndex += 1
212
+ if (rowIndex > range.maxRow) { rowIndex = range.minRow; colIndex += 1 }
213
+ if (colIndex > range.maxCol) colIndex = range.minCol
214
+ } else if (intent === 'moveUp') {
215
+ rowIndex -= 1
216
+ if (rowIndex < range.minRow) { rowIndex = range.maxRow; colIndex -= 1 }
217
+ if (colIndex < range.minCol) colIndex = range.maxCol
218
+ } else if (intent === 'tabNext') {
219
+ colIndex += 1
220
+ if (colIndex > range.maxCol) { colIndex = range.minCol; rowIndex += 1 }
221
+ if (rowIndex > range.maxRow) rowIndex = range.minRow
222
+ } else {
223
+ colIndex -= 1
224
+ if (colIndex < range.minCol) { colIndex = range.maxCol; rowIndex -= 1 }
225
+ if (rowIndex < range.minRow) rowIndex = range.maxRow
226
+ }
227
+ return { cell: { rowIndex, colIndex, cellId: current.cellId }, withinRange: true, tabOrigin: null }
228
+ }
229
+ const bounds = { maxRow: opts.maxRow, maxCol: opts.maxCol }
230
+ if (intent === 'tabNext' || intent === 'tabPrev') {
231
+ return {
232
+ cell: getNextActiveCell(current, intent, bounds),
233
+ withinRange: false,
234
+ tabOrigin: opts.tabOrigin ?? current.colIndex,
235
+ }
236
+ }
237
+ const from = opts.tabOrigin === null ? current : { ...current, colIndex: opts.tabOrigin }
238
+ return { cell: getNextActiveCell(from, intent, bounds), withinRange: false, tabOrigin: null }
239
+ }
package/src/menus.test.ts CHANGED
@@ -621,6 +621,32 @@ describe('createMenus - contextMenuItems', () => {
621
621
  expect(ctx.clearSelectedCells).toHaveBeenCalled()
622
622
  })
623
623
 
624
+ it('an object on a built-in key with no action keeps the built-in, and carries the icon', () => {
625
+ const icon = { paths: [{ d: 'M2 2h12v12H2z' }] }
626
+ const ctx = makeCtx({
627
+ props: { contextMenu: [{ key: 'cut', icon }, { key: 'copy', label: 'Copy cells', icon: 'columns' }] },
628
+ contextMenuFor: { rowIndex: 0, colIndex: 0, columnId: 'name', rowId: 'r0', row: { id: 0 } },
629
+ })
630
+ const items = createMenus(ctx).contextMenuItems()
631
+ const cut = items.find((i) => i.key === 'cut')!
632
+ expect(cut.label).toBe('Cut')
633
+ expect(cut.icon).toBe(icon)
634
+ cut.run!()
635
+ expect(ctx.cutSelectionToClipboard).toHaveBeenCalled()
636
+ const copy = items.find((i) => i.key === 'copy')!
637
+ expect(copy.label).toBe('Copy cells')
638
+ expect(copy.icon).toBe('columns')
639
+ })
640
+
641
+ it('an unknown key with no action resolves to an inert item rather than throwing', () => {
642
+ const ctx = makeCtx({
643
+ props: { contextMenu: [{ key: 'nothing', label: 'Nothing' }] },
644
+ contextMenuFor: { rowIndex: 0, colIndex: 0, columnId: 'name', rowId: 'r0', row: { id: 0 } },
645
+ })
646
+ const item = createMenus(ctx).contextMenuItems()[0]!
647
+ expect(item.label).toBe('Nothing')
648
+ expect(item.run).toBeUndefined()
649
+ })
624
650
  it('row_above / row_below / remove_row mutate internalData', () => {
625
651
  const rowA = { id: 1 }
626
652
  const rowB = { id: 2 }
package/src/menus.ts CHANGED
@@ -8,8 +8,10 @@ import {
8
8
  import "./sv-grid-scrollbar";
9
9
  import { untrack } from "svelte";
10
10
  import type {
11
+ ContextMenuIcon,
11
12
  FilterOperator,
12
13
  } from "./SvGrid.types";
14
+ import type { GridIconName } from "./grid-icons";
13
15
  import {
14
16
  clampMenuX,
15
17
  cssEscape,
@@ -414,7 +416,7 @@ export function createMenus<
414
416
 
415
417
  // ---- Right-click context menu --------------------------------------
416
418
  type CtxTarget = { rowIndex: number; colIndex: number; columnId: string; rowId: string; row: TData | null };
417
- type ResolvedItem = { key: string; label: string; separator?: boolean; disabled?: boolean; run?: () => void };
419
+ type ResolvedItem = { key: string; label: string; icon?: GridIconName | ContextMenuIcon; separator?: boolean; disabled?: boolean; run?: () => void };
418
420
 
419
421
  function insertRowAt(t: CtxTarget, offset: number) {
420
422
  const data = ctx.internalData.slice() as TData[];
@@ -543,11 +545,16 @@ export function createMenus<
543
545
  continue;
544
546
  }
545
547
  if (entry.hidden?.(target)) continue;
548
+ // An object on a built-in's key with no action of its own is that
549
+ // built-in with an icon (or a label) of the consumer's choosing.
550
+ const b = builtins[entry.key];
551
+ const builtinLabel = b ? ((b.msg && messages?.[b.msg]) || b.label) : entry.key;
546
552
  out.push({
547
553
  key: entry.key,
548
- label: entry.label,
554
+ label: entry.label ?? builtinLabel,
555
+ icon: entry.icon,
549
556
  disabled: entry.disabled?.(target) ?? false,
550
- run: () => entry.action(target),
557
+ run: entry.action ? () => entry.action(target) : b ? () => b.run(target) : undefined,
551
558
  });
552
559
  }
553
560
  // Drop leading/trailing/double separators.
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildMergeIndex, mergeAt, isCovered, originOf, endOf, expandRectToMerges, stepPastMerge, mergeDrawAt,
4
+ } from './merges'
5
+
6
+ // A1:C1 (three across), B3:B5 (three down), E5:F6 (two by two).
7
+ const index = buildMergeIndex([
8
+ { rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: 3 },
9
+ { rowIndex: 2, colIndex: 1, rowSpan: 3, colSpan: 1 },
10
+ { rowIndex: 4, colIndex: 4, rowSpan: 2, colSpan: 2 },
11
+ ])
12
+ const bounds = { maxRow: 9, maxCol: 9 }
13
+
14
+ describe('buildMergeIndex', () => {
15
+ it('is null for nothing, and drops 1x1 merges', () => {
16
+ expect(buildMergeIndex([])).toBeNull()
17
+ expect(buildMergeIndex(undefined)).toBeNull()
18
+ expect(buildMergeIndex([{ rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: 1 }])).toBeNull()
19
+ })
20
+
21
+ it('indexes every covered cell to its merge', () => {
22
+ expect(mergeAt(index, 0, 2)?.colSpan).toBe(3)
23
+ expect(mergeAt(index, 4, 1)?.rowSpan).toBe(3)
24
+ expect(mergeAt(index, 5, 5)).toMatchObject({ rowIndex: 4, colIndex: 4 })
25
+ expect(mergeAt(index, 1, 1)).toBeUndefined()
26
+ expect(isCovered(index, 0, 0)).toBe(false)
27
+ expect(isCovered(index, 0, 1)).toBe(true)
28
+ expect(originOf(index, 5, 5)).toEqual({ rowIndex: 4, colIndex: 4 })
29
+ expect(originOf(index, 7, 7)).toEqual({ rowIndex: 7, colIndex: 7 })
30
+ expect(endOf(index, 2, 1)).toEqual({ rowIndex: 4, colIndex: 1 })
31
+ expect(endOf(index, 7, 7)).toEqual({ rowIndex: 7, colIndex: 7 })
32
+ })
33
+ })
34
+
35
+ describe('expandRectToMerges', () => {
36
+ it('grows a rectangle to hold every merge it touches, to a fixed point', () => {
37
+ expect(expandRectToMerges(index, { minRow: 0, maxRow: 0, minCol: 1, maxCol: 1 })).toEqual({ minRow: 0, maxRow: 0, minCol: 0, maxCol: 2 })
38
+ // B4 alone pulls in B3:B5; a rectangle reaching E5 pulls in E5:F6.
39
+ expect(expandRectToMerges(index, { minRow: 3, maxRow: 3, minCol: 1, maxCol: 1 })).toEqual({ minRow: 2, maxRow: 4, minCol: 1, maxCol: 1 })
40
+ expect(expandRectToMerges(index, { minRow: 4, maxRow: 4, minCol: 1, maxCol: 4 })).toEqual({ minRow: 2, maxRow: 5, minCol: 1, maxCol: 5 })
41
+ })
42
+
43
+ it('hands the same object back when nothing grows', () => {
44
+ const rect = { minRow: 7, maxRow: 8, minCol: 7, maxCol: 8 }
45
+ expect(expandRectToMerges(index, rect)).toBe(rect)
46
+ expect(expandRectToMerges(null, rect)).toBe(rect)
47
+ })
48
+ })
49
+
50
+ describe('stepPastMerge', () => {
51
+ it('a step into a merge lands on its origin', () => {
52
+ expect(stepPastMerge(index, { rowIndex: 1, colIndex: 1 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 0 })
53
+ expect(stepPastMerge(index, { rowIndex: 5, colIndex: 3 }, { rowIndex: 5, colIndex: 4 }, bounds)).toEqual({ rowIndex: 4, colIndex: 4 })
54
+ })
55
+
56
+ it('a step inside the merge the move began in carries on past its far edge', () => {
57
+ // Right from A1 (A1:C1) reaches D1, not B1.
58
+ expect(stepPastMerge(index, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 3 })
59
+ // Down from B3 (B3:B5) reaches B6.
60
+ expect(stepPastMerge(index, { rowIndex: 2, colIndex: 1 }, { rowIndex: 3, colIndex: 1 }, bounds)).toEqual({ rowIndex: 5, colIndex: 1 })
61
+ // Up from E5 (E5:F6) reaches E4; left reaches D5.
62
+ expect(stepPastMerge(index, { rowIndex: 4, colIndex: 4 }, { rowIndex: 3, colIndex: 4 }, bounds)).toEqual({ rowIndex: 3, colIndex: 4 })
63
+ expect(stepPastMerge(index, { rowIndex: 4, colIndex: 4 }, { rowIndex: 4, colIndex: 3 }, bounds)).toEqual({ rowIndex: 4, colIndex: 3 })
64
+ })
65
+
66
+ it('stays on the merge at the sheet edge, and takes the origin of a merge it lands in', () => {
67
+ expect(stepPastMerge(index, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, { maxRow: 9, maxCol: 2 })).toEqual({ rowIndex: 0, colIndex: 0 })
68
+ const chain = buildMergeIndex([
69
+ { rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: 2 },
70
+ { rowIndex: 0, colIndex: 2, rowSpan: 1, colSpan: 2 },
71
+ ])
72
+ expect(stepPastMerge(chain, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 2 })
73
+ })
74
+
75
+ it('is a pass-through without merges or outside them', () => {
76
+ expect(stepPastMerge(null, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 1 })
77
+ expect(stepPastMerge(index, { rowIndex: 7, colIndex: 7 }, { rowIndex: 7, colIndex: 8 }, bounds)).toEqual({ rowIndex: 7, colIndex: 8 })
78
+ })
79
+ })
80
+
81
+ describe('mergeDrawAt', () => {
82
+ const all = { firstRow: 0, lastRow: 9, firstCol: 0, lastCol: 9 }
83
+
84
+ it('the origin draws with its spans and the covered cells are skipped', () => {
85
+ expect(mergeDrawAt(index, 0, 0, all)).toEqual({ kind: 'origin', merge: index!.list[0], rowSpan: 1, colSpan: 3 })
86
+ expect(mergeDrawAt(index, 0, 1, all)).toEqual({ kind: 'skip' })
87
+ expect(mergeDrawAt(index, 4, 5, all)).toEqual({ kind: 'skip' })
88
+ expect(mergeDrawAt(index, 1, 1, all)).toBeNull()
89
+ })
90
+
91
+ it('a merge whose origin is above the window continues from its first drawn row, spans clamped', () => {
92
+ const window = { firstRow: 3, lastRow: 9, firstCol: 0, lastCol: 9 }
93
+ // B3:B5 with rows 3.. shown: B4 draws the rest (two rows), B5 is skipped.
94
+ expect(mergeDrawAt(index, 3, 1, window)).toEqual({ kind: 'continuation', merge: index!.list[1], rowSpan: 2, colSpan: 1 })
95
+ expect(mergeDrawAt(index, 4, 1, window)).toEqual({ kind: 'skip' })
96
+ })
97
+
98
+ it('clamps to the window\'s last row and column', () => {
99
+ const window = { firstRow: 0, lastRow: 4, firstCol: 0, lastCol: 4 }
100
+ expect(mergeDrawAt(index, 2, 1, window)).toEqual({ kind: 'origin', merge: index!.list[1], rowSpan: 3, colSpan: 1 })
101
+ expect(mergeDrawAt(index, 4, 4, window)).toEqual({ kind: 'origin', merge: index!.list[2], rowSpan: 1, colSpan: 1 })
102
+ })
103
+
104
+ it('a merge starting in the frozen band draws its remainder in the body as a continuation', () => {
105
+ const frozen = { firstRow: 0, lastRow: 0, firstCol: 0, lastCol: 9 }
106
+ const body = { firstRow: 1, lastRow: 9, firstCol: 0, lastCol: 9 }
107
+ const tall = buildMergeIndex([{ rowIndex: 0, colIndex: 0, rowSpan: 3, colSpan: 1 }])
108
+ expect(mergeDrawAt(tall, 0, 0, frozen)).toEqual({ kind: 'origin', merge: tall!.list[0], rowSpan: 1, colSpan: 1 })
109
+ expect(mergeDrawAt(tall, 1, 0, body)).toEqual({ kind: 'continuation', merge: tall!.list[0], rowSpan: 2, colSpan: 1 })
110
+ expect(mergeDrawAt(tall, 2, 0, body)).toEqual({ kind: 'skip' })
111
+ })
112
+ })