@svgrid/grid 3.0.5 → 3.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -7
- package/dist/GridMenus.svelte +34 -4
- package/dist/SvGrid.controller.svelte.d.ts +23 -3
- package/dist/SvGrid.controller.svelte.js +235 -54
- package/dist/SvGrid.css +143 -7
- package/dist/SvGrid.helpers.js +2 -4
- package/dist/SvGrid.svelte +223 -38
- package/dist/SvGrid.types.d.ts +77 -4
- package/dist/SvGridDropdown.svelte +20 -0
- package/dist/SvGridDropdown.svelte.d.ts +12 -0
- package/dist/SvModal.svelte +22 -0
- package/dist/SvModal.svelte.d.ts +7 -0
- package/dist/a11y/dismissable.d.ts +10 -0
- package/dist/a11y/dismissable.js +24 -2
- package/dist/build-api.js +81 -31
- package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-BgUS-W3o.js} +151 -142
- package/dist/cdn/GridMenus-CcwU8I4t.js +644 -0
- package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
- package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-9yz-SbdZ.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-D7-ql1Hd.js} +2 -2
- package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DMUOFxOe.js} +4 -4
- package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-N7ik0oWy.js} +4 -4
- package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-CB_Jnm2k.js} +4 -4
- package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-Ca7gF8ZK.js} +4 -4
- package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
- package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
- package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-B1LFgoPP.js} +2 -2
- package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-By3n7DGZ.js} +2 -2
- package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
- package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
- package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
- package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
- package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-Mnv84zoE.js} +28 -6
- package/dist/cdn/{chart-Dz7SqMXH.js → chart-By7h2Guv.js} +1 -1
- package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-BC-tOgFY.js} +1 -1
- package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DUaXr-sb.js} +1 -1
- package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-BDtrNEiA.js} +1 -1
- package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-C1aOAPBZ.js} +1 -1
- package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
- package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
- package/dist/cdn/{export-format-CUDLy2yn.js → export-format-y1wdyCcv.js} +93 -97
- package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
- package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
- package/dist/cdn/{src-jcFMkBnN.js → src-25JmJ_7g.js} +4768 -4149
- package/dist/cdn/{src-Bklofsad.js → src-kE9B98r6.js} +4796 -4177
- package/dist/cdn/svgrid.js +18 -18
- package/dist/cdn/svgrid.svelte-external.js +18 -18
- package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
- package/dist/cell-formatting.d.ts +9 -0
- package/dist/cell-formatting.js +52 -1
- package/dist/cell-render.js +25 -6
- package/dist/clipboard.d.ts +2 -2
- package/dist/clipboard.js +72 -23
- package/dist/columns.js +7 -2
- package/dist/conditional-formatting.d.ts +2 -0
- package/dist/conditional-formatting.js +2 -0
- package/dist/core.d.ts +22 -6
- package/dist/core.js +478 -40
- package/dist/editing.js +24 -0
- package/dist/export-format.d.ts +8 -0
- package/dist/export-format.js +23 -16
- package/dist/grid-messages.d.ts +4 -0
- package/dist/grid-messages.js +4 -0
- package/dist/headless.d.ts +5 -0
- package/dist/headless.js +12 -0
- package/dist/history.d.ts +21 -0
- package/dist/history.js +34 -4
- package/dist/index.d.ts +1 -1
- package/dist/keyboard-handlers.js +24 -1
- package/dist/keyboard.js +4 -0
- package/dist/row-drag-touch.d.ts +1 -1
- package/dist/row-drag.js +35 -1
- package/dist/row-model.d.ts +6 -0
- package/dist/row-resize.js +22 -1
- package/dist/selection.js +1 -1
- package/dist/server-block-cache.d.ts +5 -0
- package/dist/server-block-cache.js +8 -0
- package/dist/server-data-source.d.ts +35 -3
- package/dist/server-data-source.js +11 -2
- package/dist/server.d.ts +1 -1
- package/dist/sparkline.js +7 -5
- package/dist/svgrid-wrapper.types.d.ts +33 -0
- package/dist/validate.js +9 -1
- package/dist/virtualization/column-virtualizer.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +3 -0
- package/dist/virtualization/virtualizer.js +14 -1
- package/package.json +6 -1
- package/src/GridMenus.svelte +34 -4
- package/src/SvGrid.controller.svelte.ts +236 -57
- package/src/SvGrid.css +143 -7
- package/src/SvGrid.helpers.ts +2 -4
- package/src/SvGrid.svelte +223 -38
- package/src/SvGrid.types.ts +77 -4
- package/src/SvGridDropdown.svelte +20 -0
- package/src/SvModal.svelte +22 -0
- package/src/SvModal.test.ts +30 -1
- package/src/a11y/dismissable.test.ts +10 -1
- package/src/a11y/dismissable.ts +22 -2
- package/src/build-api.ts +85 -27
- package/src/cell-formatting.test.ts +17 -0
- package/src/cell-formatting.ts +49 -1
- package/src/cell-render.test.ts +24 -2
- package/src/cell-render.ts +24 -8
- package/src/clipboard.test.ts +107 -0
- package/src/clipboard.ts +62 -23
- package/src/columns.test.ts +4 -4
- package/src/columns.ts +7 -2
- package/src/conditional-formatting.test.ts +8 -0
- package/src/conditional-formatting.ts +3 -0
- package/src/core.sort.test.ts +86 -0
- package/src/core.tick-repair.test.ts +287 -0
- package/src/core.ts +478 -40
- package/src/editing.test.ts +52 -0
- package/src/editing.ts +25 -0
- package/src/export-dates.test.ts +114 -0
- package/src/export-format.ts +26 -14
- package/src/grid-messages.test.ts +7 -0
- package/src/grid-messages.ts +10 -0
- package/src/headless.ts +24 -0
- package/src/history.test.ts +49 -1
- package/src/history.ts +48 -2
- package/src/index.ts +2 -0
- package/src/keyboard-handlers.coverage.test.ts +65 -0
- package/src/keyboard-handlers.ts +26 -1
- package/src/keyboard.ts +3 -0
- package/src/row-drag-touch.ts +2 -2
- package/src/row-drag.test.ts +51 -0
- package/src/row-drag.ts +35 -2
- package/src/row-model.ts +6 -0
- package/src/row-resize.ts +24 -1
- package/src/selection.ts +1 -1
- package/src/server-block-cache.test.ts +27 -0
- package/src/server-block-cache.ts +13 -0
- package/src/server-data-source.infinite.test.ts +62 -0
- package/src/server-data-source.test.ts +19 -0
- package/src/server-data-source.ts +48 -4
- package/src/server.ts +2 -0
- package/src/sparkline.test.ts +7 -0
- package/src/sparkline.ts +6 -4
- package/src/svgrid-wrapper.types.ts +32 -0
- package/src/svgrid.api.test.ts +21 -0
- package/src/svgrid.charting.test.ts +12 -4
- package/src/svgrid.context-menu.test.ts +12 -0
- package/src/svgrid.data-push.svelte.test.ts +94 -0
- package/src/svgrid.detail-rows.svelte.test.ts +187 -0
- package/src/svgrid.filter-menu-scroll.test.ts +9 -0
- package/src/svgrid.gantt-seam.test.ts +1 -1
- package/src/svgrid.live-update-paths.svelte.test.ts +186 -0
- package/src/svgrid.menu-scroll-close.test.ts +10 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
- package/src/svgrid.row-pinning.test.ts +15 -1
- package/src/svgrid.selection-bar-seam.test.ts +1 -1
- package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
- package/src/svgrid.upsell-license.test.ts +1 -1
- package/src/test-setup.ts +12 -0
- package/src/transaction.test.ts +58 -0
- package/src/validate.ts +7 -1
- package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
- package/src/virtualization/virtualizer.test.ts +44 -0
- package/src/virtualization/virtualizer.ts +13 -1
- package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
package/src/editing.test.ts
CHANGED
|
@@ -14,6 +14,8 @@ interface ColSpec {
|
|
|
14
14
|
editable?: boolean | ((cellCtx: any) => boolean)
|
|
15
15
|
editorType?: string
|
|
16
16
|
editorMultiple?: boolean
|
|
17
|
+
validate?: (p: { value: unknown; row: any }) => string | boolean | null | undefined
|
|
18
|
+
rejectInvalid?: boolean
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
interface FakeOptions {
|
|
@@ -40,6 +42,8 @@ function makeCtx(opts: FakeOptions = {}) {
|
|
|
40
42
|
editable: c.editable,
|
|
41
43
|
editorType: c.editorType,
|
|
42
44
|
editorMultiple: c.editorMultiple,
|
|
45
|
+
validate: c.validate,
|
|
46
|
+
rejectInvalid: c.rejectInvalid,
|
|
43
47
|
},
|
|
44
48
|
}))
|
|
45
49
|
|
|
@@ -381,6 +385,54 @@ describe('saveEditingCell', () => {
|
|
|
381
385
|
expect(ctx.internalData[0].a).toEqual(['x', 'y'])
|
|
382
386
|
})
|
|
383
387
|
|
|
388
|
+
it('refuses a value the column validates against when rejectInvalid is set', () => {
|
|
389
|
+
const onCellValueChange = vi.fn()
|
|
390
|
+
const { ctx, ed } = editingFor({
|
|
391
|
+
columns: [{
|
|
392
|
+
id: 'b', field: 'b', editorType: 'number', rejectInvalid: true,
|
|
393
|
+
validate: ({ value }) => (Number(value) < 0 ? 'Amount must be positive' : null),
|
|
394
|
+
}],
|
|
395
|
+
data: [{ b: 5 }],
|
|
396
|
+
})
|
|
397
|
+
ctx.props.onCellValueChange = onCellValueChange
|
|
398
|
+
ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '-5' }
|
|
399
|
+
ed.saveEditingCell()
|
|
400
|
+
// The row, the overlay, the history and the consumer are untouched; the
|
|
401
|
+
// editor closes.
|
|
402
|
+
expect(ctx.internalData[0].b).toBe(5)
|
|
403
|
+
expect('0:b' in ctx.editedCellValues).toBe(false)
|
|
404
|
+
expect(ctx.history).toHaveLength(0)
|
|
405
|
+
expect(onCellValueChange).not.toHaveBeenCalled()
|
|
406
|
+
expect(ctx.editingCell).toBeNull()
|
|
407
|
+
|
|
408
|
+
// A value the rule accepts still lands.
|
|
409
|
+
ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '7' }
|
|
410
|
+
ed.saveEditingCell()
|
|
411
|
+
expect(ctx.internalData[0].b).toBe(7)
|
|
412
|
+
expect(onCellValueChange).toHaveBeenCalledTimes(1)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('without rejectInvalid a flagged value lands, as before', () => {
|
|
416
|
+
const { ctx, ed } = editingFor({
|
|
417
|
+
columns: [{ id: 'b', field: 'b', editorType: 'number', validate: () => 'always wrong' }],
|
|
418
|
+
data: [{ b: 5 }],
|
|
419
|
+
})
|
|
420
|
+
ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '-5' }
|
|
421
|
+
ed.saveEditingCell()
|
|
422
|
+
expect(ctx.internalData[0].b).toBe(-5)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('checks the value after valueParser, so the rule sees what would be stored', () => {
|
|
426
|
+
const { ctx, ed } = editingFor({
|
|
427
|
+
columns: [{ id: 'b', field: 'b', editorType: 'number', rejectInvalid: true, validate: ({ value }) => (Number(value) > 100 ? false : null) }],
|
|
428
|
+
data: [{ b: 5 }],
|
|
429
|
+
})
|
|
430
|
+
;(ctx.allColumns[0].columnDef as any).valueParser = ({ newValue }: { newValue: number }) => newValue * 100
|
|
431
|
+
ctx.editingCell = { rowId: '0', columnId: 'b', editorType: 'number', value: '2' }
|
|
432
|
+
ed.saveEditingCell()
|
|
433
|
+
expect(ctx.internalData[0].b).toBe(5) // 200 after the parser, refused
|
|
434
|
+
})
|
|
435
|
+
|
|
384
436
|
it('pushes a history step and bumps the version on a real change', () => {
|
|
385
437
|
const { ctx, ed } = editingFor({
|
|
386
438
|
columns: [{ id: 'a', field: 'a', editorType: 'text' }],
|
package/src/editing.ts
CHANGED
|
@@ -158,6 +158,24 @@ export function createEditing<
|
|
|
158
158
|
runHistoryGroup(ctx, () => commitEditingCell());
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* True when a column with `rejectInvalid` refuses `value`: its `validate`
|
|
163
|
+
* hook returns `false` or a message. Without the flag nothing is refused.
|
|
164
|
+
*/
|
|
165
|
+
function editRejected(column: any, value: unknown, rowData: unknown): boolean {
|
|
166
|
+
const def = column?.columnDef as
|
|
167
|
+
| { rejectInvalid?: boolean; validate?: (p: unknown) => string | boolean | null | undefined }
|
|
168
|
+
| undefined;
|
|
169
|
+
if (!def?.rejectInvalid || typeof def.validate !== "function") return false;
|
|
170
|
+
const verdict = def.validate({
|
|
171
|
+
value,
|
|
172
|
+
row: rowData,
|
|
173
|
+
rowIndex: ctx.internalData.indexOf(rowData as TData),
|
|
174
|
+
column,
|
|
175
|
+
});
|
|
176
|
+
return verdict === false || (typeof verdict === "string" && verdict.length > 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
161
179
|
function commitEditingCell() {
|
|
162
180
|
if (!ctx.editingCell) return;
|
|
163
181
|
const editing = ctx.editingCell;
|
|
@@ -200,6 +218,12 @@ export function createEditing<
|
|
|
200
218
|
columnId: ctx.editingCell.columnId,
|
|
201
219
|
});
|
|
202
220
|
}
|
|
221
|
+
// A refused edit closes the editor and leaves the row, the overlay,
|
|
222
|
+
// the history and the consumer untouched.
|
|
223
|
+
if (editRejected(column, finalValue, rowData)) {
|
|
224
|
+
ctx.editingCell = null;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
203
227
|
rowData[column.columnDef.field] = finalValue;
|
|
204
228
|
}
|
|
205
229
|
const key = getCellKey(ctx.editingCell.rowId, ctx.editingCell.columnId);
|
|
@@ -778,6 +802,7 @@ export function createEditing<
|
|
|
778
802
|
columnId: column.id,
|
|
779
803
|
})
|
|
780
804
|
: parsed;
|
|
805
|
+
if (editRejected(column, finalValue, row.original)) continue;
|
|
781
806
|
if (oldValue !== finalValue) {
|
|
782
807
|
(row.original as Record<string, unknown>)[field] = finalValue;
|
|
783
808
|
const key = getCellKey(fr.rowId, column.id);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates must come out of an export as the day the grid showed, in every
|
|
3
|
+
* timezone (#103). Text exports used `toISOString()`, which is UTC, so local
|
|
4
|
+
* midnight on the 15th came out as the 14th east of UTC. A bare 'YYYY-MM-DD'
|
|
5
|
+
* was parsed as UTC midnight, so west of UTC it formatted as the day before.
|
|
6
|
+
*
|
|
7
|
+
* These pin `process.env.TZ` per case: a suite run on a UTC machine (CI) would
|
|
8
|
+
* pass the old code without it.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import { parseDateValue } from './cell-formatting'
|
|
12
|
+
import { asDate } from './SvGrid.helpers'
|
|
13
|
+
import {
|
|
14
|
+
coerceExportDate,
|
|
15
|
+
formatValueForExport,
|
|
16
|
+
serializeDelimited,
|
|
17
|
+
serializeHtml,
|
|
18
|
+
serializeMarkdown,
|
|
19
|
+
toLocalIsoString,
|
|
20
|
+
} from './export-format'
|
|
21
|
+
|
|
22
|
+
async function inZone<T>(tz: string, fn: () => T | Promise<T>): Promise<T> {
|
|
23
|
+
const prev = process.env.TZ
|
|
24
|
+
process.env.TZ = tz
|
|
25
|
+
try {
|
|
26
|
+
return await fn()
|
|
27
|
+
} finally {
|
|
28
|
+
// Assigning undefined would set the string "undefined".
|
|
29
|
+
if (prev === undefined) delete process.env.TZ
|
|
30
|
+
else process.env.TZ = prev
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EAST = 'Europe/Sofia' // UTC+2 in January
|
|
35
|
+
const FAR_EAST = 'Pacific/Auckland' // UTC+13 in January
|
|
36
|
+
const WEST = 'America/New_York' // UTC-5 in January
|
|
37
|
+
|
|
38
|
+
describe('parseDateValue', () => {
|
|
39
|
+
it('reads a bare calendar date as local midnight of that day', async () => {
|
|
40
|
+
for (const tz of [EAST, FAR_EAST, WEST, 'UTC']) {
|
|
41
|
+
await inZone(tz, () => {
|
|
42
|
+
const d = parseDateValue('2026-06-27')!
|
|
43
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()], tz).toEqual([2026, 5, 27, 0])
|
|
44
|
+
// The grid's display path uses the same parser.
|
|
45
|
+
expect(asDate('2026-06-27')!.getTime(), tz).toBe(d.getTime())
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects an impossible calendar date instead of rolling it over', () => {
|
|
51
|
+
expect(parseDateValue('2026-02-30')).toBeNull()
|
|
52
|
+
expect(parseDateValue('2026-13-01')).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves full timestamps, Dates and epoch numbers alone', () => {
|
|
56
|
+
expect(parseDateValue('2024-01-15T10:00:00Z')!.toISOString()).toBe('2024-01-15T10:00:00.000Z')
|
|
57
|
+
const d = new Date(2024, 0, 15)
|
|
58
|
+
expect(parseDateValue(d)).toBe(d)
|
|
59
|
+
expect(parseDateValue(0)!.getTime()).toBe(0)
|
|
60
|
+
expect(parseDateValue(null)).toBeNull()
|
|
61
|
+
expect(parseDateValue('')).toBeNull()
|
|
62
|
+
expect(parseDateValue('nope')).toBeNull()
|
|
63
|
+
expect(parseDateValue(true)).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('coerceExportDate follows it', async () => {
|
|
67
|
+
await inZone(WEST, () => {
|
|
68
|
+
expect(coerceExportDate('2024-01-15')!.getDate()).toBe(15)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('formatted date export west of UTC', () => {
|
|
74
|
+
it('shows the calendar day of a date-only string', async () => {
|
|
75
|
+
await inZone(WEST, () => {
|
|
76
|
+
expect(formatValueForExport('2024-01-15', { type: 'date', locales: 'en-US' })).toBe('01/15/2024')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('toLocalIsoString', () => {
|
|
82
|
+
it('writes the local wall clock with its offset', async () => {
|
|
83
|
+
await inZone(EAST, () => {
|
|
84
|
+
expect(toLocalIsoString(new Date(2024, 0, 15))).toBe('2024-01-15T00:00:00.000+02:00')
|
|
85
|
+
})
|
|
86
|
+
await inZone(WEST, () => {
|
|
87
|
+
expect(toLocalIsoString(new Date(2024, 0, 15, 9, 5, 7, 42))).toBe('2024-01-15T09:05:07.042-05:00')
|
|
88
|
+
})
|
|
89
|
+
await inZone('Asia/Kolkata', () => {
|
|
90
|
+
expect(toLocalIsoString(new Date(2024, 0, 15))).toBe('2024-01-15T00:00:00.000+05:30')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('is the same instant as the Date', async () => {
|
|
95
|
+
await inZone(FAR_EAST, () => {
|
|
96
|
+
const d = new Date(2024, 0, 15, 3, 4, 5)
|
|
97
|
+
expect(new Date(toLocalIsoString(d)).getTime()).toBe(d.getTime())
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('text exports keep the local day (#103)', () => {
|
|
103
|
+
it('CSV, HTML and Markdown write 2024-01-15 east of UTC', async () => {
|
|
104
|
+
for (const tz of [EAST, FAR_EAST]) {
|
|
105
|
+
await inZone(tz, async () => {
|
|
106
|
+
// Build the Date inside the zone: local midnight there.
|
|
107
|
+
const local = [{ when: 'When' }, { when: new Date(2024, 0, 15) }]
|
|
108
|
+
expect(await serializeDelimited(local, ['when']), tz).toContain('2024-01-15T00:00:00.000+')
|
|
109
|
+
expect(await serializeHtml(local, ['when']), tz).toContain('2024-01-15T00:00:00.000+')
|
|
110
|
+
expect(await serializeMarkdown(local, ['when']), tz).toContain('2024-01-15T00:00:00.000+')
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
package/src/export-format.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { CellFormatConfig } from './core'
|
|
|
20
20
|
import {
|
|
21
21
|
formatNumericWithConfig,
|
|
22
22
|
getDateFormatter,
|
|
23
|
+
parseDateValue,
|
|
23
24
|
resolveDatePattern,
|
|
24
25
|
} from './cell-formatting'
|
|
25
26
|
|
|
@@ -39,16 +40,27 @@ export {
|
|
|
39
40
|
/** Best-effort coercion of a cell value to a Date. Accepts Date, epoch ms
|
|
40
41
|
* numbers, and parseable date strings. Returns null when it isn't a date. */
|
|
41
42
|
export function coerceExportDate(value: unknown): Date | null {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
return parseDateValue(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A Date as ISO 8601 in LOCAL time with its offset, e.g.
|
|
48
|
+
* `2024-01-15T00:00:00.000+02:00`. Text exports used `toISOString()`, which
|
|
49
|
+
* is UTC: local midnight on the 15th came out as `2024-01-14T22:00:00.000Z`
|
|
50
|
+
* east of UTC, and anything reading the date part got the wrong day (#103).
|
|
51
|
+
* Same instant, but the date part is the one the grid showed.
|
|
52
|
+
*/
|
|
53
|
+
export function toLocalIsoString(d: Date): string {
|
|
54
|
+
const pad = (n: number, w = 2) => String(Math.abs(n)).padStart(w, '0')
|
|
55
|
+
const offset = -d.getTimezoneOffset()
|
|
56
|
+
const sign = offset < 0 ? '-' : '+'
|
|
57
|
+
const year = d.getFullYear()
|
|
58
|
+
return (
|
|
59
|
+
(year < 0 ? '-' + pad(year, 6) : pad(year, 4)) +
|
|
60
|
+
`-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
61
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` +
|
|
62
|
+
`${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`
|
|
63
|
+
)
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
@@ -325,7 +337,7 @@ function csvCell(value: unknown, delimiter: string): string {
|
|
|
325
337
|
value == null
|
|
326
338
|
? ''
|
|
327
339
|
: value instanceof Date
|
|
328
|
-
? value
|
|
340
|
+
? toLocalIsoString(value)
|
|
329
341
|
: String(value)
|
|
330
342
|
if (s === '') return s
|
|
331
343
|
if (s.includes(delimiter) || s.includes('"') || s.includes('\n') || s.includes('\r')) {
|
|
@@ -373,7 +385,7 @@ const HTML_ESCAPE: Record<string, string> = {
|
|
|
373
385
|
}
|
|
374
386
|
function htmlCell(value: unknown): string {
|
|
375
387
|
const s =
|
|
376
|
-
value == null ? '' : value instanceof Date ? value
|
|
388
|
+
value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value)
|
|
377
389
|
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c]!)
|
|
378
390
|
}
|
|
379
391
|
|
|
@@ -472,7 +484,7 @@ export async function serializeJson(
|
|
|
472
484
|
|
|
473
485
|
/** Escape a Markdown table cell: pipes and newlines would break the row. */
|
|
474
486
|
function mdCell(value: unknown): string {
|
|
475
|
-
const s = value == null ? '' : value instanceof Date ? value
|
|
487
|
+
const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value)
|
|
476
488
|
return s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>')
|
|
477
489
|
}
|
|
478
490
|
|
|
@@ -520,7 +532,7 @@ const XML_ESCAPE: Record<string, string> = {
|
|
|
520
532
|
"'": ''',
|
|
521
533
|
}
|
|
522
534
|
function xmlText(value: unknown): string {
|
|
523
|
-
const s = value == null ? '' : value instanceof Date ? value
|
|
535
|
+
const s = value == null ? '' : value instanceof Date ? toLocalIsoString(value) : String(value)
|
|
524
536
|
// Stripping control characters is the point: XML 1.0 forbids them outright,
|
|
525
537
|
// so a stray \x00 from a data source would produce an unopenable file.
|
|
526
538
|
// eslint-disable-next-line no-control-regex
|
|
@@ -8,6 +8,13 @@ describe('resolveGridMessages', () => {
|
|
|
8
8
|
expect(resolveGridMessages({})).toEqual(defaultGridMessages)
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
+
it('names the client group expander, so a localized grid has no English left in it', () => {
|
|
12
|
+
const m = resolveGridMessages({ expandGroup: 'Gruppe aufklappen', rowsSuffix: 'Zeilen' })
|
|
13
|
+
expect(m.expandGroup).toBe('Gruppe aufklappen')
|
|
14
|
+
expect(m.collapseGroup).toBe('Collapse group')
|
|
15
|
+
expect(m.rowsSuffix).toBe('Zeilen')
|
|
16
|
+
})
|
|
17
|
+
|
|
11
18
|
it('replaces only the provided keys, keeping English for the rest', () => {
|
|
12
19
|
const m = resolveGridMessages({ noRows: 'Aucune ligne', pageSize: 'Taille :' })
|
|
13
20
|
expect(m.noRows).toBe('Aucune ligne')
|
package/src/grid-messages.ts
CHANGED
|
@@ -109,6 +109,12 @@ export type GridChromeMessages = {
|
|
|
109
109
|
// Rows whose data has not arrived (server-side / infinite loading)
|
|
110
110
|
rowLoadFailed: string
|
|
111
111
|
rowLoadRetry: string
|
|
112
|
+
// The expander on a client-side group row
|
|
113
|
+
expandGroup: string
|
|
114
|
+
collapseGroup: string
|
|
115
|
+
// The chevron of the detail-toggle column (`showDetailToggle`)
|
|
116
|
+
openDetail: string
|
|
117
|
+
closeDetail: string
|
|
112
118
|
// Upsell notes (enterprise views not installed)
|
|
113
119
|
pivotUpsellTitle: string
|
|
114
120
|
pivotUpsellBody: string
|
|
@@ -194,6 +200,10 @@ export const defaultGridMessages: GridChromeMessages = {
|
|
|
194
200
|
chartRange: 'Chart selected range',
|
|
195
201
|
rowLoadFailed: 'Could not load these rows.',
|
|
196
202
|
rowLoadRetry: 'Retry',
|
|
203
|
+
expandGroup: 'Expand group',
|
|
204
|
+
collapseGroup: 'Collapse group',
|
|
205
|
+
openDetail: 'Open details',
|
|
206
|
+
closeDetail: 'Close details',
|
|
197
207
|
pivotUpsellTitle: 'Pivot mode',
|
|
198
208
|
pivotUpsellBody: 'Pivot mode is an Enterprise feature. Install @svgrid/enterprise and call enablePivot() to use it.',
|
|
199
209
|
ganttUpsellTitle: 'Gantt view',
|
package/src/headless.ts
CHANGED
|
@@ -85,3 +85,27 @@ export {
|
|
|
85
85
|
export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
|
|
86
86
|
export { createGridState, createSvGridState } from './createGridState.svelte'
|
|
87
87
|
export { subscribeGrid, subscribeSvGrid } from './subscribe'
|
|
88
|
+
|
|
89
|
+
// The virtualizers are pure window arithmetic (scroll offset in, visible
|
|
90
|
+
// range out) with no DOM of their own, and a custom renderer over 50k rows
|
|
91
|
+
// needs one. They were only on the barrel, so the Headless demos imported
|
|
92
|
+
// the barrel to reach them and paid for the whole thing.
|
|
93
|
+
export { createVirtualizer } from './virtualization/virtualizer'
|
|
94
|
+
export { createSvelteVirtualizer } from './virtualization/svelte-virtualizer.svelte'
|
|
95
|
+
export { createColumnVirtualizer } from './virtualization/column-virtualizer'
|
|
96
|
+
export type { VirtualItem, VirtualizerOptions, VirtualizerState } from './virtualization/types'
|
|
97
|
+
|
|
98
|
+
// The WAI-ARIA attribute factories: plain objects to spread onto your own
|
|
99
|
+
// markup (`role`, `aria-sort`, `aria-rowindex`, ...). No DOM is touched here
|
|
100
|
+
// either; a custom renderer that wants keyboard navigation reads
|
|
101
|
+
// `activeCell` from the store and spreads these.
|
|
102
|
+
export {
|
|
103
|
+
getGridCellDomId,
|
|
104
|
+
getGridCellA11yProps,
|
|
105
|
+
getGridHeaderA11yProps,
|
|
106
|
+
getGridRootA11yProps,
|
|
107
|
+
getGridRowA11yProps,
|
|
108
|
+
type GridCellA11yInput,
|
|
109
|
+
type GridColumnA11yInput,
|
|
110
|
+
type GridSortDirection,
|
|
111
|
+
} from './a11y'
|
package/src/history.test.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
pushHistory,
|
|
6
6
|
runHistoryGroup,
|
|
7
7
|
nextGroupId,
|
|
8
|
+
peekUndoHistory,
|
|
9
|
+
peekRedoHistory,
|
|
8
10
|
type HistoryStep,
|
|
9
11
|
} from './history'
|
|
10
12
|
|
|
@@ -162,7 +164,7 @@ describe('runHistoryGroup', () => {
|
|
|
162
164
|
// This is why the group id is ambient rather than stamped afterwards: the
|
|
163
165
|
// cap drops entries from the front, so any index captured before the call
|
|
164
166
|
// would point at the wrong steps by the end of it.
|
|
165
|
-
const { ctx } = makeCtx([step(0)],
|
|
167
|
+
const { ctx } = makeCtx([step(0)], 1)
|
|
166
168
|
runHistoryGroup(ctx, () => {
|
|
167
169
|
pushHistory(ctx, [step(1)])
|
|
168
170
|
pushHistory(ctx, [step(2)])
|
|
@@ -172,6 +174,21 @@ describe('runHistoryGroup', () => {
|
|
|
172
174
|
expect(new Set(ctx.history.map((s) => s.groupId)).size).toBe(1)
|
|
173
175
|
})
|
|
174
176
|
|
|
177
|
+
it('caps by actions, so a group larger than the buffer stays whole', () => {
|
|
178
|
+
// A sort of 5,000 rows is one Ctrl+Z of 20,000 steps; a cap that
|
|
179
|
+
// counted steps cut it to its tail and undid a fifth of it.
|
|
180
|
+
const { ctx } = makeCtx([step(0)], 2)
|
|
181
|
+
runHistoryGroup(ctx, () => { for (let i = 1; i <= 5; i += 1) pushHistory(ctx, [step(i)]) })
|
|
182
|
+
expect(ctx.history.map((s) => s.rowId)).toEqual(['r0', 'r1', 'r2', 'r3', 'r4', 'r5'])
|
|
183
|
+
pushHistory(ctx, [step(9)])
|
|
184
|
+
// Three actions now; the oldest one goes and the group survives whole.
|
|
185
|
+
expect(ctx.history.map((s) => s.rowId)).toEqual(['r1', 'r2', 'r3', 'r4', 'r5', 'r9'])
|
|
186
|
+
expect(ctx.historyPtr).toBe(5)
|
|
187
|
+
undoHistory(ctx)
|
|
188
|
+
undoHistory(ctx)
|
|
189
|
+
expect(ctx.historyPtr).toBe(-1)
|
|
190
|
+
})
|
|
191
|
+
|
|
175
192
|
it('nests into the outer group so one action is one undo', () => {
|
|
176
193
|
const { ctx } = makeCtx()
|
|
177
194
|
runHistoryGroup(ctx, () => {
|
|
@@ -194,3 +211,34 @@ describe('runHistoryGroup', () => {
|
|
|
194
211
|
expect(runHistoryGroup(ctx, () => 42)).toBe(42)
|
|
195
212
|
})
|
|
196
213
|
})
|
|
214
|
+
|
|
215
|
+
describe('tags', () => {
|
|
216
|
+
it('stamps the ambient tag on every step recorded while it is set, grouped or not', () => {
|
|
217
|
+
const { ctx } = makeCtx()
|
|
218
|
+
const sheet = { name: 'Orders' }
|
|
219
|
+
pushHistory(ctx, [step(1)])
|
|
220
|
+
;(ctx as { historyTag?: unknown }).historyTag = sheet
|
|
221
|
+
pushHistory(ctx, [step(2)])
|
|
222
|
+
runHistoryGroup(ctx, () => { pushHistory(ctx, [step(3)]); pushHistory(ctx, [step(4)]) })
|
|
223
|
+
;(ctx as { historyTag?: unknown }).historyTag = undefined
|
|
224
|
+
pushHistory(ctx, [step(5)])
|
|
225
|
+
expect(ctx.history.map((s) => s.tag)).toEqual([undefined, sheet, sheet, sheet, undefined])
|
|
226
|
+
expect(ctx.history[2]!.groupId).toBe(ctx.history[3]!.groupId)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('peeks at the step the next undo or redo would take', () => {
|
|
230
|
+
const { ctx } = makeCtx()
|
|
231
|
+
;(ctx as { historyTag?: unknown }).historyTag = 'Summary'
|
|
232
|
+
pushHistory(ctx, [step(1)])
|
|
233
|
+
;(ctx as { historyTag?: unknown }).historyTag = 'Orders'
|
|
234
|
+
runHistoryGroup(ctx, () => { pushHistory(ctx, [step(2)]); pushHistory(ctx, [step(3)]) })
|
|
235
|
+
expect(peekUndoHistory(ctx)).toEqual({ tag: 'Orders' })
|
|
236
|
+
expect(peekRedoHistory(ctx)).toBeNull()
|
|
237
|
+
undoHistory(ctx)
|
|
238
|
+
expect(peekUndoHistory(ctx)).toEqual({ tag: 'Summary' })
|
|
239
|
+
expect(peekRedoHistory(ctx)).toEqual({ tag: 'Orders' })
|
|
240
|
+
undoHistory(ctx)
|
|
241
|
+
expect(peekUndoHistory(ctx)).toBeNull()
|
|
242
|
+
expect(peekRedoHistory(ctx)).toEqual({ tag: 'Summary' })
|
|
243
|
+
})
|
|
244
|
+
})
|
package/src/history.ts
CHANGED
|
@@ -36,6 +36,15 @@ export type HistoryStep = {
|
|
|
36
36
|
* functions do the work. Never serialised, since a closure cannot be.
|
|
37
37
|
*/
|
|
38
38
|
custom?: { undo: () => void; redo: () => void }
|
|
39
|
+
/**
|
|
40
|
+
* The consumer's own mark on the step, copied from `api.setHistoryTag` at
|
|
41
|
+
* the moment it is recorded. The grid never reads it: it is there for a
|
|
42
|
+
* consumer that shows one of several data sets through the grid and has
|
|
43
|
+
* to know which one a step belongs to before it lets Ctrl+Z apply it (a
|
|
44
|
+
* workbook with several sheets, say). Kept as given, so an object tag
|
|
45
|
+
* compares by identity. Not serialised.
|
|
46
|
+
*/
|
|
47
|
+
tag?: unknown
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/** Ctx surface these helpers touch. The controller satisfies it structurally. */
|
|
@@ -48,6 +57,8 @@ type HistoryCtx = {
|
|
|
48
57
|
* inside it joins that group, so a command does not have to thread a group
|
|
49
58
|
* id down through the writers it calls. */
|
|
50
59
|
historyGroupId: string | undefined
|
|
60
|
+
/** Stamped on every step recorded while it is set; see `HistoryStep.tag`. */
|
|
61
|
+
historyTag?: unknown
|
|
51
62
|
applyHistoryStep(step: HistoryStep, direction: 'undo' | 'redo'): void
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -71,6 +82,37 @@ function groupEnd(history: ReadonlyArray<HistoryStep>, start: number): number {
|
|
|
71
82
|
return i
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The last `limit` ACTIONS of a history, whole groups kept. The cap used
|
|
87
|
+
* to count steps, and a sort of 5,000 rows is 20,000 steps in one group:
|
|
88
|
+
* it cut the group to its last 200 cells, so Ctrl+Z put a fifth of a
|
|
89
|
+
* column back and left the rest sorted. An action is one press of Ctrl+Z,
|
|
90
|
+
* so that is what the buffer counts; the steps inside are the cost of it.
|
|
91
|
+
*/
|
|
92
|
+
function lastActions(history: HistoryStep[], limit: number): HistoryStep[] {
|
|
93
|
+
let i = history.length
|
|
94
|
+
let actions = 0
|
|
95
|
+
while (i > 0 && actions < limit) {
|
|
96
|
+
i = groupStart(history, i - 1)
|
|
97
|
+
actions += 1
|
|
98
|
+
}
|
|
99
|
+
return i === 0 ? history : history.slice(i)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What the next undo would apply: the tag of the step Ctrl+Z takes next
|
|
103
|
+
* (a group carries one tag, so its last step's is the group's). Null when
|
|
104
|
+
* there is nothing to undo. */
|
|
105
|
+
export function peekUndoHistory(ctx: HistoryCtx): { tag: unknown } | null {
|
|
106
|
+
const step = ctx.historyPtr >= 0 ? ctx.history[ctx.historyPtr] : undefined
|
|
107
|
+
return step ? { tag: step.tag } : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The redo counterpart of `peekUndoHistory`. */
|
|
111
|
+
export function peekRedoHistory(ctx: HistoryCtx): { tag: unknown } | null {
|
|
112
|
+
const step = ctx.historyPtr < ctx.history.length - 1 ? ctx.history[ctx.historyPtr + 1] : undefined
|
|
113
|
+
return step ? { tag: step.tag } : null
|
|
114
|
+
}
|
|
115
|
+
|
|
74
116
|
/** Undo one action. Returns false when there is nothing left to undo. */
|
|
75
117
|
export function undoHistory(ctx: HistoryCtx): boolean {
|
|
76
118
|
if (ctx.historyPtr < 0) return false
|
|
@@ -123,9 +165,13 @@ export function pushHistory(
|
|
|
123
165
|
// Outside one, a group of one is just a step. Tagging it would make undo do
|
|
124
166
|
// the same work through a slower path and would show up in serialized state.
|
|
125
167
|
const id = ctx.historyGroupId ?? (groupId && steps.length > 1 ? groupId : undefined)
|
|
168
|
+
const tag = ctx.historyTag
|
|
126
169
|
let next = ctx.history.slice(0, ctx.historyPtr + 1)
|
|
127
|
-
for (const step of steps)
|
|
128
|
-
|
|
170
|
+
for (const step of steps) {
|
|
171
|
+
const stamped = tag === undefined ? step : { ...step, tag }
|
|
172
|
+
next.push(id ? { ...stamped, groupId: id } : stamped)
|
|
173
|
+
}
|
|
174
|
+
next = lastActions(next, ctx.UNDO_LIMIT)
|
|
129
175
|
ctx.history = next
|
|
130
176
|
ctx.historyPtr = ctx.history.length - 1
|
|
131
177
|
ctx.historyVersion += 1
|
package/src/index.ts
CHANGED
|
@@ -779,6 +779,7 @@ export {
|
|
|
779
779
|
type ServerState,
|
|
780
780
|
type ServerSortModel,
|
|
781
781
|
type ServerFilterModel,
|
|
782
|
+
type ServerAggFn,
|
|
782
783
|
type ServerAggregation,
|
|
783
784
|
type ServerGroupRow,
|
|
784
785
|
type ServerLeafRow,
|
|
@@ -788,6 +789,7 @@ export {
|
|
|
788
789
|
type ServerGrandTotalRow,
|
|
789
790
|
type ServerPlaceholderRow,
|
|
790
791
|
type ServerDisplayRow,
|
|
792
|
+
type ServerDetailRow,
|
|
791
793
|
} from './server-data-source'
|
|
792
794
|
export {
|
|
793
795
|
createBlockCache,
|
|
@@ -576,6 +576,17 @@ describe('onWindowKeydown', () => {
|
|
|
576
576
|
expect(ctx.closeMenus).toHaveBeenCalled()
|
|
577
577
|
})
|
|
578
578
|
|
|
579
|
+
it('Escape closes the funnel filter popover, the value suggestions and the column chooser too', () => {
|
|
580
|
+
// The filter popover focuses its condition input, so Escape from there
|
|
581
|
+
// used to go unanswered while the column menu closed as expected.
|
|
582
|
+
for (const open of [{ filterMenuFor: 'col1' }, { inSuggestFor: 'col1' }, { chooseColumnsPos: { x: 1, y: 1 } }]) {
|
|
583
|
+
const ctx = makeCtx(open)
|
|
584
|
+
const { onWindowKeydown } = createKeyboard(ctx)
|
|
585
|
+
onWindowKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
|
586
|
+
expect(ctx.closeMenus).toHaveBeenCalled()
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
|
|
579
590
|
it('does nothing when no menu is open', () => {
|
|
580
591
|
const ctx = makeCtx()
|
|
581
592
|
const { onWindowKeydown } = createKeyboard(ctx)
|
|
@@ -678,3 +689,57 @@ describe('onHeaderSortClick', () => {
|
|
|
678
689
|
expect(ctx._state.sorting).toEqual([{ id: 'name', desc: false }])
|
|
679
690
|
})
|
|
680
691
|
})
|
|
692
|
+
|
|
693
|
+
describe('onGridKeyDown / Ctrl+Enter on a server-side tree', () => {
|
|
694
|
+
const tree = () => {
|
|
695
|
+
const toggle = vi.fn()
|
|
696
|
+
const toggleDetail = vi.fn()
|
|
697
|
+
const rows = [
|
|
698
|
+
{ original: { id: 'g', kind: 'group' } },
|
|
699
|
+
{ original: { id: 'a', kind: 'leaf' } },
|
|
700
|
+
]
|
|
701
|
+
const serverGroup = {
|
|
702
|
+
isGroup: (r: { kind: string }) => r.kind === 'group',
|
|
703
|
+
level: () => 0,
|
|
704
|
+
onToggle: toggle,
|
|
705
|
+
toggleDetail,
|
|
706
|
+
}
|
|
707
|
+
return { toggle, toggleDetail, rows, serverGroup }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
it('opens or closes the group under the active cell', () => {
|
|
711
|
+
const t = tree()
|
|
712
|
+
const ctx = makeCtx({ allRows: t.rows, props: { rowHeight: 30, serverGroup: t.serverGroup, renderDetailRow: () => {} }, state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } } })
|
|
713
|
+
createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
|
|
714
|
+
expect(t.toggle).toHaveBeenCalledWith(t.rows[0]!.original)
|
|
715
|
+
expect(t.toggleDetail).not.toHaveBeenCalled()
|
|
716
|
+
expect(ctx.onCellDoubleClick).not.toHaveBeenCalled()
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it('opens or closes the detail panel under a leaf, when the grid draws one', () => {
|
|
720
|
+
const t = tree()
|
|
721
|
+
const ctx = makeCtx({ allRows: t.rows, props: { rowHeight: 30, serverGroup: t.serverGroup, renderDetailRow: () => {} }, state: { activeCell: { rowIndex: 1, colIndex: 0, cellId: null } } })
|
|
722
|
+
createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
|
|
723
|
+
expect(t.toggleDetail).toHaveBeenCalledWith(t.rows[1]!.original)
|
|
724
|
+
expect(t.toggle).not.toHaveBeenCalled()
|
|
725
|
+
// Without a renderer there is no panel to open: Enter falls through to editing.
|
|
726
|
+
const plain = makeCtx({ allRows: t.rows, props: { rowHeight: 30, serverGroup: t.serverGroup }, state: { activeCell: { rowIndex: 1, colIndex: 0, cellId: null } } })
|
|
727
|
+
createKeyboard(plain).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
|
|
728
|
+
expect(t.toggleDetail).toHaveBeenCalledTimes(1)
|
|
729
|
+
expect(plain.onCellDoubleClick).toHaveBeenCalled()
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('on a client master-detail calls onDetailToggle with the row, never on the detail row itself', () => {
|
|
733
|
+
const onDetailToggle = vi.fn()
|
|
734
|
+
const rows = [{ original: { id: 'a', kind: 'data' } }, { original: { id: 'a-detail', kind: 'detail' } }]
|
|
735
|
+
const props = { rowHeight: 30, renderDetailRow: () => {}, onDetailToggle, isDetailRow: (r: { kind: string }) => r.kind === 'detail' }
|
|
736
|
+
const ctx = makeCtx({ allRows: rows, props, state: { activeCell: { rowIndex: 0, colIndex: 0, cellId: null } } })
|
|
737
|
+
createKeyboard(ctx).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
|
|
738
|
+
expect(onDetailToggle).toHaveBeenCalledWith(rows[0]!.original, 0)
|
|
739
|
+
expect(ctx.onCellDoubleClick).not.toHaveBeenCalled()
|
|
740
|
+
const onDetail = makeCtx({ allRows: rows, props, state: { activeCell: { rowIndex: 1, colIndex: 0, cellId: null } } })
|
|
741
|
+
createKeyboard(onDetail).onGridKeyDown(rootKeyEvent({ key: 'Enter', ctrlKey: true }))
|
|
742
|
+
expect(onDetailToggle).toHaveBeenCalledTimes(1)
|
|
743
|
+
expect(onDetail.onCellDoubleClick).toHaveBeenCalled()
|
|
744
|
+
})
|
|
745
|
+
})
|
package/src/keyboard-handlers.ts
CHANGED
|
@@ -193,6 +193,28 @@ export function createKeyboard<
|
|
|
193
193
|
row.toggleExpanded?.();
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
|
+
// The same key on a server-side tree: a group opens or closes, a leaf
|
|
197
|
+
// opens or closes its detail panel when the grid draws one.
|
|
198
|
+
const sg = ctx.props.serverGroup;
|
|
199
|
+
if (event.ctrlKey && sg && row) {
|
|
200
|
+
const data = row.original;
|
|
201
|
+
if (sg.isGroup(data)) {
|
|
202
|
+
sg.onToggle(data);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (sg.toggleDetail && ctx.props.renderDetailRow && (sg.hasDetail?.(data) ?? true)) {
|
|
206
|
+
sg.toggleDetail(data);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ...and on a client-side master-detail: the app's own toggle.
|
|
211
|
+
if (event.ctrlKey && row && ctx.props.onDetailToggle && ctx.props.renderDetailRow) {
|
|
212
|
+
const data = row.original;
|
|
213
|
+
if (!ctx.props.isDetailRow?.(data, current.rowIndex) && (ctx.props.hasDetail?.(data) ?? true)) {
|
|
214
|
+
ctx.props.onDetailToggle(data, current.rowIndex);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
196
218
|
if (column?.columnDef.editorType === "checkbox") {
|
|
197
219
|
ctx.toggleBooleanCell(current.rowIndex, current.colIndex);
|
|
198
220
|
return;
|
|
@@ -287,7 +309,10 @@ export function createKeyboard<
|
|
|
287
309
|
}
|
|
288
310
|
|
|
289
311
|
function onWindowKeydown(event: KeyboardEvent) {
|
|
290
|
-
if (
|
|
312
|
+
if (
|
|
313
|
+
event.key === "Escape" &&
|
|
314
|
+
(ctx.columnMenuFor || ctx.operatorMenuFor || ctx.filterMenuFor || ctx.inSuggestFor || ctx.chooseColumnsPos)
|
|
315
|
+
) {
|
|
291
316
|
ctx.closeMenus();
|
|
292
317
|
}
|
|
293
318
|
}
|