@svgrid/grid 2.2.28 → 2.2.30

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 (60) hide show
  1. package/dist/GridMenus.svelte +148 -100
  2. package/dist/SvGrid.controller.svelte.d.ts +14 -1
  3. package/dist/SvGrid.controller.svelte.js +112 -28
  4. package/dist/SvGrid.css +15 -34
  5. package/dist/SvGrid.svelte +49 -21
  6. package/dist/SvGrid.types.d.ts +20 -5
  7. package/dist/SvListBox.svelte +42 -3
  8. package/dist/SvListBox.svelte.d.ts +10 -1
  9. package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
  10. package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
  11. package/dist/cdn/{src-BxNEslEo.js → src-BLJfdyL0.js} +4014 -3869
  12. package/dist/cdn/{src-C4yKQZ5t.js → src-DYDCiC0D.js} +7036 -6891
  13. package/dist/cdn/svgrid.js +8 -8
  14. package/dist/cdn/svgrid.svelte-external.js +8 -8
  15. package/dist/column-groups.js +8 -10
  16. package/dist/column-id.d.ts +9 -0
  17. package/dist/column-id.js +25 -0
  18. package/dist/core.js +2 -1
  19. package/dist/createListbox.svelte.d.ts +15 -2
  20. package/dist/createListbox.svelte.js +27 -6
  21. package/dist/editing.d.ts +1 -0
  22. package/dist/editing.js +45 -10
  23. package/dist/filtering/excel-filters.d.ts +30 -10
  24. package/dist/filtering/excel-filters.js +96 -15
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +2 -2
  27. package/dist/menus.d.ts +2 -0
  28. package/dist/menus.js +25 -0
  29. package/dist/row-resize.js +40 -0
  30. package/dist/selection.js +1 -1
  31. package/package.json +1 -1
  32. package/src/GridMenus.svelte +148 -100
  33. package/src/SvGrid.controller.svelte.ts +123 -27
  34. package/src/SvGrid.css +15 -34
  35. package/src/SvGrid.svelte +49 -21
  36. package/src/SvGrid.types.ts +20 -5
  37. package/src/SvListBox.svelte +42 -3
  38. package/src/column-groups.test.ts +48 -0
  39. package/src/column-groups.ts +8 -10
  40. package/src/column-id.ts +30 -0
  41. package/src/core.ts +2 -1
  42. package/src/createListbox.svelte.ts +39 -7
  43. package/src/editing.test.ts +90 -0
  44. package/src/editing.ts +46 -12
  45. package/src/filtering/excel-filters.test.ts +44 -1
  46. package/src/filtering/excel-filters.ts +91 -14
  47. package/src/index.ts +2 -0
  48. package/src/menus.test.ts +40 -4
  49. package/src/menus.ts +27 -0
  50. package/src/row-resize.test.ts +100 -0
  51. package/src/row-resize.ts +36 -0
  52. package/src/selection.ts +1 -1
  53. package/src/svgrid.api-extensions.test.ts +8 -3
  54. package/src/svgrid.cell-dom-ids.test.ts +96 -0
  55. package/src/svgrid.conditional-stat-scope.test.ts +111 -0
  56. package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
  57. package/src/svgrid.filter-menu-scroll.test.ts +3 -1
  58. package/src/svgrid.group-header-ids.test.ts +121 -0
  59. package/dist/cdn/GridMenus-BbzEvTYO.js +0 -421
  60. package/dist/cdn/GridMenus-E220X2kM.js +0 -417
