@svgrid/grid 2.2.30 → 2.2.32

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 (145) hide show
  1. package/README.md +1 -1
  2. package/dist/SvAvatar.svelte +1 -1
  3. package/dist/SvCheckBox.svelte +2 -1
  4. package/dist/SvCircularProgress.svelte +1 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +20 -0
  6. package/dist/SvGrid.controller.svelte.js +358 -18
  7. package/dist/SvGrid.css +103 -3
  8. package/dist/SvGrid.svelte +162 -10
  9. package/dist/SvGrid.types.d.ts +81 -0
  10. package/dist/SvGridDropdown.svelte +38 -6
  11. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  12. package/dist/SvGridSelect.svelte +12 -3
  13. package/dist/SvGridSelect.svelte.d.ts +6 -0
  14. package/dist/SvGroupCell.svelte +9 -2
  15. package/dist/SvMultiSelect.svelte +12 -3
  16. package/dist/SvMultiSelect.svelte.d.ts +6 -0
  17. package/dist/SvPasswordInput.svelte +1 -1
  18. package/dist/SvProgress.svelte +1 -1
  19. package/dist/SvRating.svelte +1 -1
  20. package/dist/SvResult.svelte +1 -1
  21. package/dist/SvRichText.svelte +31 -16
  22. package/dist/SvRichText.svelte.d.ts +9 -0
  23. package/dist/SvSwitchButton.svelte +2 -1
  24. package/dist/SvToaster.svelte +1 -1
  25. package/dist/SvTree.svelte +9 -2
  26. package/dist/SvTree.svelte.d.ts +5 -0
  27. package/dist/SvTreeSelect.svelte +6 -2
  28. package/dist/build-api.js +16 -0
  29. package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-C6ixSGWW.js} +33 -33
  30. package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-Cyk3qpJ7.js} +33 -33
  31. package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-CdqaP61H.js} +2 -1
  32. package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
  33. package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
  34. package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-Dh-2ej8j.js} +92 -87
  35. package/dist/cdn/{src-BLJfdyL0.js → src-BgHjWF0Q.js} +4193 -3837
  36. package/dist/cdn/{src-DYDCiC0D.js → src-CgD-wiuS.js} +5956 -5602
  37. package/dist/cdn/svgrid.js +8 -8
  38. package/dist/cdn/svgrid.svelte-external.js +8 -8
  39. package/dist/cell-render.d.ts +1 -0
  40. package/dist/cell-render.js +115 -1
  41. package/dist/cell-values.js +5 -0
  42. package/dist/core.d.ts +80 -9
  43. package/dist/core.js +89 -0
  44. package/dist/editor-contract.d.ts +12 -5
  45. package/dist/editor-registry.d.ts +18 -7
  46. package/dist/editor-registry.js +10 -1
  47. package/dist/group-display.d.ts +39 -0
  48. package/dist/group-display.js +52 -0
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.js +2 -2
  51. package/dist/keyboard-handlers.js +16 -0
  52. package/dist/recurrence.js +3 -2
  53. package/dist/svgrid-wrapper.types.d.ts +8 -2
  54. package/dist/themes/index.d.ts +10 -0
  55. package/dist/themes/index.js +26 -0
  56. package/package.json +1 -1
  57. package/src/SvAccordion.test.ts +5 -5
  58. package/src/SvAvatar.svelte +1 -1
  59. package/src/SvButtonGroup.test.ts +6 -6
  60. package/src/SvCheckBox.svelte +2 -1
  61. package/src/SvCircularProgress.svelte +1 -1
  62. package/src/SvFileUpload.test.ts +1 -1
  63. package/src/SvGrid.controller.svelte.ts +367 -17
  64. package/src/SvGrid.css +103 -3
  65. package/src/SvGrid.svelte +162 -10
  66. package/src/SvGrid.types.ts +81 -0
  67. package/src/SvGridDropdown.svelte +38 -6
  68. package/src/SvGridSelect.svelte +12 -3
  69. package/src/SvGroupCell.svelte +9 -2
  70. package/src/SvMultiSelect.svelte +12 -3
  71. package/src/SvPasswordInput.svelte +1 -1
  72. package/src/SvProgress.svelte +1 -1
  73. package/src/SvRating.svelte +1 -1
  74. package/src/SvResult.svelte +1 -1
  75. package/src/SvRichText.svelte +31 -16
  76. package/src/SvSwitchButton.svelte +2 -1
  77. package/src/SvToaster.svelte +1 -1
  78. package/src/SvTree.svelte +9 -2
  79. package/src/SvTreeSelect.svelte +6 -2
  80. package/src/build-api.ts +15 -0
  81. package/src/builtin-editors.test.ts +3 -1
  82. package/src/cell-render.test.ts +27 -3
  83. package/src/cell-render.ts +122 -1
  84. package/src/cell-values.ts +4 -0
  85. package/src/chart-export.test.ts +2 -2
  86. package/src/clipboard.test.ts +0 -2
  87. package/src/core.ts +157 -8
  88. package/src/editing.test.ts +1 -1
  89. package/src/editor-contract.ts +12 -5
  90. package/src/editor-registry.grid.test.ts +48 -0
  91. package/src/editor-registry.test.ts +3 -1
  92. package/src/editor-registry.ts +23 -7
  93. package/src/facet-buckets.test.ts +33 -33
  94. package/src/group-display.test.ts +91 -1
  95. package/src/group-display.ts +83 -0
  96. package/src/index.ts +9 -0
  97. package/src/keyboard-handlers.ts +17 -0
  98. package/src/list-power.test.ts +2 -2
  99. package/src/menus.test.ts +4 -4
  100. package/src/named-views.coverage.test.ts +3 -3
  101. package/src/recurrence.ts +3 -2
  102. package/src/row-resize.test.ts +40 -40
  103. package/src/scheduler-model.ts +1 -1
  104. package/src/server-data-source.coverage.test.ts +1 -1
  105. package/src/server-group-model.test.ts +6 -6
  106. package/src/spreadsheet.test.ts +0 -1
  107. package/src/svgrid-wrapper.types.ts +11 -9
  108. package/src/svgrid.async-editor-options.test.ts +271 -0
  109. package/src/svgrid.auto-row-height.test.ts +204 -0
  110. package/src/svgrid.charting.test.ts +4 -4
  111. package/src/svgrid.comments-autocomplete.test.ts +8 -2
  112. package/src/svgrid.context-menu.test.ts +25 -9
  113. package/src/svgrid.filter-depth.test.ts +163 -0
  114. package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
  115. package/src/svgrid.filter-menu-scroll.test.ts +10 -2
  116. package/src/svgrid.grand-total.test.ts +188 -0
  117. package/src/svgrid.group-display-mode.test.ts +171 -0
  118. package/src/svgrid.group-footers.test.ts +121 -0
  119. package/src/svgrid.group-pagination.test.ts +153 -0
  120. package/src/svgrid.tree-data.test.ts +186 -0
  121. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
  122. package/src/themes/index.ts +42 -0
  123. package/src/tree-edit.test.ts +4 -4
  124. package/src/tree-row-model.test.ts +169 -0
  125. package/src/ui-localization.test.ts +113 -0
  126. package/themes/ag-alpine.css +22 -0
  127. package/themes/antd.css +22 -0
  128. package/themes/atlassian.css +22 -0
  129. package/themes/bootstrap.css +22 -0
  130. package/themes/carbon.css +22 -0
  131. package/themes/catppuccin.css +22 -0
  132. package/themes/dracula.css +22 -0
  133. package/themes/ember.css +22 -0
  134. package/themes/excel.css +22 -0
  135. package/themes/fluent.css +22 -0
  136. package/themes/github.css +22 -0
  137. package/themes/linear.css +22 -0
  138. package/themes/material.css +22 -0
  139. package/themes/nord.css +22 -0
  140. package/themes/notion.css +22 -0
  141. package/themes/salesforce.css +22 -0
  142. package/themes/sap.css +22 -0
  143. package/themes/shadcn.css +22 -0
  144. package/themes/tailwind.css +22 -0
  145. package/themes/vercel.css +22 -0
