@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.
- package/README.md +6 -5
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +122 -41
- package/dist/SvGrid.controller.svelte.js +846 -207
- package/dist/SvGrid.css +178 -2
- package/dist/SvGrid.svelte +717 -720
- package/dist/SvGrid.types.d.ts +566 -12
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +119 -28
- package/dist/cdn/GridMenus-CID62rlI.js +636 -0
- package/dist/cdn/GridMenus-DLL-qZeu.js +635 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
- package/dist/cdn/{src-D6XvA1Ij.js → src-Bklofsad.js} +10615 -10155
- package/dist/cdn/{src-Bugf5XlI.js → src-jcFMkBnN.js} +7111 -6651
- package/dist/cdn/svgrid.js +27 -15
- package/dist/cdn/svgrid.svelte-external.js +27 -15
- package/dist/cdn/validate-C7Rn_Bns.js +76 -0
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/core.js +36 -13
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +272 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/gantt-view.svelte.d.ts +24 -0
- package/dist/gantt-view.svelte.js +13 -0
- package/dist/grid-icons.d.ts +2 -2
- package/dist/grid-icons.js +2 -2
- package/dist/grid-messages.d.ts +16 -3
- package/dist/grid-messages.js +10 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +29 -10
- package/dist/index.js +23 -5
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-model.d.ts +133 -0
- package/dist/row-model.js +16 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +18 -1
- package/dist/selection.js +138 -18
- package/dist/server-block-cache.d.ts +214 -0
- package/dist/server-block-cache.js +531 -0
- package/dist/server-data-source.d.ts +240 -6
- package/dist/server-data-source.js +220 -20
- package/dist/server.d.ts +13 -0
- package/dist/server.js +13 -0
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/validate.d.ts +4 -0
- package/dist/validate.js +21 -3
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +11 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +819 -179
- package/src/SvGrid.css +178 -2
- package/src/SvGrid.svelte +717 -720
- package/src/SvGrid.types.ts +564 -11
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +118 -22
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.rowmodel-cache.test.ts +51 -0
- package/src/core.ts +40 -12
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +264 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/gantt-stub.test.svelte +38 -0
- package/src/gantt-view.svelte.ts +35 -0
- package/src/grid-icons.ts +2 -2
- package/src/grid-messages.ts +28 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +5 -6
- package/src/index.ts +186 -12
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-model.ts +146 -0
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +33 -2
- package/src/selection.ts +143 -21
- package/src/server-block-cache.test.ts +645 -0
- package/src/server-block-cache.ts +677 -0
- package/src/server-data-source.infinite.test.ts +343 -0
- package/src/server-data-source.ts +450 -27
- package/src/server.ts +47 -0
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.gantt-seam.test.ts +199 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
- package/src/svgrid.upsell-license.test.ts +7 -4
- package/src/validate.test.ts +29 -0
- package/src/validate.ts +28 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/SvGroupCell.svelte +0 -141
- package/dist/SvGroupCell.svelte.d.ts +0 -49
- package/dist/SvRowGroupPanel.svelte +0 -186
- package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
- package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
- package/dist/cdn/GridMenus-DhdPF700.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
- package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
- package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
- package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
- package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
- package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
- package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
- package/dist/cdn/validate-_CDJzgIo.js +0 -75
- package/dist/server-group-model.d.ts +0 -98
- package/dist/server-group-model.js +0 -263
- package/src/SvGroupCell.svelte +0 -141
- package/src/SvRowGroupPanel.svelte +0 -186
- package/src/server-group-model.test.ts +0 -294
- 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
|
+
})
|
package/src/keyboard.test.ts
CHANGED
|
@@ -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
|
+
})
|