@@ -62,6 +62,16 @@ function makeCtx(opts: FakeOptions = {}) {
62
62
  get allRows() {
63
63
  return ctx._rows
64
64
  },
65
+ // The full filtered model. Same rows here (the fake has no pagination);
66
+ // tests that exercise the "row left the model" path override `_rows`
67
+ // alone so the two genuinely diverge.
68
+ get allRowsBeforePagination() {
69
+ return ctx._rowsBeforePagination ?? ctx._rows
70
+ },
71
+ get activeCell() {
72
+ return activeCell
73
+ },
74
+ scrollActiveCellIntoView: () => {},
65
75
  editingEnabled: opts.editingEnabled ?? true,
66
76
  editingCell: null,
67
77
  editorSelectAll: false,
@@ -244,6 +254,9 @@ describe('startEditingWithChar', () => {
244
254
  columnId: 'a',
245
255
  editorType: 'text',
246
256
  value: 'Z',
257
+ // The row's data object, kept so the commit still lands if a filter
258
+ // drops the row from the model mid-edit (#49).
259
+ rowRef: ctx.internalData[0],
247
260
  })
248
261
  expect(ctx.editorSelectAll).toBe(false)
249
262
  expect(ctx.activeCalls).toEqual([[0, 0]])
@@ -595,6 +608,82 @@ describe('onEditorKeyDown', () => {
595
608
  expect(() => ed.onEditorKeyDown(fakeEvent('Escape'))).not.toThrow()
596
609
  expect(ctx.editingCell).toBeNull()
597
610
  })
611
+
612
+ // #45 in the original report: Tab used to commit and then let the browser
613
+ // walk focus out of the grid, because editors stopPropagation() every key
614
+ // and nothing called preventDefault() for Tab.
615
+ it('Tab saves and advances the active cell one column (#48)', () => {
616
+ const { ctx, ed } = editingFor({
617
+ columns: [
618
+ { id: 'a', field: 'a', editorType: 'text' },
619
+ { id: 'b', field: 'b', editorType: 'text' },
620
+ ],
621
+ data: [{ a: 'old', b: 'b0' }],
622
+ })
623
+ ctx.gridRootEl = { focus: vi.fn() }
624
+ ed.onCellDoubleClick(0, 0)
625
+ ctx.editingCell = { ...ctx.editingCell, value: 'new' }
626
+ const ev = fakeEvent('Tab')
627
+ ed.onEditorKeyDown(ev)
628
+ expect(ev.preventDefault).toHaveBeenCalled()
629
+ expect(ctx.internalData[0].a).toBe('new')
630
+ expect(ctx.activeCalls.at(-1)).toEqual([0, 1])
631
+ })
632
+
633
+ it('Shift+Tab moves back a column and wraps to the previous row (#48)', () => {
634
+ const { ctx, ed } = editingFor({
635
+ columns: [
636
+ { id: 'a', field: 'a', editorType: 'text' },
637
+ { id: 'b', field: 'b', editorType: 'text' },
638
+ ],
639
+ data: [
640
+ { a: 'a0', b: 'b0' },
641
+ { a: 'a1', b: 'b1' },
642
+ ],
643
+ })
644
+ ctx.gridRootEl = { focus: vi.fn() }
645
+ ed.onCellDoubleClick(1, 0)
646
+ const ev = { ...fakeEvent('Tab'), shiftKey: true } as unknown as KeyboardEvent
647
+ ;(ev as any).preventDefault = vi.fn()
648
+ ;(ev as any).stopPropagation = vi.fn()
649
+ ed.onEditorKeyDown(ev)
650
+ // Off the left edge -> last column of the row above.
651
+ expect(ctx.activeCalls.at(-1)).toEqual([0, 1])
652
+ })
653
+ })
654
+
655
+ describe('saveEditingCell when the row leaves the row model (#49)', () => {
656
+ it('writes through the captured row when a filter hides it mid-edit', () => {
657
+ const { ctx, ed } = editingFor({
658
+ columns: [{ id: 'a', field: 'a', editorType: 'text' }],
659
+ data: [{ a: 'old' }, { a: 'other' }],
660
+ })
661
+ const target = ctx.internalData[0]
662
+ ed.onCellDoubleClick(0, 0)
663
+ ctx.editingCell = { ...ctx.editingCell, value: 'typed' }
664
+ // A filter typed into the filter row drops the edited row from BOTH the
665
+ // page slice and the full filtered model while the editor is still open.
666
+ ctx._rows = ctx._rows.slice(1)
667
+ ctx._rowsBeforePagination = ctx._rows
668
+ ed.saveEditingCell()
669
+ expect(target.a).toBe('typed')
670
+ expect(ctx.editingCell).toBeNull()
671
+ })
672
+
673
+ it('still resolves a row that is only off the current page', () => {
674
+ const { ctx, ed } = editingFor({
675
+ columns: [{ id: 'a', field: 'a', editorType: 'text' }],
676
+ data: [{ a: 'old' }, { a: 'other' }],
677
+ })
678
+ const target = ctx.internalData[0]
679
+ ed.onCellDoubleClick(0, 0)
680
+ ctx.editingCell = { ...ctx.editingCell, value: 'typed' }
681
+ // Paginated away: gone from `allRows`, still in the full filtered model.
682
+ ctx._rowsBeforePagination = ctx._rows
683
+ ctx._rows = ctx._rows.slice(1)
684
+ ed.saveEditingCell()
685
+ expect(target.a).toBe('typed')
686
+ })
598
687
  })
599
688
 