package/src/core.ts CHANGED
@@ -45,6 +45,11 @@ export type CellSpanParams<TData extends RowData = RowData> = {
45
45
  value: unknown
46
46
  }
47
47
 
48
+ /** The raw option list a column's `editorOptions` can supply. */
49
+ export type EditorOptionSource = ReadonlyArray<
50
+ string | number | { value: string | number; label?: string; color?: string }
51
+ >
52
+
48
53
  /** Params passed to a column's `valueParser(...)` on edit commit. */
49
54
  export type ValueParserParams<TData extends RowData = RowData> = {
50
55
  /** The value after built-in per-`editorType` coercion. */
@@ -172,6 +177,16 @@ export function applyGroupAggregate<TData extends RowData>(
172
177
  }
173
178
  }
174
179
 
180
+ /**
181
+ * A column definition.
182
+ *
183
+ * `TFeatures` is a phantom parameter - it is threaded through nested
184
+ * `columns` groups but no member depends on it, so `{}`, `TableFeatures` and
185
+ * `typeof features` are all interchangeable here. It is deliberately left
186
+ * WITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot
187
+ * and silently type your data as `RowData`, losing every field-name check.
188
+ * Prefer {@link GridColumns} / {@link GridColumnDef} for the common case.
189
+ */
175
190
  export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
176
191
  id?: string
177
192
  field?: keyof TData & string
@@ -336,16 +351,20 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
336
351
  *
337
352
  * Pass a function `(row) => options` for row-dependent (cascading)
338
353
  * options - e.g. City options that depend on Country in the same row.
354
+ *
355
+ * Either form may return a **Promise**, for options that come from the
356
+ * server. While it resolves, the editor shows a loading state and the cell
357
+ * renders its raw value.
358
+ *
359
+ * Results are cached so reopening an editor does not refetch: a static source
360
+ * per column, a per-row source per row AND per that row's data - so a cascade
361
+ * reloads by itself when the cell it depends on is edited. Call
362
+ * `api.refreshEditorOptions(columnId?)` when the list changes server-side.
339
363
  */
