@svgrid/grid 2.6.22 → 2.6.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/GridMenus.svelte +184 -6
  2. package/dist/SvAutoComplete.svelte +4 -0
  3. package/dist/SvComboBox.svelte +5 -1
  4. package/dist/SvDateRangeInput.svelte +25 -3
  5. package/dist/SvDropDownList.svelte +4 -0
  6. package/dist/SvForm.svelte +8 -8
  7. package/dist/SvGrid.controller.svelte.d.ts +48 -1
  8. package/dist/SvGrid.controller.svelte.js +214 -52
  9. package/dist/SvGrid.css +221 -0
  10. package/dist/SvGrid.helpers.d.ts +55 -0
  11. package/dist/SvGrid.helpers.js +83 -0
  12. package/dist/SvGrid.svelte +145 -0
  13. package/dist/SvGrid.types.d.ts +105 -0
  14. package/dist/SvGridCellEditor.svelte +99 -17
  15. package/dist/SvGridSelect.svelte +6 -2
  16. package/dist/SvGridSelect.svelte.d.ts +1 -1
  17. package/dist/SvListBox.svelte +8 -0
  18. package/dist/SvMultiSelect.svelte +6 -2
  19. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  20. package/dist/SvNumberInput.svelte +4 -0
  21. package/dist/SvRichCell.svelte +125 -0
  22. package/dist/SvRichCell.svelte.d.ts +15 -0
  23. package/dist/SvTextArea.svelte +5 -1
  24. package/dist/SvTreeSelect.svelte +6 -2
  25. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  26. package/dist/builtin-editors.d.ts +2 -2
  27. package/dist/builtin-editors.js +19 -3
  28. package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
  29. package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
  30. package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
  31. package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
  32. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
  33. package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
  34. package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
  35. package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
  36. package/dist/cdn/date-format-BNii4zeD.js +1393 -0
  37. package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
  38. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  39. package/dist/cdn/{src-D1lXwq1l.js → src-DGo7IHug.js} +5730 -5198
  40. package/dist/cdn/{src-C9Hihx1W.js → src-skQN5eqh.js} +7216 -6684
  41. package/dist/cdn/svgrid.js +14 -12
  42. package/dist/cdn/svgrid.svelte-external.js +14 -12
  43. package/dist/cell-values.d.ts +16 -0
  44. package/dist/cell-values.js +28 -0
  45. package/dist/clipboard.d.ts +22 -0
  46. package/dist/clipboard.js +405 -27
  47. package/dist/editing.js +12 -7
  48. package/dist/editors/cell-editors.d.ts +11 -0
  49. package/dist/editors/cell-editors.js +18 -1
  50. package/dist/grid-messages.d.ts +15 -0
  51. package/dist/grid-messages.js +15 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +4 -0
  54. package/dist/list-option.d.ts +6 -0
  55. package/dist/menus.js +40 -7
  56. package/dist/sanitize-html.d.ts +37 -0
  57. package/dist/sanitize-html.js +234 -0
  58. package/dist/selection-bar-view.svelte.d.ts +25 -0
  59. package/dist/selection-bar-view.svelte.js +13 -0
  60. package/dist/selection.d.ts +2 -1
  61. package/dist/selection.js +129 -13
  62. package/package.json +1 -1
  63. package/src/GridMenus.svelte +184 -6
  64. package/src/SvAutoComplete.svelte +4 -0
  65. package/src/SvComboBox.svelte +5 -1
  66. package/src/SvDateRangeInput.svelte +25 -3
  67. package/src/SvDropDownList.svelte +4 -0
  68. package/src/SvForm.svelte +8 -8
  69. package/src/SvGrid.controller.svelte.ts +251 -61
  70. package/src/SvGrid.css +221 -0
  71. package/src/SvGrid.helpers.ts +87 -0
  72. package/src/SvGrid.svelte +145 -0
  73. package/src/SvGrid.types.ts +112 -0
  74. package/src/SvGridCellEditor.svelte +99 -17
  75. package/src/SvGridSelect.svelte +6 -2
  76. package/src/SvListBox.svelte +8 -0
  77. package/src/SvMultiSelect.svelte +6 -2
  78. package/src/SvNumberInput.svelte +4 -0
  79. package/src/SvRichCell.svelte +125 -0
  80. package/src/SvTextArea.svelte +5 -1
  81. package/src/SvTreeSelect.svelte +6 -2
  82. package/src/builtin-editors.test.ts +18 -1
  83. package/src/builtin-editors.ts +19 -3
  84. package/src/cell-values.ts +30 -0
  85. package/src/clipboard.test.ts +93 -20
  86. package/src/clipboard.ts +433 -33
  87. package/src/date-string-column.test.ts +87 -0
  88. package/src/editing.test.ts +25 -0
  89. package/src/editing.ts +12 -9
  90. package/src/editor-block-prop.test.ts +132 -0
  91. package/src/editors/cell-editors.ts +27 -1
  92. package/src/grid-messages.ts +34 -0
  93. package/src/index.ts +13 -0
  94. package/src/list-option.ts +6 -0
  95. package/src/menus.test.ts +43 -0
  96. package/src/menus.ts +37 -7
  97. package/src/move-cells.test.ts +850 -0
  98. package/src/number-editor-literal.test.ts +84 -0
  99. package/src/number-editor.grid.test.ts +160 -0
  100. package/src/sanitize-html.test.ts +182 -0
  101. package/src/sanitize-html.ts +235 -0
  102. package/src/selection-bar-view.svelte.ts +36 -0
  103. package/src/selection.test.ts +157 -0
  104. package/src/selection.ts +131 -13
  105. package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
  106. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  107. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  108. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  109. package/src/svgrid.upsell-license.test.ts +117 -0
  110. package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
  111. package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
  112. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  113. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  114. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  115. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
