@svgrid/grid 2.6.21 → 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 (151) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +22 -0
  3. package/dist/GridMenus.svelte +201 -18
  4. package/dist/SvAutoComplete.svelte +4 -0
  5. package/dist/SvComboBox.svelte +5 -1
  6. package/dist/SvDateRangeInput.svelte +25 -3
  7. package/dist/SvDropDownList.svelte +4 -0
  8. package/dist/SvForm.svelte +8 -8
  9. package/dist/SvGrid.controller.svelte.d.ts +61 -9
  10. package/dist/SvGrid.controller.svelte.js +363 -123
  11. package/dist/SvGrid.css +222 -1
  12. package/dist/SvGrid.helpers.d.ts +55 -0
  13. package/dist/SvGrid.helpers.js +83 -0
  14. package/dist/SvGrid.svelte +255 -56
  15. package/dist/SvGrid.types.d.ts +146 -1
  16. package/dist/SvGridCellEditor.svelte +99 -17
  17. package/dist/SvGridSelect.svelte +6 -2
  18. package/dist/SvGridSelect.svelte.d.ts +1 -1
  19. package/dist/SvListBox.svelte +8 -0
  20. package/dist/SvMultiSelect.svelte +6 -2
  21. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  22. package/dist/SvNumberInput.svelte +4 -0
  23. package/dist/SvRichCell.svelte +125 -0
  24. package/dist/SvRichCell.svelte.d.ts +15 -0
  25. package/dist/SvTextArea.svelte +5 -1
  26. package/dist/SvTreeSelect.svelte +6 -2
  27. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  28. package/dist/builtin-editors.d.ts +2 -2
  29. package/dist/builtin-editors.js +19 -3
  30. package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
  31. package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
  32. package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
  33. package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
  34. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
  35. package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
  36. package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
  37. package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
  38. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  39. package/dist/cdn/date-format-BNii4zeD.js +1393 -0
  40. package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
  41. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  42. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  43. package/dist/cdn/{src-DBel9wRZ.js → src-DGo7IHug.js} +10017 -9488
  44. package/dist/cdn/{src-BYq-qyrp.js → src-skQN5eqh.js} +7829 -7300
  45. package/dist/cdn/svgrid.js +14 -10
  46. package/dist/cdn/svgrid.svelte-external.js +14 -10
  47. package/dist/cell-values.d.ts +16 -0
  48. package/dist/cell-values.js +28 -0
  49. package/dist/clipboard.d.ts +22 -0
  50. package/dist/clipboard.js +405 -27
  51. package/dist/column-groups.js +1 -1
  52. package/dist/column-resize.d.ts +46 -0
  53. package/dist/column-resize.js +205 -0
  54. package/dist/columns.d.ts +0 -3
  55. package/dist/columns.js +0 -57
  56. package/dist/core.d.ts +19 -4
  57. package/dist/core.js +460 -119
  58. package/dist/editing.js +12 -7
  59. package/dist/editors/cell-editors.d.ts +11 -0
  60. package/dist/editors/cell-editors.js +18 -1
  61. package/dist/filtering/excel-filters.js +28 -0
  62. package/dist/grid-messages.d.ts +15 -0
  63. package/dist/grid-messages.js +15 -0
  64. package/dist/group-display.d.ts +1 -1
  65. package/dist/index.d.ts +6 -1
  66. package/dist/index.js +10 -0
  67. package/dist/list-option.d.ts +6 -0
  68. package/dist/menus.js +41 -8
  69. package/dist/row-resize.d.ts +11 -0
  70. package/dist/row-resize.js +7 -1
  71. package/dist/sanitize-html.d.ts +37 -0
  72. package/dist/sanitize-html.js +234 -0
  73. package/dist/selection-bar-view.svelte.d.ts +25 -0
  74. package/dist/selection-bar-view.svelte.js +13 -0
  75. package/dist/selection.d.ts +2 -1
  76. package/dist/selection.js +133 -8
  77. package/dist/spreadsheet.d.ts +1 -1
  78. package/dist/spreadsheet.js +1 -1
  79. package/package.json +1 -1
  80. package/src/GridMenus.svelte +201 -18
  81. package/src/SvAutoComplete.svelte +4 -0
  82. package/src/SvComboBox.svelte +5 -1
  83. package/src/SvDateRangeInput.svelte +25 -3
  84. package/src/SvDropDownList.svelte +4 -0
  85. package/src/SvForm.svelte +8 -8
  86. package/src/SvGrid.controller.svelte.ts +404 -133
  87. package/src/SvGrid.css +222 -1
  88. package/src/SvGrid.helpers.ts +87 -0
  89. package/src/SvGrid.svelte +255 -56
  90. package/src/SvGrid.types.ts +153 -1
  91. package/src/SvGridCellEditor.svelte +99 -17
  92. package/src/SvGridSelect.svelte +6 -2
  93. package/src/SvListBox.svelte +8 -0
  94. package/src/SvMultiSelect.svelte +6 -2
  95. package/src/SvNumberInput.svelte +4 -0
  96. package/src/SvRichCell.svelte +125 -0
  97. package/src/SvTextArea.svelte +5 -1
  98. package/src/SvTreeSelect.svelte +6 -2
  99. package/src/builtin-editors.test.ts +18 -1
  100. package/src/builtin-editors.ts +19 -3
  101. package/src/cell-values.ts +30 -0
  102. package/src/clipboard.test.ts +93 -20
  103. package/src/clipboard.ts +433 -33
  104. package/src/column-groups.ts +1 -1
  105. package/src/column-resize.test.ts +381 -0
  106. package/src/column-resize.ts +227 -0
  107. package/src/columns.test.ts +0 -103
  108. package/src/columns.ts +0 -58
  109. package/src/core.aggregate.test.ts +134 -0
  110. package/src/core.filter.test.ts +156 -0
  111. package/src/core.grouping.test.ts +146 -0
  112. package/src/core.row-shape.test.ts +119 -0
  113. package/src/core.rowmodel-cache.test.ts +121 -0
  114. package/src/core.sort.test.ts +293 -0
  115. package/src/core.ts +516 -119
  116. package/src/date-string-column.test.ts +87 -0
  117. package/src/editing.test.ts +25 -0
  118. package/src/editing.ts +12 -9
  119. package/src/editor-block-prop.test.ts +132 -0
  120. package/src/editors/cell-editors.ts +27 -1
  121. package/src/filtering/excel-filters.ts +30 -0
  122. package/src/filtering/normalize-fast-path.test.ts +104 -0
  123. package/src/grid-messages.ts +34 -0
  124. package/src/group-display.ts +1 -1
  125. package/src/index.ts +25 -1
  126. package/src/list-option.ts +6 -0
  127. package/src/menus.test.ts +43 -0
  128. package/src/menus.ts +38 -8
  129. package/src/move-cells.test.ts +850 -0
  130. package/src/number-editor-literal.test.ts +84 -0
  131. package/src/number-editor.grid.test.ts +160 -0
  132. package/src/resize-props.test.ts +361 -0
  133. package/src/row-resize.test.ts +31 -0
  134. package/src/row-resize.ts +21 -3
  135. package/src/sanitize-html.test.ts +182 -0
  136. package/src/sanitize-html.ts +235 -0
  137. package/src/selection-bar-view.svelte.ts +36 -0
  138. package/src/selection.test.ts +157 -0
  139. package/src/selection.ts +135 -8
  140. package/src/spreadsheet.ts +1 -1
  141. package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
  142. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  143. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  144. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  145. package/src/svgrid.upsell-license.test.ts +117 -0
  146. package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
  147. package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
  148. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  149. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  150. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  151. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