340
364
  editorOptions?:
341
- | ReadonlyArray<
342
- string | number | { value: string | number; label?: string; color?: string }
343
- >
344
- | ((
345
- row: TData,
346
- ) => ReadonlyArray<
347
- string | number | { value: string | number; label?: string; color?: string }
348
- >)
365
+ | EditorOptionSource
366
+ | Promise<EditorOptionSource>
367
+ | ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
349
368
  /** When true, list/chips allow multiple selections. Cell value becomes an array. */
350
369
  editorMultiple?: boolean
351
370
  /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
@@ -409,6 +428,23 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
409
428
  | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
410
429
  }
411
430
 
431
+ /**
432
+ * A column definition keyed only by your row type - the ergonomic form of
433
+ * {@link ColumnDef}, whose first parameter is a phantom feature bag that is
434
+ * almost always `{}`.
435
+ *
436
+ * ```ts
437
+ * const columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]
438
+ * ```
439
+ *
440
+ * Interchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,
441
+ * TData>` in both directions, so it mixes freely with existing code.
442
+ */
443
+ export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>
444
+
445
+ /** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
446
+ export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
447
+
412
448
  export type Column<TData extends RowData> = {
413
449
  id: string
414
450
  columnDef: ColumnDef<any, TData>
@@ -647,6 +683,119 @@ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<
647
683
  return buildGroups(rows, 0, 0, 'group')
648
684
  }
649
685
  }
686
+ export type TreeRowModelOptions = {
687
+ /** Field holding each row's parent id. Rows with no parent are roots. */
688
+ parentField: string
689
+ /** Field holding the row's own id. Defaults to `'id'`. */
690
+ idField?: string
691
+ }
692
+
693
+ /**
694
+ * Client-side tree data: nest the grid's own flat rows into a parent/child
695
+ * hierarchy that `createExpandedRowModel` then walks.
696
+ *
697
+ * This works on the rows the grid already built rather than on raw data, so
698
+ * tree rows keep their cells, editing, selection and formatting - they are real
699
+ * data rows that happen to have children, not synthetic banners like grouping's.
700
+ * That is also why the model is parent-id based: nested source arrays never
701
+ * become rows (the grid only builds rows for `data`), so nested input is
702
+ * flattened first with {@link flattenTreeData}. One code path, no duplicated
703
+ * row construction.
704
+ *
705
+ * Rows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable
706
+ * data row for a full-width group banner.
707
+ */
708
+ export function createTreeRowModel<TData extends RowData>(
709
+ options: TreeRowModelOptions,
710
+ ): RowModelFactory<TData> {
711
+ const { parentField, idField = 'id' } = options
712
+ return ({ table, rows }) => {
713
+ if (!rows.length) return rows
714
+ const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]
715
+ const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]
716
+
717
+ const present = new Set<unknown>()
718
+ for (const row of rows) present.add(keyOf(row))
719
+
720
+ const childrenByParent = new Map<unknown, Array<Row<TData>>>()
721
+ const roots: Array<Row<TData>> = []
722
+ for (const row of rows) {
723
+ const parent = parentOf(row)
724
+ // A row whose parent is absent (filtered out, or never existed) becomes a
725
+ // root rather than disappearing - silently dropping rows is worse than a
726
+ // shallower tree. Self-parenting is treated the same way.
727
+ if (parent == null || parent === keyOf(row) || !present.has(parent)) {
728
+ roots.push(row)
729
+ continue
730
+ }
731
+ const list = childrenByParent.get(parent) ?? []
732
+ list.push(row)
733
+ childrenByParent.set(parent, list)
734
+ }
735
+
736
+ // Guards a cycle in the parent chain from recursing forever.
737
+ const seen = new Set<unknown>()
738
+ const build = (row: Row<TData>, depth: number): Row<TData> => {
739
+ const key = keyOf(row)
740
+ const id = row.id
741
+ if (seen.has(key)) {
742
+ return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>
743
+ }
744
+ seen.add(key)
745
+ const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))
746
+ return {
747
+ ...row,
748
+ depth,
749
+ subRows,
750
+ leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),
751
+ __treeRow: true,
752
+ getCanExpand: () => subRows.length > 0,
753
+ getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
754
+ toggleExpanded: () => {
755
+ table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
756
+ },
757
+ } as Row<TData>
758
+ }
759
+
760
+ return roots.map((root) => build(root, 0))
761
+ }
762
+ }
763
+
764
+ export type FlattenTreeOptions = {
765
+ /** Field holding an array of child objects. */
766
+ childrenField: string
767
+ /** Field holding each object's id. Defaults to `'id'`. */
768
+ idField?: string
769
+ /** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */
770
+ parentField?: string
771
+ }
772
+
773
+ /**
774
+ * Flatten nested tree data into the flat parent-id shape `createTreeRowModel`
775
+ * consumes, stamping each child with its parent's id.
776
+ *
777
+ * Children are emitted directly after their parent so the natural order already
778
+ * matches the rendered tree. The `childrenField` array is left on the objects
779
+ * (harmless, and callers often still want it); only the parent link is added.
780
+ */
781
+ export function flattenTreeData<T extends RowData>(
782
+ data: ReadonlyArray<T>,
783
+ options: FlattenTreeOptions,
784
+ ): T[] {
785
+ const { childrenField, idField = 'id', parentField = '__parentId' } = options
786
+ const out: T[] = []
787
+ const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {
788
+ for (const node of nodes) {
789
+ const flat = { ...node, [parentField]: parentId } as T
790
+ out.push(flat)
791
+ const kids = (node as any)[childrenField]
792
+ if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])
793
+ }
794
+ }
795
+ walk(data, null)
796
+ return out
797
+ }
798
+
650
799
  export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