600
689
  describe('focusOnMount', () => {
@@ -667,6 +756,7 @@ describe('onCellDoubleClick', () => {
667
756
  columnId: 'a',
668
757
  editorType: 'text',
669
758
  value: 'val',
759
+ rowRef: ctx.internalData[0],
670
760
  })
671
761
  expect(ctx.activeCalls).toEqual([[0, 0]])
672
762
  expect(ctx.selectionCalls).toEqual([[0, 0]])
package/src/editing.ts CHANGED
@@ -231,6 +231,7 @@ export function createEditing<
231
231
  columnId: column.id,
232
232
  editorType,
233
233
  value: char,
234
+ rowRef: row.original,
234
235
  };
235
236
  ctx.setActiveCell(rowIndex, colIndex);
236
237
  ctx.setSelection(rowIndex, colIndex);
@@ -239,7 +240,17 @@ export function createEditing<
239
240
 
240
241
  function saveEditingCell() {
241
242
  if (!ctx.editingCell) return;
242
- const row = ctx.allRows.find((entry: any) => entry.id === ctx.editingCell?.rowId);
243
+ const editing = ctx.editingCell;
244
+ // Resolve the row this edit belongs to. `allRows` is only the current page,
245
+ // so fall back to the full filtered model, then to the data object captured
246
+ // when the edit started. Without that last hop an edit is silently dropped
247
+ // when a filter typed mid-edit hides the row from both models (#49).
248
+ const row =
249
+ ctx.allRows.find((entry: any) => entry.id === editing.rowId) ??
250
+ ctx.allRowsBeforePagination.find((entry: any) => entry.id === editing.rowId);
251
+ const rowData = (row?.original ?? editing.rowRef) as
252
+ | Record<string, unknown>
253
+ | undefined;
243
254
  const column = ctx.allColumns.find(
244
255
  (entry: any) => entry.id === ctx.editingCell?.columnId,
245
256
  );
@@ -252,10 +263,8 @@ export function createEditing<
252
263
  );
253
264
  let oldValue: unknown = undefined;
254
265
  let finalValue: unknown = parsedValue;
255
- if (row?.original && column?.columnDef.field) {
256
- oldValue = (row.original as Record<string, unknown>)[
257
- column.columnDef.field
258
- ];
266
+ if (rowData && column?.columnDef.field) {
267
+ oldValue = rowData[column.columnDef.field];
259
268
  // Per-column valueParser refines the type-coerced value before storing.
260
269
  const parser = (
261
270
  column.columnDef as { valueParser?: (p: unknown) => unknown }
@@ -265,12 +274,11 @@ export function createEditing<
265
274
  newValue: parsedValue,
266
275
  oldValue,
267
276
  rawInput: ctx.editingCell.value,
268
- data: row.original,
277
+ data: rowData,
269
278
  columnId: ctx.editingCell.columnId,
270
279
  });
271
280
  }
272
- (row.original as Record<string, unknown>)[column.columnDef.field] =
273
- finalValue;
281
+ rowData[column.columnDef.field] = finalValue;
274
282
  }
275
283
  const key = getCellKey(ctx.editingCell.rowId, ctx.editingCell.columnId);