package/src/selection.ts CHANGED
@@ -242,7 +242,23 @@ export function createSelection<
242
242
  * that contains the cell are returned (active range checked last so its
243
243
  * outline wins on overlap). Returns null when the cell is in no range.
244
244
  */
245
+ /** Could `getSelectionRects()` return anything? An anchored active range or
246
+ * a committed one is the only way a rect can exist, and both are plain
247
+ * property reads - so this is the cheap guard in front of anything that
248
+ * would otherwise allocate a rect array to discover there is nothing. */
249
+ function hasAnySelectionRect() {
250
+ const active = ctx.selectionRange as SelectionRange | undefined;
251
+ const committed = ctx.selectionRanges as SelectionRange[] | undefined;
252
+ return Boolean(committed?.length) || Boolean(active?.anchor && active?.focus);
253
+ }
254
+
245
255
  function getCellRangeEdges(rowIndex: number, colIndex: number) {
256
+ // Bail before `getSelectionRects()`, which allocates an array and resolves
257
+ // every range. This runs once per rendered CELL on every render - about 250
258
+ // times per frame on a default grid - and with nothing selected it did all
259
+ // that work to return null every time.
260
+ if (!hasAnySelectionRect()) return null;
261
+
246
262
  for (const rect of getSelectionRects()) {
247
263
  if (
248
264
  rowIndex < rect.minRow ||
@@ -262,10 +278,40 @@ export function createSelection<
262
278
  return null;
263
279
  }
264
280
 
281
+ /**
282
+ * The rectangle a drag is currently promising, or null when no drag is in
283
+ * flight. A fill grows the source rectangle out to the pointer; a move
284
+ * detaches an identically-sized rectangle and drops it at the offset the
285
+ * pointer has travelled. Both feed the same preview tint and marquee, so a
286
+ * range move looks like the fill handle users already know.
287
+ */
288
+ function dragPreviewRect() {
289
+ const m = ctx.moveDrag;
290
+ if (m) return ctx.moveDestRect(m);
291
+ const d = ctx.fillDrag;
292
+ if (!d) return null;
293
+ return {
294
+ minRow: Math.min(d.sourceMinRow, d.targetRow),
295
+ maxRow: Math.max(d.sourceMaxRow, d.targetRow),
296
+ minCol: Math.min(d.sourceMinCol, d.targetCol),
297
+ maxCol: Math.max(d.sourceMaxCol, d.targetCol),
298
+ };
299
+ }
300
+
265
301
  /** Returns true when the given cell is inside the fill-drag preview
266
302
  * range BUT outside the original source range. Used to paint a
267
303
  * dashed-outline preview while the user is dragging the handle. */
268
304
  function isInFillPreview(rowIndex: number, colIndex: number) {
305
+ if (ctx.moveDrag) {
306
+ const rect = dragPreviewRect();
307
+ if (!rect) return false;
308
+ return (
309
+ rowIndex >= rect.minRow &&
310
+ rowIndex <= rect.maxRow &&
311
+ colIndex >= rect.minCol &&
312
+ colIndex <= rect.maxCol
313
+ );
314
+ }
269
315
  const d = ctx.fillDrag;
270
316
  if (!d) return false;
271
317
  const minR = Math.min(d.sourceMinRow, d.targetRow);
@@ -294,12 +340,12 @@ export function createSelection<
294
340
  * border. Returns null when the cell isn't on the rectangle's boundary.
295
341
  */
296
342
  function fillMarqueeEdges(rowIndex: number, colIndex: number) {
297
- const d = ctx.fillDrag;
298
- if (!d) return null;
299
- const minR = Math.min(d.sourceMinRow, d.targetRow);
300
- const maxR = Math.max(d.sourceMaxRow, d.targetRow);
301
- const minC = Math.min(d.sourceMinCol, d.targetCol);
302
- const maxC = Math.max(d.sourceMaxCol, d.targetCol);
343
+ // Cheap bail before the shared rect helper, which allocates: this runs
344
+ // once per rendered cell and no drag is in flight almost all the time.
345
+ if (!ctx.fillDrag && !ctx.moveDrag) return null;
346
+ const rect = dragPreviewRect();
347
+ if (!rect) return null;
348
+ const { minRow: minR, maxRow: maxR, minCol: minC, maxCol: maxC } = rect;
303
349
  if (rowIndex < minR || rowIndex > maxR || colIndex < minC || colIndex > maxC) {
304
350
  return null;
305
351
  }
@@ -326,6 +372,18 @@ export function createSelection<
326
372
  const row = ctx.allRows[rowIndex];
327
373
  const column = ctx.allColumns[colIndex];
328
374
  if (!row || !column || isGroupRow(row)) return;
375
+ // A pointerdown on the outer few pixels of a selection border grabs the
376
+ // range to move it. Checked before anything else, because everything below
377
+ // would first collapse the selection this drag is about to relocate.
378
+ // Returns false unless the pointer really was on the strip, so a click
379
+ // anywhere further inside falls straight through to the old behaviour.
380
+ if (ctx.startMoveDrag(event, rowIndex, colIndex)) {
381
+ // A grab that never moves still ends in a `click` on the cell under the
382
+ // border. Left alone that click would collapse the range the user was
383
+ // clearly reaching for, and on a checkbox column it would flip the value.
384
+ moveGrabSwallowsClick = true;
385
+ return;
386
+ }
329
387
  // NOTE: we no longer bail for checkbox columns. Bailing here meant a
330
388
  // range drag could never START on a checkbox cell. A plain click still
331
389
  // toggles the checkbox via onCellClick (a same-cell click), while a
@@ -360,27 +418,95 @@ export function createSelection<
360
418
  setActiveCell(rowIndex, colIndex);
361
419
  }
362
420
 
363
- function endDragSelection() {
421
+ /** `event` is the window `pointerup` when this comes from the real gesture,
422
+ * and absent when `onWindowPointerMove` calls it to recover from a release
423
+ * it never saw. A range move reads its copy modifier from it, so holding
424
+ * Ctrl between the last movement and the release still copies. */
425
+ function endDragSelection(event?: PointerEvent) {
364
426
  ctx.isDraggingSelection = false;
427
+ // Every drag shares one edge-scroll loop, and it exits on its own once no
428
+ // drag is live - but stopping it here cancels the pending frame rather
429
+ // than letting one more fire after the gesture is over.
430
+ ctx.stopEdgeScroll();
365
431
  // Also commit a fill-handle drag if one was in progress - the user
366
432
  // released the mouse, time to apply the pattern.
367
433
  if (ctx.fillDrag) ctx.onFillPointerUp();
434
+ if (ctx.moveDrag) ctx.onMovePointerUp(event);
368
435
  }
369
436
 
370
437
  function onWindowPointerMove(event: PointerEvent) {
438
+ if (ctx.moveDrag) {
439
+ ctx.onMovePointerMove(event);
440
+ return;
441
+ }
371
442
  if (ctx.fillDrag) {
372
443
  ctx.onFillPointerMove(event);
373
444
  return;
374
445
  }
375
- if (!ctx.isDraggingSelection) return;
446
+ if (!ctx.isDraggingSelection) {
447
+ updateMoveGrabHover(event);
448
+ return;
449
+ }
376
450
  // Safety: if no mouse button is held the drag is over (we may have
377
451
  // missed pointerup because the user lifted outside the window).
378
452
  if (event.buttons === 0) {
379
453
  endDragSelection();
454
+ return;
380
455
  }
456
+ // Drag-select past the viewport edge scrolls the grid, same as the fill
457
+ // and move drags. Without it a range can only ever cover what was already
458
+ // on screen when the drag started. `onCellPointerEnter` keeps extending
459
+ // the range for cells the pointer actually crosses; this covers the case
460
+ // where the pointer has stopped at the edge and the CELLS are moving.
461
+ ctx.trackEdgeScroll(event);
381
462
  }
382
463
 
464
+ /**
465
+ * Switch the grid to a move cursor while the pointer sits on the grab strip
466
+ * of a selection border - without it the feature is invisible, since the
467
+ * border is painted, not a hit target.
468
+ *
469
+ * This runs on every window pointermove, so the order of the guards is the
470
+ * point: `moveCellsEffective` and the already-hovering flag are plain
471
+ * property reads, and `isOnMoveGrabStrip` bails on `getCellRangeEdges`
472
+ * before measuring anything when no range is selected.
473
+ */
474
+ function updateMoveGrabHover(event: PointerEvent) {
475
+ const wasHovering = ctx.moveGrabHover;
476
+ // Two property reads before anything touches the DOM. `closest()` walks
477
+ // ancestors on EVERY pointer move over the page, so it has to sit behind
478
+ // both "the feature is on" and "there is a range to grab".
479
+ if (!ctx.moveCellsEffective || !hasAnySelectionRect()) {
480
+ if (wasHovering) ctx.moveGrabHover = false;
481
+ return;
482
+ }
483
+ const target = event.target as HTMLElement | null;
484
+ const cell = target?.closest?.(
485
+ "td[data-svgrid-row][data-svgrid-col]",
486
+ ) as HTMLElement | null;
487
+ let hovering = false;
488
+ if (cell) {
489
+ const r = Number(cell.dataset.svgridRow);
490
+ const c = Number(cell.dataset.svgridCol);
491
+ if (Number.isFinite(r) && Number.isFinite(c)) {
492
+ hovering = ctx.isOnMoveGrabStrip(cell, r, c, event.clientX, event.clientY);
493
+ }
494
+ }
495
+ if (hovering !== wasHovering) ctx.moveGrabHover = hovering;
496
+ }
497
+
498
+ /** Set when a pointerdown started a range move, so the `click` that
499
+ * follows a zero-distance grab is discarded instead of collapsing the
500
+ * selection it grabbed. One gesture, one flag - cleared on the next click
501
+ * whether or not the grab turned into a real drag. */
502
+ let moveGrabSwallowsClick = false;
503
+
383
504
  function onCellClick(rowIndex: number, colIndex: number) {
505
+ if (moveGrabSwallowsClick) {
506
+ moveGrabSwallowsClick = false;
507
+ ctx.gridRootEl?.focus({ preventScroll: true });
508
+ return;
509
+ }
384
510
  const row = ctx.allRows[rowIndex];
385
511
  const column = ctx.allColumns[colIndex];
386
512
  if (!row || !column) return;
@@ -480,6 +606,7 @@ export function createSelection<
480
606
  fillMarqueeEdges,
481
607
  getSelectionRects,
482
608
  isInFillPreview,
609
+ dragPreviewRect,
483
610
  findColumnById,
484
611
  onCellPointerDown,
485
612
  onCellPointerEnter,
@@ -57,7 +57,7 @@ export type SpanColumn<TData = Record<string, unknown>> = {
57
57
  /**
58
58
  * Turn declarative per-column `colSpan` / `rowSpan` callbacks into a
59
59
  * `MergeSpec[]` you can hand to `spreadsheetLayout` - so value-driven,
60
- * AG-Grid-style spanning runs on the SAME real colspan/rowspan merge engine
60
+ * Declarative spanning runs on the SAME real colspan/rowspan merge engine
61
61
  * instead of a second code path. Recompute after sort/filter (indexes are
62
62
  * display-row indexes). A common pattern is "merge runs of equal values":
63
63
  *
@@ -79,8 +79,10 @@ function mountGrid(data: Row[]) {
79
79
  }
80
80
 
81
81
  /** Open the funnel popover on the first column and return the menu element. */
82
- async function openFilterMenu(target: HTMLElement) {
83
- const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
82
+ async function openFilterMenu(target: HTMLElement, columnIndex = 0) {
83
+ const btn = target.querySelectorAll('.sv-grid-col-filter-btn')[
84
+ columnIndex
85
+ ] as HTMLButtonElement
84
86
  btn.click()
85
87
  // GridMenus is a lazy chunk, so poll until the popover mounts AND its value
86
88
  // checklist has rendered.
@@ -110,8 +112,21 @@ async function openFilterMenu(target: HTMLElement) {
110
112
  return target.querySelector('.sv-grid-filter-menu') as HTMLElement
111
113
  }
112
114
 
115
+ // The LABEL only. The row also carries a trailing match count, which these
116
+ // tests are not about - reading the whole row's text would couple them to it.
117
+ const labelOf = (el: Element) =>
118
+ (el.querySelector('.sv-listbox__label') ?? el).textContent?.trim() ?? ''
119
+
113
120
  const optionLabels = (menu: HTMLElement) =>
114
- Array.from(menu.querySelectorAll('[role="option"]')).map((el) => el.textContent?.trim() ?? '')
121
+ Array.from(menu.querySelectorAll('[role="option"]')).map(labelOf)
122
+
123
+ const findOption = (menu: HTMLElement, label: string) => {
124
+ const hit = Array.from(menu.querySelectorAll('[role="option"]')).find(
125
+ (el) => labelOf(el) === label,
126
+ )
127
+ if (!hit) throw new Error(`no option labelled "${label}" in [${optionLabels(menu)}]`)
128
+ return hit as HTMLElement
129
+ }
115
130
 
116
131
  describe('filter menu value checklist', () => {
117
132
  it('windows a high-cardinality column instead of mounting every value', async () => {
@@ -252,9 +267,7 @@ describe('filter menu value checklist', () => {
252
267
  expect(optionLabels(suggest).length).toBeGreaterThan(1)
253
268
 
254
269
  // Pick another: the fragment is consumed, not left beside it.
255
- const pick = Array.from(suggest.querySelectorAll('[role="option"]')).find(
256
- (el) => el.textContent?.trim() === 'SYM00005',
257
- ) as HTMLElement
270
+ const pick = findOption(suggest, 'SYM00005')
258
271
  pick.click()
259
272
  await tick()
260
273
 
@@ -263,7 +276,7 @@ describe('filter menu value checklist', () => {
263
276
  // dropdown has no trailing separator, so anything that mistook the last
264
277
  // token for a half-typed fragment would show it unchecked.
265
278
  const checked = suggest.querySelectorAll('[role="option"][aria-selected="true"]')
266
- expect(Array.from(checked).map((el) => el.textContent?.trim()).sort()).toEqual([
279
+ expect(Array.from(checked).map(labelOf).sort()).toEqual([
267
280
  'SYM00003',
268
281
  'SYM00005',
269
282
  ])
@@ -291,9 +304,7 @@ describe('filter menu value checklist', () => {
291
304
  await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
292
305
  const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
293
306
 
294
- const pick = Array.from(suggest.querySelectorAll('[role="option"]')).find(
295
- (el) => el.textContent?.trim() === 'Aug 17, 2026',
296
- ) as HTMLElement
307
+ const pick = findOption(suggest, 'Aug 17, 2026')
297
308
  pick.click()
298
309
  await tick()
299
310
 
@@ -328,4 +339,39 @@ describe('filter menu value checklist', () => {
328
339
 
329
340
  destroy()
330
341
  })
342
+
343
+ it('shows how many rows each value matches', async () => {
344
+ // 9 rows over teams A/B/C, so three each.
345
+ const { target, destroy } = await mountGrid(makeRows(9))
346
+ await tick()
347
+ const menu = await openFilterMenu(target, 1)
348
+ const rows = Array.from(menu.querySelectorAll('[role="option"]')).map((el) => ({
349
+ label: el.querySelector('.sv-listbox__label')?.textContent?.trim(),
350
+ hint: el.querySelector('.sv-listbox__hint')?.textContent?.trim(),
351
+ }))
352
+ expect(rows).toEqual([
353
+ { label: 'A', hint: '3' },
354
+ { label: 'B', hint: '3' },
355
+ { label: 'C', hint: '3' },
356
+ ])
357
+ destroy()
358
+ })
359
+
360
+ it('offers only the values still reachable under the other columns\' filters', async () => {
361
+ // Symbols SYM00000..2 are teams A, B, C in turn. Filtering Symbol down to
362
+ // one row must leave the Team list showing that row's team alone - the
363
+ // list used to be built from the whole dataset, so it offered every team
364
+ // and two of the three were a one-click route to an empty grid.
365
+ const { target, api, destroy } = await mountGrid(makeRows(9))
366
+ await tick()
367
+ api.setFilter('symbol', { operator: 'equals', value: 'SYM00001' })
368
+ await tick()
369
+ expect(api.getDisplayedRows().length).toBe(1)
370
+
371
+ const menu = await openFilterMenu(target, 1)
372
+ const labels = optionLabels(menu)
373
+ expect(labels).toEqual(['B'])
374
+
375
+ destroy()
376
+ })
331
377
  })
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The `in` / `notIn` suggestions dropdown shares the facet scan with the
3
+ * filter menu's checklist. That scan now also runs the filter pipeline, to
4
+ * offer only values still reachable under the other columns' filters - which
5
+ * put an O(rows) pass on the typing path of the chip input, because the
6
+ * suggestions re-derive on every keystroke.
7
+ *
8
+ * They are snapshotted per open instead, the same way the menu's checklist is.
9
+ * This pins that: typing must not re-scan.
10
+ */
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { mount, unmount } from 'svelte'
13
+ import SvGrid from './SvGrid.svelte'
14
+ import {
15
+ columnFilteringFeature,
16
+ rowSortingFeature,
17
+ tableFeatures,
18
+ } from './index'
19
+ import type { ColumnDef } from './index'
20
+
21
+ type Row = { id: number; symbol: string }
22
+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
23
+ const tick = () => new Promise<void>((r) => setTimeout(r))
24
+
25
+ describe('in/notIn suggestions', () => {
26
+ it('scans once per open, not once per keystroke', async () => {
27
+ const data: Row[] = Array.from({ length: 40 }, (_, i) => ({
28
+ id: i + 1,
29
+ symbol: `SYM${String(i).padStart(3, '0')}`,
30
+ }))
31
+
32
+ // Count reads of the accessor the facet scan goes through. A per-keystroke
33
+ // re-scan shows up here as another 40 reads per character typed.
34
+ let reads = 0
35
+ const columns: ColumnDef<typeof features, Row>[] = [
36
+ {
37
+ id: 'symbol',
38
+ header: 'Symbol',
39
+ width: 200,
40
+ fieldFn: (row: Row) => {
41
+ reads += 1
42
+ return row.symbol
43
+ },
44
+ },
45
+ ]
46
+
47
+ const target = document.createElement('div')
48
+ document.body.appendChild(target)
49
+ const app = mount(SvGrid<typeof features, Row>, {
50
+ target,
51
+ props: { data, columns, features, filterMode: 'row', height: 300 } as never,
52
+ })
53
+ await tick()
54
+
55
+ // Switch the column to `in`, which is what turns the filter-row box into a
56
+ // chip input with suggestions.
57
+ const operatorBtn = target.querySelector(
58
+ '.sv-grid-filter-operator-btn',
59
+ ) as HTMLButtonElement
60
+ operatorBtn.click()
61
+ await vi.waitFor(() => expect(document.querySelector('.sv-grid-operator-menu')).not.toBeNull())
62
+ const inItem = Array.from(
63
+ document.querySelectorAll('.sv-grid-operator-menu .sv-grid-menu-item'),
64
+ ).find((el) => el.textContent?.trim() === 'In') as HTMLElement
65
+ inItem.click()
66
+ await tick()
67
+
68
+ const input = target.querySelector(
69
+ '.sv-grid-filter-chip-input, .sv-grid-filter-value',
70
+ ) as HTMLInputElement
71
+ expect(input).not.toBeNull()
72
+ input.focus()
73
+ input.dispatchEvent(new Event('focus', { bubbles: true }))
74
+ await vi.waitFor(() => expect(document.querySelector('.sv-grid-in-suggest')).not.toBeNull())
75
+
76
+ const afterOpen = reads
77
+ expect(afterOpen).toBeGreaterThan(0)
78
+
79
+ // Type into the box: the list narrows from the snapshot, no fresh scan.
80
+ for (const text of ['S', 'SY', 'SYM', 'SYM0', 'SYM00']) {
81
+ input.value = text
82
+ input.dispatchEvent(new Event('input', { bubbles: true }))
83
+ await tick()
84
+ }
85
+ expect(document.querySelector('.sv-grid-in-suggest')).not.toBeNull()
86
+ expect(reads).toBe(afterOpen)
87
+
88
+ unmount(app)
89
+ target.remove()
90
+ })
91
+ })
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The scroll-close gate for the CELL CONTEXT MENU and the COMMENT EDITOR.
3
+ *
4
+ * Both are positioned from a one-time cursor measurement and rendered
5
+ * `position: fixed`, exactly like the column / filter / operator menus - but
6
+ * both were missing from the effect that closes those on an outside scroll, so
7
+ * after any scroll they floated over unrelated cells.
8
+ *
9
+ * The comment editor is closed by SAVING, not discarding. Backdrop-click and
10
+ * Escape throw the draft away and that is right, because both are the user
11
+ * saying "away with this". A scroll is not a dismissal gesture, so it must not
12
+ * destroy text the user has typed.
13
+ *
14
+ * The sibling file `svgrid.filter-menu-scroll.test.ts` covers the four menus
15
+ * that were already gated, including the must-NOT-close cases.
16
+ */
17
+ import { describe, expect, it, vi } from 'vitest'
18
+ import { mount, unmount } from 'svelte'
19
+ import SvGrid from './SvGrid.svelte'
20
+ import {
21
+ createCoreRowModel,
22
+ createFilteredRowModel,
23
+ createSortedRowModel,
24
+ rowSortingFeature,
25
+ sortFns,
26
+ tableFeatures,
27
+ } from './index'
28
+ import type { ColumnDef, SvGridApi } from './index'
29
+
30
+ type Row = { id: number; name: string; team: string }
31
+ const features = tableFeatures({ rowSortingFeature })
32
+ const rows: Row[] = Array.from({ length: 6 }, (_, i) => ({
33
+ id: i + 1,
34
+ name: `Person ${i + 1}`,
35
+ team: ['A', 'B'][i % 2]!,
36
+ }))
37
+ const cols: ColumnDef<typeof features, Row>[] = [
38
+ { field: 'name', header: 'Name', width: 200, editorType: 'text' },
39
+ { field: 'team', header: 'Team', width: 160, editorType: 'text' },
40
+ ]
41
+
42
+ /** Macrotask boundary - drains the overlay's lazy import() plus its flush. */
43
+ const tick = () => new Promise<void>((r) => setTimeout(r))
44
+
45
+ function mountGrid(extra: Record<string, unknown> = {}) {
46
+ return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>(
47
+ (res, rej) => {
48
+ const target = document.createElement('div')
49
+ document.body.appendChild(target)
50
+ const app = mount(SvGrid, {
51
+ target,
52
+ props: {
53
+ data: rows,
54
+ columns: cols,
55
+ features,
56
+ _rowModels: {
57
+ coreRowModel: createCoreRowModel(),
58
+ filteredRowModel: createFilteredRowModel(),
59
+ sortedRowModel: createSortedRowModel(sortFns),
60
+ },
61
+ rowHeight: 32,
62
+ containerHeight: 240,
63
+ virtualization: false,
64
+ // `comment` is not one of the default items - ask for it by id so
65
+ // the comment editor is reachable at all.
66
+ contextMenu: ['copy', 'comment'],
67
+ onApiReady(api: SvGridApi<typeof features, Row>) {
68
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
69
+ },
70
+ ...extra,
71
+ } as any,
72
+ })
73
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
74
+ },
75
+ )
76
+ }
77
+
78
+ async function openContextMenu(target: HTMLElement) {
79
+ const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
80
+ const r = cell.getBoundingClientRect()
81
+ cell.dispatchEvent(
82
+ new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: r.x + 4, clientY: r.y + 4 }),
83
+ )
84
+ await vi.waitFor(() => {
85
+ const menu = target.querySelector('.sv-grid-context-menu')
86
+ expect(menu).not.toBeNull()
87
+ expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
88
+ })
89
+ return target.querySelector('.sv-grid-context-menu') as HTMLElement
90
+ }
91
+
92
+ /** Right-click, then pick "Edit comment" to get the comment popover open. */
93
+ async function openCommentEditor(target: HTMLElement) {
94
+ const menu = await openContextMenu(target)
95
+ const item = [...menu.querySelectorAll<HTMLElement>('.sv-grid-menu-item')].find((b) =>
96
+ (b.textContent ?? '').includes('Edit comment'),
97
+ )
98
+ expect(item, 'the "Edit comment" item should be in the configured menu').toBeTruthy()
99
+ item!.click()
100
+ await vi.waitFor(() => {
101
+ expect(target.querySelector('.sv-grid-comment-editor')).not.toBeNull()
102
+ })
103
+ return target.querySelector('.sv-grid-comment-editor') as HTMLElement
104
+ }
105
+
106
+ /** Scroll something that is NOT inside any menu. */
107
+ function scrollOutside(target: HTMLElement) {
108
+ const viewport = target.querySelector('.sv-grid-viewport') ?? target
109
+ viewport.dispatchEvent(new Event('scroll'))
110
+ }
111
+
112
+ describe('cell context menu - scroll', () => {
113
+ it('closes when something outside it scrolls', async () => {
114
+ const { target, destroy } = await mountGrid()
115
+ await tick()
116
+ await openContextMenu(target)
117
+
118
+ scrollOutside(target)
119
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-context-menu')).toBeNull())
120
+
121
+ destroy()
122
+ })
123
+
124
+ it('stays open while the menu itself scrolls', async () => {
125
+ const { target, destroy } = await mountGrid()
126
+ await tick()
127
+ const menu = await openContextMenu(target)
128
+
129
+ menu.dispatchEvent(new Event('scroll'))
130
+ await tick()
131
+ expect(target.querySelector('.sv-grid-context-menu')).not.toBeNull()
132
+
133
+ destroy()
134
+ })
135
+
136
+ it('carries the grid id, so a second grid can tell whose menu it is', async () => {
137
+ const { target, destroy } = await mountGrid()
138
+ await tick()
139
+ const menu = await openContextMenu(target)
140
+
141
+ const id = menu.getAttribute('data-svgrid-menu')
142
+ expect(id).toBeTruthy()
143
+ // The same id the grid stamps on its cells, so the two are one grid.
144
+ const cellId = target.querySelector('.sv-grid-cell[id]')?.getAttribute('id')
145
+ expect(cellId).toContain(id!)
146
+
147
+ destroy()
148
+ })
149
+ })
150
+
151
+ describe('comment editor - scroll', () => {
152
+ it('closes when something outside it scrolls', async () => {
153
+ const { target, destroy } = await mountGrid()
154
+ await tick()
155
+ await openCommentEditor(target)
156
+
157
+ scrollOutside(target)
158
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-comment-editor')).toBeNull())
159
+
160
+ destroy()
161
+ })
162
+
163
+ it('SAVES the draft rather than throwing it away', async () => {
164
+ const changes: Array<{ note: string }> = []
165
+ const { target, destroy } = await mountGrid({
166
+ onNoteChange: (e: { note: string }) => changes.push(e),
167
+ })
168
+ await tick()
169
+ const editor = await openCommentEditor(target)
170
+
171
+ const textarea = editor.querySelector('textarea') as HTMLTextAreaElement
172
+ textarea.value = 'half-written thought'
173
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
174
+ await tick()
175
+
176
+ scrollOutside(target)
177
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-comment-editor')).toBeNull())
178
+
179
+ expect(changes.map((c) => c.note)).toContain('half-written thought')
180
+
181
+ destroy()
182
+ })
183
+
184
+ it('still DISCARDS on Escape, which is a real dismissal', async () => {
185
+ const changes: Array<{ note: string }> = []
186
+ const { target, destroy } = await mountGrid({
187
+ onNoteChange: (e: { note: string }) => changes.push(e),
188
+ })
189
+ await tick()
190
+ const editor = await openCommentEditor(target)
191
+
192
+ const textarea = editor.querySelector('textarea') as HTMLTextAreaElement
193
+ textarea.value = 'never mind'
194
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
195
+ await tick()
196
+
197
+ textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
198
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-comment-editor')).toBeNull())
199
+
200
+ expect(changes).toHaveLength(0)
201
+
202
+ destroy()
203
+ })
204
+ })