651
800
  return ({ table, rows }) => {
652
801
  const expanded: ExpandedState = table.getState().expanded ?? {}
@@ -154,7 +154,7 @@ describe('isCellEditable', () => {
154
154
  data: [{ a: 'hello' }],
155
155
  })
156
156
  ed.isCellEditable(ctx.allColumns[0], ctx.allRows[0])
157
- const cc = spy.mock.calls[0][0]
157
+ const cc = spy.mock.calls[0]![0]
158
158
  expect(cc.getValue()).toBe('hello')
159
159
  expect(cc.cell.getValue()).toBe('hello')
160
160
  expect(cc.cell.getContext()).toBe(cc)
@@ -68,16 +68,23 @@ export type SvEditorProps = {
68
68
  * The interaction surface an editor exposes when embedded as a grid cell editor.
69
69
  * Standalone usage ignores these; the grid supplies them so every editor shares
70
70
  * one commit / cancel / move contract (Enter commits, Escape cancels, Tab moves)
71
- * instead of each cell-editor re-implementing key handling. Consumed in Phase 2
72
- * (the editor registry).
71
+ * instead of each cell-editor re-implementing key handling.
72
+ *
73
+ * `CellEditorContext` (editor-registry.ts) extends this type, so what the grid
74
+ * hands a registered editor and what this contract promises are the same thing
75
+ * by construction rather than by convention.
73
76
  */
74
77
  export type EditorInteraction<V = unknown> = {
75
- /** Commit the current value and stop editing (Enter, option pick, blur-commit). */
76
- onCommit?: (value: V) => void
78
+ /**
79
+ * Commit the value and stop editing (Enter, option pick, blur-commit).
80
+ * The argument is optional: an editor that has been reporting through
81
+ * `onChange` can just call `onCommit()` to commit what the grid already has.
82
+ */
83
+ onCommit?: (value?: V) => void
77
84
  /** Abandon the edit and restore the previous value (Escape). */
78
85
  onCancel?: () => void
79
86
  /** Commit, then move to the adjacent cell (Tab = 1, Shift+Tab = -1). */
80
- onCommitAndMove?: (value: V, direction: 1 | -1) => void
87
+ onCommitAndMove?: (value: V | undefined, direction: 1 | -1) => void
81
88
  /** Ask the surrounding popover/panel to close without committing. */
82
89
  onRequestClose?: () => void
83
90
  /** True while mounted inside a grid cell (lets an editor tune autofocus etc.). */
@@ -95,4 +95,52 @@ describe('registry wiring in SvGrid', () => {
95
95
  destroy()
96
96
  }
97
97
  })
98
+
99
+ it('hands the editor the full EditorInteraction contract, not a subset', async () => {
100
+ // The contract type used to be documented but never supplied: the registry
101
+ // passed only value/onChange/onCommit/onCancel, so an editor written against
102
+ // `EditorInteraction` (Tab-to-move, dismiss-without-commit, in-cell tuning)
103
+ // silently got undefined for half of it.
104
+ let seen: Record<string, unknown> = {}
105
+ registerCellEditor('stars', {
106
+ component: SvRating,
107
+ props: (ctx) => {
108
+ seen = ctx as unknown as Record<string, unknown>
109
+ return { value: ctx.value }
110
+ },
111
+ })
112
+
113
+ const { api, target, destroy } = await mountGrid()
114
+ try {
115
+ api.startEditing(0, 'score')
116
+ await tick()
117
+ await tick()
118
+ expect(typeof seen.onCommit).toBe('function')
119
+ expect(typeof seen.onCancel).toBe('function')
120
+ expect(typeof seen.onChange).toBe('function')
121
+ expect(typeof seen.onCommitAndMove).toBe('function')
122
+ expect(typeof seen.onRequestClose).toBe('function')
123
+ expect(seen.inCell).toBe(true)
124
+ expect(seen.rowId).toBeTruthy()
125
+ expect(seen.columnId).toBe('score')
126
+ void target
127
+ } finally {
128
+ destroy()
129
+ }
130
+ })
131
+
132
+ it('defaults to passing the whole contract when a registration has no props mapping', async () => {
133
+ // A registration with no `props` should still work for a contract-aware
134
+ // editor - that is the point of the default mapping.
135
+ registerCellEditor('stars', SvRating)
136
+ const { api, target, destroy } = await mountGrid()
137
+ try {
138
+ api.startEditing(0, 'score')
139
+ await tick()
140
+ await tick()
141
+ expect(target.querySelector('.sv-rating')).not.toBeNull()
142
+ } finally {
143
+ destroy()
144
+ }
145
+ })
98
146
  })
