@svgrid/grid 3.0.3 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- 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 +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- 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 +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -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/{src-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-Bugf5XlI.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- 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/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- 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-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 +16 -0
- package/dist/selection.js +108 -16
- 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/virtualization/virtualizer.js +38 -17
- package/package.json +6 -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 +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- 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 +107 -18
- 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.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- 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-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 +30 -2
- package/src/selection.ts +111 -19
- 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.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- 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/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
|
+
})
|
package/src/merges.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merged cells: a rectangle of cells drawn as one, the way a spreadsheet's
|
|
3
|
+
* Merge & Center does. The grid takes them as `mergedCells`, a list of
|
|
4
|
+
* origins with their spans in display indices, and this module is the
|
|
5
|
+
* pure part: an index over them, the questions the renderer, the
|
|
6
|
+
* selection and the keyboard ask, and nothing that touches the DOM.
|
|
7
|
+
*
|
|
8
|
+
* A merge is drawn by its origin td, which takes `rowspan` and `colspan`;
|
|
9
|
+
* the cells it covers are not emitted. Selection rectangles grow to whole
|
|
10
|
+
* merges (a merge is selected as one), the active cell inside a merge is
|
|
11
|
+
* its origin, and an arrow key moving into a merge lands on the origin
|
|
12
|
+
* and moving out of one steps past its far edge.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type MergedCell = {
|
|
16
|
+
/** Top-left cell of the merge, display indices. */
|
|
17
|
+
rowIndex: number
|
|
18
|
+
colIndex: number
|
|
19
|
+
/** How many rows and columns it covers, 1 or more. */
|
|
20
|
+
rowSpan: number
|
|
21
|
+
colSpan: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MergeIndex = {
|
|
25
|
+
readonly list: ReadonlyArray<MergedCell>
|
|
26
|
+
/** Every cell a merge covers (origin included) -> its merge. */
|
|
27
|
+
readonly byCell: ReadonlyMap<string, MergedCell>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type MergeRect = { minRow: number; maxRow: number; minCol: number; maxCol: number }
|
|
31
|
+
|
|
32
|
+
const key = (r: number, c: number) => `${r}:${c}`
|
|
33
|
+
|
|
34
|
+
/** An index over the merges, or null when there are none (the common case). */
|
|
35
|
+
export function buildMergeIndex(merges: ReadonlyArray<MergedCell> | undefined | null): MergeIndex | null {
|
|
36
|
+
if (!merges || merges.length === 0) return null
|
|
37
|
+
const byCell = new Map<string, MergedCell>()
|
|
38
|
+
const list: MergedCell[] = []
|
|
39
|
+
for (const raw of merges) {
|
|
40
|
+
const rowSpan = Math.max(1, Math.floor(raw.rowSpan))
|
|
41
|
+
const colSpan = Math.max(1, Math.floor(raw.colSpan))
|
|
42
|
+
if (rowSpan === 1 && colSpan === 1) continue
|
|
43
|
+
const merge = { rowIndex: raw.rowIndex, colIndex: raw.colIndex, rowSpan, colSpan }
|
|
44
|
+
list.push(merge)
|
|
45
|
+
for (let r = merge.rowIndex; r < merge.rowIndex + rowSpan; r += 1) {
|
|
46
|
+
for (let c = merge.colIndex; c < merge.colIndex + colSpan; c += 1) byCell.set(key(r, c), merge)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return list.length ? { list, byCell } : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The merge covering (r, c), origin included, or undefined. */
|
|
53
|
+
export function mergeAt(index: MergeIndex | null, r: number, c: number): MergedCell | undefined {
|
|
54
|
+
return index?.byCell.get(key(r, c))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether (r, c) is inside a merge without being its origin. */
|
|
58
|
+
export function isCovered(index: MergeIndex | null, r: number, c: number): boolean {
|
|
59
|
+
const m = mergeAt(index, r, c)
|
|
60
|
+
return !!m && (m.rowIndex !== r || m.colIndex !== c)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The origin of the merge (r, c) sits in, or (r, c) itself. */
|
|
64
|
+
export function originOf(index: MergeIndex | null, r: number, c: number): { rowIndex: number; colIndex: number } {
|
|
65
|
+
const m = mergeAt(index, r, c)
|
|
66
|
+
return m ? { rowIndex: m.rowIndex, colIndex: m.colIndex } : { rowIndex: r, colIndex: c }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The far corner of the merge (r, c) sits in, or (r, c) itself. */
|
|
70
|
+
export function endOf(index: MergeIndex | null, r: number, c: number): { rowIndex: number; colIndex: number } {
|
|
71
|
+
const m = mergeAt(index, r, c)
|
|
72
|
+
return m
|
|
73
|
+
? { rowIndex: m.rowIndex + m.rowSpan - 1, colIndex: m.colIndex + m.colSpan - 1 }
|
|
74
|
+
: { rowIndex: r, colIndex: c }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The rectangle grown to hold every merge it touches, to a fixed point: a
|
|
79
|
+
* merge that pokes out of the rectangle pulls the edge out, which can
|
|
80
|
+
* reach another merge, and so on, as a spreadsheet's selection does.
|
|
81
|
+
*/
|
|
82
|
+
export function expandRectToMerges(index: MergeIndex | null, rect: MergeRect): MergeRect {
|
|
83
|
+
if (!index) return rect
|
|
84
|
+
let { minRow, maxRow, minCol, maxCol } = rect
|
|
85
|
+
for (let guard = 0; guard < 64; guard += 1) {
|
|
86
|
+
let grew = false
|
|
87
|
+
for (const m of index.list) {
|
|
88
|
+
const r2 = m.rowIndex + m.rowSpan - 1
|
|
89
|
+
const c2 = m.colIndex + m.colSpan - 1
|
|
90
|
+
const touches = m.rowIndex <= maxRow && r2 >= minRow && m.colIndex <= maxCol && c2 >= minCol
|
|
91
|
+
if (!touches) continue
|
|
92
|
+
if (m.rowIndex < minRow) { minRow = m.rowIndex; grew = true }
|
|
93
|
+
if (r2 > maxRow) { maxRow = r2; grew = true }
|
|
94
|
+
if (m.colIndex < minCol) { minCol = m.colIndex; grew = true }
|
|
95
|
+
if (c2 > maxCol) { maxCol = c2; grew = true }
|
|
96
|
+
}
|
|
97
|
+
if (!grew) break
|
|
98
|
+
}
|
|
99
|
+
return minRow === rect.minRow && maxRow === rect.maxRow && minCol === rect.minCol && maxCol === rect.maxCol
|
|
100
|
+
? rect
|
|
101
|
+
: { minRow, maxRow, minCol, maxCol }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Where a move from `from` to `next` really lands. A step into a merge
|
|
106
|
+
* lands on its origin; a step that stays inside the merge the move started
|
|
107
|
+
* in carries on past its far edge (right from a three-wide merge reaches
|
|
108
|
+
* the column after it), clamped to the bounds; a step that then lands in
|
|
109
|
+
* another merge takes that one's origin.
|
|
110
|
+
*/
|
|
111
|
+
export function stepPastMerge(
|
|
112
|
+
index: MergeIndex | null,
|
|
113
|
+
from: { rowIndex: number; colIndex: number },
|
|
114
|
+
next: { rowIndex: number; colIndex: number },
|
|
115
|
+
bounds: { maxRow: number; maxCol: number },
|
|
116
|
+
): { rowIndex: number; colIndex: number } {
|
|
117
|
+
if (!index) return next
|
|
118
|
+
const target = mergeAt(index, next.rowIndex, next.colIndex)
|
|
119
|
+
if (!target) return next
|
|
120
|
+
const source = mergeAt(index, from.rowIndex, from.colIndex)
|
|
121
|
+
if (source !== target) return { rowIndex: target.rowIndex, colIndex: target.colIndex }
|
|
122
|
+
// Still inside the merge the move began in: keep going in that direction.
|
|
123
|
+
const dr = Math.sign(next.rowIndex - from.rowIndex)
|
|
124
|
+
const dc = Math.sign(next.colIndex - from.colIndex)
|
|
125
|
+
if (dr === 0 && dc === 0) return { rowIndex: target.rowIndex, colIndex: target.colIndex }
|
|
126
|
+
let rowIndex = next.rowIndex
|
|
127
|
+
let colIndex = next.colIndex
|
|
128
|
+
if (dr > 0) rowIndex = target.rowIndex + target.rowSpan
|
|
129
|
+
else if (dr < 0) rowIndex = target.rowIndex - 1
|
|
130
|
+
if (dc > 0) colIndex = target.colIndex + target.colSpan
|
|
131
|
+
else if (dc < 0) colIndex = target.colIndex - 1
|
|
132
|
+
if (rowIndex < 0 || rowIndex > bounds.maxRow || colIndex < 0 || colIndex > bounds.maxCol) {
|
|
133
|
+
// Nowhere further to go: the merge itself stays current.
|
|
134
|
+
return { rowIndex: target.rowIndex, colIndex: target.colIndex }
|
|
135
|
+
}
|
|
136
|
+
const landed = mergeAt(index, rowIndex, colIndex)
|
|
137
|
+
return landed ? { rowIndex: landed.rowIndex, colIndex: landed.colIndex } : { rowIndex, colIndex }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* How a td at (r, c) draws inside a rendered window of rows and columns.
|
|
142
|
+
* A merge is drawn once per window, by the top-left cell of the part of it
|
|
143
|
+
* the window holds: the origin when the origin is in the window (`origin`),
|
|
144
|
+
* else the first covered cell the window shows (`continuation`, which
|
|
145
|
+
* draws the origin's content itself), with the spans clamped to the window
|
|
146
|
+
* so a table never spans into rows it has not drawn. Every other cell of
|
|
147
|
+
* the merge is `skip`: no td, the drawn one covers its slot.
|
|
148
|
+
*/
|
|
149
|
+
export type MergeDraw =
|
|
150
|
+
| { kind: 'skip' }
|
|
151
|
+
| { kind: 'origin' | 'continuation'; merge: MergedCell; rowSpan: number; colSpan: number }
|
|
152
|
+
|
|
153
|
+
export function mergeDrawAt(
|
|
154
|
+
index: MergeIndex | null,
|
|
155
|
+
r: number,
|
|
156
|
+
c: number,
|
|
157
|
+
window: { firstRow: number; lastRow: number; firstCol: number; lastCol: number },
|
|
158
|
+
): MergeDraw | null {
|
|
159
|
+
const m = mergeAt(index, r, c)
|
|
160
|
+
if (!m) return null
|
|
161
|
+
const anchorRow = Math.max(m.rowIndex, window.firstRow)
|
|
162
|
+
const anchorCol = Math.max(m.colIndex, window.firstCol)
|
|
163
|
+
if (r !== anchorRow || c !== anchorCol) return { kind: 'skip' }
|
|
164
|
+
const isOrigin = anchorRow === m.rowIndex && anchorCol === m.colIndex
|
|
165
|
+
const rowSpan = Math.max(1, Math.min(m.rowIndex + m.rowSpan - r, window.lastRow - r + 1))
|
|
166
|
+
const colSpan = Math.max(1, Math.min(m.colIndex + m.colSpan - c, window.lastCol - c + 1))
|
|
167
|
+
return { kind: isOrigin ? 'origin' : 'continuation', merge: m, rowSpan, colSpan }
|
|
168
|
+
}
|
package/src/pivot.grid.test.ts
CHANGED
|
@@ -35,27 +35,32 @@ const data: Row[] = [
|
|
|
35
35
|
const engine = (rows: ReadonlyArray<Record<string, unknown>>) => {
|
|
36
36
|
const byRegion = new Map<string, number>()
|
|
37
37
|
for (const r of rows) byRegion.set(String(r.region), (byRegion.get(String(r.region)) ?? 0) + Number(r.sales))
|
|
38
|
-
const pivotRows: GridPivotRow
|
|
39
|
-
__pivotId: `p${i}`, __pivotDepth: 0, __pivotLabel: region, __pivotParentId: null, __pivotExpandable: false,
|
|
40
|
-
region, total,
|
|
38
|
+
const pivotRows: Array<GridPivotRow & { __pivotKind: string }> = [...byRegion].map(([region, total], i) => ({
|
|
39
|
+
__pivotId: `p${i}`, __pivotDepth: 0, __pivotLabel: region, __pivotParentId: null, __pivotExpandable: false, __pivotKind: 'leaf',
|
|
40
|
+
region, total, pv_grand__total: total,
|
|
41
41
|
}))
|
|
42
|
+
const all = [...byRegion.values()].reduce((a, b) => a + b, 0)
|
|
43
|
+
pivotRows.push({ __pivotId: 'grand', __pivotDepth: 0, __pivotLabel: 'Total', __pivotParentId: null, __pivotExpandable: false, __pivotKind: 'grandTotal', region: 'Total', total: all, pv_grand__total: all })
|
|
42
44
|
return {
|
|
43
45
|
rows: pivotRows,
|
|
44
46
|
columns: [
|
|
45
|
-
{ id: '
|
|
47
|
+
{ id: '__pivotRowHeader', header: 'Region', field: 'region', width: 140 },
|
|
46
48
|
{ id: 'total', header: 'Total sales', field: 'total', width: 140 },
|
|
49
|
+
{ id: 'pv_group__grand', header: 'Grand total', columns: [{ id: 'pv_grand__total', header: 'Total sales', field: 'pv_grand__total', width: 140 }] },
|
|
47
50
|
] as never,
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
52
55
|
|
|
53
|
-
function mountGrid() {
|
|
56
|
+
function mountGrid(extra: Record<string, unknown> = {}) {
|
|
54
57
|
const target = document.createElement('div')
|
|
58
|
+
target.style.width = '1000px'
|
|
55
59
|
document.body.appendChild(target)
|
|
56
60
|
const app = mount(SvGrid, {
|
|
57
61
|
target,
|
|
58
62
|
props: {
|
|
63
|
+
...extra,
|
|
59
64
|
data,
|
|
60
65
|
columns: cols,
|
|
61
66
|
features,
|
|
@@ -99,4 +104,34 @@ describe('in-grid pivot mode', () => {
|
|
|
99
104
|
expect(target.textContent).toContain('Total sales')
|
|
100
105
|
expect(target.textContent).toContain('140') // EU 100 + 40
|
|
101
106
|
})
|
|
107
|
+
|
|
108
|
+
it('the Chart panel charts the pivot on screen, not the source rows', async () => {
|
|
109
|
+
registerPivotEngine(engine as never)
|
|
110
|
+
const { target, destroy } = mountGrid({ charting: { defaultOpen: true, position: 'right' } })
|
|
111
|
+
cleanup = destroy
|
|
112
|
+
// The panel and the chart engine are lazy chunks: a few macrotasks.
|
|
113
|
+
for (let i = 0; i < 6; i += 1) await new Promise((r) => setTimeout(r))
|
|
114
|
+
const panel = target.querySelector('.sv-grid-chart-panel')!
|
|
115
|
+
expect(panel).toBeTruthy()
|
|
116
|
+
// The categories are the pivot's leaf rows and the series its value column;
|
|
117
|
+
// the grand total is not a bar. The source rows would have drawn three bars
|
|
118
|
+
// (one per row) or asked for a Group by.
|
|
119
|
+
const table = panel.querySelector('.sv-grid-chart-sr-only tbody')!
|
|
120
|
+
const cells = [...table.querySelectorAll('td')].map((td) => td.textContent)
|
|
121
|
+
expect(cells).toEqual(['EU', '140', 'US', '70'])
|
|
122
|
+
expect(panel.querySelector('.sv-grid-chart-sr-only thead')!.textContent).toContain('Total sales')
|
|
123
|
+
// The data pickers are gone and the note says why; Type stays.
|
|
124
|
+
const labels = [...panel.querySelectorAll('.sv-grid-chart-ctl-lbl')].map((l) => l.textContent)
|
|
125
|
+
expect(labels).not.toContain('Group by')
|
|
126
|
+
expect(labels).not.toContain('Value')
|
|
127
|
+
expect(labels).not.toContain('Aggregate')
|
|
128
|
+
expect(labels).toContain('Type')
|
|
129
|
+
expect(panel.querySelector('.sv-grid-chart-pivot-hint')!.textContent).toContain('Charting the pivot')
|
|
130
|
+
// The type list keeps the shapes a pivot can take.
|
|
131
|
+
const options = [...panel.querySelectorAll('.sv-grid-chart-ctl select option')].map((o) => (o as HTMLOptionElement).value)
|
|
132
|
+
expect(options).toContain('line')
|
|
133
|
+
expect(options).toContain('radar')
|
|
134
|
+
expect(options).not.toContain('scatter')
|
|
135
|
+
expect(options).not.toContain('candlestick')
|
|
136
|
+
})
|
|
102
137
|
})
|
package/src/row-resize.test.ts
CHANGED
|
@@ -209,6 +209,50 @@ describe('rowResize - strip injection', () => {
|
|
|
209
209
|
// Drag lifecycle
|
|
210
210
|
// ---------------------------------------------------------------------------
|
|
211
211
|
|
|
212
|
+
describe('rowResize - double-click', () => {
|
|
213
|
+
it('asks for an autosize of the row and keeps the click from the row itself', () => {
|
|
214
|
+
const { host, rows } = buildGrid([{ rowIndex: 0 }, { rowIndex: 1 }])
|
|
215
|
+
track(host)
|
|
216
|
+
const onAutosize = vi.fn()
|
|
217
|
+
const rowSaw = vi.fn()
|
|
218
|
+
rows[1]!.addEventListener('dblclick', rowSaw)
|
|
219
|
+
const onResize = vi.fn()
|
|
220
|
+
const action = rowResize(host, { onResize, onAutosize })
|
|
221
|
+
const strip = stripOf(rows[1]!)!
|
|
222
|
+
// Two presses on one strip inside 400ms are a double-click.
|
|
223
|
+
strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
|
|
224
|
+
window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
|
|
225
|
+
strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
|
|
226
|
+
expect(onAutosize).toHaveBeenCalledWith(1)
|
|
227
|
+
// No drag started, so nothing to release.
|
|
228
|
+
expect(strip.classList.contains('is-resizing')).toBe(false)
|
|
229
|
+
window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
|
|
230
|
+
// The first press's release reported the unchanged height; the second
|
|
231
|
+
// press started no drag.
|
|
232
|
+
expect(onResize).toHaveBeenCalledTimes(1)
|
|
233
|
+
expect(rowSaw).not.toHaveBeenCalled()
|
|
234
|
+
action.destroy()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('does nothing without an onAutosize handler', () => {
|
|
238
|
+
const { host, rows } = buildGrid([{ rowIndex: 0 }])
|
|
239
|
+
track(host)
|
|
240
|
+
const rowSaw = vi.fn()
|
|
241
|
+
rows[0]!.addEventListener('dblclick', rowSaw)
|
|
242
|
+
const onResize = vi.fn()
|
|
243
|
+
const action = rowResize(host, { onResize })
|
|
244
|
+
const strip = stripOf(rows[0]!)!
|
|
245
|
+
strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
|
|
246
|
+
window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
|
|
247
|
+
strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
|
|
248
|
+
// Without a handler the second press is an ordinary drag start.
|
|
249
|
+
expect(strip.classList.contains('is-resizing')).toBe(true)
|
|
250
|
+
window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
|
|
251
|
+
expect(rowSaw).not.toHaveBeenCalled()
|
|
252
|
+
action.destroy()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
212
256
|
describe('rowResize - drag', () => {
|
|
213
257
|
it('reports the new height on pointerup and fires move callbacks', () => {
|
|
214
258
|
const { host, rows } = buildGrid([{ rowIndex: 0 }])
|
package/src/row-resize.ts
CHANGED
|
@@ -33,6 +33,9 @@ export type RowResizeOptions = {
|
|
|
33
33
|
onResize: (rowIndex: number, height: number) => void
|
|
34
34
|
/** Optional callback fired during the drag (every pointermove). */
|
|
35
35
|
onResizeMove?: (rowIndex: number, height: number) => void
|
|
36
|
+
/** Double-click on the strip: size the row to its content, the
|
|
37
|
+
* spreadsheet gesture. Without it a double-click does nothing. */
|
|
38
|
+
onAutosize?: (rowIndex: number) => void
|
|
36
39
|
/** Min row height in px. Default 20. */
|
|
37
40
|
min?: number
|
|
38
41
|
/** Max row height in px. Default 320. */
|
|
@@ -63,6 +66,8 @@ type ActiveDrag = {
|
|
|
63
66
|
export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
|
|
64
67
|
let current = opts
|
|
65
68
|
let drag: ActiveDrag | null = null
|
|
69
|
+
/** The last press on a strip, for telling a double-click from two drags. */
|
|
70
|
+
let lastPress: { rowIndex: number; at: number } | null = null
|
|
66
71
|
|
|
67
72
|
function rowIndexOf(tr: HTMLTableRowElement): number {
|
|
68
73
|
const dataRow = tr.querySelector<HTMLElement>('[data-svgrid-row]')
|
|
@@ -133,6 +138,18 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
|
|
|
133
138
|
if (!Number.isFinite(rowIndex)) return
|
|
134
139
|
e.preventDefault()
|
|
135
140
|
e.stopPropagation()
|
|
141
|
+
// The second press of a double-click: size the row to its content
|
|
142
|
+
// instead of starting another drag. Timed here rather than read from
|
|
143
|
+
// a dblclick event, whose target is the row once the first press's
|
|
144
|
+
// resize has re-rendered the strip; pointerdown carries no count.
|
|
145
|
+
const now = e.timeStamp
|
|
146
|
+
const again = lastPress !== null && lastPress.rowIndex === rowIndex && now - lastPress.at < 400
|
|
147
|
+
lastPress = { rowIndex, at: now }
|
|
148
|
+
if (again && current.onAutosize) {
|
|
149
|
+
lastPress = null
|
|
150
|
+
current.onAutosize(rowIndex)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
136
153
|
t.classList.add('is-resizing')
|
|
137
154
|
drag = {
|
|
138
155
|
rowIndex,
|