@svgrid/grid 2.2.28 → 2.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GridMenus.svelte +148 -100
- package/dist/SvGrid.controller.svelte.d.ts +14 -1
- package/dist/SvGrid.controller.svelte.js +112 -28
- package/dist/SvGrid.css +15 -34
- package/dist/SvGrid.svelte +49 -21
- package/dist/SvGrid.types.d.ts +20 -5
- package/dist/SvListBox.svelte +42 -3
- package/dist/SvListBox.svelte.d.ts +10 -1
- package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
- package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
- package/dist/cdn/{src-BxNEslEo.js → src-BLJfdyL0.js} +4014 -3869
- package/dist/cdn/{src-C4yKQZ5t.js → src-DYDCiC0D.js} +7036 -6891
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/column-groups.js +8 -10
- package/dist/column-id.d.ts +9 -0
- package/dist/column-id.js +25 -0
- package/dist/core.js +2 -1
- package/dist/createListbox.svelte.d.ts +15 -2
- package/dist/createListbox.svelte.js +27 -6
- package/dist/editing.d.ts +1 -0
- package/dist/editing.js +45 -10
- package/dist/filtering/excel-filters.d.ts +30 -10
- package/dist/filtering/excel-filters.js +96 -15
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/menus.d.ts +2 -0
- package/dist/menus.js +25 -0
- package/dist/row-resize.js +40 -0
- package/dist/selection.js +1 -1
- package/package.json +1 -1
- package/src/GridMenus.svelte +148 -100
- package/src/SvGrid.controller.svelte.ts +123 -27
- package/src/SvGrid.css +15 -34
- package/src/SvGrid.svelte +49 -21
- package/src/SvGrid.types.ts +20 -5
- package/src/SvListBox.svelte +42 -3
- package/src/column-groups.test.ts +48 -0
- package/src/column-groups.ts +8 -10
- package/src/column-id.ts +30 -0
- package/src/core.ts +2 -1
- package/src/createListbox.svelte.ts +39 -7
- package/src/editing.test.ts +90 -0
- package/src/editing.ts +46 -12
- package/src/filtering/excel-filters.test.ts +44 -1
- package/src/filtering/excel-filters.ts +91 -14
- package/src/index.ts +2 -0
- package/src/menus.test.ts +40 -4
- package/src/menus.ts +27 -0
- package/src/row-resize.test.ts +100 -0
- package/src/row-resize.ts +36 -0
- package/src/selection.ts +1 -1
- package/src/svgrid.api-extensions.test.ts +8 -3
- package/src/svgrid.cell-dom-ids.test.ts +96 -0
- package/src/svgrid.conditional-stat-scope.test.ts +111 -0
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
- package/src/svgrid.filter-menu-scroll.test.ts +3 -1
- package/src/svgrid.group-header-ids.test.ts +121 -0
- package/dist/cdn/GridMenus-BbzEvTYO.js +0 -421
- package/dist/cdn/GridMenus-E220X2kM.js +0 -417
package/src/editing.test.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
256
|
-
oldValue =
|
|
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:
|
|
277
|
+
data: rowData,
|
|
269
278
|
columnId: ctx.editingCell.columnId,
|
|
270
279
|
});
|
|
271
280
|
}
|
|
272
|
-
|
|
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 &&
|
|
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 &&
|
|
310
|
-
const rowIndex = ctx.internalData.indexOf(
|
|
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:
|
|
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
|
|
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.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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 = '
|
|
68
|
+
export const IN_TOKEN_SEP = ', '
|
|
66
69
|
|
|
67
70
|
/**
|
|
68
|
-
* Split a serialised `in` / `notIn` value into its individual tokens.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/src/row-resize.test.ts
CHANGED
|
@@ -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
|
+
})
|