@@ -15,6 +15,8 @@ import {
15
15
  const Fake = (() => {}) as unknown as Component<any>
16
16
  const Fake2 = (() => {}) as unknown as Component<any>
17
17
 
18
+ // onCommitAndMove / onRequestClose / inCell are deliberately left off: the
19
+ // tests below assert that defaultEditorProps maps them to undefined.
18
20
  function ctx(overrides: Partial<CellEditorContext> = {}): CellEditorContext {
19
21
  return {
20
22
  value: 42,
@@ -24,7 +26,7 @@ function ctx(overrides: Partial<CellEditorContext> = {}): CellEditorContext {
24
26
  onCommit: () => {},
25
27
  onCancel: () => {},
26
28
  ...overrides,
27
- }
29
+ } as CellEditorContext
28
30
  }
29
31
 
30
32
  afterEach(() => {
@@ -20,9 +20,20 @@
20
20
  * mounting.
21
21
  */
22
22
  import type { Component } from 'svelte'
23
+ import type { EditorInteraction } from './editor-contract'
23
24
 
24
- /** Context the grid hands a cell editor when it mounts one for an edit. */
25
- export type CellEditorContext<T = unknown> = {
25
+ /**
26
+ * Context the grid hands a cell editor when it mounts one for an edit.
27
+ *
28
+ * Extends {@link EditorInteraction} - the shared commit / cancel / move contract
29
+ * the `Sv*` editors are written against - and narrows the parts the grid always
30
+ * supplies, so a registered component and a built-in editor speak the same
31
+ * language. `onCommitAndMove`, `onRequestClose` and `inCell` come from the
32
+ * contract and are always populated by the grid.
33
+ */
34
+ export type CellEditorContext<T = unknown> = Required<
35
+ Pick<EditorInteraction<T>, 'onCommit' | 'onCancel' | 'onCommitAndMove' | 'onRequestClose' | 'inCell'>
36
+ > & {
26
37
  /** The value currently being edited. */
27
38
  value: T
28
39
  /** Id of the row being edited. */
@@ -31,10 +42,6 @@ export type CellEditorContext<T = unknown> = {
31
42
  columnId: string
32
43
  /** Update the in-progress value WITHOUT ending the edit. */
33
44
  onChange: (value: T) => void
34
- /** Commit the value and stop editing (optionally pass a final value). */
35
- onCommit: (value?: T) => void
36
- /** Discard changes and stop editing (returns focus to the grid). */
37
- onCancel: () => void
38
45
  }
39
46
 
40
47
  /** How a registered component is mounted and wired for a cell edit. */
@@ -87,13 +94,22 @@ export function registeredCellEditorTypes(): string[] {
87
94
  return [...registry.keys()]
88
95
  }
89
96
 
90
- /** The default context → props mapping used when a registration has no `props`. */
97
+ /**
98
+ * The default context → props mapping used when a registration has no `props`.
99
+ * Passes the whole {@link EditorInteraction} surface, so an editor written
100
+ * against the shared contract works when registered with no mapping at all.
101
+ * Svelte ignores props a component doesn't declare, so the extra keys are inert
102
+ * for a simple editor that only wants `value` + `onCommit`.
103
+ */
91
104
  export function defaultEditorProps(ctx: CellEditorContext): Record<string, unknown> {
92
105
  return {
93
106
  value: ctx.value,
94
107
  onChange: ctx.onChange,
95
108
  onCommit: ctx.onCommit,
96
109
  onCancel: ctx.onCancel,
110
+ onCommitAndMove: ctx.onCommitAndMove,
111
+ onRequestClose: ctx.onRequestClose,
112
+ inCell: ctx.inCell,
97
113
  }
98
114
  }
99
115
 
@@ -62,11 +62,11 @@ describe('isBucketableColumn', () => {
62
62
  const cols = makeColumns()
63
63
 
64
64
  it('returns { isDate: false } for a number column', () => {
65
- expect(isBucketableColumn(cols.num)).toEqual({ isDate: false })
65
+ expect(isBucketableColumn(cols.num!)).toEqual({ isDate: false })
66
66
  })
67
67
 
68
68
  it('returns { isDate: true } for a date column', () => {
69
- expect(isBucketableColumn(cols.when)).toEqual({ isDate: true })
69
+ expect(isBucketableColumn(cols.when!)).toEqual({ isDate: true })
70
70
  })
71
71
 
72
72
  it('returns { isDate: true } for a datetime column', () => {
@@ -81,15 +81,15 @@ describe('isBucketableColumn', () => {
81
81
  })
82
82
 
83
83
  it('returns null for a text column', () => {
84
- expect(isBucketableColumn(cols.id)).toBeNull()
84
+ expect(isBucketableColumn(cols.id!)).toBeNull()
85
85
  })
86
86
 
87
87
  it('returns null for a checkbox column', () => {
88
- expect(isBucketableColumn(cols.flag)).toBeNull()
88
+ expect(isBucketableColumn(cols.flag!)).toBeNull()
89
89
  })
90
90
 
91
91
  it('returns null for a column with no editorType', () => {
92
- expect(isBucketableColumn(cols.name)).toBeNull()
92
+ expect(isBucketableColumn(cols.name!)).toBeNull()
93
93
  })
94
94
  })
95
95
 
@@ -105,7 +105,7 @@ describe('buildBuckets - numeric', () => {
105
105
  name: '',
106
106
  flag: false,
107
107
  }))
108
- expect(buildBuckets(cols.num, false, data, accessor)).toBeNull()
108
+ expect(buildBuckets(cols.num!, false, data, accessor)).toBeNull()
109
109
  })
110
110
 
111
111
  it('builds 10 buckets when distinct values exceed the threshold', () => {
@@ -117,7 +117,7 @@ describe('buildBuckets - numeric', () => {
117
117
  name: '',
118
118
  flag: false,
119
119
  }))
120
- const buckets = buildBuckets(cols.num, false, data, accessor)
120
+ const buckets = buildBuckets(cols.num!, false, data, accessor)
121
121
  expect(buckets).not.toBeNull()
122
122
  expect(buckets!).toHaveLength(10)
123
123
  })
@@ -130,12 +130,12 @@ describe('buildBuckets - numeric', () => {
130
130
  name: '',
131
131
  flag: false,
132
132
  }))
133
- const buckets = buildBuckets(cols.num, false, data, accessor)!
134
- expect(buckets[0].numericMin).toBe(0)
135
- expect(buckets[9].numericMax).toBe(30)
133
+ const buckets = buildBuckets(cols.num!, false, data, accessor)!
134
+ expect(buckets[0]!.numericMin).toBe(0)
135
+ expect(buckets[9]!.numericMax).toBe(30)
136
136
  // bucket i's max equals bucket i+1's min (contiguous, no gaps)
137
137
  for (let i = 0; i < buckets.length - 1; i += 1) {
138
- expect(buckets[i].numericMax).toBeCloseTo(buckets[i + 1].numericMin, 10)
138
+ expect(buckets[i]!.numericMax).toBeCloseTo(buckets[i + 1]!.numericMin, 10)
139
139
  }
140
140
  })