package/dist/clipboard.js CHANGED
@@ -5,12 +5,26 @@ import { parseEditorValue, } from "./index.js";
5
5
  import "./sv-grid-scrollbar.js";
6
6
  import { buildFillPattern } from "./fill-patterns.js";
7
7
  import { getCellKey, } from "./SvGrid.helpers.js";
8
- import { getColumnBaseValue, isGroupRow, toolPanelHeaderLabel, } from "./cell-values.js";
8
+ import { createDataIndexLookup, getColumnBaseValue, isGroupRow, toolPanelHeaderLabel, } from "./cell-values.js";
9
9
  export function createClipboard(ctx) {
10
+ /**
11
+ * `rowIndex` here is a DISPLAY index - a position in `allRows`, the space the
12
+ * selection rectangle, the fill drag and `isCellEditableAt` all work in.
13
+ * These two functions used to index `internalData` with it directly, which is
14
+ * the same row only while the grid is unsorted, unfiltered and unpaginated.
15
+ * Sort a column and the fill handle read its pattern from one row and wrote
16
+ * to another; enterprise bulk edit wrote to the wrong rows the same way.
17
+ *
18
+ * `row.original` IS the object in `data`, so identity resolves the slot no
19
+ * matter how the view is ordered.
20
+ */
21
+ function rowDataAt(rowIndex) {
22
+ return ctx.allRows[rowIndex]?.original;
23
+ }
10
24
  /** Read the raw underlying value for the cell at (rowIndex, columnId)
11
25
  * for pattern extraction. */
12
26
  function readCellRaw(rowIndex, columnId) {
13
- const row = ctx.internalData[rowIndex];
27
+ const row = rowDataAt(rowIndex);
14
28
  const column = ctx.findColumnById(columnId);
15
29
  if (!row || !column?.columnDef.field)
16
30
  return undefined;
@@ -20,7 +34,7 @@ export function createClipboard(ctx) {
20
34
  * edit lifecycle. Fires `onCellValueChange` per write so consumers
21
35
  * can react (formula recompute, autosave, etc.). */
22
36
  function writeCellRaw(rowIndex, columnId, value) {
23
- const row = ctx.internalData[rowIndex];
37
+ const row = rowDataAt(rowIndex);
24
38
  const column = ctx.findColumnById(columnId);
25
39
  if (!row || !column?.columnDef.field)
26
40
  return;
@@ -28,24 +42,28 @@ export function createClipboard(ctx) {
28
42
  const oldValue = row[field];
29
43
  if (oldValue === value)
30
44
  return;
31
- // Resolve the row's id BEFORE swapping internalData - otherwise the
32
- // recomputed `allRows` references the new row object and the
33
- // `r.original === row` lookup fails, dropping our edit out of the
34
- // `editedCellValues` map (which getCellDisplayValue consults first).
35
- const rowId = ctx.allRows.find((r) => r.original === row)?.id;
45
+ // The slot in `data` backing this displayed row. Also read BEFORE swapping
46
+ // internalData: afterwards the recomputed `allRows` references the new row
47
+ // object, so an `original`-based lookup would miss and drop the edit out of
48
+ // `editedCellValues` (which getCellDisplayValue consults first).
49
+ const dataIndex = ctx.internalData.indexOf(row);
50
+ if (dataIndex < 0)
51
+ return;
52
+ const rowId = ctx.allRows[rowIndex]?.id;
36
53
  const next = ctx.internalData.slice();
37
- next[rowIndex] = { ...row, [field]: value };
54
+ next[dataIndex] = { ...row, [field]: value };
38
55
  ctx.internalData = next;
39
56
  if (rowId) {
40
57
  const key = getCellKey(rowId, columnId);
41
58
  ctx.editedCellValues = { ...ctx.editedCellValues, [key]: value };
42
59
  }
43
60
  ctx.props.onCellValueChange?.({
44
- rowIndex,
61
+ // The position in `props.data`, matching what the inline editor reports.
62
+ rowIndex: dataIndex,
45
63
  columnId,
46
64
  oldValue,
47
65
  newValue: value,
48
- row: next[rowIndex],
66
+ row: next[dataIndex],
49
67
  });
50
68
  }
51
69
  /** Apply the pattern fill on pointerup. Each NEW row (or column) is
@@ -58,6 +76,7 @@ export function createClipboard(ctx) {
58
76
  // Clear fillDrag FIRST so a thrown error doesn't leave the grid
59
77
  // stuck tracking the pointer.
60
78
  ctx.fillDrag = null;
79
+ stopEdgeScroll();
61
80
  const newMinRow = Math.min(d.sourceMinRow, d.targetRow);
62
81
  const newMaxRow = Math.max(d.sourceMaxRow, d.targetRow);
63
82
  const newMinCol = Math.min(d.sourceMinCol, d.targetCol);
@@ -202,29 +221,379 @@ export function createClipboard(ctx) {
202
221
  // back to the handle, breaking the lookup. Window-level handlers
203
222
  // (`onWindowPointerMove` / `endDragSelection`) keep tracking.
204
223
  }
205
- /** Pointermove during fill-drag. We don't get cell coords from the
206
- * event directly - find the td under the pointer via elementFromPoint
207
- * and read its data-svgrid-row / data-svgrid-col attributes. */
208
- function onFillPointerMove(event) {
209
- if (!ctx.fillDrag)
210
- return;
211
- const el = document.elementFromPoint(event.clientX, event.clientY);
224
+ /** The grid cell under a viewport point, or null. Drags cannot read their
225
+ * target off the event: the pointer is over whatever `<td>` happens to be
226
+ * there, and during a fill/move drag the event target is the cell the drag
227
+ * STARTED on. */
228
+ function cellAtPoint(x, y) {
229
+ const el = document.elementFromPoint(x, y);
212
230
  const cell = el?.closest("td[data-svgrid-row][data-svgrid-col]");
213
231
  if (!cell)
232
+ return null;
233
+ const row = Number(cell.dataset.svgridRow);
234
+ const col = Number(cell.dataset.svgridCol);
235
+ if (!Number.isFinite(row) || !Number.isFinite(col))
236
+ return null;
237
+ return { row, col };
238
+ }
239
+ // ---- Edge auto-scroll -------------------------------------------------
240
+ // Shared by all three drags that extend a rectangle - fill, range move, and
241
+ // plain drag-select. Without it a drag can only reach cells that are already
242
+ // on screen, which on a grid the size this one is built for means most of
243
+ // them are unreachable.
244
+ /** How far from the viewport edge the scroll starts, in CSS pixels. */
245
+ const EDGE_BAND_PX = 40;
246
+ /** Fastest scroll, in pixels per frame, reached at the very edge. */
247
+ const EDGE_MAX_PX = 20;
248
+ let edgeFrame = null;
249
+ let edgePointer = null;
250
+ function stopEdgeScroll() {
251
+ if (edgeFrame != null)
252
+ cancelAnimationFrame(edgeFrame);
253
+ edgeFrame = null;
254
+ edgePointer = null;
255
+ }
256
+ /**
257
+ * Pixels to scroll this frame along one axis. Zero in the middle, ramping to
258
+ * EDGE_MAX_PX at the edge and staying there once the pointer leaves the
259
+ * container entirely - a linear ramp rather than a constant step, so nudging
260
+ * one row past the edge creeps instead of lurching.
261
+ */
262
+ function edgeVelocity(pos, min, max) {
263
+ if (pos < min + EDGE_BAND_PX) {
264
+ const depth = Math.min(EDGE_BAND_PX, min + EDGE_BAND_PX - pos);
265
+ return -Math.ceil((depth / EDGE_BAND_PX) * EDGE_MAX_PX);
266
+ }
267
+ if (pos > max - EDGE_BAND_PX) {
268
+ const depth = Math.min(EDGE_BAND_PX, pos - (max - EDGE_BAND_PX));
269
+ return Math.ceil((depth / EDGE_BAND_PX) * EDGE_MAX_PX);
270
+ }
271
+ return 0;
272
+ }
273
+ /** Point the in-flight drag at whatever cell is under (x, y) now. */
274
+ function retargetDrag(x, y) {
275
+ const hit = cellAtPoint(x, y);
276
+ if (!hit)
214
277
  return;
215
- const r = Number(cell.dataset.svgridRow);
216
- const c = Number(cell.dataset.svgridCol);
217
- if (!Number.isFinite(r) || !Number.isFinite(c))
278
+ if (ctx.moveDrag) {
279
+ if (hit.row === ctx.moveDrag.targetRow && hit.col === ctx.moveDrag.targetCol)
280
+ return;
281
+ ctx.moveDrag = { ...ctx.moveDrag, targetRow: hit.row, targetCol: hit.col };
282
+ }
283
+ else if (ctx.fillDrag) {
284
+ if (hit.row === ctx.fillDrag.targetRow && hit.col === ctx.fillDrag.targetCol)
285
+ return;
286
+ ctx.fillDrag = { ...ctx.fillDrag, targetRow: hit.row, targetCol: hit.col };
287
+ }
288
+ else if (ctx.isDraggingSelection) {
289
+ ctx.extendSelection(hit.row, hit.col);
290
+ ctx.setActiveCell(hit.row, hit.col);
291
+ }
292
+ }
293
+ function stepEdgeScroll() {
294
+ edgeFrame = null;
295
+ const scroller = ctx.scrollContainer;
296
+ const p = edgePointer;
297
+ const dragging = ctx.moveDrag || ctx.fillDrag || ctx.isDraggingSelection;
298
+ if (!scroller || !p || !dragging) {
299
+ stopEdgeScroll();
218
300
  return;
219
- if (r === ctx.fillDrag.targetRow && c === ctx.fillDrag.targetCol)
301
+ }
302
+ const rect = scroller.getBoundingClientRect();
303
+ const dx = edgeVelocity(p.x, rect.left, rect.right);
304
+ const dy = edgeVelocity(p.y, rect.top, rect.bottom);
305
+ if (dx || dy) {
306
+ const fromX = scroller.scrollLeft;
307
+ const fromY = scroller.scrollTop;
308
+ scroller.scrollLeft += dx;
309
+ scroller.scrollTop += dy;
310
+ // Only re-target when the view actually moved. At either end the scroll
311
+ // clamps, and re-targeting then would fight a pointer that is parked
312
+ // still - the selection would flicker between two cells every frame.
313
+ if (scroller.scrollLeft !== fromX || scroller.scrollTop !== fromY) {
314
+ retargetDrag(p.x, p.y);
315
+ }
316
+ }
317
+ edgeFrame = requestAnimationFrame(stepEdgeScroll);
318
+ }
319
+ /**
320
+ * Record where the pointer is and make sure the scroll loop is running. The
321
+ * loop keeps going after the pointer stops moving, which is the whole point:
322
+ * a user parks the cursor at the edge and expects the grid to keep coming.
323
+ */
324
+ function trackEdgeScroll(event) {
325
+ edgePointer = { x: event.clientX, y: event.clientY };
326
+ if (edgeFrame == null)
327
+ edgeFrame = requestAnimationFrame(stepEdgeScroll);
328
+ }
329
+ /** Pointermove during fill-drag. */
330
+ function onFillPointerMove(event) {
331
+ if (!ctx.fillDrag)
332
+ return;
333
+ trackEdgeScroll(event);
334
+ const hit = cellAtPoint(event.clientX, event.clientY);
335
+ if (!hit)
220
336
  return;
221
- ctx.fillDrag = { ...ctx.fillDrag, targetRow: r, targetCol: c };
337
+ if (hit.row === ctx.fillDrag.targetRow && hit.col === ctx.fillDrag.targetCol)
338
+ return;
339
+ ctx.fillDrag = { ...ctx.fillDrag, targetRow: hit.row, targetCol: hit.col };
222
340
  }
223
341
  function onFillPointerUp() {
224
342
  if (!ctx.fillDrag)
225
343
  return;
226
344
  applyFillPattern();
227
345
  }
346
+ // ---- Range move / copy (drag the selection border) --------------------
347
+ /** How thick the draggable strip on a range border is, in CSS pixels.
348
+ * Anything further inside the cell keeps its old behaviour of starting a
349
+ * fresh selection, so this only claims the outer sliver. */
350
+ const MOVE_GRAB_PX = 4;
351
+ /** Is (clientX, clientY) on the grab strip of the range border that the
352
+ * cell at (rowIndex, colIndex) sits on? Returns false for any cell that
353
+ * is not on a range edge, so the common case costs one call to
354
+ * `getCellRangeEdges` - which itself bails immediately with no selection. */
355
+ function isOnMoveGrabStrip(cell, rowIndex, colIndex, clientX, clientY) {
356
+ if (!ctx.moveCellsEffective)
357
+ return false;
358
+ const edges = ctx.getCellRangeEdges(rowIndex, colIndex);
359
+ if (!edges)
360
+ return false;
361
+ // One layout read per pointermove, and only once the pointer is over a
362
+ // cell that IS on a range edge. Reading a single rect is cheap unless
363
+ // the DOM is dirty, and the only write this path makes is a boolean flag
364
+ // that changes at most twice per border crossing.
365
+ const box = cell.getBoundingClientRect();
366
+ const x = clientX - box.left;
367
+ const y = clientY - box.top;
368
+ return ((edges.top && y >= 0 && y <= MOVE_GRAB_PX) ||
369
+ (edges.bottom && box.height - y >= 0 && box.height - y <= MOVE_GRAB_PX) ||
370
+ (edges.left && x >= 0 && x <= MOVE_GRAB_PX) ||
371
+ (edges.right && box.width - x >= 0 && box.width - x <= MOVE_GRAB_PX));
372
+ }
373
+ /** Border pointerdown - seed a range move. Returns false when the pointer
374
+ * was not on the grab strip, which is the caller's signal to fall through
375
+ * to normal selection behaviour. */
376
+ function startMoveDrag(event, rowIndex, colIndex) {
377
+ if (event.button !== 0)
378
+ return false;
379
+ // Mouse and pen only, the same policy drag-select uses (issue #23) and for
380
+ // a worse reason: this path DOES preventDefault, so a finger that happened
381
+ // to land within 4px of a selection border would start a move and kill the
382
+ // browser's native scroll for that gesture. There is no hover on touch
383
+ // either, so the border carries no affordance to find in the first place.
384
+ // Touch already has cut/paste for the same job.
385
+ if (event.pointerType === "touch")
386
+ return false;
387
+ // `event.target`, not `currentTarget`: Svelte 5 delegates pointerdown to
388
+ // the root, and the strip test needs the cell's own rect - the target can
389
+ // be a span inside the cell, so walk up to the td.
390
+ const cell = event.target?.closest?.("td[data-svgrid-row][data-svgrid-col]");
391
+ if (!cell)
392
+ return false;
393
+ if (!isOnMoveGrabStrip(cell, rowIndex, colIndex, event.clientX, event.clientY))
394
+ return false;
395
+ const rect = moveSourceRect(rowIndex, colIndex);
396
+ if (!rect)
397
+ return false;
398
+ event.stopPropagation();
399
+ // preventDefault stops the drag from rubber-banding a text selection over
400
+ // the rows it passes, which also means the grid never gets focus from the
401
+ // gesture - so take it explicitly, or the keyboard stops working after a
402
+ // move.
403
+ event.preventDefault();
404
+ ctx.gridRootEl?.focus({ preventScroll: true });
405
+ ctx.moveDrag = {
406
+ ...rect,
407
+ grabRow: rowIndex,
408
+ grabCol: colIndex,
409
+ targetRow: rowIndex,
410
+ targetCol: colIndex,
411
+ copy: event.ctrlKey || event.metaKey,
412
+ };
413
+ // No `setPointerCapture`, for the same reason the fill handle skips it:
414
+ // the drag finds its target cell with `elementFromPoint`, and capture
415
+ // would route every move back to the cell the drag started on.
416
+ return true;
417
+ }
418
+ /** The selection rectangle that CONTAINS (rowIndex, colIndex), which is the
419
+ * one whose border the user grabbed. With several committed ranges the
420
+ * grabbed one is the one that moves, not whichever happens to be active. */
421
+ function moveSourceRect(rowIndex, colIndex) {
422
+ for (const rect of ctx.getSelectionRects()) {
423
+ if (rowIndex >= rect.minRow &&
424
+ rowIndex <= rect.maxRow &&
425
+ colIndex >= rect.minCol &&
426
+ colIndex <= rect.maxCol) {
427
+ return {
428
+ sourceMinRow: rect.minRow,
429
+ sourceMaxRow: rect.maxRow,
430
+ sourceMinCol: rect.minCol,
431
+ sourceMaxCol: rect.maxCol,
432
+ };
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+ function onMovePointerMove(event) {
438
+ const d = ctx.moveDrag;
439
+ if (!d)
440
+ return;
441
+ trackEdgeScroll(event);
442
+ const hit = cellAtPoint(event.clientX, event.clientY);
443
+ // Track the modifier even when the pointer is between cells, so releasing
444
+ // Ctrl over a gap still flips a copy back to a move.
445
+ const copy = event.ctrlKey || event.metaKey;
446
+ if (!hit) {
447
+ if (copy !== d.copy)
448
+ ctx.moveDrag = { ...d, copy };
449
+ return;
450
+ }
451
+ if (hit.row === d.targetRow && hit.col === d.targetCol && copy === d.copy)
452
+ return;
453
+ ctx.moveDrag = { ...d, targetRow: hit.row, targetCol: hit.col, copy };
454
+ }
455
+ function onMovePointerUp(event) {
456
+ if (!ctx.moveDrag)
457
+ return;
458
+ // Take the modifier from the RELEASE. Reading it only on pointermove made
459
+ // "drag, then press Ctrl, then let go" a move - which is the natural way
460
+ // to do it, and is a copy in every spreadsheet.
461
+ if (event) {
462
+ const copy = event.ctrlKey || event.metaKey;
463
+ if (copy !== ctx.moveDrag.copy)
464
+ ctx.moveDrag = { ...ctx.moveDrag, copy };
465
+ }
466
+ applyMoveRange();
467
+ }
468
+ /** Where a move would land, or null when the drag has not left the spot it
469
+ * started on. Shared by the drop and by the preview marquee so the outline
470
+ * can never promise a rectangle the drop would refuse. */
471
+ function moveDestRect(d) {
472
+ const dr = d.targetRow - d.grabRow;
473
+ const dc = d.targetCol - d.grabCol;
474
+ if (dr === 0 && dc === 0)
475
+ return null;
476
+ const minRow = d.sourceMinRow + dr;
477
+ const maxRow = d.sourceMaxRow + dr;
478
+ const minCol = d.sourceMinCol + dc;
479
+ const maxCol = d.sourceMaxCol + dc;
480
+ if (minRow < 0 || minCol < 0)
481
+ return null;
482
+ if (maxRow >= ctx.allRows.length)
483
+ return null;
484
+ if (maxCol >= ctx.allColumns.length)
485
+ return null;
486
+ return { minRow, maxRow, minCol, maxCol };
487
+ }
488
+ /**
489
+ * Commit a range move (or copy). Refuses the whole operation rather than
490
+ * applying the part that fits: a move that silently skipped a read-only or
491
+ * grouped destination cell would leave the range split across two places
492
+ * with no way to tell which half landed.
493
+ */
494
+ function applyMoveRange() {
495
+ const d = ctx.moveDrag;
496
+ if (!d)
497
+ return;
498
+ // Clear the drag FIRST so a thrown error can't leave the grid tracking
499
+ // the pointer forever, exactly as applyFillPattern does.
500
+ ctx.moveDrag = null;
501
+ stopEdgeScroll();
502
+ const dest = moveDestRect(d);
503
+ if (!dest)
504
+ return;
505
+ const height = d.sourceMaxRow - d.sourceMinRow + 1;
506
+ const width = d.sourceMaxCol - d.sourceMinCol + 1;
507
+ // Every column the move touches must be field-backed on BOTH sides: a
508
+ // computed / accessor column has nowhere to write the value back to.
509
+ for (let c = 0; c < width; c += 1) {
510
+ if (!ctx.allColumns[d.sourceMinCol + c]?.columnDef.field)
511
+ return;
512
+ if (!ctx.allColumns[dest.minCol + c]?.columnDef.field)
513
+ return;
514
+ }
515
+ // Destination must be writable, and so must the source when the values
516
+ // are leaving it. Group rows fail this through isCellEditableAt.
517
+ for (let r = 0; r < height; r += 1) {
518
+ for (let c = 0; c < width; c += 1) {
519
+ if (!ctx.isCellEditableAt(dest.minRow + r, dest.minCol + c))
520
+ return;
521
+ if (!d.copy &&
522
+ !ctx.isCellEditableAt(d.sourceMinRow + r, d.sourceMinCol + c))
523
+ return;
524
+ }
525
+ }
526
+ // Snapshot the source before touching anything - source and destination
527
+ // overlap on any short drag, so reading lazily would move already-moved
528
+ // values a second time.
529
+ const values = [];
530
+ for (let r = 0; r < height; r += 1) {
531
+ const rowValues = [];
532
+ for (let c = 0; c < width; c += 1) {
533
+ const column = ctx.allColumns[d.sourceMinCol + c];
534
+ rowValues.push(readCellRaw(d.sourceMinRow + r, column.id));
535
+ }
536
+ values.push(rowValues);
537
+ }
538
+ const steps = [];
539
+ const write = (rowIndex, colIndex, value) => {
540
+ const column = ctx.allColumns[colIndex];
541
+ if (!column?.columnDef.field)
542
+ return;
543
+ const before = readCellRaw(rowIndex, column.id);
544
+ if (before === value)
545
+ return;
546
+ // Resolve the row id BEFORE the write: writeCellRaw swaps in a fresh
547
+ // row object, so an `original`-based lookup afterwards would miss.
548
+ const rowId = ctx.allRows[rowIndex]?.id;
549
+ writeCellRaw(rowIndex, column.id, value);
550
+ if (rowId) {
551
+ steps.push({
552
+ rowId,
553
+ columnId: column.id,
554
+ field: column.columnDef.field,
555
+ before,
556
+ after: value,
557
+ });
558
+ }
559
+ };
560
+ // Clear the whole source first, then write the destination. With the
561
+ // values already snapshotted this is correct even when the rectangles
562
+ // overlap: the destination write always lands after the clear.
563
+ if (!d.copy) {
564
+ for (let r = 0; r < height; r += 1) {
565
+ for (let c = 0; c < width; c += 1) {
566
+ write(d.sourceMinRow + r, d.sourceMinCol + c, null);
567
+ }
568
+ }
569
+ }
570
+ for (let r = 0; r < height; r += 1) {
571
+ for (let c = 0; c < width; c += 1) {
572
+ write(dest.minRow + r, dest.minCol + c, values[r][c]);
573
+ }
574
+ }
575
+ // One undo entry per changed cell, appended together so a single Ctrl+Z
576
+ // sequence walks the whole move back. Same shape and truncation rule as
577
+ // a whole-row commit.
578
+ if (steps.length) {
579
+ let hist = ctx.history.slice(0, ctx.historyPtr + 1);
580
+ for (const step of steps)
581
+ hist.push(step);
582
+ if (hist.length > ctx.UNDO_LIMIT)
583
+ hist = hist.slice(hist.length - ctx.UNDO_LIMIT);
584
+ ctx.history = hist;
585
+ ctx.historyPtr = ctx.history.length - 1;
586
+ ctx.historyVersion += 1;
587
+ }
588
+ // Follow the values: the range stays selected where it landed, so a
589
+ // second drag can chain off the first the way it does in a spreadsheet.
590
+ ctx.selectionRanges = [];
591
+ ctx.selectionRange = {
592
+ anchor: { rowIndex: dest.minRow, colIndex: dest.minCol },
593
+ focus: { rowIndex: dest.maxRow, colIndex: dest.maxCol },
594
+ };
595
+ ctx.setActiveCell(dest.minRow, dest.minCol);
596
+ }
228
597
  function toggleBooleanCell(rowIndex, colIndex) {
229
598
  const row = ctx.allRows[rowIndex];
230
599
  const column = ctx.allColumns[colIndex];
@@ -356,14 +725,15 @@ export function createClipboard(ctx) {
356
725
  const next = ctx.internalData.slice();
357
726
  let mutated = false;
358
727
  const changes = [];
728
+ const dataIndexOf = createDataIndexLookup(next);
359
729
  for (let r = startRow; r <= endRow; r += 1) {
360
730
  const row = ctx.allRows[r];
361
731
  if (!row || isGroupRow(row))
362
732
  continue;
363
- const dataIndex = Number(row.id);
364
- if (!Number.isInteger(dataIndex) ||
365
- dataIndex < 0 ||
366
- dataIndex >= next.length)
733
+ // By identity, not `Number(row.id)` - that is the array index only under
734
+ // the default getRowId, so a custom one cleared the wrong row.
735
+ const dataIndex = dataIndexOf(row);
736
+ if (dataIndex < 0)
367
737
  continue;
368
738
  const originalRow = next[dataIndex];
369
739
  if (!originalRow)
@@ -430,6 +800,14 @@ export function createClipboard(ctx) {
430
800
  startFillDrag,
431
801
  onFillPointerMove,
432
802
  onFillPointerUp,
803
+ isOnMoveGrabStrip,
804
+ startMoveDrag,
805
+ onMovePointerMove,
806
+ onMovePointerUp,
807
+ trackEdgeScroll,
808
+ stopEdgeScroll,
809
+ applyMoveRange,
810
+ moveDestRect,
433
811
  toggleBooleanCell,
434
812
  copySelectionToClipboard,
435
813
  clearSelectedCells,
package/dist/editing.js CHANGED
@@ -5,7 +5,7 @@ import { parseEditorValue, } from "./index.js";
5
5
  import "./sv-grid-scrollbar.js";
6
6
  import { getNextActiveCell } from "./keyboard.js";
7
7
  import { getCellKey, toValueArray, } from "./SvGrid.helpers.js";
8
- import { getColumnBaseValue, isGroupRow, } from "./cell-values.js";
8
+ import { createDataIndexLookup, getColumnBaseValue, isGroupRow, } from "./cell-values.js";
9
9
  export function createEditing(ctx) {
10
10
  /**
11
11
  * Resolve `columnDef.editable` for one row × column pair.
@@ -117,6 +117,8 @@ export function createEditing(ctx) {
117
117
  const column = ctx.allColumns.find((entry) => entry.id === ctx.editingCell?.columnId);
118
118
  const parsedValue = parseEditorValue(ctx.editingCell.editorType, ctx.editingCell.value, {
119
119
  multiple: column?.columnDef.editorMultiple === true,
120
+ // A dateString column holds 'YYYY-MM-DD', not a timestamp.
121
+ dateOnly: column?.columnDef.cellDataType === "dateString",
120
122
  });
121
123
  let oldValue = undefined;
122
124
  let finalValue = parsedValue;
@@ -378,16 +380,16 @@ export function createEditing(ctx) {
378
380
  ? endCol - startCol + 1
379
381
  : Math.max(...lines.map((l) => l.split("\t").length));
380
382
  const next = ctx.internalData.slice();
383
+ const dataIndexOf = createDataIndexLookup(next);
381
384
  for (let i = 0; i < rowSpan; i += 1) {
382
385
  const targetRowIndex = startRow + i;
383
386
  const row = ctx.allRows[targetRowIndex];
384
387
  if (!row || isGroupRow(row))
385
388
  continue;
386
- // Map the visible row back to its index in the data array via row.id (default = String(index)).
387
- const dataIndex = Number(row.id);
388
- if (!Number.isInteger(dataIndex) ||
389
- dataIndex < 0 ||
390
- dataIndex >= next.length)
389
+ // Map the visible row back to its slot in the data array by identity -
390
+ // `row.id` is only the index under the default getRowId.
391
+ const dataIndex = dataIndexOf(row);
392
+ if (dataIndex < 0)
391
393
  continue;
392
394
  const originalRow = next[dataIndex];
393
395
  if (!originalRow)
@@ -407,7 +409,9 @@ export function createEditing(ctx) {
407
409
  const editorType = (column.columnDef.editorType ??
408
410
  "text");
409
411
  const raw = fillRange ? lines[0] : sourceCells?.[j] ?? "";
410
- updated[column.columnDef.field] = parseEditorValue(editorType, raw);
412
+ updated[column.columnDef.field] = parseEditorValue(editorType, raw, {
413
+ dateOnly: column.columnDef.cellDataType === "dateString",
414
+ });
411
415
  }
412
416
  next[dataIndex] = updated;
413
417
  }
@@ -496,6 +500,7 @@ export function createEditing(ctx) {
496
500
  const editorType = (column.columnDef.editorType ?? "text");
497
501
  const parsed = parseEditorValue(editorType, fr.draft[column.id], {
498
502
  multiple: column.columnDef.editorMultiple === true,
503
+ dateOnly: column.columnDef.cellDataType === "dateString",
499
504
  });
500
505
  const oldValue = row.original[field];
501
506
  const parser = column.columnDef.valueParser;
@@ -12,6 +12,17 @@ export type CellEditorOption = {
12
12
  export type ParseEditorValueOptions = {
13
13
  /** When true, list/chips return an array; otherwise a scalar. */
14
14
  multiple?: boolean;
15
+ /**
16
+ * Keep a date as the plain `YYYY-MM-DD` string the user picked, instead of a
17
+ * full ISO timestamp. Set for `cellDataType: 'dateString'` columns, which
18
+ * exist precisely to hold ISO date strings.
19
+ *
20
+ * It is not only about shape. `new Date('2026-12-25').toISOString()` sends
21
+ * the value through UTC, so a local midnight becomes the previous or next
22
+ * day either side of the meridian - the stored DATE changes depending on
23
+ * where the user is sitting. Slicing the picked date avoids that entirely.
24
+ */
25
+ dateOnly?: boolean;
15
26
  };
16
27
  /** Normalize the loose `editorOptions` ColumnDef prop into a uniform shape.
17
28
  * Dynamic (function) editorOptions are resolved before reaching here. */
@@ -36,7 +36,24 @@ export function parseEditorValue(rawType, value, opts) {
36
36
  return s || '#000000';
37
37
  }
38
38
  if (type === 'date') {
39
- const date = new Date(String(value));
39
+ const raw = String(value ?? '');
40
+ if (opts?.dateOnly) {
41
+ // Take the calendar date the editor produced as-is. It already arrives
42
+ // as `YYYY-MM-DD` (or that prefix on a datetime), so there is nothing to
43
+ // convert - and converting is exactly what shifts the day across a
44
+ // timezone boundary.
45
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(raw.trim());
46
+ if (match)
47
+ return match[1];
48
+ const parsed = new Date(raw);
49
+ if (Number.isNaN(parsed.getTime()))
50
+ return null;
51
+ // A non-ISO input (a locale string) still has to become a date string.
52
+ // Use the LOCAL parts, so the day is the one the user saw.
53
+ const pad = (n) => String(n).padStart(2, '0');
54
+ return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
55
+ }
56
+ const date = new Date(raw);
40
57
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
41
58
  }
42
59
  if (type === 'datetime') {
@@ -54,9 +54,24 @@ export type GridMessages = {
54
54
  menuRemoveRow: string;
55
55
  menuRemoveColumn: string;
56
56
  menuEditComment: string;
57
+ selectionBarLabel: string;
58
+ selectionBarCount: string;
59
+ selectionBarClear: string;
60
+ selectionBarMore: string;
61
+ selectionBarSelectAll: string;
62
+ selectionBarEditFields: string;
63
+ bulkEditTitle: string;
64
+ bulkEditLead: string;
65
+ bulkEditMixed: string;
66
+ bulkEditApply: string;
67
+ bulkEditCancel: string;
68
+ bulkEditNoFields: string;
57
69
  chartRange: string;
58
70
  pivotUpsellTitle: string;
59
71
  pivotUpsellBody: string;
72
+ /** Shared licensing line under every Enterprise upsell note. */
73
+ upsellLicense: string;
74
+ upsellLicenseLink: string;
60
75
  };
61
76
  /** English defaults - the literal strings the grid shipped before localization. */
62
77
  export declare const defaultGridMessages: GridMessages;
@@ -49,6 +49,19 @@ export const defaultGridMessages = {
49
49
  menuRemoveRow: 'Remove row',
50
50
  menuRemoveColumn: 'Remove column',
51
51
  menuEditComment: 'Edit comment',
52
+ selectionBarLabel: 'Selection actions',
53
+ /** Label beside the count CHIP, which carries the number itself. */
54
+ selectionBarCount: 'selected',
55
+ selectionBarClear: 'Clear selection',
56
+ selectionBarMore: 'More actions',
57
+ selectionBarSelectAll: 'Select all',
58
+ selectionBarEditFields: 'Edit fields',
59
+ bulkEditTitle: 'Edit fields',
60
+ bulkEditLead: 'Changes apply to all {count} selected items. Fields you leave alone keep their existing values.',
61
+ bulkEditMixed: 'Multiple values',
62
+ bulkEditApply: 'Apply to {count} items',
63
+ bulkEditCancel: 'Cancel',
64
+ bulkEditNoFields: 'No editable fields on this grid.',
52
65
  opContains: 'Contains',
53
66
  opNotContains: 'Not contains',
54
67
  opEquals: 'Equals',
@@ -68,6 +81,8 @@ export const defaultGridMessages = {
68
81
  chartRange: 'Chart selected range',
69
82
  pivotUpsellTitle: 'Pivot mode',
70
83
  pivotUpsellBody: 'Pivot mode is an Enterprise feature. Install @svgrid/enterprise and call enablePivot() to use it.',
84
+ upsellLicense: 'A license key is required for production use. Without one the feature still works, but the grid renders an unlicensed watermark.',
85
+ upsellLicenseLink: 'View licensing',
71
86
  };
72
87
  /**
73
88
  * Merge a consumer's `localeText` over the English defaults. Undefined/empty