276
284
  ctx.editedCellValues = {
@@ -281,7 +289,7 @@ export function createEditing<
281
289
  // Record into the history at the current pointer. Any forward
282
290
  // history (steps the user could have redone) is truncated - this
283
291
  // is the standard "edit invalidates redo" rule.
284
- if (oldValue !== finalValue && row?.original && column?.columnDef.field) {
292
+ if (oldValue !== finalValue && rowData && column?.columnDef.field) {
285
293
  const step: HistoryStep = {
286
294
  rowId: ctx.editingCell.rowId,
287
295
  columnId: ctx.editingCell.columnId,
@@ -306,14 +314,14 @@ export function createEditing<
306
314
  // Notify the consumer AFTER the row has been updated so any callback-
307
315
  // driven recompute (cascade totals, server save, undo stack) sees the
308
316
  // post-write state. `rowIndex` matches the position in `props.data`.
309
- if (ctx.props.onCellValueChange && row?.original && column) {
310
- const rowIndex = ctx.internalData.indexOf(row.original as TData);
317
+ if (ctx.props.onCellValueChange && rowData && column) {
318
+ const rowIndex = ctx.internalData.indexOf(rowData as TData);
311
319
  ctx.props.onCellValueChange({
312
320
  rowIndex,
313
321
  columnId: column.id,
314
322
  oldValue,
315
323
  newValue: finalValue,
316
- row: row.original as TData,
324
+ row: rowData as TData,
317
325
  });
318
326
  }
319
327
  ctx.editingCell = null;
@@ -346,12 +354,36 @@ export function createEditing<
346
354
  ctx.editingCell = ctx.editingCell ? { ...ctx.editingCell, value } : ctx.editingCell;
347
355
  }
348
356
 
357
+ /**
358
+ * Commit the open editor and move the active cell one column along, wrapping
359
+ * at row boundaries the way Excel does. Editors `stopPropagation()` every key
360
+ * so the grid's own Tab handler never sees this one - without the explicit
361
+ * move, Tab committed the edit and then let the browser walk focus out of the
362
+ * grid entirely (#48). Shared by every editor type, including the textarea.
363
+ */
364
+ function commitAndMoveByTab(shiftKey: boolean) {
365
+ const active = ctx.activeCell;
366
+ saveEditingCell();
367
+ ctx.gridRootEl?.focus({ preventScroll: true });
368
+ if (!active) return;
369
+ const next = getNextActiveCell(active, shiftKey ? "tabPrev" : "tabNext", {
370
+ maxRow: ctx.allRows.length - 1,
371
+ maxCol: ctx.allColumns.length - 1,
372
+ });
373
+ ctx.setActiveCell(next.rowIndex, next.colIndex);
374
+ ctx.setSelection(next.rowIndex, next.colIndex);
375
+ ctx.scrollActiveCellIntoView(next.rowIndex, next.colIndex);
376
+ }
377
+
349
378
  function onEditorKeyDown(event: KeyboardEvent) {
350
379
  event.stopPropagation();
351
380
  if (event.key === "Enter") {
352
381
  event.preventDefault();
353
382
  saveEditingCell();
354
383
  ctx.gridRootEl?.focus({ preventScroll: true });
384
+ } else if (event.key === "Tab") {
385
+ event.preventDefault();
386
+ commitAndMoveByTab(event.shiftKey);
355
387
  } else if (event.key === "Escape") {
356
388
  event.preventDefault();
357
389
  ctx.editingCell = null;
@@ -412,6 +444,7 @@ export function createEditing<
412
444
  columnId: column.id,
413
445
  editorType,
414
446
  value: initialValue,
447
+ rowRef: row.original,
415
448
  };
416
449
  ctx.setActiveCell(rowIndex, colIndex);
417
450
  ctx.setSelection(rowIndex, colIndex);
@@ -667,6 +700,7 @@ export function createEditing<
667
700
  applyHistoryStep,
668
701
  updateEditingCellValue,
669
702
  onEditorKeyDown,
703
+ commitAndMoveByTab,
670
704
  focusOnMount,
671
705
  onCellDoubleClick,
672
706
  pasteFromClipboard,
@@ -6,6 +6,7 @@ import {
6
6
  applyExcelFilter,
7
7
  splitInTokens,
8
8
  joinInTokens,
9
+ trailingInToken,
9
10
  type ExcelFilter,
10
11
  } from './excel-filters'
11
12
 
@@ -211,6 +212,48 @@ describe('in-token serialization helpers', () => {
211
212
  })
212
213
  it('joinInTokens round-trips through splitInTokens', () => {
213
214
  expect(splitInTokens(joinInTokens(['a', 'b', 'c']))).toEqual(['a', 'b', 'c'])
214
- expect(joinInTokens([' a ', '', 'b'])).toBe('a\nb')
215
+ expect(joinInTokens([' a ', '', 'b'])).toBe('a, b')
216
+ })
217
+ it('joinInTokens survives an <input type="text"> value round-trip', () => {
218
+ // The input value sanitiser strips newlines, so the separator must not be
219
+ // one - otherwise the list comes back as a single run-together token.
220
+ const input = document.createElement('input')
221
+ input.value = joinInTokens(['AAPL', 'MSFT'])
222
+ expect(splitInTokens(input.value)).toEqual(['AAPL', 'MSFT'])
223
+ })
224
+ it('trailingInToken returns the fragment still being typed', () => {
225
+ expect(trailingInToken('AAPL, MS')).toBe('MS')
226
+ expect(trailingInToken('AAPL,MS')).toBe('MS')
227
+ expect(trailingInToken('MS')).toBe('MS')
228
+ expect(trailingInToken('AAPL\nMS')).toBe('MS')
229
+ })
230
+ it('trailingInToken is empty once the fragment is separated off', () => {
231
+ expect(trailingInToken('AAPL, ')).toBe('')
232
+ expect(trailingInToken('AAPL,')).toBe('')
233
+ expect(trailingInToken('AAPL\n')).toBe('')
234
+ expect(trailingInToken('')).toBe('')
235
+ expect(trailingInToken(null)).toBe('')
236
+ })
237
+ it('quotes tokens containing a separator so they survive the round-trip', () => {
238
+ // Facet labels really do contain commas: numeric buckets come from
239
+ // toLocaleString ("1,234 - 5,678") and date buckets use a short month.
240
+ const labels = ['1,234 - 5,678', 'Aug 17, 2026', 'plain']
241
+ expect(splitInTokens(joinInTokens(labels))).toEqual(labels)
242
+ expect(joinInTokens(['Aug 17, 2026'])).toBe('"Aug 17, 2026"')
243
+ })
244
+ it('round-trips a token containing a quote', () => {
245
+ expect(splitInTokens(joinInTokens(['5" pipe']))).toEqual(['5" pipe'])
246
+ expect(splitInTokens(joinInTokens(['a"b, c']))).toEqual(['a"b, c'])
247
+ })
248
+ it('treats a quote mid-token as literal, not as an opening quote', () => {
249
+ expect(splitInTokens('5" pipe, plain')).toEqual(['5" pipe', 'plain'])
250
+ })
251
+ it('keeps inner spacing of a quoted token, trims an unquoted one', () => {
252
+ expect(splitInTokens('" padded ", bare ')).toEqual([' padded ', 'bare'])
253
+ })
254
+ it('trailingInToken understands quoted fragments', () => {
255
+ expect(trailingInToken('AAPL, "Aug 17, 20')).toBe('Aug 17, 20')
256
+ expect(trailingInToken('AAPL, "Aug 17, 2026"')).toBe('Aug 17, 2026')
257
+ expect(trailingInToken('"Aug 17, 2026", ')).toBe('')
215
258
  })
216
259
  })
@@ -57,29 +57,106 @@ export function normalizeForFilter(
57
57
 
58
58
  /**
59
59
  * Delimiter used to serialise the `in` / `notIn` value list into the single
60
- * `value` string that the filter model carries. A newline is used because it
61
- * (unlike a comma) practically never appears inside a typed token, so tokens
62
- * survive the round-trip unescaped. The chip input in the filter row reads and
63
- * writes with the same separator via {@link splitInTokens} / {@link joinInTokens}.
60
+ * `value` string that the filter model carries.
61
+ *
62
+ * This has to be single-line safe: the tool panel and the filter menu show the
63
+ * serialised list in a plain `<input type="text">`, and the HTML input value
64
+ * sanitiser STRIPS newlines - so a newline-joined list came back out of the DOM
65
+ * as one run-together token. `splitInTokens` accepts newlines too, so values
66
+ * serialised by older builds still parse.
64
67
  */
65
- export const IN_TOKEN_SEP = '\n'
68
+ export const IN_TOKEN_SEP = ', '
66
69
 
67
70
  /**
68
- * Split a serialised `in` / `notIn` value into its individual tokens. Accepts
69
- * both the newline separator the chip input writes and a plain comma-separated
70
- * string (what a raw text field, e.g. the tool-panel filter, produces), so the
71
- * same value round-trips through either UI.
71
+ * Split a serialised `in` / `notIn` value into its individual tokens.
72
+ *
73
+ * The grammar is CSV-shaped: comma OR newline separates, and a token may be
74
+ * double-quoted to carry a separator literally (`""` escapes a quote inside
75
+ * one). Quoting is what makes a comma separator safe - facet values commonly
76
+ * contain commas of their own, because numeric bucket labels are built with
77
+ * `toLocaleString` ("1,234 - 5,678") and date labels with a short month
78
+ * ("Aug 17, 2026"). A quote only opens a token when it is the token's first
79
+ * non-space character, so an unquoted `5" pipe` stays literal.
72
80
  */
73
81
  export function splitInTokens(value: unknown): string[] {
74
- return String(value ?? '')
75
- .split(/[\n,]/)
82
+ const { tokens, trailing } = scanInTokens(value)
83
+ if (trailing) tokens.push(trailing)
84
+ return tokens
85
+ }
86
+
87
+ /**
88
+ * Serialise a list of tokens back into the single `in` / `notIn` value,
89
+ * quoting any token that carries a separator or a quote of its own.
90
+ */
91
+ export function joinInTokens(tokens: ReadonlyArray<string>): string {
92
+ return tokens
76
93
  .map((t) => t.trim())
77
94
  .filter((t) => t.length > 0)
95
+ .map((t) => (/["\n,]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t))
96
+ .join(IN_TOKEN_SEP)
78
97
  }
79
98
 
80
- /** Serialise a list of tokens back into the single `in` / `notIn` value. */
81
- export function joinInTokens(tokens: ReadonlyArray<string>): string {
82
- return tokens.map((t) => t.trim()).filter((t) => t.length > 0).join(IN_TOKEN_SEP)
99
+ /**
100
+ * The token still being typed at the end of an `in` / `notIn` value - the text
101
+ * after the last separator. `'AAPL, MS'` -> `'MS'`; a value ending in a
102
+ * separator (or empty) has no trailing token.
103
+ *
104
+ * Inputs that hold the whole token list use this to drive the value-suggestion
105
+ * dropdown: the trailing fragment is the search query, not a committed token.
106
+ */
107
+ export function trailingInToken(value: unknown): string {
108
+ return scanInTokens(value).trailing
109
+ }
110
+
111
+ /**
112
+ * One pass over a serialised `in` / `notIn` value, splitting it into the
113
+ * tokens that are terminated by a separator and the unterminated fragment at
114
+ * the end. Every token helper above is a view onto this, so they can never
115
+ * disagree about where a quoted token starts or ends.
116
+ */
117
+ function scanInTokens(value: unknown): { tokens: string[]; trailing: string } {
118
+ const raw = String(value ?? '')
119
+ const tokens: string[] = []
120
+ let buf = ''
121
+ let quoted = false
122
+ let wasQuoted = false
123
+ let closed = false
124
+
125
+ // A quoted token keeps its inner spacing; an unquoted one is trimmed.
126
+ const take = () => {
127
+ const t = wasQuoted ? buf : buf.trim()
128
+ buf = ''
129
+ wasQuoted = false
130
+ closed = false
131
+ return t
132
+ }
133
+
134
+ for (let i = 0; i < raw.length; i += 1) {
135
+ const ch = raw[i]
136
+ if (quoted) {
137
+ if (ch !== '"') { buf += ch; continue }
138
+ // A doubled quote is an escaped literal quote, not the closing one.
139
+ if (raw[i + 1] === '"') { buf += '"'; i += 1; continue }
140
+ quoted = false
141
+ closed = true
142
+ continue
143
+ }
144
+ if (ch === ',' || ch === '\n') {
145
+ const t = take()
146
+ if (t.length) tokens.push(t)
147
+ continue
148
+ }
149
+ // Only opens a quoted token at the token's start - `5" pipe` is literal.
150
+ if (ch === '"' && !closed && !buf.trim()) {
151
+ quoted = true
152
+ wasQuoted = true
153
+ buf = ''
154
+ continue
155
+ }
156
+ buf += ch
157
+ }
158
+
159
+ return { tokens, trailing: take() }
83
160
  }
84
161
 
85
162
  export function applyExcelFilter(
package/src/index.ts CHANGED
@@ -254,6 +254,7 @@ export { default as SvField } from './SvField.svelte'
254
254
  export {
255
255
  createListbox,
256
256
  toSelectedArray,
257
+ toSelectedSet,
257
258
  type Listbox,
258
259
  type ListboxConfig,
259
260
  type ListboxValue,
@@ -692,6 +693,7 @@ export {
692
693
  normalizeForFilter,
693
694
  splitInTokens,
694
695
  joinInTokens,
696
+ trailingInToken,
695
697
  type ExcelFilter,
696
698
  type ExcelFilterOperator,
697
699
  type ExcelFilterOptions,
package/src/menus.test.ts CHANGED
@@ -98,23 +98,24 @@ describe('createMenus - filter row/menu value updates', () => {
98
98
  expect(ctx.filterRowValues.name).toBe('foo')
99
99
  })
100
100
 
101
- it('addFilterToken builds a newline-serialised in/notIn list, ignoring dupes/blanks', () => {
101
+ it('addFilterToken builds a comma-serialised in/notIn list, ignoring dupes/blanks', () => {
102
102
  const ctx = makeCtx()
103
103
  const m = createMenus(ctx)
104
104
  m.addFilterToken('symbol', 'TSM')
105
105
  m.addFilterToken('symbol', 'BP')
106
106
  m.addFilterToken('symbol', 'TSM') // dupe - ignored
107
107
  m.addFilterToken('symbol', ' ') // blank - ignored
108
- expect(ctx.filterRowValues.symbol).toBe('TSM\nBP')
108
+ expect(ctx.filterRowValues.symbol).toBe('TSM, BP')
109
109
  // mirrored into the menu model too, so both filter surfaces agree
110
- expect(ctx.filterMenuValues.symbol.value).toBe('TSM\nBP')
110
+ expect(ctx.filterMenuValues.symbol.value).toBe('TSM, BP')
111
111
  })
112
112
 
113
113
  it('removeFilterToken drops a value from the list', () => {
114
+ // Newline-separated input still parses (values serialised by older builds).
114
115
  const ctx = makeCtx({ filterRowValues: { symbol: 'TSM\nBP\nBABA' } })
115
116
  const m = createMenus(ctx)
116
117
  m.removeFilterToken('symbol', 'BP')
117
- expect(ctx.filterRowValues.symbol).toBe('TSM\nBABA')
118
+ expect(ctx.filterRowValues.symbol).toBe('TSM, BABA')
118
119
  })
119
120
 
120
121
  it('toggleFilterToken adds when absent and removes when present', () => {
@@ -126,6 +127,15 @@ describe('createMenus - filter row/menu value updates', () => {
126
127
  expect(ctx.filterRowValues.symbol).toBe('')
127
128
  })
128
129
 
130
+ it('setFilterTokens replaces the whole list, dropping blanks', () => {
131
+ const ctx = makeCtx()
132
+ const m = createMenus(ctx)
133
+ m.setFilterTokens('symbol', ['BP', ' ', ' TSM '])
134
+ expect(ctx.filterRowValues.symbol).toBe('BP, TSM')
135
+ m.setFilterTokens('symbol', new Set<string>())
136
+ expect(ctx.filterRowValues.symbol).toBe('')
137
+ })
138
+
129
139
  it('openInSuggest anchors the dropdown and targets the column; closeInSuggest clears it', () => {
130
140
  const ctx = makeCtx()
131
141
  const m = createMenus(ctx)
@@ -367,6 +377,32 @@ describe('createMenus - facet checkboxes', () => {
367
377
  expect(ctx.valueFilters.team).toBeUndefined()
368
378
  })
369
379
 
380
+ it('setFacetSelection stores a subset as-is', () => {
381
+ const ctx = makeCtx({ columnMenuFacetValues: ['A', 'B', 'C'] })
382
+ const m = createMenus(ctx)
383
+ m.setFacetSelection('team', new Set(['B', 'C']))
384
+ expect([...ctx.valueFilters.team].sort()).toEqual(['B', 'C'])
385
+ })
386
+
387
+ it('setFacetSelection with everything selected drops the entry', () => {
388
+ const ctx = makeCtx({
389
+ columnMenuFacetValues: ['A', 'B'],
390
+ valueFilters: { team: new Set(['A']) },
391
+ })
392
+ const m = createMenus(ctx)
393
+ m.setFacetSelection('team', new Set(['A', 'B']))
394
+ expect(ctx.valueFilters.team).toBeUndefined()
395
+ })
396
+
397
+ it('setFacetSelection copies the set it is handed', () => {
398
+ const ctx = makeCtx({ columnMenuFacetValues: ['A', 'B', 'C'] })
399
+ const m = createMenus(ctx)
400
+ const incoming = new Set(['A'])
401
+ m.setFacetSelection('team', incoming)
402
+ incoming.add('B')
403
+ expect([...ctx.valueFilters.team]).toEqual(['A'])
404
+ })
405
+
370
406
  it('isAllFacetsChecked true with no filter, false with a subset', () => {
371
407
  const ctx = makeCtx({
372
408
  columnMenuFacetValues: ['A', 'B'],
package/src/menus.ts CHANGED
@@ -209,6 +209,16 @@ export function createMenus<
209
209
  else addFilterToken(columnId, token);
210
210
  }
211
211
 
212
+ /**
213
+ * Replace a column's `in` / `notIn` list wholesale. The suggestions dropdown
214
+ * hands back the full token set after a click, which keeps tokens the user
215
+ * typed by hand (or that the search box is currently hiding) intact.
216
+ */
217
+ function setFilterTokens(columnId: string, tokens: Iterable<string>) {
218
+ const list = [...tokens].map((t) => t.trim()).filter(Boolean);
219
+ updateFilterRow(columnId, joinInTokens(list));
220
+ }
221
+
212
222
  function toggleCheckboxWithKeyboard(
213
223
  event: KeyboardEvent,
214
224
  toggle: () => void,
@@ -381,6 +391,21 @@ export function createMenus<
381
391
  }
382
392
  }
383
393
 
394
+ /**
395
+ * Replace a column's checked-facet set. "Everything checked" is stored as an
396
+ * ABSENT entry (so an unfiltered high-cardinality column never holds a set of
397
+ * every value), which is why the full-size case deletes instead of writing.
398
+ */
399
+ function setFacetSelection(columnId: string, next: ReadonlySet<string>) {
400
+ if (next.size >= ctx.columnMenuFacetValues.length) {
401
+ const copy = { ...ctx.valueFilters };
402
+ delete copy[columnId];
403
+ ctx.valueFilters = copy;
404
+ return;
405
+ }
406
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set(next) };
407
+ }
408
+
384
409
  function isAllFacetsChecked(columnId: string) {
385
410
  const selected = ctx.valueFilters[columnId];
386
411
  return !selected || selected.size >= ctx.columnMenuFacetValues.length;
@@ -636,6 +661,8 @@ export function createMenus<
636
661
  clearGroupingFromMenu,
637
662
  isFacetChecked,
638
663
  toggleFacetValue,
664
+ setFacetSelection,
665
+ setFilterTokens,
639
666
  isAllFacetsChecked,
640
667
  toggleAllFacets,
641
668
  clearColumnFilter,
@@ -392,3 +392,103 @@ describe('rowResize - update + destroy', () => {
392
392
  action.destroy()
393
393
  })
394
394
  })
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Keyboard resize (#79)
398
+ // ---------------------------------------------------------------------------
399
+
400
+ describe('rowResize - keyboard', () => {
401
+ function key(k: string, init: Partial<KeyboardEvent> = {}): KeyboardEvent {
402
+ return new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init })
403
+ }
404
+
405
+ it('makes the strip focusable', () => {
406
+ const { host, rows } = buildGrid([{}])
407
+ track(host)
408
+ const action = rowResize(host, { onResize: vi.fn() })
409
+ expect(stripOf(rows[0]!)!.tabIndex).toBe(0)
410
+ action.destroy()
411
+ })
412
+
413
+ it('ArrowDown grows the row by 10px and reports the new height', () => {
414
+ const { host, rows } = buildGrid([{ rowIndex: 0 }])
415
+ track(host)
416
+ mockRowHeight(rows[0]!, 32)
417
+ const onResize = vi.fn()
418
+ const action = rowResize(host, { onResize })
419
+ stripOf(rows[0]!)!.dispatchEvent(key('ArrowDown'))
420
+ expect(rows[0]!.style.height).toBe('42px')
421
+ expect(onResize).toHaveBeenCalledWith(0, 42)
422
+ action.destroy()
423
+ })
424
+
425
+ it('ArrowUp shrinks the row and Shift gives a 1px step', () => {
426
+ const { host, rows } = buildGrid([{ rowIndex: 0 }])
427
+ track(host)
428
+ mockRowHeight(rows[0]!, 60)
429
+ const onResize = vi.fn()
430
+ const action = rowResize(host, { onResize })
431
+ const strip = stripOf(rows[0]!)!
432
+ strip.dispatchEvent(key('ArrowUp'))
433
+ expect(onResize).toHaveBeenLastCalledWith(0, 50)
434
+ strip.dispatchEvent(key('ArrowUp', { shiftKey: true }))
435
+ expect(onResize).toHaveBeenLastCalledWith(0, 59)
436
+ action.destroy()
437
+ })
438
+
439
+ it('clamps to the configured min and max', () => {
440
+ const { host, rows } = buildGrid([{ rowIndex: 0 }])
441
+ track(host)
442
+ mockRowHeight(rows[0]!, 42)
443
+ const onResize = vi.fn()
444
+ const action = rowResize(host, { onResize, min: 40, max: 44 })
445
+ const strip = stripOf(rows[0]!)!
446
+ strip.dispatchEvent(key('ArrowUp'))
447
+ expect(onResize).toHaveBeenLastCalledWith(0, 40)
448
+ strip.dispatchEvent(key('ArrowDown'))
449
+ expect(onResize).toHaveBeenLastCalledWith(0, 44)
450
+ action.destroy()
451
+ })
452
+
453
+ it('ignores keys other than the arrows and consumes the ones it handles', () => {
454
+ const { host, rows } = buildGrid([{ rowIndex: 0 }])
455
+ track(host)
456
+ mockRowHeight(rows[0]!, 32)
457
+ const onResize = vi.fn()
458
+ const action = rowResize(host, { onResize })
459
+ const strip = stripOf(rows[0]!)!
460
+ const other = key('Enter')
461
+ strip.dispatchEvent(other)
462
+ expect(onResize).not.toHaveBeenCalled()
463
+ expect(other.defaultPrevented).toBe(false)
464
+ const handled = key('ArrowDown')
465
+ strip.dispatchEvent(handled)
466
+ expect(handled.defaultPrevented).toBe(true)
467
+ action.destroy()
468
+ })
469
+
470
+ it('does nothing while disabled, and stops after destroy', () => {
471
+ const { host, rows } = buildGrid([{ rowIndex: 0 }])
472
+ track(host)
473
+ mockRowHeight(rows[0]!, 32)
474
+ const onResize = vi.fn()
475
+ const action = rowResize(host, { onResize, disabled: true })
476
+ // Disabled removes the strips entirely, so drive a hand-made one.
477
+ const strip = document.createElement('div')
478
+ strip.className = STRIP_CLASS
479
+ gutterOf(rows[0]!).appendChild(strip)
480
+ strip.dispatchEvent(key('ArrowDown'))
481
+ expect(onResize).not.toHaveBeenCalled()
482
+
483
+ action.update({ onResize })
484
+ stripOf(rows[0]!)!.dispatchEvent(key('ArrowDown'))
485
+ expect(onResize).toHaveBeenCalledTimes(1)
486
+
487
+ action.destroy()
488
+ const orphan = document.createElement('div')
489
+ orphan.className = STRIP_CLASS
490
+ gutterOf(rows[0]!).appendChild(orphan)
491
+ orphan.dispatchEvent(key('ArrowDown'))
492
+ expect(onResize).toHaveBeenCalledTimes(1)
493
+ })
494
+ })