141
141
 
@@ -147,10 +147,10 @@ describe('buildBuckets - numeric', () => {
147
147
  name: '',
148
148
  flag: false,
149
149
  }))
150
- const buckets = buildBuckets(cols.num, false, data, accessor)!
150
+ const buckets = buildBuckets(cols.num!, false, data, accessor)!
151
151
  expect(buckets.filter((b) => b.isLast)).toHaveLength(1)
152
- expect(buckets[9].isLast).toBe(true)
153
- expect(buckets[0].isLast).toBe(false)
152
+ expect(buckets[9]!.isLast).toBe(true)
153
+ expect(buckets[0]!.isLast).toBe(false)
154
154
  expect(buckets.every((b) => b.isDate === false)).toBe(true)
155
155
  })
156
156
 
@@ -162,8 +162,8 @@ describe('buildBuckets - numeric', () => {
162
162
  name: '',
163
163
  flag: false,
164
164
  }))
165
- const buckets = buildBuckets(cols.num, false, data, accessor)!
166
- expect(buckets[0].label).toContain('–')
165
+ const buckets = buildBuckets(cols.num!, false, data, accessor)!
166
+ expect(buckets[0]!.label).toContain('–')
167
167
  })
168
168
 
169
169
  it('ignores non-finite / blank values when computing min/max and distinct', () => {
@@ -180,10 +180,10 @@ describe('buildBuckets - numeric', () => {
180
180
  { id: 'b', num: undefined as unknown as number, when: '', name: '', flag: false },
181
181
  { id: 'c', num: 'not-a-number' as unknown as number, when: '', name: '', flag: false },
182
182
  ]
183
- const buckets = buildBuckets(cols.num, false, data, accessor)!
183
+ const buckets = buildBuckets(cols.num!, false, data, accessor)!
184
184
  // range is still 0..30 - the junk rows were skipped
185
- expect(buckets[0].numericMin).toBe(0)
186
- expect(buckets[9].numericMax).toBe(30)
185
+ expect(buckets[0]!.numericMin).toBe(0)
186
+ expect(buckets[9]!.numericMax).toBe(30)
187
187
  })
188
188
 
189
189
  it('returns null when all finite values are identical (min === max)', () => {
@@ -196,7 +196,7 @@ describe('buildBuckets - numeric', () => {
196
196
  name: '',
197
197
  flag: false,
198
198
  }))
199
- expect(buildBuckets(cols.num, false, data, accessor)).toBeNull()
199
+ expect(buildBuckets(cols.num!, false, data, accessor)).toBeNull()
200
200
  })
201
201
 
202
202
  it('returns null when there is no finite data at all', () => {
@@ -207,11 +207,11 @@ describe('buildBuckets - numeric', () => {
207
207
  name: '',
208
208
  flag: false,
209
209
  }))
210
- expect(buildBuckets(cols.num, false, data, accessor)).toBeNull()
210
+ expect(buildBuckets(cols.num!, false, data, accessor)).toBeNull()
211
211
  })
212
212
 
213
213
  it('returns null for an empty dataset', () => {
214
- expect(buildBuckets(cols.num, false, [], accessor)).toBeNull()
214
+ expect(buildBuckets(cols.num!, false, [], accessor)).toBeNull()
215
215
  })
216
216
 
217
217
  it('handles negative ranges', () => {
@@ -222,9 +222,9 @@ describe('buildBuckets - numeric', () => {
222
222
  name: '',
223
223
  flag: false,
224
224
  }))
225
- const buckets = buildBuckets(cols.num, false, data, accessor)!
226
- expect(buckets[0].numericMin).toBe(-20)
227
- expect(buckets[9].numericMax).toBe(20)
225
+ const buckets = buildBuckets(cols.num!, false, data, accessor)!
226
+ expect(buckets[0]!.numericMin).toBe(-20)
227
+ expect(buckets[9]!.numericMax).toBe(20)
228
228
  })
229
229
  })
230
230
 
@@ -241,11 +241,11 @@ describe('buildBuckets - date', () => {
241
241
  name: '',
242
242
  flag: false,
243
243
  }))
244
- const buckets = buildBuckets(cols.when, true, data, accessor)!
244
+ const buckets = buildBuckets(cols.when!, true, data, accessor)!
245
245
  expect(buckets).toHaveLength(10)
246
246
  expect(buckets.every((b) => b.isDate === true)).toBe(true)
247
247
  // labels should look like dates (contain a 4-digit year)
248
- expect(buckets[0].label).toMatch(/\d{4}/)
248
+ expect(buckets[0]!.label).toMatch(/\d{4}/)
249
249
  })
250
250
 
251
251
  it('accepts Date instances directly', () => {
@@ -257,9 +257,9 @@ describe('buildBuckets - date', () => {
257
257
  )
258
258
  // accessor reads row[column.id]; column id is 'when'
259
259
  const rows = data.map((d) => ({ when: d.when })) as unknown as Row[]
260
- const buckets = buildBuckets(cols.when, true, rows, accessor)!
260
+ const buckets = buildBuckets(cols.when!, true, rows, accessor)!
261
261
  expect(buckets).toHaveLength(10)
262
- expect(buckets[0].numericMin).toBe(start)
262
+ expect(buckets[0]!.numericMin).toBe(start)
263
263
  })
264
264
 
265
265
  it('returns null when date values are too few', () => {
@@ -270,7 +270,7 @@ describe('buildBuckets - date', () => {
270
270
  name: '',
271
271
  flag: false,
272
272
  }))
273
- expect(buildBuckets(cols.when, true, data, accessor)).toBeNull()
273
+ expect(buildBuckets(cols.when!, true, data, accessor)).toBeNull()
274
274
  })
275
275
 
276
276
  it('skips invalid date strings', () => {
@@ -286,9 +286,9 @@ describe('buildBuckets - date', () => {
286
286
  })),
287
287
  { id: 'bad', num: 0, when: 'not-a-date', name: '', flag: false },
288
288
  ]
289
- const buckets = buildBuckets(cols.when, true, data, accessor)!
289
+ const buckets = buildBuckets(cols.when!, true, data, accessor)!
290
290
  expect(buckets).toHaveLength(10)
291
- expect(buckets[0].numericMin).toBe(start)
291
+ expect(buckets[0]!.numericMin).toBe(start)
292
292
  })
293
293
  })
294
294
 
@@ -344,7 +344,7 @@ describe('isInBucket', () => {
344
344
  name: '',
345
345
  flag: false,
346
346
  }))
347
- const buckets = buildBuckets(cols.num, false, data, accessor)!
347
+ const buckets = buildBuckets(cols.num!, false, data, accessor)!
348
348
  for (const v of [0, 1, 15, 50, 99, 100]) {
349
349
  const hits = buckets.filter((b) => isInBucket(v, b))
350
350
  expect(hits).toHaveLength(1)
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { buildAutoGroupColumns, insertGroupFooters } from './group-display'
2
+ import { buildAutoGroupColumns, insertGroupFooters, paginateGroupedRows } from './group-display'
3
3
 
4
4
  type R = { id: string; depth: number; group?: boolean; total?: number; footer?: boolean }
5
5
 
@@ -75,3 +75,93 @@ describe('buildAutoGroupColumns', () => {
75
75
  expect(r.autoColumns.map((c) => c.field)).toEqual(['region', 'category'])
76
76
  })
77
77
  })
78
+
79
+ describe('paginateGroupedRows (#73)', () => {
80
+ // Ten groups of two rows each - the shape from the issue report, where
81
+ // slicing the flat list by pageSize leaves almost no data on a page.
82
+ const many: R[] = []
83
+ for (let g = 0; g < 10; g += 1) {
84
+ many.push({ id: `g${g}`, depth: 0, group: true })
85
+ many.push({ id: `g${g}r0`, depth: 1 })
86
+ many.push({ id: `g${g}r1`, depth: 1 })
87
+ }
88
+ const base = { getDepth: (r: R) => r.depth, isGroup: (r: R) => !!r.group }
89
+
90
+ it('counts only data rows against pageSize', () => {
91
+ const { rows, dataRowCount } = paginateGroupedRows(many, { ...base, pageIndex: 0, pageSize: 10 })
92
+ expect(dataRowCount).toBe(20)
93
+ // A naive slice would have yielded 10 rows TOTAL, only ~3 of them data.
94
+ expect(rows.filter((r) => !r.group)).toHaveLength(10)
95
+ })
96
+
97
+ it('reprints the banner each page a group appears on', () => {
98
+ const page0 = paginateGroupedRows(many, { ...base, pageIndex: 0, pageSize: 3 }).rows
99
+ expect(page0.map((r) => r.id)).toEqual(['g0', 'g0r0', 'g0r1', 'g1', 'g1r0'])
100
+ // g1 is reprinted because its second row leads the next page.
101
+ const page1 = paginateGroupedRows(many, { ...base, pageIndex: 1, pageSize: 3 }).rows
102
+ expect(page1.map((r) => r.id)).toEqual(['g1', 'g1r1', 'g2', 'g2r0', 'g2r1'])
103
+ })
104
+
105
+ it('emits a banner once for a group spanning many rows on one page', () => {
106
+ const wide: R[] = [{ id: 'g', depth: 0, group: true }]
107
+ for (let i = 0; i < 5; i += 1) wide.push({ id: `r${i}`, depth: 1 })
108
+ const { rows } = paginateGroupedRows(wide, { ...base, pageIndex: 0, pageSize: 5 })
109
+ expect(rows.filter((r) => r.group)).toHaveLength(1)
110
+ expect(rows).toHaveLength(6)
111
+ })
112
+
113
+ it('reprints the whole ancestor chain for nested groups', () => {
114
+ const nested: R[] = [
115
+ { id: 'A', depth: 0, group: true },
116
+ { id: 'A1', depth: 1, group: true },
117
+ { id: 'x', depth: 2 },
118
+ { id: 'y', depth: 2 },
119
+ ]
120
+ const page1 = paginateGroupedRows(nested, { ...base, pageIndex: 1, pageSize: 1 }).rows
121
+ expect(page1.map((r) => r.id)).toEqual(['A', 'A1', 'y'])
122
+ })
123
+
124
+ it('gives a COLLAPSED group its own page slot', () => {
125
+ // All groups collapsed: the flat model is banners only. Treating those as
126
+ // zero-cost headers left every page empty - a real bug caught by the DOM test.
127
+ const collapsed: R[] = []
128
+ for (let g = 0; g < 10; g += 1) collapsed.push({ id: `c${g}`, depth: 0, group: true })
129
+ const { rows, dataRowCount } = paginateGroupedRows(collapsed, {
130
+ ...base,
131
+ isExpanded: () => false,
132
+ pageIndex: 0,
133
+ pageSize: 4,
134
+ })
135
+ expect(dataRowCount).toBe(10)
136
+ expect(rows.map((r) => r.id)).toEqual(['c0', 'c1', 'c2', 'c3'])
137
+ })
138
+
139
+ it('mixes an expanded group with a collapsed sibling', () => {
140
+ const mixed: R[] = [
141
+ { id: 'open', depth: 0, group: true },
142
+ { id: 'o1', depth: 1 },
143
+ { id: 'o2', depth: 1 },
144
+ { id: 'shut', depth: 0, group: true },
145
+ ]
146
+ const { rows, dataRowCount } = paginateGroupedRows(mixed, {
147
+ ...base,
148
+ isExpanded: (r: R) => r.id === 'open',
149
+ pageIndex: 0,
150
+ pageSize: 5,
151
+ })
152
+ // 2 data rows + 1 collapsed group = 3 units; the open banner is free.
153
+ expect(dataRowCount).toBe(3)
154
+ expect(rows.map((r) => r.id)).toEqual(['open', 'o1', 'o2', 'shut'])
155
+ })
156
+
157
+ it('handles an out-of-range page', () => {
158
+ expect(paginateGroupedRows(many, { ...base, pageIndex: 99, pageSize: 10 }).rows).toEqual([])
159
+ })
160
+
161
+ it('leaves ungrouped data untouched', () => {
162
+ const flat: R[] = [{ id: 'a', depth: 0 }, { id: 'b', depth: 0 }, { id: 'c', depth: 0 }]
163
+ const { rows, dataRowCount } = paginateGroupedRows(flat, { ...base, pageIndex: 1, pageSize: 2 })
164
+ expect(dataRowCount).toBe(3)
165
+ expect(rows.map((r) => r.id)).toEqual(['c'])
166
+ })
167
+ })