@svgrid/grid 2.6.23 → 3.0.0
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/CHANGELOG.md +88 -88
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +181 -181
- package/dist/SvAutoComplete.svelte +169 -169
- package/dist/SvAvatar.svelte +75 -75
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +102 -102
- package/dist/SvCircularProgress.svelte +109 -109
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +279 -279
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +163 -163
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +378 -378
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGridCellEditor.svelte +744 -744
- package/dist/SvGridChart.svelte +1716 -1716
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +270 -270
- package/dist/SvGroupCell.svelte +116 -116
- package/dist/SvListBox.svelte +334 -334
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +293 -293
- package/dist/SvNumberInput.svelte +172 -172
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -133
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvProgress.svelte +116 -116
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRating.svelte +112 -112
- package/dist/SvResult.svelte +73 -73
- package/dist/SvRichText.svelte +211 -211
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +203 -203
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +115 -115
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +239 -239
- package/dist/cdn/{GridMenus-BzWXQjv3.js → GridMenus-DMihUtma.js} +3 -3
- package/dist/cdn/{GridMenus-xr_rHx8F.js → GridMenus-nmNDj1a3.js} +3 -3
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-Bh0A0JkF.js} +1 -1
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-DflbiP7N.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-BWwpfB_o.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-CCbDZNZB.js → SvDateTimePicker-Bivn8dAP.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-BAPSC7EL.js → SvGridCellEditor-AXL8cHGO.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-BwzmUWtL.js → SvGridCellEditor-Ba7rY3eu.js} +1 -1
- package/dist/cdn/{SvGridChart-BwVos976.js → SvGridChart-Bs2GIR2Q.js} +1 -1
- package/dist/cdn/{SvGridChart-BEJmNNx9.js → SvGridChart-CwhFz7GV.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-BOknOkrP.js → SvGridChartPanel-1WXuStzM.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-D2PjII4O.js → SvGridChartPanel-e5_nqNP9.js} +1 -1
- package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-SmPW10dI.js} +1 -1
- package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-eSPuJE6g.js} +1 -1
- package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
- package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CtdbHcIS.js} +1 -1
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CcP1tafP.js} +2 -2
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-DZT7T1wf.js} +2 -2
- package/dist/cdn/{row-drag-touch-Ddaa-QeB.js → row-drag-touch-CycRR65n.js} +12 -10
- package/dist/cdn/{src-skQN5eqh.js → src-B_YS5AOc.js} +45 -45
- package/dist/cdn/{src-DGo7IHug.js → src-DYXXpuSk.js} +44 -44
- package/dist/cdn/svgrid.js +7 -7
- package/dist/cdn/svgrid.svelte-external.js +7 -7
- package/dist/chart-export.js +8 -8
- package/dist/row-drag-touch.d.ts +5 -1
- package/dist/row-drag-touch.js +15 -4
- package/dist/row-drag.js +7 -3
- package/package.json +11 -11
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +181 -181
- package/src/SvAutoComplete.svelte +169 -169
- package/src/SvAvatar.svelte +75 -75
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +102 -102
- package/src/SvCircularProgress.svelte +109 -109
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +279 -279
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +163 -163
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +378 -378
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.types.ts +2092 -2092
- package/src/SvGridCellEditor.svelte +744 -744
- package/src/SvGridChart.svelte +1716 -1716
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +270 -270
- package/src/SvGroupCell.svelte +116 -116
- package/src/SvListBox.svelte +334 -334
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +293 -293
- package/src/SvNumberInput.svelte +172 -172
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -133
- package/src/SvPopover.svelte +197 -197
- package/src/SvProgress.svelte +116 -116
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRating.svelte +112 -112
- package/src/SvResult.svelte +73 -73
- package/src/SvRichText.svelte +211 -211
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +203 -203
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +115 -115
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +239 -239
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1419 -1419
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +846 -846
- package/src/builtin-editors.grid.test.ts +83 -83
- package/src/cell-formatting.ts +171 -171
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +148 -148
- package/src/chart-export.ts +202 -202
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2321 -2321
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.ts +78 -78
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1865 -1865
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createListbox.svelte.ts +250 -250
- package/src/createMenu.svelte.ts +224 -224
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +322 -322
- package/src/datetime/date-core.ts +206 -206
- package/src/datetime/date-restrict.ts +61 -61
- package/src/datetime/timezone.ts +135 -135
- package/src/dock-manager-model.ts +596 -596
- package/src/dock-model.ts +374 -374
- package/src/editing.test.ts +974 -974
- package/src/editing.ts +609 -609
- package/src/editor-contract.ts +171 -171
- package/src/editor-registry.grid.test.ts +144 -144
- package/src/editor-registry.ts +122 -122
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +601 -601
- package/src/filter-operators.ts +160 -160
- package/src/filtering/excel-filters.ts +325 -325
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/headless.ts +87 -87
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard-handlers.ts +270 -270
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +179 -179
- package/src/menus.ts +597 -597
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/positioning.ts +268 -268
- package/src/render-component.ts +28 -28
- package/src/row-drag-touch.ts +20 -5
- package/src/row-drag.test.ts +401 -353
- package/src/row-drag.ts +419 -415
- package/src/row-resize.test.ts +524 -524
- package/src/row-resize.ts +228 -228
- package/src/scheduler-ical.ts +181 -181
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +873 -873
- package/src/selection.test.ts +885 -885
- package/src/server-data-source.test.ts +383 -383
- package/src/server-data-source.ts +469 -469
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +113 -113
- package/src/svgrid-wrapper.types.ts +563 -563
- package/src/svgrid.async-editor-options.test.ts +273 -273
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +910 -910
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.comments-autocomplete.test.ts +127 -127
- package/src/svgrid.context-menu.test.ts +147 -147
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-depth.test.ts +163 -163
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -377
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.grand-total.test.ts +188 -188
- package/src/svgrid.group-display-mode.test.ts +171 -171
- package/src/svgrid.group-footers.test.ts +121 -121
- package/src/svgrid.group-pagination.test.ts +153 -153
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.tree-data.test.ts +186 -186
- package/src/svgrid.wrapper.test.ts +63 -63
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +288 -288
- package/src/toast-store.svelte.ts +250 -250
- package/src/toast-store.test.ts +147 -147
- package/src/tree-row-model.test.ts +168 -168
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-localization.test.ts +113 -113
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +322 -322
package/src/core.ts
CHANGED
|
@@ -1,1865 +1,1865 @@
|
|
|
1
|
-
import type { SparklineConfig } from './sparkline'
|
|
2
|
-
import { resolveColumnId } from './column-id'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The constraint every row type satisfies: an object keyed by string. Your own
|
|
6
|
-
* row type (`type Person = { name: string }`) is what flows through the generics
|
|
7
|
-
* below; this is only the lower bound they are declared against.
|
|
8
|
-
*/
|
|
9
|
-
export type RowData = Record<string, unknown>
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* A new value, or a function that derives it from the previous one - the shape
|
|
13
|
-
* every `set*` on the grid accepts, so callers can update state without first
|
|
14
|
-
* reading it.
|
|
15
|
-
*
|
|
16
|
-
* api.setSorting([{ id: 'name', desc: false }])
|
|
17
|
-
* api.setSorting((prev) => [...prev, { id: 'age', desc: true }])
|
|
18
|
-
*/
|
|
19
|
-
export type Updater<T> = T | ((prev: T) => T)
|
|
20
|
-
|
|
21
|
-
/** Active sort clauses, outermost first. `desc: false` is ascending. */
|
|
22
|
-
export type SortingState = Array<{ id: string; desc: boolean }>
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* One column's filter: the column `id`, the `value` being matched, and
|
|
26
|
-
* optionally which comparison to use. `fn` defaults to the column's own type -
|
|
27
|
-
* see {@link filterFns} for the available names.
|
|
28
|
-
*/
|
|
29
|
-
export type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }
|
|
30
|
-
|
|
31
|
-
/** Every active column filter. A column with no entry here is unfiltered. */
|
|
32
|
-
export type ColumnFiltersState = Array<ColumnFilter>
|
|
33
|
-
|
|
34
|
-
/** Current page position. `pageIndex` is 0-based, so page 1 is index 0. */
|
|
35
|
-
export type PaginationState = { pageIndex: number; pageSize: number }
|
|
36
|
-
|
|
37
|
-
/** Column ids the rows are grouped by, outermost first. */
|
|
38
|
-
export type GroupingState = Array<string>
|
|
39
|
-
|
|
40
|
-
/** Which rows are expanded, keyed by row id. Absent means collapsed. */
|
|
41
|
-
export type ExpandedState = Record<string, boolean>
|
|
42
|
-
|
|
43
|
-
/** Which rows are selected, keyed by row id. Absent means unselected. */
|
|
44
|
-
export type RowSelectionState = Record<string, boolean>
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Where keyboard focus sits. The indices address the *displayed* grid (after
|
|
48
|
-
* sorting, filtering and paging), not the source data.
|
|
49
|
-
*/
|
|
50
|
-
export type ActiveCellState = {
|
|
51
|
-
rowIndex: number
|
|
52
|
-
colIndex: number
|
|
53
|
-
cellId: string | null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The set of features a grid has registered, as built by {@link tableFeatures}.
|
|
58
|
-
* Deliberately open: a feature is identified by its key, so the type carries
|
|
59
|
-
* which ones are on without enumerating them.
|
|
60
|
-
*/
|
|
61
|
-
export type TableFeatures = Record<string, unknown>
|
|
62
|
-
|
|
63
|
-
/** A cell's value. Unconstrained - a column can hold anything. */
|
|
64
|
-
export type CellData = unknown
|
|
65
|
-
|
|
66
|
-
/** What a column's `header` render function receives. */
|
|
67
|
-
export type HeaderContext<TData extends RowData> = {
|
|
68
|
-
header: Header<TData>
|
|
69
|
-
column: Column<TData>
|
|
70
|
-
table: SvGrid<TData>
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* What a column's `cell` render function receives. `getValue()` applies the
|
|
75
|
-
* column's accessor (`field` or `fieldFn`); `row.original` is the raw object.
|
|
76
|
-
*/
|
|
77
|
-
export type CellContext<TData extends RowData> = {
|
|
78
|
-
cell: Cell<TData>
|
|
79
|
-
row: Row<TData>
|
|
80
|
-
column: Column<TData>
|
|
81
|
-
table: SvGrid<TData>
|
|
82
|
-
getValue: () => unknown
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
|
|
86
|
-
export type CellSpanParams<TData extends RowData = RowData> = {
|
|
87
|
-
/** The row's underlying data object. */
|
|
88
|
-
data: TData
|
|
89
|
-
/** Display-row index in the current (filtered/sorted) row set. */
|
|
90
|
-
rowIndex: number
|
|
91
|
-
/** The column's id. */
|
|
92
|
-
columnId: string
|
|
93
|
-
/** The cell's base value for this column. */
|
|
94
|
-
value: unknown
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The raw option list a column's `editorOptions` can supply. */
|
|
98
|
-
export type EditorOptionSource = ReadonlyArray<
|
|
99
|
-
string | number | { value: string | number; label?: string; color?: string }
|
|
100
|
-
>
|
|
101
|
-
|
|
102
|
-
/** Params passed to a column's `valueParser(...)` on edit commit. */
|
|
103
|
-
export type ValueParserParams<TData extends RowData = RowData> = {
|
|
104
|
-
/** The value after built-in per-`editorType` coercion. */
|
|
105
|
-
newValue: unknown
|
|
106
|
-
/** The cell's previous value. */
|
|
107
|
-
oldValue: unknown
|
|
108
|
-
/** The raw string the editor produced (pre-coercion). */
|
|
109
|
-
rawInput: string
|
|
110
|
-
/** The row's underlying data object. */
|
|
111
|
-
data: TData
|
|
112
|
-
/** The column's id. */
|
|
113
|
-
columnId: string
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Context passed to a custom `cellEditor` snippet/component. Three write
|
|
118
|
-
* helpers cover the lifecycle:
|
|
119
|
-
*
|
|
120
|
-
* - `update(next)` - stage `next` as the draft, keep the editor open.
|
|
121
|
-
* Use this for live-preview controls (sliders,
|
|
122
|
-
* color pickers) so the user can keep adjusting.
|
|
123
|
-
* - `commit(next?)` - write the value AND close the editor. The
|
|
124
|
-
* argument is optional; when omitted, the most
|
|
125
|
-
* recently `update()`d value is saved. Use this
|
|
126
|
-
* for "done" gestures (Enter, picking an option).
|
|
127
|
-
* - `cancel()` - discard the draft and close the editor.
|
|
128
|
-
*/
|
|
129
|
-
export type EditorContext<TData extends RowData> = CellContext<TData> & {
|
|
130
|
-
value: unknown
|
|
131
|
-
update: (next: unknown) => void
|
|
132
|
-
commit: (next?: unknown) => void
|
|
133
|
-
cancel: () => void
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Declarative cell formatting, applied through `Intl` - number, currency,
|
|
138
|
-
* percent, date and datetime. Prefer this over a `formatter` function: it is
|
|
139
|
-
* locale-aware, and export and the clipboard reuse the same configuration.
|
|
140
|
-
*/
|
|
141
|
-
export type CellFormatConfig =
|
|
142
|
-
| {
|
|
143
|
-
type: 'number'
|
|
144
|
-
locales?: string | Array<string>
|
|
145
|
-
options?: Intl.NumberFormatOptions
|
|
146
|
-
}
|
|
147
|
-
| {
|
|
148
|
-
type: 'currency'
|
|
149
|
-
/** ISO 4217 (default USD) */
|
|
150
|
-
currency?: string
|
|
151
|
-
locales?: string | Array<string>
|
|
152
|
-
options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>
|
|
153
|
-
}
|
|
154
|
-
| {
|
|
155
|
-
type: 'percent'
|
|
156
|
-
locales?: string | Array<string>
|
|
157
|
-
options?: Omit<Intl.NumberFormatOptions, 'style'>
|
|
158
|
-
/**
|
|
159
|
-
* If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).
|
|
160
|
-
* Default false.
|
|
161
|
-
*/
|
|
162
|
-
valueIsPercentPoints?: boolean
|
|
163
|
-
}
|
|
164
|
-
| {
|
|
165
|
-
type: 'date' | 'datetime'
|
|
166
|
-
locales?: string | Array<string>
|
|
167
|
-
/**
|
|
168
|
-
* Shortcut patterns merged with `options`:
|
|
169
|
-
* `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,
|
|
170
|
-
* `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.
|
|
171
|
-
*/
|
|
172
|
-
pattern?: string
|
|
173
|
-
options?: Intl.DateTimeFormatOptions
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* A column's custom display function, for anything {@link CellFormatConfig}
|
|
178
|
-
* cannot express. Returns a string - to render markup, use `cell` instead.
|
|
179
|
-
*/
|
|
180
|
-
export type CellFormatter<TData extends RowData> = (context: {
|
|
181
|
-
value: unknown
|
|
182
|
-
row: Row<TData>
|
|
183
|
-
column: Column<TData>
|
|
184
|
-
table: SvGrid<TData>
|
|
185
|
-
}) => string
|
|
186
|
-
|
|
187
|
-
/** A header or cell slot: a literal string, or a function returning renderable content. */
|
|
188
|
-
export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* How a column's value is aggregated for a group row when `columnGrouping`
|
|
192
|
-
* is active. Built-in reducers cover the common cases; pass a function for
|
|
193
|
-
* anything custom (weighted average, median, percentile, distinct count).
|
|
194
|
-
* The function receives the finite numeric values AND the raw leaf rows.
|
|
195
|
-
*/
|
|
196
|
-
export type GroupAggregator<TData = any> =
|
|
197
|
-
| 'sum'
|
|
198
|
-
| 'avg'
|
|
199
|
-
| 'min'
|
|
200
|
-
| 'max'
|
|
201
|
-
| 'count'
|
|
202
|
-
| 'countDistinct'
|
|
203
|
-
| 'extent'
|
|
204
|
-
| 'first'
|
|
205
|
-
| ((values: number[], rows: Array<TData>) => unknown)
|
|
206
|
-
|
|
207
|
-
/** Apply a group aggregator over a bucket's leaf rows for one column. */
|
|
208
|
-
export function applyGroupAggregate<TData extends RowData>(
|
|
209
|
-
agg: GroupAggregator<TData>,
|
|
210
|
-
columnId: string,
|
|
211
|
-
rows: ReadonlyArray<Row<TData>>,
|
|
212
|
-
): unknown {
|
|
213
|
-
// One pass, no intermediate arrays.
|
|
214
|
-
//
|
|
215
|
-
// This used to build a `raw` array, then a coerced one, then a filtered one -
|
|
216
|
-
// three allocations per aggregated column PER GROUP - before reducing. On a
|
|
217
|
-
// 100k-row grid grouped two levels deep, aggregation was about two thirds of
|
|
218
|
-
// the total grouping cost (213ms with three aggregators against 81ms with
|
|
219
|
-
// none), and each additional aggregated column added roughly 80ms.
|
|
220
|
-
//
|
|
221
|
-
// `count` first: it never needs to look at a value at all.
|
|
222
|
-
if (agg === 'count') return rows.length
|
|
223
|
-
|
|
224
|
-
if (agg === 'first') {
|
|
225
|
-
return rows.length ? rows[0]!.getCellValueByColumnId(columnId) : undefined
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (agg === 'countDistinct') {
|
|
229
|
-
const seen = new Set<string>()
|
|
230
|
-
for (const row of rows) seen.add(String(row.getCellValueByColumnId(columnId) ?? ''))
|
|
231
|
-
return seen.size
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (typeof agg === 'function') {
|
|
235
|
-
// Custom aggregators keep their contract: the finite numbers, then the
|
|
236
|
-
// original row objects. Note `Number(null)` is 0 and therefore finite, so
|
|
237
|
-
// nulls DO reach the callback as zeros - long-standing behaviour.
|
|
238
|
-
const nums: number[] = []
|
|
239
|
-
for (const row of rows) {
|
|
240
|
-
const n = Number(row.getCellValueByColumnId(columnId))
|
|
241
|
-
if (Number.isFinite(n)) nums.push(n)
|
|
242
|
-
}
|
|
243
|
-
return agg(nums, rows.map((r) => r.original))
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// sum / avg / min / max / extent share one accumulation pass.
|
|
247
|
-
let count = 0
|
|
248
|
-
let sum = 0
|
|
249
|
-
let min = Infinity
|
|
250
|
-
let max = -Infinity
|
|
251
|
-
for (const row of rows) {
|
|
252
|
-
const n = Number(row.getCellValueByColumnId(columnId))
|
|
253
|
-
if (!Number.isFinite(n)) continue
|
|
254
|
-
count++
|
|
255
|
-
// Left-to-right, matching the previous `reduce`, so float rounding is
|
|
256
|
-
// bit-identical rather than merely close.
|
|
257
|
-
sum += n
|
|
258
|
-
// Math.min/max on scalars rather than `<`, which differs on -0, and rather
|
|
259
|
-
// than the old `Math.min(...nums)` - spreading a whole group throws
|
|
260
|
-
// RangeError once the bucket is big enough to exhaust the argument stack.
|
|
261
|
-
min = Math.min(min, n)
|
|
262
|
-
max = Math.max(max, n)
|
|
263
|
-
}
|
|
264
|
-
if (!count) return undefined
|
|
265
|
-
switch (agg) {
|
|
266
|
-
case 'sum':
|
|
267
|
-
return sum
|
|
268
|
-
case 'avg':
|
|
269
|
-
return sum / count
|
|
270
|
-
case 'min':
|
|
271
|
-
return min
|
|
272
|
-
case 'max':
|
|
273
|
-
return max
|
|
274
|
-
case 'extent':
|
|
275
|
-
return `${min} – ${max}`
|
|
276
|
-
default:
|
|
277
|
-
return undefined
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* A column definition.
|
|
283
|
-
*
|
|
284
|
-
* `TFeatures` is a phantom parameter - it is threaded through nested
|
|
285
|
-
* `columns` groups but no member depends on it, so `{}`, `TableFeatures` and
|
|
286
|
-
* `typeof features` are all interchangeable here. It is deliberately left
|
|
287
|
-
* WITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot
|
|
288
|
-
* and silently type your data as `RowData`, losing every field-name check.
|
|
289
|
-
* Prefer {@link GridColumns} / {@link GridColumnDef} for the common case.
|
|
290
|
-
*/
|
|
291
|
-
export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
|
|
292
|
-
id?: string
|
|
293
|
-
field?: keyof TData & string
|
|
294
|
-
fieldFn?: (row: TData) => unknown
|
|
295
|
-
header?: ColumnDefTemplate<HeaderContext<TData>>
|
|
296
|
-
footer?: ColumnDefTemplate<HeaderContext<TData>>
|
|
297
|
-
cell?: ColumnDefTemplate<CellContext<TData>>
|
|
298
|
-
columns?: Array<ColumnDef<TFeatures, TData>>
|
|
299
|
-
/**
|
|
300
|
-
* Declarative cell spanning (merged cells). Return how many COLUMNS this
|
|
301
|
-
* cell spans to the right (1 = no span). Value-driven. Feed
|
|
302
|
-
* `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
|
|
303
|
-
* the same real `colspan`/`rowspan` merge engine (no separate code path).
|
|
304
|
-
*/
|
|
305
|
-
colSpan?: (params: CellSpanParams<TData>) => number
|
|
306
|
-
/**
|
|
307
|
-
* Declarative cell spanning (merged cells). Return how many ROWS this cell
|
|
308
|
-
* spans downward (1 = no span). See `colSpan` for how to apply.
|
|
309
|
-
*/
|
|
310
|
-
rowSpan?: (params: CellSpanParams<TData>) => number
|
|
311
|
-
/**
|
|
312
|
-
* High-level data type for the column. A convenience that resolves to the
|
|
313
|
-
* right `editorType`, alignment, date `format`, and filter operators without
|
|
314
|
-
* setting each by hand:
|
|
315
|
-
* 'text' → text editor, left-aligned
|
|
316
|
-
* 'number' → number editor, right-aligned, numeric filter operators
|
|
317
|
-
* 'boolean' → checkbox editor, centered
|
|
318
|
-
* 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
|
|
319
|
-
* 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
|
|
320
|
-
* Anything you set explicitly (`editorType`, `align`, `format`) still wins -
|
|
321
|
-
* `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
|
|
322
|
-
* this from the first data row for columns that declare neither.
|
|
323
|
-
*/
|
|
324
|
-
cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
|
|
325
|
-
/**
|
|
326
|
-
* Hide this column when the grid's `responsive` mode is on and the grid is
|
|
327
|
-
* narrower than this many pixels - drop low-priority columns on small
|
|
328
|
-
* screens. No effect unless the grid has `responsive` set.
|
|
329
|
-
*/
|
|
330
|
-
hideBelow?: number
|
|
331
|
-
/**
|
|
332
|
-
* For a column INSIDE a collapsible column group: `'open'` shows this column
|
|
333
|
-
* only while the group is expanded, `'closed'` only while collapsed. Omit to
|
|
334
|
-
* always show it. Setting it on any direct child gives the parent group a
|
|
335
|
-
* collapse toggle. Pair with `openByDefault` on the group.
|
|
336
|
-
*/
|
|
337
|
-
columnGroupShow?: 'open' | 'closed'
|
|
338
|
-
/**
|
|
339
|
-
* For a GROUP column (one with `columns: [...]`): start the group expanded.
|
|
340
|
-
* Defaults to `false` (collapsed), the conventional default - so only the always-on
|
|
341
|
-
* and `columnGroupShow: 'closed'` children show until the user expands it.
|
|
342
|
-
*/
|
|
343
|
-
openByDefault?: boolean
|
|
344
|
-
editorType?:
|
|
345
|
-
| 'text'
|
|
346
|
-
| 'number'
|
|
347
|
-
| 'date' // rich SvCalendar popover (opt out with 'date-native')
|
|
348
|
-
| 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
|
|
349
|
-
| 'time' // rich SvTimePicker dial (opt out with 'time-native')
|
|
350
|
-
| 'date-native' // plain <input type="date">
|
|
351
|
-
| 'datetime-native' // plain <input type="datetime-local">
|
|
352
|
-
| 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
|
|
353
|
-
| 'password' // native <input type="password"> with masked rendering
|
|
354
|
-
| 'checkbox'
|
|
355
|
-
| 'list'
|
|
356
|
-
| 'chips'
|
|
357
|
-
| 'select' // custom dropdown - single value, no typeahead
|
|
358
|
-
| 'rich-select' // custom dropdown with a typeahead search input
|
|
359
|
-
| 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)
|
|
360
|
-
| 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline
|
|
361
|
-
| 'color' // native <input type="color"> swatch
|
|
362
|
-
| 'rating' // 5-star rating control
|
|
363
|
-
// Any other string names a CUSTOM editor registered via `registerCellEditor`
|
|
364
|
-
// (or `registerBuiltinEditors`). `(string & {})` keeps the literals above
|
|
365
|
-
// autocompleting while allowing arbitrary custom type names.
|
|
366
|
-
| (string & {})
|
|
367
|
-
/**
|
|
368
|
-
* Custom in-cell editor. Receives the cell context PLUS a `commit(value)`
|
|
369
|
-
* and `cancel()` helper. Use when none of the built-in `editorType`s fit;
|
|
370
|
-
* the snippet's outer element is mounted inside the editing cell and
|
|
371
|
-
* inherits keyboard handling (Esc cancels, Enter commits unless your
|
|
372
|
-
* snippet preventDefaults it).
|
|
373
|
-
*
|
|
374
|
-
* Coexists with `editorType`: when both are set, `cellEditor` wins and
|
|
375
|
-
* `editorType` is treated as a hint for parsing the saved value.
|
|
376
|
-
*/
|
|
377
|
-
cellEditor?: ColumnDefTemplate<EditorContext<TData>>
|
|
378
|
-
/**
|
|
379
|
-
* Per-column tooltip. String shows as a native `title=`; `(ctx) => string`
|
|
380
|
-
* runs per cell so the tooltip can reflect the value. Returning an empty
|
|
381
|
-
* string skips the tooltip.
|
|
382
|
-
*/
|
|
383
|
-
tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
|
|
384
|
-
/**
|
|
385
|
-
* Declarative per-cell validation. Runs for EVERY
|
|
386
|
-
* rendered cell - including values already present in `data` on load, not
|
|
387
|
-
* just on edit - so bad data is flagged immediately. Invalid cells get the
|
|
388
|
-
* `sv-grid-cell-invalid` class (red highlight) and the returned message as
|
|
389
|
-
* their tooltip.
|
|
390
|
-
*
|
|
391
|
-
* Return value:
|
|
392
|
-
* - `null` / `undefined` / `true` → valid (no highlight)
|
|
393
|
-
* - `false` → invalid, no message
|
|
394
|
-
* - a non-empty `string` → invalid, string is the tooltip
|
|
395
|
-
*
|
|
396
|
-
* The value keeps rendering as-is (the grid does NOT roll it back); pair
|
|
397
|
-
* with `onCellValueChange` if you also want to reject the commit.
|
|
398
|
-
*/
|
|
399
|
-
validate?: (params: {
|
|
400
|
-
value: unknown
|
|
401
|
-
row: TData
|
|
402
|
-
rowIndex: number
|
|
403
|
-
column: Column<TData>
|
|
404
|
-
}) => string | boolean | null | undefined
|
|
405
|
-
/**
|
|
406
|
-
* Gate editing per column or per cell.
|
|
407
|
-
*
|
|
408
|
-
* - `true` (or omitted): the column is fully editable.
|
|
409
|
-
* - `false`: the column is read-only - double-click, type-to-edit,
|
|
410
|
-
* fill-handle drag, Delete, and clipboard paste all skip it.
|
|
411
|
-
* - `(ctx) => boolean`: evaluated for each cell, so you can lock
|
|
412
|
-
* individual rows (e.g. by role, status, ownership). Returning
|
|
413
|
-
* `false` opts the cell out of every editing path, identical to
|
|
414
|
-
* setting `editable: false` on the whole column for that row.
|
|
415
|
-
*
|
|
416
|
-
* The grid-wide `enableInlineEditing` prop still wins when set to
|
|
417
|
-
* `false`.
|
|
418
|
-
*/
|
|
419
|
-
editable?: boolean | ((context: CellContext<TData>) => boolean)
|
|
420
|
-
/**
|
|
421
|
-
* Transform the committed edit value before it is written to the row.
|
|
422
|
-
* Runs after the built-in per-`editorType` coercion, so `newValue` is
|
|
423
|
-
* already type-parsed; return the final value to store (e.g. round a
|
|
424
|
-
* number, uppercase a code, look up an id). A `valueParser` hook.
|
|
425
|
-
*/
|
|
426
|
-
valueParser?: (params: ValueParserParams<TData>) => unknown
|
|
427
|
-
/**
|
|
428
|
-
* Briefly flash / highlight this column's cell when its value changes
|
|
429
|
-
* (streaming feeds, edits, server pushes). `true` uses the default flash;
|
|
430
|
-
* pass `{ className }` to apply your own animation class instead.
|
|
431
|
-
*/
|
|
432
|
-
cellFlash?: boolean | { className?: string }
|
|
433
|
-
/**
|
|
434
|
-
* When `false`, this column never shows a sort indicator and clicking
|
|
435
|
-
* its header is a no-op - `api.setSort(thisColumn, ...)` is also
|
|
436
|
-
* ignored. Defaults to `true` (the column participates in sorting as
|
|
437
|
-
* long as `rowSortingFeature` is registered).
|
|
438
|
-
*/
|
|
439
|
-
sortable?: boolean
|
|
440
|
-
/**
|
|
441
|
-
* When `false`, this column never shows a filter funnel / menu and
|
|
442
|
-
* `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the
|
|
443
|
-
* column is filterable as long as `columnFilteringFeature` is
|
|
444
|
-
* registered).
|
|
445
|
-
*/
|
|
446
|
-
filterable?: boolean
|
|
447
|
-
/**
|
|
448
|
-
* Options for `editorType: 'list' | 'chips'`. Either bare values (the
|
|
449
|
-
* string is both value and label) or `{ value, label }` objects.
|
|
450
|
-
* For `chips` this is optional - when omitted, the chips editor becomes
|
|
451
|
-
* free-form (user types and presses Enter to commit a chip).
|
|
452
|
-
*
|
|
453
|
-
* Pass a function `(row) => options` for row-dependent (cascading)
|
|
454
|
-
* options - e.g. City options that depend on Country in the same row.
|
|
455
|
-
*
|
|
456
|
-
* Either form may return a **Promise**, for options that come from the
|
|
457
|
-
* server. While it resolves, the editor shows a loading state and the cell
|
|
458
|
-
* renders its raw value.
|
|
459
|
-
*
|
|
460
|
-
* Results are cached so reopening an editor does not refetch: a static source
|
|
461
|
-
* per column, a per-row source per row AND per that row's data - so a cascade
|
|
462
|
-
* reloads by itself when the cell it depends on is edited. Call
|
|
463
|
-
* `api.refreshEditorOptions(columnId?)` when the list changes server-side.
|
|
464
|
-
*/
|
|
465
|
-
editorOptions?:
|
|
466
|
-
| EditorOptionSource
|
|
467
|
-
| Promise<EditorOptionSource>
|
|
468
|
-
| ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
|
|
469
|
-
/** When true, list/chips allow multiple selections. Cell value becomes an array. */
|
|
470
|
-
editorMultiple?: boolean
|
|
471
|
-
/** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
|
|
472
|
-
editorSeparator?: string
|
|
473
|
-
format?: CellFormatConfig
|
|
474
|
-
formatter?: CellFormatter<TData>
|
|
475
|
-
/**
|
|
476
|
-
* Aggregate this column's values into the group row when grouping is
|
|
477
|
-
* active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |
|
|
478
|
-
* 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result
|
|
479
|
-
* is formatted with this column's `format` and shown in the group header.
|
|
480
|
-
*/
|
|
481
|
-
aggregate?: GroupAggregator<TData>
|
|
482
|
-
/**
|
|
483
|
-
* What this column contributes to the grid's footer summary row (the one
|
|
484
|
-
* turned on with `summary` / `enableRowSummaries`). Takes the same
|
|
485
|
-
* aggregators as {@link aggregate}, and the result is formatted with this
|
|
486
|
-
* column's `format`.
|
|
487
|
-
*
|
|
488
|
-
* Without it the footer falls back to its default: the sum of a numeric
|
|
489
|
-
* column, `Count: N` otherwise. Set `false` to leave the cell blank, which is
|
|
490
|
-
* usually what an actions or checkbox column wants.
|
|
491
|
-
*
|
|
492
|
-
* { field: 'amount', summary: 'avg' }
|
|
493
|
-
* { id: 'actions', summary: false }
|
|
494
|
-
*/
|
|
495
|
-
summary?: GroupAggregator<TData> | false
|
|
496
|
-
/**
|
|
497
|
-
* Render the cell as an in-cell sparkline chart. The cell value should be
|
|
498
|
-
* an array of numbers (or a comma/space separated string). Mutually
|
|
499
|
-
* exclusive with a custom `cell` renderer (a `cell` wins if both are set).
|
|
500
|
-
*
|
|
501
|
-
* { sparkline: { type: 'line' } } // default line
|
|
502
|
-
* { sparkline: { type: 'bar', color: '#16a34a' } }
|
|
503
|
-
* { sparkline: { type: 'winloss' } } // sign-only up/down
|
|
504
|
-
*
|
|
505
|
-
* See `SparklineConfig` for the full option set (type, color,
|
|
506
|
-
* negativeColor, width, height, fixed min/max).
|
|
507
|
-
*/
|
|
508
|
-
sparkline?: SparklineConfig
|
|
509
|
-
/** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
|
|
510
|
-
width?: number
|
|
511
|
-
/**
|
|
512
|
-
* Whether the user may resize this column. Only consulted when the grid has
|
|
513
|
-
* `columnResize` on - it narrows that, it does not enable anything.
|
|
514
|
-
*
|
|
515
|
-
* `false` removes the column's drag handle entirely, so pointer drag, the
|
|
516
|
-
* keyboard arrows and double-click-to-autosize are all gone with it, and the
|
|
517
|
-
* column menu drops its Autosize item. Use it for the columns whose width is
|
|
518
|
-
* part of the layout rather than a preference: a row-number gutter, a
|
|
519
|
-
* checkbox column, a fixed icon column.
|
|
520
|
-
*
|
|
521
|
-
* Programmatic sizing is unaffected - `api.autosizeColumn()`,
|
|
522
|
-
* `api.setColumnWidth()` and `fitColumns` all still apply, the same way they
|
|
523
|
-
* do when `columnResize` is off. This governs the user affordance only.
|
|
524
|
-
*/
|
|
525
|
-
resizable?: boolean
|
|
526
|
-
/**
|
|
527
|
-
* Initial visibility. Set `false` to start the column hidden while still
|
|
528
|
-
* listing it in the Choose Columns UI for the user to re-enable. Applied
|
|
529
|
-
* once at mount; after that `api.setColumnVisible` / user toggles win.
|
|
530
|
-
* On a group column, `false` hides the whole group's leaf columns.
|
|
531
|
-
*/
|
|
532
|
-
visible?: boolean
|
|
533
|
-
/**
|
|
534
|
-
* Horizontal alignment for header and body cells. When omitted, the
|
|
535
|
-
* default is inferred from `editorType`:
|
|
536
|
-
* - `'number' | 'date' | 'datetime'` → `'right'`
|
|
537
|
-
* - `'checkbox'` → `'center'`
|
|
538
|
-
* - everything else → `'left'`
|
|
539
|
-
*/
|
|
540
|
-
align?: 'left' | 'center' | 'right'
|
|
541
|
-
/**
|
|
542
|
-
* Per-cell conditional CSS. Two shapes:
|
|
543
|
-
*
|
|
544
|
-
* - **String** (or array of strings): class name(s) added to the
|
|
545
|
-
* cell's `<td>` for every row in this column.
|
|
546
|
-
* - **Function**: invoked per cell with the same `CellContext` shape
|
|
547
|
-
* the `cell` renderer receives. Return a string, an array of
|
|
548
|
-
* strings, or an object mapping class names to booleans.
|
|
549
|
-
*
|
|
550
|
-
* Use it for status tinting, conditional bold, "negative number"
|
|
551
|
-
* coloring - anything that's a function of the row's value. Cells
|
|
552
|
-
* still receive their format / cell renderer; the class just
|
|
553
|
-
* augments the rendered `<td>`.
|
|
554
|
-
*/
|
|
555
|
-
cellClass?:
|
|
556
|
-
| string
|
|
557
|
-
| ReadonlyArray<string>
|
|
558
|
-
| ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* A column definition keyed only by your row type - the ergonomic form of
|
|
563
|
-
* {@link ColumnDef}, whose first parameter is a phantom feature bag that is
|
|
564
|
-
* almost always `{}`.
|
|
565
|
-
*
|
|
566
|
-
* ```ts
|
|
567
|
-
* const columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]
|
|
568
|
-
* ```
|
|
569
|
-
*
|
|
570
|
-
* Interchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,
|
|
571
|
-
* TData>` in both directions, so it mixes freely with existing code.
|
|
572
|
-
*/
|
|
573
|
-
export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>
|
|
574
|
-
|
|
575
|
-
/** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
|
|
576
|
-
export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* A resolved column: your {@link ColumnDef} plus everything the grid computed
|
|
580
|
-
* from it - its id, its depth under any group header, and the sort handlers a
|
|
581
|
-
* header needs. This is what you receive in render contexts; the `ColumnDef`
|
|
582
|
-
* is what you wrote.
|
|
583
|
-
*/
|
|
584
|
-
export type Column<TData extends RowData> = {
|
|
585
|
-
id: string
|
|
586
|
-
columnDef: ColumnDef<any, TData>
|
|
587
|
-
depth: number
|
|
588
|
-
parentId?: string
|
|
589
|
-
getCanSort: () => boolean
|
|
590
|
-
getCanFilter: () => boolean
|
|
591
|
-
getIsSorted: () => false | 'asc' | 'desc'
|
|
592
|
-
getToggleSortingHandler: () => () => void
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* One header cell. `colSpan` is how many leaf columns it covers, and
|
|
597
|
-
* `isPlaceholder` marks the empty cells that pad a group-header row so the
|
|
598
|
-
* levels line up.
|
|
599
|
-
*/
|
|
600
|
-
export type Header<TData extends RowData> = {
|
|
601
|
-
id: string
|
|
602
|
-
isPlaceholder: boolean
|
|
603
|
-
colSpan: number
|
|
604
|
-
column: Column<TData>
|
|
605
|
-
getContext: () => HeaderContext<TData>
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/** One row of header cells. A grid with grouped columns has several, outermost first. */
|
|
609
|
-
export type HeaderGroup<TData extends RowData> = {
|
|
610
|
-
id: string
|
|
611
|
-
headers: Array<Header<TData>>
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/** One cell: the intersection of a {@link Row} and a {@link Column}. */
|
|
615
|
-
export type Cell<TData extends RowData> = {
|
|
616
|
-
id: string
|
|
617
|
-
row: Row<TData>
|
|
618
|
-
column: Column<TData>
|
|
619
|
-
getValue: () => unknown
|
|
620
|
-
getContext: () => CellContext<TData>
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* A row in the display model. `original` is your untouched data object;
|
|
625
|
-
* everything else is grid-computed. `index` is the position in the displayed
|
|
626
|
-
* set, so it shifts as sorting and filtering change - key on `id`, not index.
|
|
627
|
-
*
|
|
628
|
-
* Group rows and tree parents carry `subRows`; a plain data row does not.
|
|
629
|
-
*/
|
|
630
|
-
export type Row<TData extends RowData> = {
|
|
631
|
-
id: string
|
|
632
|
-
index: number
|
|
633
|
-
original: TData
|
|
634
|
-
depth: number
|
|
635
|
-
subRows?: Array<Row<TData>>
|
|
636
|
-
/** Total leaf (data) rows under this group row. Undefined for data rows. */
|
|
637
|
-
leafCount?: number
|
|
638
|
-
getCanExpand: () => boolean
|
|
639
|
-
getIsExpanded: () => boolean
|
|
640
|
-
toggleExpanded: () => void
|
|
641
|
-
getIsSelected: () => boolean
|
|
642
|
-
toggleSelected: () => void
|
|
643
|
-
getAllCells: () => Array<Cell<TData>>
|
|
644
|
-
getCellValueByColumnId: (columnId: string) => unknown
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** The output of the row pipeline: the rows to display, in order. */
|
|
648
|
-
export type RowModel<TData extends RowData> = {
|
|
649
|
-
rows: Array<Row<TData>>
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* The minimal reactive store behind the headless core - read `state`, write
|
|
654
|
-
* through `setState`, and `subscribe` for changes. Deliberately framework
|
|
655
|
-
* free, which is what lets the core run under plain Node.
|
|
656
|
-
*
|
|
657
|
-
* In Svelte you rarely touch this: `subscribeGrid` wraps it with fine-grained
|
|
658
|
-
* selectors so a component only re-runs for the slice it read.
|
|
659
|
-
*/
|
|
660
|
-
export type Store<T> = {
|
|
661
|
-
readonly state: T
|
|
662
|
-
setState: (updater: (prev: T) => T) => void
|
|
663
|
-
subscribe: (listener: () => void) => () => void
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function createStore<T>(initial: T): Store<T> {
|
|
667
|
-
let value = initial
|
|
668
|
-
const listeners = new Set<() => void>()
|
|
669
|
-
return {
|
|
670
|
-
get state() {
|
|
671
|
-
return value
|
|
672
|
-
},
|
|
673
|
-
setState(updater) {
|
|
674
|
-
value = updater(value)
|
|
675
|
-
listeners.forEach((listener) => listener())
|
|
676
|
-
},
|
|
677
|
-
subscribe(listener) {
|
|
678
|
-
listeners.add(listener)
|
|
679
|
-
return () => listeners.delete(listener)
|
|
680
|
-
},
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Click-to-sort. Injected by the `sortable` shortcut.
|
|
686
|
-
*
|
|
687
|
-
* This and the five features below are opaque markers: pass the ones you want
|
|
688
|
-
* to {@link tableFeatures} and the grid wires up the matching row model. With
|
|
689
|
-
* `<SvGrid>` you rarely name them - the boolean shortcuts (`sortable`,
|
|
690
|
-
* `filterable`, `pageable`, `groupable`) inject them for you. Reach for them
|
|
691
|
-
* directly when driving the headless core, or when you want a feature on
|
|
692
|
-
* without its UI.
|
|
693
|
-
*
|
|
694
|
-
* The names match TanStack Table v9, so a features object written for it works
|
|
695
|
-
* here unchanged.
|
|
696
|
-
*/
|
|
697
|
-
export const rowSortingFeature = { key: 'rowSortingFeature' }
|
|
698
|
-
/** Per-column filtering. Injected by the `filterable` shortcut. */
|
|
699
|
-
export const columnFilteringFeature = { key: 'columnFilteringFeature' }
|
|
700
|
-
/** Paging of the row model. Injected by the `pageable` shortcut. */
|
|
701
|
-
export const rowPaginationFeature = { key: 'rowPaginationFeature' }
|
|
702
|
-
/** Row grouping with aggregation. Injected by the `groupable` shortcut. */
|
|
703
|
-
export const columnGroupingFeature = { key: 'columnGroupingFeature' }
|
|
704
|
-
/** Row selection state (the checkbox column reads it). */
|
|
705
|
-
export const rowSelectionFeature = { key: 'rowSelectionFeature' }
|
|
706
|
-
/** Expand / collapse, for tree rows and master-detail. */
|
|
707
|
-
export const rowExpandingFeature = { key: 'rowExpandingFeature' }
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Declare which features a grid uses. Identity at runtime - its whole job is to
|
|
711
|
-
* capture the exact set in the type, so `ColumnDef<typeof features, Row>` knows
|
|
712
|
-
* what is registered and anything you did not register is tree-shaken out.
|
|
713
|
-
*
|
|
714
|
-
* ```ts
|
|
715
|
-
* const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
|
|
716
|
-
* ```
|
|
717
|
-
*
|
|
718
|
-
* Same call signature as TanStack Table v9, so a features object written for it
|
|
719
|
-
* transfers unchanged.
|
|
720
|
-
*/
|
|
721
|
-
export function tableFeatures<T extends TableFeatures>(features: T): T {
|
|
722
|
-
return features
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Built-in comparators, chosen per column by its data type. `auto` compares as
|
|
727
|
-
* text; set a column's type or supply your own comparator to override.
|
|
728
|
-
*/
|
|
729
|
-
export const sortFns = {
|
|
730
|
-
auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
|
|
731
|
-
number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
|
|
732
|
-
date: (a: unknown, b: unknown) => {
|
|
733
|
-
const aa = new Date(a as any).getTime()
|
|
734
|
-
const bb = new Date(b as any).getTime()
|
|
735
|
-
return aa - bb
|
|
736
|
-
},
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/**
|
|
740
|
-
* Built-in match functions, named by {@link ColumnFilter}'s `fn`.
|
|
741
|
-
* `includesString` is case-insensitive substring; `equals` is strict identity.
|
|
742
|
-
*/
|
|
743
|
-
export const filterFns = {
|
|
744
|
-
includesString: (value: unknown, query: string) =>
|
|
745
|
-
String(value).toLowerCase().includes(query.toLowerCase()),
|
|
746
|
-
equals: (value: unknown, query: unknown) => value === query,
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/**
|
|
750
|
-
* Everything a base row needs that is the same for every row in the table.
|
|
751
|
-
*
|
|
752
|
-
* One object per table, referenced by every row, instead of one closure scope
|
|
753
|
-
* per row. See {@link BASE_ROW_METHODS}.
|
|
754
|
-
*/
|
|
755
|
-
type BaseRowCtx<TData extends RowData> = {
|
|
756
|
-
grid: SvGrid<TData>
|
|
757
|
-
store: { state: Record<string, any> }
|
|
758
|
-
columns: Array<Column<TData>>
|
|
759
|
-
columnCount: number
|
|
760
|
-
columnIndexById: Map<string, number>
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Keys for a base row's private fields.
|
|
765
|
-
*
|
|
766
|
-
* Symbols, not string keys, and that is load-bearing. A row's shared methods
|
|
767
|
-
* need a pointer back to the table, but `_ctx` as a normal property made every
|
|
768
|
-
* row serialise the entire grid: `JSON.stringify(oneRow)` grew with the dataset
|
|
769
|
-
* (981 chars at 3 rows, 67,719 at 3,000) because `options.data` is reachable
|
|
770
|
-
* through it, so stringifying a row model was quadratic. Rows used to serialise
|
|
771
|
-
* to a small constant and must again.
|
|
772
|
-
*
|
|
773
|
-
* A symbol key is invisible to `JSON.stringify`, `Object.keys` and `for...in`,
|
|
774
|
-
* yet IS copied by object spread - which matters because several row models
|
|
775
|
-
* legitimately do `{ ...row, depth }` and the clone needs these to work.
|
|
776
|
-
* Non-enumerable string keys would have hidden them from JSON but also from the
|
|
777
|
-
* spread, silently breaking every cloned row.
|
|
778
|
-
*/
|
|
779
|
-
const ROW_CTX = Symbol('svgrid.row.ctx')
|
|
780
|
-
const ROW_VALUES = Symbol('svgrid.row.values')
|
|
781
|
-
const ROW_CELLS = Symbol('svgrid.row.cells')
|
|
782
|
-
|
|
783
|
-
/** A base row's private fields, on top of the public {@link Row} surface. */
|
|
784
|
-
type BaseRowState<TData extends RowData> = Row<TData> & {
|
|
785
|
-
[ROW_CTX]: BaseRowCtx<TData>
|
|
786
|
-
[ROW_VALUES]: Array<unknown> | null
|
|
787
|
-
[ROW_CELLS]: Array<Cell<TData>> | null
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* The methods every base row carries, defined ONCE and assigned by reference.
|
|
792
|
-
*
|
|
793
|
-
* Rows used to be built as object literals whose methods were closures, which
|
|
794
|
-
* meant a 100k-row grid allocated 700k closures and a closure scope per row
|
|
795
|
-
* before painting anything. Measured at 100k x 9: 13.8 ms and 56.5 MB to build,
|
|
796
|
-
* against 2.0 ms and 14.5 MB for this shape - the single largest cost in
|
|
797
|
-
* mounting a large grid.
|
|
798
|
-
*
|
|
799
|
-
* They read their row through `this` rather than a captured variable, which is
|
|
800
|
-
* why they can be shared. Note they are assigned as OWN properties rather than
|
|
801
|
-
* put on a prototype: `Row` is public, several row models legitimately do
|
|
802
|
-
* `{ ...row, depth }`, and a spread copies own properties but not a prototype.
|
|
803
|
-
* A class here would silently strip every method off a cloned row.
|
|
804
|
-
*/
|
|
805
|
-
const BASE_ROW_METHODS = {
|
|
806
|
-
getCanExpand(this: BaseRowState<RowData>) {
|
|
807
|
-
return false
|
|
808
|
-
},
|
|
809
|
-
getIsExpanded(this: BaseRowState<RowData>) {
|
|
810
|
-
return Boolean((this[ROW_CTX].store.state.expanded ?? {})[this.id])
|
|
811
|
-
},
|
|
812
|
-
toggleExpanded(this: BaseRowState<RowData>) {
|
|
813
|
-
const id = this.id
|
|
814
|
-
this[ROW_CTX].grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
815
|
-
},
|
|
816
|
-
getIsSelected(this: BaseRowState<RowData>) {
|
|
817
|
-
return Boolean((this[ROW_CTX].store.state.rowSelection ?? {})[this.id])
|
|
818
|
-
},
|
|
819
|
-
toggleSelected(this: BaseRowState<RowData>) {
|
|
820
|
-
const id = this.id
|
|
821
|
-
this[ROW_CTX].grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
822
|
-
},
|
|
823
|
-
getAllCells(this: BaseRowState<RowData>) {
|
|
824
|
-
return this[ROW_CELLS] ?? buildBaseRowCells(this)
|
|
825
|
-
},
|
|
826
|
-
getCellValueByColumnId(this: BaseRowState<RowData>, columnId: string) {
|
|
827
|
-
const idx = this[ROW_CTX].columnIndexById.get(columnId)
|
|
828
|
-
if (idx === undefined) return undefined
|
|
829
|
-
if (!this[ROW_VALUES]) this[ROW_VALUES] = baseRowValues(this)
|
|
830
|
-
return this[ROW_VALUES][idx]
|
|
831
|
-
},
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Materialise one row's `Cell[]`, memoised on the row.
|
|
836
|
-
*
|
|
837
|
-
* A free function taking the row rather than a method using `this`, because the
|
|
838
|
-
* cell closures need a stable reference to it and aliasing `this` inside a
|
|
839
|
-
* method is exactly the pattern that produces `self`/`that` bugs.
|
|
840
|
-
*/
|
|
841
|
-
function buildBaseRowCells<TData extends RowData>(row: BaseRowState<TData>): Array<Cell<TData>> {
|
|
842
|
-
const { columns, columnCount, grid } = row[ROW_CTX]
|
|
843
|
-
const built = new Array<Cell<TData>>(columnCount)
|
|
844
|
-
for (let i = 0; i < columnCount; i++) {
|
|
845
|
-
const column = columns[i]!
|
|
846
|
-
const colIndex = i
|
|
847
|
-
const cell: Cell<TData> = {
|
|
848
|
-
id: `${row.id}_${column.id}`,
|
|
849
|
-
row,
|
|
850
|
-
column,
|
|
851
|
-
getValue: () => {
|
|
852
|
-
if (!row[ROW_VALUES]) row[ROW_VALUES] = baseRowValues(row)
|
|
853
|
-
return row[ROW_VALUES][colIndex]
|
|
854
|
-
},
|
|
855
|
-
getContext: () => ({
|
|
856
|
-
cell,
|
|
857
|
-
row,
|
|
858
|
-
column,
|
|
859
|
-
table: grid,
|
|
860
|
-
getValue: () => cell.getValue(),
|
|
861
|
-
}),
|
|
862
|
-
}
|
|
863
|
-
built[i] = cell
|
|
864
|
-
}
|
|
865
|
-
row[ROW_CELLS] = built
|
|
866
|
-
return built
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* Resolve every column's value for one row. Kept lazy: a 100k-row grid showing
|
|
871
|
-
* twenty rows must not materialise 900k values to paint.
|
|
872
|
-
*/
|
|
873
|
-
function baseRowValues<TData extends RowData>(row: BaseRowState<TData>): Array<unknown> {
|
|
874
|
-
const { columns, columnCount } = row[ROW_CTX]
|
|
875
|
-
const original = row.original as Record<string, unknown>
|
|
876
|
-
const values = new Array<unknown>(columnCount)
|
|
877
|
-
for (let i = 0; i < columnCount; i++) {
|
|
878
|
-
const def = columns[i]!.columnDef
|
|
879
|
-
if (def.fieldFn) values[i] = def.fieldFn(original as TData)
|
|
880
|
-
else if (def.field) values[i] = original[def.field]
|
|
881
|
-
else values[i] = undefined
|
|
882
|
-
}
|
|
883
|
-
return values
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* One stage of the row pipeline: takes the rows produced so far and returns the
|
|
888
|
-
* next set. Stages compose in the order given to `_rowModels`, so filtering
|
|
889
|
-
* before sorting sorts only what survived the filter.
|
|
890
|
-
*/
|
|
891
|
-
export type RowModelFactory<TData extends RowData> = (args: {
|
|
892
|
-
table: SvGrid<TData>
|
|
893
|
-
rows: Array<Row<TData>>
|
|
894
|
-
}) => Array<Row<TData>>
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* The identity stage that starts every pipeline. Always required, even when no
|
|
898
|
-
* other stage is: it is what turns your data into rows.
|
|
899
|
-
*/
|
|
900
|
-
export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
901
|
-
return ({ rows }) => rows
|
|
902
|
-
}
|
|
903
|
-
/**
|
|
904
|
-
* Drops rows that fail the active {@link ColumnFiltersState}. Pairs with
|
|
905
|
-
* `columnFilteringFeature`; without it there are no filters to apply.
|
|
906
|
-
*/
|
|
907
|
-
export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
908
|
-
return ({ table, rows }) => {
|
|
909
|
-
const filters: ColumnFiltersState = table.getState().columnFilters ?? []
|
|
910
|
-
if (!filters.length) return rows
|
|
911
|
-
|
|
912
|
-
// Resolve each filter's match function once, outside the row loop.
|
|
913
|
-
const compiled = filters.map((filter) => ({
|
|
914
|
-
id: filter.id,
|
|
915
|
-
value: filter.value,
|
|
916
|
-
fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
|
|
917
|
-
}))
|
|
918
|
-
|
|
919
|
-
return rows.filter((row) => {
|
|
920
|
-
for (let i = 0; i < compiled.length; i++) {
|
|
921
|
-
const filter = compiled[i]!
|
|
922
|
-
// `getCellValueByColumnId` rather than `getAllCells().find(...)`.
|
|
923
|
-
// Both read the same lazily-built `cachedValues` array, but the latter
|
|
924
|
-
// also builds and caches the row's whole `Cell[]` - one object per
|
|
925
|
-
// column - purely to reach one field. On a 100k-row grid that is
|
|
926
|
-
// 100,000 cell arrays the filter never looks at again, and it defeats
|
|
927
|
-
// the laziness the row factory exists to provide.
|
|
928
|
-
if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false
|
|
929
|
-
}
|
|
930
|
-
return true
|
|
931
|
-
})
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
/**
|
|
935
|
-
* Narrows the rows to the current page. Put it LAST: anything after it would
|
|
936
|
-
* only ever see one page of data.
|
|
937
|
-
*/
|
|
938
|
-
export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
939
|
-
return ({ table, rows }) => {
|
|
940
|
-
const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
|
|
941
|
-
const start = pagination.pageIndex * pagination.pageSize
|
|
942
|
-
return rows.slice(start, start + pagination.pageSize)
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
/**
|
|
946
|
-
* Buckets rows by the active {@link GroupingState} and inserts a group row
|
|
947
|
-
* ahead of each bucket, carrying that bucket's aggregates.
|
|
948
|
-
*/
|
|
949
|
-
export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
950
|
-
return ({ table, rows }) => {
|
|
951
|
-
const grouping: GroupingState = table.getState().grouping ?? []
|
|
952
|
-
if (!grouping.length) return rows
|
|
953
|
-
const columns = table.getAllColumns()
|
|
954
|
-
|
|
955
|
-
// Recursively bucket rows by each grouping column in turn. At every level a
|
|
956
|
-
// group row is built that stands in for its children - a non-group column
|
|
957
|
-
// resolves to the value shared by every leaf row, or to undefined when the
|
|
958
|
-
// leaves disagree.
|
|
959
|
-
function buildGroups(
|
|
960
|
-
input: Array<Row<TData>>,
|
|
961
|
-
levelIndex: number,
|
|
962
|
-
depth: number,
|
|
963
|
-
idPrefix: string,
|
|
964
|
-
/** Grouping columns already fixed by an ancestor bucket, and their raw
|
|
965
|
-
* values - `undefined` where that bucket mixed several. */
|
|
966
|
-
fixedValues: ReadonlyMap<string, unknown>,
|
|
967
|
-
): Array<Row<TData>> {
|
|
968
|
-
if (levelIndex >= grouping.length) {
|
|
969
|
-
// Leaves: actual data rows, with their nesting depth recorded.
|
|
970
|
-
return input.map((row) => ({ ...row, depth }))
|
|
971
|
-
}
|
|
972
|
-
const groupKey = grouping[levelIndex]
|
|
973
|
-
if (!groupKey) return input
|
|
974
|
-
|
|
975
|
-
// Buckets carry the RAW grouping value alongside the rows, plus whether
|
|
976
|
-
// the bucket saw more than one distinct raw value. Both are needed to let
|
|
977
|
-
// deeper levels skip re-scanning this column: buckets are keyed by
|
|
978
|
-
// `String(value ?? '')`, so `null`, `undefined` and `''` collapse into one
|
|
979
|
-
// bucket, and a scan of such a bucket would report disagreement. Tracking
|
|
980
|
-
// it here costs one comparison per row and keeps the shortcut honest.
|
|
981
|
-
type Bucket = { rows: Array<Row<TData>>; raw: unknown; mixed: boolean }
|
|
982
|
-
const buckets = new Map<string, Bucket>()
|
|
983
|
-
for (const row of input) {
|
|
984
|
-
const value = row.getCellValueByColumnId(groupKey)
|
|
985
|
-
const key = String(value ?? '')
|
|
986
|
-
const bucket = buckets.get(key)
|
|
987
|
-
if (bucket) {
|
|
988
|
-
bucket.rows.push(row)
|
|
989
|
-
if (!bucket.mixed && bucket.raw !== value) bucket.mixed = true
|
|
990
|
-
} else {
|
|
991
|
-
buckets.set(key, { rows: [row], raw: value, mixed: false })
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
const groupRows: Array<Row<TData>> = []
|
|
996
|
-
let index = 0
|
|
997
|
-
buckets.forEach((bucket, key) => {
|
|
998
|
-
const children = bucket.rows
|
|
999
|
-
const id = `${idPrefix}_${groupKey}_${key}`
|
|
1000
|
-
// Record this column as fixed for deeper levels ONLY when the bucket is
|
|
1001
|
-
// homogeneous. If all rows here share a raw value, so does every subset
|
|
1002
|
-
// of them, which is what makes the shortcut sound.
|
|
1003
|
-
//
|
|
1004
|
-
// A MIXED bucket must not be recorded at all - not even as "undefined".
|
|
1005
|
-
// `null`, `undefined` and `''` share a bucket key, so a mixed bucket can
|
|
1006
|
-
// still split into homogeneous children one level down, and those
|
|
1007
|
-
// children have a real shared value that a scan would find. Marking the
|
|
1008
|
-
// column resolved here would hand them the parent's disagreement.
|
|
1009
|
-
const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw)
|
|
1010
|
-
const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed)
|
|
1011
|
-
const isDeepest = levelIndex + 1 >= grouping.length
|
|
1012
|
-
const leafCount = isDeepest
|
|
1013
|
-
? subRows.length
|
|
1014
|
-
: subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
|
|
1015
|
-
|
|
1016
|
-
// Every grouping column ABOVE this level is already resolved: bucketing
|
|
1017
|
-
// by it is what made it constant, so scanning the children to rediscover
|
|
1018
|
-
// it is pure waste. Only the current level's key short-circuited before,
|
|
1019
|
-
// so a second-level group walked all of its children to re-derive the
|
|
1020
|
-
// first level's value - about 100,000 reads on the 100k x 9 two-level
|
|
1021
|
-
// case, for an answer already in hand.
|
|
1022
|
-
//
|
|
1023
|
-
// The current level still returns the stringified bucket key rather than
|
|
1024
|
-
// the raw value, because that is what it has always returned and the
|
|
1025
|
-
// group row's display depends on it.
|
|
1026
|
-
const resolveColumnValue = (columnId: string): unknown => {
|
|
1027
|
-
if (columnId === groupKey) return key
|
|
1028
|
-
if (fixedValues.has(columnId)) return fixedValues.get(columnId)
|
|
1029
|
-
let resolved: unknown
|
|
1030
|
-
let hasResolved = false
|
|
1031
|
-
for (const child of children) {
|
|
1032
|
-
const childValue = child.getCellValueByColumnId(columnId)
|
|
1033
|
-
if (!hasResolved) {
|
|
1034
|
-
resolved = childValue
|
|
1035
|
-
hasResolved = true
|
|
1036
|
-
} else if (childValue !== resolved) {
|
|
1037
|
-
return undefined
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
return resolved
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
const groupOriginal: Record<string, unknown> = {}
|
|
1044
|
-
columns.forEach((column) => {
|
|
1045
|
-
const field = column.columnDef.field
|
|
1046
|
-
if (!field) return
|
|
1047
|
-
const agg = column.columnDef.aggregate
|
|
1048
|
-
groupOriginal[field] = agg
|
|
1049
|
-
? applyGroupAggregate(agg, column.id, children)
|
|
1050
|
-
: resolveColumnValue(column.id)
|
|
1051
|
-
})
|
|
1052
|
-
|
|
1053
|
-
const groupRow: Row<TData> = {
|
|
1054
|
-
id,
|
|
1055
|
-
index: index++,
|
|
1056
|
-
original: groupOriginal as TData,
|
|
1057
|
-
depth,
|
|
1058
|
-
subRows,
|
|
1059
|
-
leafCount,
|
|
1060
|
-
getCanExpand: () => true,
|
|
1061
|
-
getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
|
|
1062
|
-
toggleExpanded: () => {
|
|
1063
|
-
table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
1064
|
-
},
|
|
1065
|
-
getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),
|
|
1066
|
-
toggleSelected: () => {
|
|
1067
|
-
table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
1068
|
-
},
|
|
1069
|
-
getAllCells: () => [],
|
|
1070
|
-
// Prefer the precomputed group value (which carries aggregates)
|
|
1071
|
-
// and fall back to the shared-value resolver for columns without
|
|
1072
|
-
// a field.
|
|
1073
|
-
getCellValueByColumnId: (columnId: string) => {
|
|
1074
|
-
const col = columns.find((c) => c.id === columnId)
|
|
1075
|
-
const field = col?.columnDef.field
|
|
1076
|
-
if (field && field in groupOriginal) return groupOriginal[field]
|
|
1077
|
-
return resolveColumnValue(columnId)
|
|
1078
|
-
},
|
|
1079
|
-
}
|
|
1080
|
-
groupRows.push(groupRow)
|
|
1081
|
-
})
|
|
1082
|
-
return groupRows
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
return buildGroups(rows, 0, 0, 'group', new Map())
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
/**
|
|
1089
|
-
* How to read a hierarchy out of FLAT rows: each row names its parent, and the
|
|
1090
|
-
* grid reconstructs the tree. Rows whose parent id matches nothing become roots
|
|
1091
|
-
* rather than disappearing.
|
|
1092
|
-
*
|
|
1093
|
-
* For nested source data (`children: [...]`), flatten it first with
|
|
1094
|
-
* {@link flattenTreeData}.
|
|
1095
|
-
*/
|
|
1096
|
-
export type TreeRowModelOptions = {
|
|
1097
|
-
/** Field holding each row's parent id. Rows with no parent are roots. */
|
|
1098
|
-
parentField: string
|
|
1099
|
-
/** Field holding the row's own id. Defaults to `'id'`. */
|
|
1100
|
-
idField?: string
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* Client-side tree data: nest the grid's own flat rows into a parent/child
|
|
1105
|
-
* hierarchy that `createExpandedRowModel` then walks.
|
|
1106
|
-
*
|
|
1107
|
-
* This works on the rows the grid already built rather than on raw data, so
|
|
1108
|
-
* tree rows keep their cells, editing, selection and formatting - they are real
|
|
1109
|
-
* data rows that happen to have children, not synthetic banners like grouping's.
|
|
1110
|
-
* That is also why the model is parent-id based: nested source arrays never
|
|
1111
|
-
* become rows (the grid only builds rows for `data`), so nested input is
|
|
1112
|
-
* flattened first with {@link flattenTreeData}. One code path, no duplicated
|
|
1113
|
-
* row construction.
|
|
1114
|
-
*
|
|
1115
|
-
* Rows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable
|
|
1116
|
-
* data row for a full-width group banner.
|
|
1117
|
-
*/
|
|
1118
|
-
export function createTreeRowModel<TData extends RowData>(
|
|
1119
|
-
options: TreeRowModelOptions,
|
|
1120
|
-
): RowModelFactory<TData> {
|
|
1121
|
-
const { parentField, idField = 'id' } = options
|
|
1122
|
-
return ({ table, rows }) => {
|
|
1123
|
-
if (!rows.length) return rows
|
|
1124
|
-
const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]
|
|
1125
|
-
const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]
|
|
1126
|
-
|
|
1127
|
-
const present = new Set<unknown>()
|
|
1128
|
-
for (const row of rows) present.add(keyOf(row))
|
|
1129
|
-
|
|
1130
|
-
const childrenByParent = new Map<unknown, Array<Row<TData>>>()
|
|
1131
|
-
const roots: Array<Row<TData>> = []
|
|
1132
|
-
for (const row of rows) {
|
|
1133
|
-
const parent = parentOf(row)
|
|
1134
|
-
// A row whose parent is absent (filtered out, or never existed) becomes a
|
|
1135
|
-
// root rather than disappearing - silently dropping rows is worse than a
|
|
1136
|
-
// shallower tree. Self-parenting is treated the same way.
|
|
1137
|
-
if (parent == null || parent === keyOf(row) || !present.has(parent)) {
|
|
1138
|
-
roots.push(row)
|
|
1139
|
-
continue
|
|
1140
|
-
}
|
|
1141
|
-
const list = childrenByParent.get(parent) ?? []
|
|
1142
|
-
list.push(row)
|
|
1143
|
-
childrenByParent.set(parent, list)
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// Guards a cycle in the parent chain from recursing forever.
|
|
1147
|
-
const seen = new Set<unknown>()
|
|
1148
|
-
const build = (row: Row<TData>, depth: number): Row<TData> => {
|
|
1149
|
-
const key = keyOf(row)
|
|
1150
|
-
const id = row.id
|
|
1151
|
-
if (seen.has(key)) {
|
|
1152
|
-
return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>
|
|
1153
|
-
}
|
|
1154
|
-
seen.add(key)
|
|
1155
|
-
const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))
|
|
1156
|
-
return {
|
|
1157
|
-
...row,
|
|
1158
|
-
depth,
|
|
1159
|
-
subRows,
|
|
1160
|
-
leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),
|
|
1161
|
-
__treeRow: true,
|
|
1162
|
-
getCanExpand: () => subRows.length > 0,
|
|
1163
|
-
getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
|
|
1164
|
-
toggleExpanded: () => {
|
|
1165
|
-
table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
1166
|
-
},
|
|
1167
|
-
} as Row<TData>
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
return roots.map((root) => build(root, 0))
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
/**
|
|
1175
|
-
* How to flatten NESTED source data into the parent-id shape tree rows need.
|
|
1176
|
-
* `parentField` is written onto each row, so point `treeData.parentField` at
|
|
1177
|
-
* the same name afterwards.
|
|
1178
|
-
*/
|
|
1179
|
-
export type FlattenTreeOptions = {
|
|
1180
|
-
/** Field holding an array of child objects. */
|
|
1181
|
-
childrenField: string
|
|
1182
|
-
/** Field holding each object's id. Defaults to `'id'`. */
|
|
1183
|
-
idField?: string
|
|
1184
|
-
/** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */
|
|
1185
|
-
parentField?: string
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/**
|
|
1189
|
-
* Flatten nested tree data into the flat parent-id shape `createTreeRowModel`
|
|
1190
|
-
* consumes, stamping each child with its parent's id.
|
|
1191
|
-
*
|
|
1192
|
-
* Children are emitted directly after their parent so the natural order already
|
|
1193
|
-
* matches the rendered tree. The `childrenField` array is left on the objects
|
|
1194
|
-
* (harmless, and callers often still want it); only the parent link is added.
|
|
1195
|
-
*/
|
|
1196
|
-
export function flattenTreeData<T extends RowData>(
|
|
1197
|
-
data: ReadonlyArray<T>,
|
|
1198
|
-
options: FlattenTreeOptions,
|
|
1199
|
-
): T[] {
|
|
1200
|
-
const { childrenField, idField = 'id', parentField = '__parentId' } = options
|
|
1201
|
-
const out: T[] = []
|
|
1202
|
-
const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {
|
|
1203
|
-
for (const node of nodes) {
|
|
1204
|
-
const flat = { ...node, [parentField]: parentId } as T
|
|
1205
|
-
out.push(flat)
|
|
1206
|
-
const kids = (node as any)[childrenField]
|
|
1207
|
-
if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
walk(data, null)
|
|
1211
|
-
return out
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* Hides the descendants of collapsed rows. Needed for grouping, tree data and
|
|
1216
|
-
* master-detail alike - all three are the same expand/collapse mechanism.
|
|
1217
|
-
*/
|
|
1218
|
-
export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
1219
|
-
return ({ table, rows }) => {
|
|
1220
|
-
const expanded: ExpandedState = table.getState().expanded ?? {}
|
|
1221
|
-
const flattened: Array<Row<TData>> = []
|
|
1222
|
-
const visit = (row: Row<TData>) => {
|
|
1223
|
-
flattened.push(row)
|
|
1224
|
-
if (row.subRows?.length && expanded[row.id]) {
|
|
1225
|
-
for (const sub of row.subRows) visit(sub)
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
for (const row of rows) visit(row)
|
|
1229
|
-
return flattened
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Orders rows by the active {@link SortingState}. Pass your own comparators to
|
|
1234
|
-
* override the built-in {@link sortFns} - useful for locale-aware or
|
|
1235
|
-
* domain-specific ordering.
|
|
1236
|
-
*/
|
|
1237
|
-
export function createSortedRowModel<TData extends RowData>(
|
|
1238
|
-
localSortFns: typeof sortFns = sortFns,
|
|
1239
|
-
): RowModelFactory<TData> {
|
|
1240
|
-
return function sortedRowModelStage({ table, rows }) {
|
|
1241
|
-
const sorting = table.getState().sorting ?? []
|
|
1242
|
-
if (!sorting.length) return rows
|
|
1243
|
-
|
|
1244
|
-
// Resolve every clause ONCE, before sorting.
|
|
1245
|
-
//
|
|
1246
|
-
// This used to live inside the comparator, so `getAllColumns().find(...)`
|
|
1247
|
-
// ran per comparison per clause: a single-clause sort of 100k rows made
|
|
1248
|
-
// 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
|
|
1249
|
-
// `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
|
|
1250
|
-
// times, so anything inside it that is not O(1) sets the cost of the sort.
|
|
1251
|
-
const allColumns = table.getAllColumns()
|
|
1252
|
-
const clauses: Array<{
|
|
1253
|
-
keys: Array<any>
|
|
1254
|
-
desc: boolean
|
|
1255
|
-
compare: (a: any, b: any) => number
|
|
1256
|
-
}> = []
|
|
1257
|
-
|
|
1258
|
-
for (const clause of sorting) {
|
|
1259
|
-
const column = allColumns.find((col) => col.id === clause.id)
|
|
1260
|
-
if (!column) continue
|
|
1261
|
-
const editorType = column.columnDef.editorType
|
|
1262
|
-
const comparator =
|
|
1263
|
-
editorType === 'number'
|
|
1264
|
-
? localSortFns.number
|
|
1265
|
-
: editorType === 'date' || editorType === 'datetime'
|
|
1266
|
-
? localSortFns.date
|
|
1267
|
-
: localSortFns.auto
|
|
1268
|
-
|
|
1269
|
-
// Precompute one sort key per row, so the comparator reads an array slot
|
|
1270
|
-
// instead of walking the row's column index on every comparison. For the
|
|
1271
|
-
// three built-in comparators the key is also cheaper to compare than the
|
|
1272
|
-
// raw value: a timestamp rather than two `new Date()` allocations, a
|
|
1273
|
-
// number rather than two `Number()` coercions, a collator rather than a
|
|
1274
|
-
// fresh one per `localeCompare` call.
|
|
1275
|
-
//
|
|
1276
|
-
// The identity checks against `sortFns` matter: `localSortFns` is a
|
|
1277
|
-
// public parameter, so a caller can substitute their own comparators.
|
|
1278
|
-
// When they have, we fall through to calling their function with the raw
|
|
1279
|
-
// values - still hoisted, just not specialised.
|
|
1280
|
-
const columnId = column.id
|
|
1281
|
-
const n = rows.length
|
|
1282
|
-
let keys: Array<any> = new Array(n)
|
|
1283
|
-
let compare: (a: any, b: any) => number
|
|
1284
|
-
|
|
1285
|
-
if (comparator === sortFns.number) {
|
|
1286
|
-
for (let i = 0; i < n; i++) keys[i] = Number(rows[i]!.getCellValueByColumnId(columnId) ?? 0)
|
|
1287
|
-
compare = compareNumericKeys
|
|
1288
|
-
} else if (comparator === sortFns.date) {
|
|
1289
|
-
for (let i = 0; i < n; i++) {
|
|
1290
|
-
keys[i] = new Date(rows[i]!.getCellValueByColumnId(columnId) as any).getTime()
|
|
1291
|
-
}
|
|
1292
|
-
compare = compareNumericKeys
|
|
1293
|
-
} else if (comparator === sortFns.auto) {
|
|
1294
|
-
const strings: string[] = new Array(n)
|
|
1295
|
-
// Decide whether ranking is worth attempting BEFORE paying for it.
|
|
1296
|
-
//
|
|
1297
|
-
// Building the distinct set and then discarding it costs about 9 ms on
|
|
1298
|
-
// a 100k-row column where nearly every value is unique, and ranking
|
|
1299
|
-
// saves about 19 ms where they repeat - so guessing wrong in either
|
|
1300
|
-
// direction is measurable. A small stride sample answers it for well
|
|
1301
|
-
// under a millisecond.
|
|
1302
|
-
//
|
|
1303
|
-
// Strided rather than the first N rows: data arrives sorted or
|
|
1304
|
-
// clustered often enough that a prefix is a bad estimator of the whole
|
|
1305
|
-
// column. Reading every k-th row is no more expensive and does not care
|
|
1306
|
-
// how the rows are arranged.
|
|
1307
|
-
const rankLimit = n >> 1
|
|
1308
|
-
let distinct: Set<string> | null = null
|
|
1309
|
-
if (n > 0) {
|
|
1310
|
-
const sampleTarget = Math.min(n, 256)
|
|
1311
|
-
const stride = Math.max(1, Math.floor(n / sampleTarget))
|
|
1312
|
-
const sample = new Set<string>()
|
|
1313
|
-
let sampled = 0
|
|
1314
|
-
for (let i = 0; i < n; i += stride) {
|
|
1315
|
-
sample.add(String(rows[i]!.getCellValueByColumnId(columnId)))
|
|
1316
|
-
sampled++
|
|
1317
|
-
}
|
|
1318
|
-
// Only attempt ranking when the sample suggests real repetition.
|
|
1319
|
-
if (sample.size * 2 <= sampled) distinct = new Set()
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
for (let i = 0; i < n; i++) {
|
|
1323
|
-
const s = String(rows[i]!.getCellValueByColumnId(columnId))
|
|
1324
|
-
strings[i] = s
|
|
1325
|
-
if (distinct) {
|
|
1326
|
-
distinct.add(s)
|
|
1327
|
-
if (distinct.size > rankLimit) distinct = null
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
// Collation is by far the most expensive comparison we do - a CPU
|
|
1332
|
-
// profile of a 100k text sort put 65% of the whole operation inside the
|
|
1333
|
-
// collator. But a column's DISTINCT values are usually far fewer than
|
|
1334
|
-
// its rows (statuses, regions, categories, owners), so rank the
|
|
1335
|
-
// distinct values once and sort by rank afterwards. That turns
|
|
1336
|
-
// O(n log n) collator calls into O(u log u), where u is the number of
|
|
1337
|
-
// distinct values, and the resulting order is identical because rank is
|
|
1338
|
-
// a monotone relabelling of the collated order - equal strings share a
|
|
1339
|
-
// rank, so ties still fall through to the stable sort exactly as before.
|
|
1340
|
-
//
|
|
1341
|
-
// Guarded on the uniqueness ratio: when nearly every value is distinct
|
|
1342
|
-
// the ranking pass cannot save any collator calls and would just add an
|
|
1343
|
-
// O(n) Map build, so that case keeps comparing directly.
|
|
1344
|
-
if (distinct) {
|
|
1345
|
-
const ordered = Array.from(distinct).sort(compareCollatedKeys)
|
|
1346
|
-
const rankOf = new Map<string, number>()
|
|
1347
|
-
for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
|
|
1348
|
-
for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
|
|
1349
|
-
compare = compareNumericKeys
|
|
1350
|
-
} else {
|
|
1351
|
-
keys = strings
|
|
1352
|
-
compare = compareCollatedKeys
|
|
1353
|
-
}
|
|
1354
|
-
} else {
|
|
1355
|
-
for (let i = 0; i < n; i++) keys[i] = rows[i]!.getCellValueByColumnId(columnId)
|
|
1356
|
-
compare = comparator
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
clauses.push({ keys, desc: clause.desc, compare })
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
if (!clauses.length) return rows
|
|
1363
|
-
|
|
1364
|
-
// Sort an index array, then materialise. `Array.prototype.sort` is stable,
|
|
1365
|
-
// so equal keys keep their original relative order exactly as the previous
|
|
1366
|
-
// `[...rows].sort(...)` did.
|
|
1367
|
-
const order = new Array<number>(rows.length)
|
|
1368
|
-
for (let i = 0; i < order.length; i++) order[i] = i
|
|
1369
|
-
|
|
1370
|
-
// Single-clause sorts get a specialised comparator.
|
|
1371
|
-
//
|
|
1372
|
-
// Most sorts are one column, and that path runs O(n log n) times - 1.66
|
|
1373
|
-
// million comparisons for 100k rows. The general loop pays a clause-array
|
|
1374
|
-
// index, three property loads and an indirect call on every one of them,
|
|
1375
|
-
// none of which vary once the clause list is fixed. Hoisting them into a
|
|
1376
|
-
// closure and, for the numeric comparator, inlining the subtraction removes
|
|
1377
|
-
// the call entirely.
|
|
1378
|
-
//
|
|
1379
|
-
// `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`
|
|
1380
|
-
// as far as sorting is concerned: both are NaN for unorderable values,
|
|
1381
|
-
// which the spec coerces to 0, and they differ only in producing 0 versus
|
|
1382
|
-
// -0 for equal keys, which sorts identically.
|
|
1383
|
-
if (clauses.length === 1) {
|
|
1384
|
-
const { keys, compare, desc } = clauses[0]!
|
|
1385
|
-
if (compare === compareNumericKeys) {
|
|
1386
|
-
order.sort(
|
|
1387
|
-
desc
|
|
1388
|
-
? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }
|
|
1389
|
-
: function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },
|
|
1390
|
-
)
|
|
1391
|
-
} else {
|
|
1392
|
-
order.sort(
|
|
1393
|
-
desc
|
|
1394
|
-
? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }
|
|
1395
|
-
: function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },
|
|
1396
|
-
)
|
|
1397
|
-
}
|
|
1398
|
-
} else {
|
|
1399
|
-
order.sort(function compareRowsByClauses(ia, ib) {
|
|
1400
|
-
for (let k = 0; k < clauses.length; k++) {
|
|
1401
|
-
const clause = clauses[k]!
|
|
1402
|
-
const result = clause.compare(clause.keys[ia], clause.keys[ib])
|
|
1403
|
-
if (result !== 0) return clause.desc ? -result : result
|
|
1404
|
-
}
|
|
1405
|
-
return 0
|
|
1406
|
-
})
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
const sorted = new Array<Row<TData>>(rows.length)
|
|
1410
|
-
for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!
|
|
1411
|
-
return sorted
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
/**
|
|
1416
|
-
* Numeric key comparison for the built-in `number` and `date` comparators.
|
|
1417
|
-
* Subtraction rather than `<`/`>` on purpose: it reproduces the originals
|
|
1418
|
-
* exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
|
|
1419
|
-
* and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
|
|
1420
|
-
* it), which is the behaviour callers already depend on.
|
|
1421
|
-
*/
|
|
1422
|
-
function compareNumericKeys(a: number, b: number): number {
|
|
1423
|
-
return a - b
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* Text key comparison for the built-in `auto` comparator.
|
|
1428
|
-
*
|
|
1429
|
-
* `localeCompare`, NOT a hoisted `Intl.Collator`. The specification defines
|
|
1430
|
-
* `localeCompare` with no locale or options as constructing a default collator
|
|
1431
|
-
* per call, so hoisting one looks like the obvious optimisation - and it is
|
|
1432
|
-
* measurably slower. V8 fast-paths `String.prototype.localeCompare` for the
|
|
1433
|
-
* default locale; going through a collator object misses that path. Measured
|
|
1434
|
-
* sorting 100k strings: 33 ms via `localeCompare` against 83 ms via a cached
|
|
1435
|
-
* collator on ASCII, 58 ms against 99 ms with accents mixed in, and the two
|
|
1436
|
-
* produce byte-identical orderings across all 100k positions.
|
|
1437
|
-
*
|
|
1438
|
-
* Left as its own function so the sort path has one place to change if that
|
|
1439
|
-
* ever stops being true. Re-measure before "optimising" this again.
|
|
1440
|
-
*/
|
|
1441
|
-
function compareCollatedKeys(a: string, b: string): number {
|
|
1442
|
-
return a.localeCompare(b)
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
/**
|
|
1446
|
-
* Everything {@link createSvGridCore} accepts: the data and columns, the
|
|
1447
|
-
* features and row models that make up the pipeline, and an `on*Change`
|
|
1448
|
-
* callback per piece of state for controlled use.
|
|
1449
|
-
*
|
|
1450
|
-
* `<SvGrid>` builds this for you from its props - you only construct it
|
|
1451
|
-
* directly when driving the headless core.
|
|
1452
|
-
*/
|
|
1453
|
-
export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
|
|
1454
|
-
_features: TFeatures
|
|
1455
|
-
_rowModels?: {
|
|
1456
|
-
coreRowModel?: RowModelFactory<TData>
|
|
1457
|
-
filteredRowModel?: RowModelFactory<TData>
|
|
1458
|
-
sortedRowModel?: RowModelFactory<TData>
|
|
1459
|
-
paginatedRowModel?: RowModelFactory<TData>
|
|
1460
|
-
groupedRowModel?: RowModelFactory<TData>
|
|
1461
|
-
expandedRowModel?: RowModelFactory<TData>
|
|
1462
|
-
}
|
|
1463
|
-
columns: Array<ColumnDef<TFeatures, TData>>
|
|
1464
|
-
data: ReadonlyArray<TData>
|
|
1465
|
-
/**
|
|
1466
|
-
* Optional row-id resolver. When set, the value it returns becomes
|
|
1467
|
-
* `row.id` (and therefore the selection / expansion / edit key). When
|
|
1468
|
-
* omitted, ids fall back to the row's array index as a string. Use a
|
|
1469
|
-
* stable id (database PK, UUID, etc.) so selection survives reorders.
|
|
1470
|
-
*/
|
|
1471
|
-
getRowId?: (row: TData, index: number) => string
|
|
1472
|
-
state?: Partial<Record<string, any>>
|
|
1473
|
-
onSortingChange?: (updater: Updater<SortingState>) => void
|
|
1474
|
-
onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
|
|
1475
|
-
onPaginationChange?: (updater: Updater<PaginationState>) => void
|
|
1476
|
-
onGroupingChange?: (updater: Updater<GroupingState>) => void
|
|
1477
|
-
onExpandedChange?: (updater: Updater<ExpandedState>) => void
|
|
1478
|
-
onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
|
|
1479
|
-
onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
/**
|
|
1483
|
-
* The headless grid instance: the state stores plus the read methods a renderer
|
|
1484
|
-
* needs (`getHeaderGroups()`, `getRowModel()`, the `set*` writers).
|
|
1485
|
-
*
|
|
1486
|
-
* Framework free by design - `<SvGrid>` is one renderer over this, and you can
|
|
1487
|
-
* write another. See the "Why headless?" guide.
|
|
1488
|
-
*/
|
|
1489
|
-
export type SvGrid<TData extends RowData> = {
|
|
1490
|
-
store: Store<Record<string, any>>
|
|
1491
|
-
optionsStore: Store<Record<string, any>>
|
|
1492
|
-
state: Record<string, any>
|
|
1493
|
-
getState: () => Record<string, any>
|
|
1494
|
-
setOptions: (updater: Updater<Record<string, any>>) => void
|
|
1495
|
-
setColumnFilters: (updater: Updater<ColumnFiltersState>) => void
|
|
1496
|
-
setPagination: (updater: Updater<PaginationState>) => void
|
|
1497
|
-
setGrouping: (updater: Updater<GroupingState>) => void
|
|
1498
|
-
setExpanded: (updater: Updater<ExpandedState>) => void
|
|
1499
|
-
setRowSelection: (updater: Updater<RowSelectionState>) => void
|
|
1500
|
-
setActiveCell: (updater: Updater<ActiveCellState>) => void
|
|
1501
|
-
moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void
|
|
1502
|
-
getAllColumns: () => Array<Column<TData>>
|
|
1503
|
-
getHeaderGroups: () => Array<HeaderGroup<TData>>
|
|
1504
|
-
getFooterGroups: () => Array<HeaderGroup<TData>>
|
|
1505
|
-
getRowModel: () => RowModel<TData>
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
type InternalGrid<TData extends RowData> = SvGrid<TData> & {
|
|
1509
|
-
getAllColumns: () => Array<Column<TData>>
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
/**
|
|
1513
|
-
* Build a headless grid: state, the row pipeline, and the read methods, with no
|
|
1514
|
-
* DOM and no Svelte. This is the engine `<SvGrid>` renders.
|
|
1515
|
-
*
|
|
1516
|
-
* Most callers want `createSvGrid` (the runes-aware wrapper) or the component
|
|
1517
|
-
* itself; reach for this when you are writing your own renderer or running the
|
|
1518
|
-
* pipeline outside a browser.
|
|
1519
|
-
*/
|
|
1520
|
-
export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
|
|
1521
|
-
options: SvGridOptions<TFeatures, TData>,
|
|
1522
|
-
): SvGrid<TData> {
|
|
1523
|
-
const internalState: Record<string, any> = {
|
|
1524
|
-
sorting: [],
|
|
1525
|
-
columnFilters: [],
|
|
1526
|
-
pagination: { pageIndex: 0, pageSize: options.data.length || 10 },
|
|
1527
|
-
grouping: [],
|
|
1528
|
-
expanded: {},
|
|
1529
|
-
rowSelection: {},
|
|
1530
|
-
activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
|
|
1531
|
-
...(options.state ?? {}),
|
|
1532
|
-
}
|
|
1533
|
-
const store = createStore(internalState)
|
|
1534
|
-
const optionsStore = createStore(options as Record<string, any>)
|
|
1535
|
-
let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null
|
|
1536
|
-
let cachedColumns: Array<Column<TData>> = []
|
|
1537
|
-
let cachedHeaderGroups: Array<HeaderGroup<TData>> = []
|
|
1538
|
-
let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
|
|
1539
|
-
let cachedBaseRowsColumns: Array<Column<TData>> | null = null
|
|
1540
|
-
let cachedBaseRows: Array<Row<TData>> = []
|
|
1541
|
-
let cachedRowModel: RowModel<TData> | null = null
|
|
1542
|
-
let cachedRowModelBaseRows: Array<Row<TData>> | null = null
|
|
1543
|
-
let cachedPipeline = options._rowModels
|
|
1544
|
-
let cachedSlices: {
|
|
1545
|
-
sorting: SortingState | undefined
|
|
1546
|
-
columnFilters: ColumnFiltersState | undefined
|
|
1547
|
-
pagination: PaginationState | undefined
|
|
1548
|
-
grouping: GroupingState | undefined
|
|
1549
|
-
expanded: ExpandedState | undefined
|
|
1550
|
-
} | null = null
|
|
1551
|
-
|
|
1552
|
-
const grid = {
|
|
1553
|
-
store,
|
|
1554
|
-
optionsStore,
|
|
1555
|
-
get state() {
|
|
1556
|
-
return store.state
|
|
1557
|
-
},
|
|
1558
|
-
getState() {
|
|
1559
|
-
return store.state
|
|
1560
|
-
},
|
|
1561
|
-
setOptions(updater: Updater<Record<string, any>>) {
|
|
1562
|
-
optionsStore.setState((prev) =>
|
|
1563
|
-
typeof updater === 'function' ? (updater as any)(prev) : updater,
|
|
1564
|
-
)
|
|
1565
|
-
},
|
|
1566
|
-
setColumnFilters(updater: Updater<ColumnFiltersState>) {
|
|
1567
|
-
store.setState((prev) => ({
|
|
1568
|
-
...prev,
|
|
1569
|
-
columnFilters:
|
|
1570
|
-
typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,
|
|
1571
|
-
}))
|
|
1572
|
-
options.onColumnFiltersChange?.(updater)
|
|
1573
|
-
},
|
|
1574
|
-
setPagination(updater: Updater<PaginationState>) {
|
|
1575
|
-
store.setState((prev) => ({
|
|
1576
|
-
...prev,
|
|
1577
|
-
pagination:
|
|
1578
|
-
typeof updater === 'function'
|
|
1579
|
-
? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })
|
|
1580
|
-
: updater,
|
|
1581
|
-
}))
|
|
1582
|
-
options.onPaginationChange?.(updater)
|
|
1583
|
-
},
|
|
1584
|
-
setGrouping(updater: Updater<GroupingState>) {
|
|
1585
|
-
store.setState((prev) => ({
|
|
1586
|
-
...prev,
|
|
1587
|
-
grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,
|
|
1588
|
-
}))
|
|
1589
|
-
options.onGroupingChange?.(updater)
|
|
1590
|
-
},
|
|
1591
|
-
setExpanded(updater: Updater<ExpandedState>) {
|
|
1592
|
-
store.setState((prev) => ({
|
|
1593
|
-
...prev,
|
|
1594
|
-
expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,
|
|
1595
|
-
}))
|
|
1596
|
-
options.onExpandedChange?.(updater)
|
|
1597
|
-
},
|
|
1598
|
-
setRowSelection(updater: Updater<RowSelectionState>) {
|
|
1599
|
-
store.setState((prev) => ({
|
|
1600
|
-
...prev,
|
|
1601
|
-
rowSelection:
|
|
1602
|
-
typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,
|
|
1603
|
-
}))
|
|
1604
|
-
options.onRowSelectionChange?.(updater)
|
|
1605
|
-
},
|
|
1606
|
-
setActiveCell(updater: Updater<ActiveCellState>) {
|
|
1607
|
-
store.setState((prev) => {
|
|
1608
|
-
const previous: ActiveCellState = prev.activeCell ?? {
|
|
1609
|
-
rowIndex: 0,
|
|
1610
|
-
colIndex: 0,
|
|
1611
|
-
cellId: null,
|
|
1612
|
-
}
|
|
1613
|
-
const nextActive =
|
|
1614
|
-
typeof updater === 'function' ? updater(previous) : updater
|
|
1615
|
-
return {
|
|
1616
|
-
...prev,
|
|
1617
|
-
activeCell: nextActive,
|
|
1618
|
-
}
|
|
1619
|
-
})
|
|
1620
|
-
options.onActiveCellChange?.(updater)
|
|
1621
|
-
},
|
|
1622
|
-
moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {
|
|
1623
|
-
const rows = grid.getRowModel().rows
|
|
1624
|
-
const columns = grid.getAllColumns()
|
|
1625
|
-
const maxRow = Math.max(rows.length - 1, 0)
|
|
1626
|
-
const maxCol = Math.max(columns.length - 1, 0)
|
|
1627
|
-
const current: ActiveCellState = grid.getState().activeCell ?? {
|
|
1628
|
-
rowIndex: 0,
|
|
1629
|
-
colIndex: 0,
|
|
1630
|
-
cellId: null,
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
const rowIndex = Math.min(
|
|
1634
|
-
Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),
|
|
1635
|
-
maxRow,
|
|
1636
|
-
)
|
|
1637
|
-
const colIndex = Math.min(
|
|
1638
|
-
Math.max(current.colIndex + (next.colDelta ?? 0), 0),
|
|
1639
|
-
maxCol,
|
|
1640
|
-
)
|
|
1641
|
-
const columnId = columns[colIndex]?.id ?? 'col_0'
|
|
1642
|
-
grid.setActiveCell({
|
|
1643
|
-
rowIndex,
|
|
1644
|
-
colIndex,
|
|
1645
|
-
cellId: `${rowIndex}_${columnId}`,
|
|
1646
|
-
})
|
|
1647
|
-
},
|
|
1648
|
-
getAllColumns() {
|
|
1649
|
-
// Cache hit: referentially identical columns array.
|
|
1650
|
-
if (cachedColumnsInput === options.columns && cachedColumns.length) {
|
|
1651
|
-
return cachedColumns
|
|
1652
|
-
}
|
|
1653
|
-
// Soft cache hit: consumers commonly recreate the columns array
|
|
1654
|
-
// inline on every render (e.g. `columns={[...]}`). If the new
|
|
1655
|
-
// array has the same length AND each entry has the same `field` /
|
|
1656
|
-
// `id` / `header` (the visibility-affecting structure of a
|
|
1657
|
-
// column), trust the previous build. Mutable inner fields like
|
|
1658
|
-
// `cell` and `editorOptions` are still picked up on the next real
|
|
1659
|
-
// render that bumps an actual data dep - they're read at cell-
|
|
1660
|
-
// render time, not at this top-level cache.
|
|
1661
|
-
if (
|
|
1662
|
-
cachedColumnsInput &&
|
|
1663
|
-
options.columns.length === cachedColumnsInput.length &&
|
|
1664
|
-
cachedColumns.length === options.columns.length &&
|
|
1665
|
-
options.columns.every((c, i) => {
|
|
1666
|
-
const prev = cachedColumnsInput![i]!
|
|
1667
|
-
return (
|
|
1668
|
-
c.field === prev.field &&
|
|
1669
|
-
c.id === prev.id &&
|
|
1670
|
-
c.header === prev.header &&
|
|
1671
|
-
c.editorType === prev.editorType
|
|
1672
|
-
)
|
|
1673
|
-
})
|
|
1674
|
-
) {
|
|
1675
|
-
// Update the stored input reference so the strict check hits
|
|
1676
|
-
// next time, but reuse the built column model.
|
|
1677
|
-
cachedColumnsInput = options.columns
|
|
1678
|
-
return cachedColumns
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
cachedColumnsInput = options.columns
|
|
1682
|
-
cachedHeaderGroups = []
|
|
1683
|
-
const build = (
|
|
1684
|
-
defs: Array<ColumnDef<TFeatures, TData>>,
|
|
1685
|
-
depth: number,
|
|
1686
|
-
parentId?: string,
|
|
1687
|
-
): Array<Column<TData>> => {
|
|
1688
|
-
const leaves: Array<Column<TData>> = []
|
|
1689
|
-
defs.forEach((columnDef, index) => {
|
|
1690
|
-
const id = resolveColumnId(columnDef, parentId, depth, index)
|
|
1691
|
-
if (columnDef.columns?.length) {
|
|
1692
|
-
leaves.push(...build(columnDef.columns, depth + 1, id))
|
|
1693
|
-
return
|
|
1694
|
-
}
|
|
1695
|
-
leaves.push({
|
|
1696
|
-
id,
|
|
1697
|
-
depth,
|
|
1698
|
-
parentId,
|
|
1699
|
-
columnDef,
|
|
1700
|
-
getCanSort: () =>
|
|
1701
|
-
Boolean((options._features as any).rowSortingFeature) &&
|
|
1702
|
-
columnDef.sortable !== false,
|
|
1703
|
-
getCanFilter: () =>
|
|
1704
|
-
Boolean((options._features as any).columnFilteringFeature) &&
|
|
1705
|
-
columnDef.filterable !== false,
|
|
1706
|
-
getIsSorted: () => {
|
|
1707
|
-
const entry = store.state.sorting?.find((s: any) => s.id === id)
|
|
1708
|
-
if (!entry) return false
|
|
1709
|
-
return entry.desc ? 'desc' : 'asc'
|
|
1710
|
-
},
|
|
1711
|
-
getToggleSortingHandler: () => () => {
|
|
1712
|
-
const clauses: SortingState = store.state.sorting ?? []
|
|
1713
|
-
const current = clauses.find((s: any) => s.id === id)
|
|
1714
|
-
const nextClause: SortingState = !current
|
|
1715
|
-
? [...clauses, { id, desc: false }]
|
|
1716
|
-
: current.desc
|
|
1717
|
-
? clauses.filter((s) => s.id !== id)
|
|
1718
|
-
: clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))
|
|
1719
|
-
store.setState((prev) => ({ ...prev, sorting: nextClause }))
|
|
1720
|
-
options.onSortingChange?.(nextClause)
|
|
1721
|
-
},
|
|
1722
|
-
})
|
|
1723
|
-
})
|
|
1724
|
-
return leaves
|
|
1725
|
-
}
|
|
1726
|
-
cachedColumns = build(options.columns, 0)
|
|
1727
|
-
return cachedColumns
|
|
1728
|
-
},
|
|
1729
|
-
getHeaderGroups() {
|
|
1730
|
-
if (cachedHeaderGroups.length) return cachedHeaderGroups
|
|
1731
|
-
const headers = grid.getAllColumns().map((column) => {
|
|
1732
|
-
const header: Header<TData> = {
|
|
1733
|
-
id: column.id,
|
|
1734
|
-
isPlaceholder: false,
|
|
1735
|
-
colSpan: 1,
|
|
1736
|
-
column,
|
|
1737
|
-
getContext: () => ({ header, column, table: grid }),
|
|
1738
|
-
}
|
|
1739
|
-
return header
|
|
1740
|
-
})
|
|
1741
|
-
cachedHeaderGroups = [{ id: 'header_group_0', headers }]
|
|
1742
|
-
return cachedHeaderGroups
|
|
1743
|
-
},
|
|
1744
|
-
getFooterGroups() {
|
|
1745
|
-
return grid.getHeaderGroups()
|
|
1746
|
-
},
|
|
1747
|
-
getRowModel() {
|
|
1748
|
-
const columns = grid.getAllColumns()
|
|
1749
|
-
if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
|
|
1750
|
-
cachedBaseRowsInput = options.data
|
|
1751
|
-
cachedBaseRowsColumns = columns
|
|
1752
|
-
// O(1) column-id → index lookup so getCellValueByColumnId doesn't do
|
|
1753
|
-
// a linear `findIndex` on every cell read (was O(rows × cells × cols)).
|
|
1754
|
-
const columnIndexById = new Map<string, number>()
|
|
1755
|
-
for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
|
|
1756
|
-
const columnCount = columns.length
|
|
1757
|
-
|
|
1758
|
-
// One shared context for every row in this table, so a row carries a
|
|
1759
|
-
// pointer rather than a closure scope. See BASE_ROW_METHODS.
|
|
1760
|
-
const rowCtx: BaseRowCtx<TData> = {
|
|
1761
|
-
grid: grid as SvGrid<TData>,
|
|
1762
|
-
store,
|
|
1763
|
-
columns,
|
|
1764
|
-
columnCount,
|
|
1765
|
-
columnIndexById,
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
cachedBaseRows = new Array(options.data.length)
|
|
1769
|
-
const getRowId = options.getRowId
|
|
1770
|
-
const m = BASE_ROW_METHODS as unknown as {
|
|
1771
|
-
getCanExpand: Row<TData>['getCanExpand']
|
|
1772
|
-
getIsExpanded: Row<TData>['getIsExpanded']
|
|
1773
|
-
toggleExpanded: Row<TData>['toggleExpanded']
|
|
1774
|
-
getIsSelected: Row<TData>['getIsSelected']
|
|
1775
|
-
toggleSelected: Row<TData>['toggleSelected']
|
|
1776
|
-
getAllCells: Row<TData>['getAllCells']
|
|
1777
|
-
getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
|
|
1778
|
-
}
|
|
1779
|
-
for (let index = 0; index < options.data.length; index++) {
|
|
1780
|
-
const original = options.data[index]!
|
|
1781
|
-
// `_values` and `_cells` stay null until something reads them - a
|
|
1782
|
-
// 100k-row grid showing twenty rows must not materialise every row's
|
|
1783
|
-
// values or cell objects to paint.
|
|
1784
|
-
const row: BaseRowState<TData> = {
|
|
1785
|
-
id: getRowId ? getRowId(original, index) : String(index),
|
|
1786
|
-
index,
|
|
1787
|
-
original,
|
|
1788
|
-
depth: 0,
|
|
1789
|
-
[ROW_CTX]: rowCtx,
|
|
1790
|
-
[ROW_VALUES]: null,
|
|
1791
|
-
[ROW_CELLS]: null,
|
|
1792
|
-
getCanExpand: m.getCanExpand,
|
|
1793
|
-
getIsExpanded: m.getIsExpanded,
|
|
1794
|
-
toggleExpanded: m.toggleExpanded,
|
|
1795
|
-
getIsSelected: m.getIsSelected,
|
|
1796
|
-
toggleSelected: m.toggleSelected,
|
|
1797
|
-
getAllCells: m.getAllCells,
|
|
1798
|
-
getCellValueByColumnId: m.getCellValueByColumnId,
|
|
1799
|
-
}
|
|
1800
|
-
cachedBaseRows[index] = row
|
|
1801
|
-
}
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
// Only the slices a pipeline stage actually READS belong in this key.
|
|
1805
|
-
//
|
|
1806
|
-
// `rowSelection` used to be here, which meant ticking one checkbox on a
|
|
1807
|
-
// 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a
|
|
1808
|
-
// row array that was identical by construction. Nothing reads it: the two
|
|
1809
|
-
// consumers are `getIsSelected` closures (on data rows and on group rows)
|
|
1810
|
-
// that read `store.state` when called, so they observe a selection change
|
|
1811
|
-
// without the model being rebuilt.
|
|
1812
|
-
//
|
|
1813
|
-
// `_rowModels` is a closed set of six named slots, so no consumer stage
|
|
1814
|
-
// can be inserted that might read selection. A caller CAN supply a custom
|
|
1815
|
-
// function for one of those slots; if one ever needs a slice that is not
|
|
1816
|
-
// listed here, add it here rather than reinstating all of them.
|
|
1817
|
-
const currentSlices = {
|
|
1818
|
-
sorting: store.state.sorting,
|
|
1819
|
-
columnFilters: store.state.columnFilters,
|
|
1820
|
-
pagination: store.state.pagination,
|
|
1821
|
-
grouping: store.state.grouping,
|
|
1822
|
-
expanded: store.state.expanded,
|
|
1823
|
-
}
|
|
1824
|
-
if (
|
|
1825
|
-
cachedRowModel &&
|
|
1826
|
-
cachedRowModelBaseRows === cachedBaseRows &&
|
|
1827
|
-
cachedPipeline === options._rowModels &&
|
|
1828
|
-
cachedSlices?.sorting === currentSlices.sorting &&
|
|
1829
|
-
cachedSlices?.columnFilters === currentSlices.columnFilters &&
|
|
1830
|
-
cachedSlices?.pagination === currentSlices.pagination &&
|
|
1831
|
-
cachedSlices?.grouping === currentSlices.grouping &&
|
|
1832
|
-
cachedSlices?.expanded === currentSlices.expanded
|
|
1833
|
-
) {
|
|
1834
|
-
return cachedRowModel
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
let rows: Array<Row<TData>> = cachedBaseRows
|
|
1838
|
-
|
|
1839
|
-
const pipeline = options._rowModels ?? {}
|
|
1840
|
-
const ordered: Array<RowModelFactory<TData> | undefined> = [
|
|
1841
|
-
pipeline.coreRowModel,
|
|
1842
|
-
pipeline.filteredRowModel,
|
|
1843
|
-
pipeline.sortedRowModel,
|
|
1844
|
-
pipeline.groupedRowModel,
|
|
1845
|
-
pipeline.expandedRowModel,
|
|
1846
|
-
pipeline.paginatedRowModel,
|
|
1847
|
-
]
|
|
1848
|
-
ordered.forEach((fn) => {
|
|
1849
|
-
if (fn) rows = fn({ table: grid, rows })
|
|
1850
|
-
})
|
|
1851
|
-
cachedPipeline = options._rowModels
|
|
1852
|
-
cachedSlices = currentSlices
|
|
1853
|
-
cachedRowModelBaseRows = cachedBaseRows
|
|
1854
|
-
cachedRowModel = { rows }
|
|
1855
|
-
return cachedRowModel
|
|
1856
|
-
},
|
|
1857
|
-
} as InternalGrid<TData>
|
|
1858
|
-
|
|
1859
|
-
return grid
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
/** Narrowing helper for the many options that accept a value or a function. */
|
|
1863
|
-
export function isFunction(value: unknown): value is (...args: Array<any>) => any {
|
|
1864
|
-
return typeof value === 'function'
|
|
1865
|
-
}
|
|
1
|
+
import type { SparklineConfig } from './sparkline'
|
|
2
|
+
import { resolveColumnId } from './column-id'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The constraint every row type satisfies: an object keyed by string. Your own
|
|
6
|
+
* row type (`type Person = { name: string }`) is what flows through the generics
|
|
7
|
+
* below; this is only the lower bound they are declared against.
|
|
8
|
+
*/
|
|
9
|
+
export type RowData = Record<string, unknown>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A new value, or a function that derives it from the previous one - the shape
|
|
13
|
+
* every `set*` on the grid accepts, so callers can update state without first
|
|
14
|
+
* reading it.
|
|
15
|
+
*
|
|
16
|
+
* api.setSorting([{ id: 'name', desc: false }])
|
|
17
|
+
* api.setSorting((prev) => [...prev, { id: 'age', desc: true }])
|
|
18
|
+
*/
|
|
19
|
+
export type Updater<T> = T | ((prev: T) => T)
|
|
20
|
+
|
|
21
|
+
/** Active sort clauses, outermost first. `desc: false` is ascending. */
|
|
22
|
+
export type SortingState = Array<{ id: string; desc: boolean }>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One column's filter: the column `id`, the `value` being matched, and
|
|
26
|
+
* optionally which comparison to use. `fn` defaults to the column's own type -
|
|
27
|
+
* see {@link filterFns} for the available names.
|
|
28
|
+
*/
|
|
29
|
+
export type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }
|
|
30
|
+
|
|
31
|
+
/** Every active column filter. A column with no entry here is unfiltered. */
|
|
32
|
+
export type ColumnFiltersState = Array<ColumnFilter>
|
|
33
|
+
|
|
34
|
+
/** Current page position. `pageIndex` is 0-based, so page 1 is index 0. */
|
|
35
|
+
export type PaginationState = { pageIndex: number; pageSize: number }
|
|
36
|
+
|
|
37
|
+
/** Column ids the rows are grouped by, outermost first. */
|
|
38
|
+
export type GroupingState = Array<string>
|
|
39
|
+
|
|
40
|
+
/** Which rows are expanded, keyed by row id. Absent means collapsed. */
|
|
41
|
+
export type ExpandedState = Record<string, boolean>
|
|
42
|
+
|
|
43
|
+
/** Which rows are selected, keyed by row id. Absent means unselected. */
|
|
44
|
+
export type RowSelectionState = Record<string, boolean>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where keyboard focus sits. The indices address the *displayed* grid (after
|
|
48
|
+
* sorting, filtering and paging), not the source data.
|
|
49
|
+
*/
|
|
50
|
+
export type ActiveCellState = {
|
|
51
|
+
rowIndex: number
|
|
52
|
+
colIndex: number
|
|
53
|
+
cellId: string | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The set of features a grid has registered, as built by {@link tableFeatures}.
|
|
58
|
+
* Deliberately open: a feature is identified by its key, so the type carries
|
|
59
|
+
* which ones are on without enumerating them.
|
|
60
|
+
*/
|
|
61
|
+
export type TableFeatures = Record<string, unknown>
|
|
62
|
+
|
|
63
|
+
/** A cell's value. Unconstrained - a column can hold anything. */
|
|
64
|
+
export type CellData = unknown
|
|
65
|
+
|
|
66
|
+
/** What a column's `header` render function receives. */
|
|
67
|
+
export type HeaderContext<TData extends RowData> = {
|
|
68
|
+
header: Header<TData>
|
|
69
|
+
column: Column<TData>
|
|
70
|
+
table: SvGrid<TData>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What a column's `cell` render function receives. `getValue()` applies the
|
|
75
|
+
* column's accessor (`field` or `fieldFn`); `row.original` is the raw object.
|
|
76
|
+
*/
|
|
77
|
+
export type CellContext<TData extends RowData> = {
|
|
78
|
+
cell: Cell<TData>
|
|
79
|
+
row: Row<TData>
|
|
80
|
+
column: Column<TData>
|
|
81
|
+
table: SvGrid<TData>
|
|
82
|
+
getValue: () => unknown
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
|
|
86
|
+
export type CellSpanParams<TData extends RowData = RowData> = {
|
|
87
|
+
/** The row's underlying data object. */
|
|
88
|
+
data: TData
|
|
89
|
+
/** Display-row index in the current (filtered/sorted) row set. */
|
|
90
|
+
rowIndex: number
|
|
91
|
+
/** The column's id. */
|
|
92
|
+
columnId: string
|
|
93
|
+
/** The cell's base value for this column. */
|
|
94
|
+
value: unknown
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The raw option list a column's `editorOptions` can supply. */
|
|
98
|
+
export type EditorOptionSource = ReadonlyArray<
|
|
99
|
+
string | number | { value: string | number; label?: string; color?: string }
|
|
100
|
+
>
|
|
101
|
+
|
|
102
|
+
/** Params passed to a column's `valueParser(...)` on edit commit. */
|
|
103
|
+
export type ValueParserParams<TData extends RowData = RowData> = {
|
|
104
|
+
/** The value after built-in per-`editorType` coercion. */
|
|
105
|
+
newValue: unknown
|
|
106
|
+
/** The cell's previous value. */
|
|
107
|
+
oldValue: unknown
|
|
108
|
+
/** The raw string the editor produced (pre-coercion). */
|
|
109
|
+
rawInput: string
|
|
110
|
+
/** The row's underlying data object. */
|
|
111
|
+
data: TData
|
|
112
|
+
/** The column's id. */
|
|
113
|
+
columnId: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Context passed to a custom `cellEditor` snippet/component. Three write
|
|
118
|
+
* helpers cover the lifecycle:
|
|
119
|
+
*
|
|
120
|
+
* - `update(next)` - stage `next` as the draft, keep the editor open.
|
|
121
|
+
* Use this for live-preview controls (sliders,
|
|
122
|
+
* color pickers) so the user can keep adjusting.
|
|
123
|
+
* - `commit(next?)` - write the value AND close the editor. The
|
|
124
|
+
* argument is optional; when omitted, the most
|
|
125
|
+
* recently `update()`d value is saved. Use this
|
|
126
|
+
* for "done" gestures (Enter, picking an option).
|
|
127
|
+
* - `cancel()` - discard the draft and close the editor.
|
|
128
|
+
*/
|
|
129
|
+
export type EditorContext<TData extends RowData> = CellContext<TData> & {
|
|
130
|
+
value: unknown
|
|
131
|
+
update: (next: unknown) => void
|
|
132
|
+
commit: (next?: unknown) => void
|
|
133
|
+
cancel: () => void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Declarative cell formatting, applied through `Intl` - number, currency,
|
|
138
|
+
* percent, date and datetime. Prefer this over a `formatter` function: it is
|
|
139
|
+
* locale-aware, and export and the clipboard reuse the same configuration.
|
|
140
|
+
*/
|
|
141
|
+
export type CellFormatConfig =
|
|
142
|
+
| {
|
|
143
|
+
type: 'number'
|
|
144
|
+
locales?: string | Array<string>
|
|
145
|
+
options?: Intl.NumberFormatOptions
|
|
146
|
+
}
|
|
147
|
+
| {
|
|
148
|
+
type: 'currency'
|
|
149
|
+
/** ISO 4217 (default USD) */
|
|
150
|
+
currency?: string
|
|
151
|
+
locales?: string | Array<string>
|
|
152
|
+
options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>
|
|
153
|
+
}
|
|
154
|
+
| {
|
|
155
|
+
type: 'percent'
|
|
156
|
+
locales?: string | Array<string>
|
|
157
|
+
options?: Omit<Intl.NumberFormatOptions, 'style'>
|
|
158
|
+
/**
|
|
159
|
+
* If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).
|
|
160
|
+
* Default false.
|
|
161
|
+
*/
|
|
162
|
+
valueIsPercentPoints?: boolean
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
type: 'date' | 'datetime'
|
|
166
|
+
locales?: string | Array<string>
|
|
167
|
+
/**
|
|
168
|
+
* Shortcut patterns merged with `options`:
|
|
169
|
+
* `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,
|
|
170
|
+
* `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.
|
|
171
|
+
*/
|
|
172
|
+
pattern?: string
|
|
173
|
+
options?: Intl.DateTimeFormatOptions
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A column's custom display function, for anything {@link CellFormatConfig}
|
|
178
|
+
* cannot express. Returns a string - to render markup, use `cell` instead.
|
|
179
|
+
*/
|
|
180
|
+
export type CellFormatter<TData extends RowData> = (context: {
|
|
181
|
+
value: unknown
|
|
182
|
+
row: Row<TData>
|
|
183
|
+
column: Column<TData>
|
|
184
|
+
table: SvGrid<TData>
|
|
185
|
+
}) => string
|
|
186
|
+
|
|
187
|
+
/** A header or cell slot: a literal string, or a function returning renderable content. */
|
|
188
|
+
export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* How a column's value is aggregated for a group row when `columnGrouping`
|
|
192
|
+
* is active. Built-in reducers cover the common cases; pass a function for
|
|
193
|
+
* anything custom (weighted average, median, percentile, distinct count).
|
|
194
|
+
* The function receives the finite numeric values AND the raw leaf rows.
|
|
195
|
+
*/
|
|
196
|
+
export type GroupAggregator<TData = any> =
|
|
197
|
+
| 'sum'
|
|
198
|
+
| 'avg'
|
|
199
|
+
| 'min'
|
|
200
|
+
| 'max'
|
|
201
|
+
| 'count'
|
|
202
|
+
| 'countDistinct'
|
|
203
|
+
| 'extent'
|
|
204
|
+
| 'first'
|
|
205
|
+
| ((values: number[], rows: Array<TData>) => unknown)
|
|
206
|
+
|
|
207
|
+
/** Apply a group aggregator over a bucket's leaf rows for one column. */
|
|
208
|
+
export function applyGroupAggregate<TData extends RowData>(
|
|
209
|
+
agg: GroupAggregator<TData>,
|
|
210
|
+
columnId: string,
|
|
211
|
+
rows: ReadonlyArray<Row<TData>>,
|
|
212
|
+
): unknown {
|
|
213
|
+
// One pass, no intermediate arrays.
|
|
214
|
+
//
|
|
215
|
+
// This used to build a `raw` array, then a coerced one, then a filtered one -
|
|
216
|
+
// three allocations per aggregated column PER GROUP - before reducing. On a
|
|
217
|
+
// 100k-row grid grouped two levels deep, aggregation was about two thirds of
|
|
218
|
+
// the total grouping cost (213ms with three aggregators against 81ms with
|
|
219
|
+
// none), and each additional aggregated column added roughly 80ms.
|
|
220
|
+
//
|
|
221
|
+
// `count` first: it never needs to look at a value at all.
|
|
222
|
+
if (agg === 'count') return rows.length
|
|
223
|
+
|
|
224
|
+
if (agg === 'first') {
|
|
225
|
+
return rows.length ? rows[0]!.getCellValueByColumnId(columnId) : undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (agg === 'countDistinct') {
|
|
229
|
+
const seen = new Set<string>()
|
|
230
|
+
for (const row of rows) seen.add(String(row.getCellValueByColumnId(columnId) ?? ''))
|
|
231
|
+
return seen.size
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (typeof agg === 'function') {
|
|
235
|
+
// Custom aggregators keep their contract: the finite numbers, then the
|
|
236
|
+
// original row objects. Note `Number(null)` is 0 and therefore finite, so
|
|
237
|
+
// nulls DO reach the callback as zeros - long-standing behaviour.
|
|
238
|
+
const nums: number[] = []
|
|
239
|
+
for (const row of rows) {
|
|
240
|
+
const n = Number(row.getCellValueByColumnId(columnId))
|
|
241
|
+
if (Number.isFinite(n)) nums.push(n)
|
|
242
|
+
}
|
|
243
|
+
return agg(nums, rows.map((r) => r.original))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// sum / avg / min / max / extent share one accumulation pass.
|
|
247
|
+
let count = 0
|
|
248
|
+
let sum = 0
|
|
249
|
+
let min = Infinity
|
|
250
|
+
let max = -Infinity
|
|
251
|
+
for (const row of rows) {
|
|
252
|
+
const n = Number(row.getCellValueByColumnId(columnId))
|
|
253
|
+
if (!Number.isFinite(n)) continue
|
|
254
|
+
count++
|
|
255
|
+
// Left-to-right, matching the previous `reduce`, so float rounding is
|
|
256
|
+
// bit-identical rather than merely close.
|
|
257
|
+
sum += n
|
|
258
|
+
// Math.min/max on scalars rather than `<`, which differs on -0, and rather
|
|
259
|
+
// than the old `Math.min(...nums)` - spreading a whole group throws
|
|
260
|
+
// RangeError once the bucket is big enough to exhaust the argument stack.
|
|
261
|
+
min = Math.min(min, n)
|
|
262
|
+
max = Math.max(max, n)
|
|
263
|
+
}
|
|
264
|
+
if (!count) return undefined
|
|
265
|
+
switch (agg) {
|
|
266
|
+
case 'sum':
|
|
267
|
+
return sum
|
|
268
|
+
case 'avg':
|
|
269
|
+
return sum / count
|
|
270
|
+
case 'min':
|
|
271
|
+
return min
|
|
272
|
+
case 'max':
|
|
273
|
+
return max
|
|
274
|
+
case 'extent':
|
|
275
|
+
return `${min} – ${max}`
|
|
276
|
+
default:
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A column definition.
|
|
283
|
+
*
|
|
284
|
+
* `TFeatures` is a phantom parameter - it is threaded through nested
|
|
285
|
+
* `columns` groups but no member depends on it, so `{}`, `TableFeatures` and
|
|
286
|
+
* `typeof features` are all interchangeable here. It is deliberately left
|
|
287
|
+
* WITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot
|
|
288
|
+
* and silently type your data as `RowData`, losing every field-name check.
|
|
289
|
+
* Prefer {@link GridColumns} / {@link GridColumnDef} for the common case.
|
|
290
|
+
*/
|
|
291
|
+
export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
|
|
292
|
+
id?: string
|
|
293
|
+
field?: keyof TData & string
|
|
294
|
+
fieldFn?: (row: TData) => unknown
|
|
295
|
+
header?: ColumnDefTemplate<HeaderContext<TData>>
|
|
296
|
+
footer?: ColumnDefTemplate<HeaderContext<TData>>
|
|
297
|
+
cell?: ColumnDefTemplate<CellContext<TData>>
|
|
298
|
+
columns?: Array<ColumnDef<TFeatures, TData>>
|
|
299
|
+
/**
|
|
300
|
+
* Declarative cell spanning (merged cells). Return how many COLUMNS this
|
|
301
|
+
* cell spans to the right (1 = no span). Value-driven. Feed
|
|
302
|
+
* `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
|
|
303
|
+
* the same real `colspan`/`rowspan` merge engine (no separate code path).
|
|
304
|
+
*/
|
|
305
|
+
colSpan?: (params: CellSpanParams<TData>) => number
|
|
306
|
+
/**
|
|
307
|
+
* Declarative cell spanning (merged cells). Return how many ROWS this cell
|
|
308
|
+
* spans downward (1 = no span). See `colSpan` for how to apply.
|
|
309
|
+
*/
|
|
310
|
+
rowSpan?: (params: CellSpanParams<TData>) => number
|
|
311
|
+
/**
|
|
312
|
+
* High-level data type for the column. A convenience that resolves to the
|
|
313
|
+
* right `editorType`, alignment, date `format`, and filter operators without
|
|
314
|
+
* setting each by hand:
|
|
315
|
+
* 'text' → text editor, left-aligned
|
|
316
|
+
* 'number' → number editor, right-aligned, numeric filter operators
|
|
317
|
+
* 'boolean' → checkbox editor, centered
|
|
318
|
+
* 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
|
|
319
|
+
* 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
|
|
320
|
+
* Anything you set explicitly (`editorType`, `align`, `format`) still wins -
|
|
321
|
+
* `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
|
|
322
|
+
* this from the first data row for columns that declare neither.
|
|
323
|
+
*/
|
|
324
|
+
cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
|
|
325
|
+
/**
|
|
326
|
+
* Hide this column when the grid's `responsive` mode is on and the grid is
|
|
327
|
+
* narrower than this many pixels - drop low-priority columns on small
|
|
328
|
+
* screens. No effect unless the grid has `responsive` set.
|
|
329
|
+
*/
|
|
330
|
+
hideBelow?: number
|
|
331
|
+
/**
|
|
332
|
+
* For a column INSIDE a collapsible column group: `'open'` shows this column
|
|
333
|
+
* only while the group is expanded, `'closed'` only while collapsed. Omit to
|
|
334
|
+
* always show it. Setting it on any direct child gives the parent group a
|
|
335
|
+
* collapse toggle. Pair with `openByDefault` on the group.
|
|
336
|
+
*/
|
|
337
|
+
columnGroupShow?: 'open' | 'closed'
|
|
338
|
+
/**
|
|
339
|
+
* For a GROUP column (one with `columns: [...]`): start the group expanded.
|
|
340
|
+
* Defaults to `false` (collapsed), the conventional default - so only the always-on
|
|
341
|
+
* and `columnGroupShow: 'closed'` children show until the user expands it.
|
|
342
|
+
*/
|
|
343
|
+
openByDefault?: boolean
|
|
344
|
+
editorType?:
|
|
345
|
+
| 'text'
|
|
346
|
+
| 'number'
|
|
347
|
+
| 'date' // rich SvCalendar popover (opt out with 'date-native')
|
|
348
|
+
| 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
|
|
349
|
+
| 'time' // rich SvTimePicker dial (opt out with 'time-native')
|
|
350
|
+
| 'date-native' // plain <input type="date">
|
|
351
|
+
| 'datetime-native' // plain <input type="datetime-local">
|
|
352
|
+
| 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
|
|
353
|
+
| 'password' // native <input type="password"> with masked rendering
|
|
354
|
+
| 'checkbox'
|
|
355
|
+
| 'list'
|
|
356
|
+
| 'chips'
|
|
357
|
+
| 'select' // custom dropdown - single value, no typeahead
|
|
358
|
+
| 'rich-select' // custom dropdown with a typeahead search input
|
|
359
|
+
| 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)
|
|
360
|
+
| 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline
|
|
361
|
+
| 'color' // native <input type="color"> swatch
|
|
362
|
+
| 'rating' // 5-star rating control
|
|
363
|
+
// Any other string names a CUSTOM editor registered via `registerCellEditor`
|
|
364
|
+
// (or `registerBuiltinEditors`). `(string & {})` keeps the literals above
|
|
365
|
+
// autocompleting while allowing arbitrary custom type names.
|
|
366
|
+
| (string & {})
|
|
367
|
+
/**
|
|
368
|
+
* Custom in-cell editor. Receives the cell context PLUS a `commit(value)`
|
|
369
|
+
* and `cancel()` helper. Use when none of the built-in `editorType`s fit;
|
|
370
|
+
* the snippet's outer element is mounted inside the editing cell and
|
|
371
|
+
* inherits keyboard handling (Esc cancels, Enter commits unless your
|
|
372
|
+
* snippet preventDefaults it).
|
|
373
|
+
*
|
|
374
|
+
* Coexists with `editorType`: when both are set, `cellEditor` wins and
|
|
375
|
+
* `editorType` is treated as a hint for parsing the saved value.
|
|
376
|
+
*/
|
|
377
|
+
cellEditor?: ColumnDefTemplate<EditorContext<TData>>
|
|
378
|
+
/**
|
|
379
|
+
* Per-column tooltip. String shows as a native `title=`; `(ctx) => string`
|
|
380
|
+
* runs per cell so the tooltip can reflect the value. Returning an empty
|
|
381
|
+
* string skips the tooltip.
|
|
382
|
+
*/
|
|
383
|
+
tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
|
|
384
|
+
/**
|
|
385
|
+
* Declarative per-cell validation. Runs for EVERY
|
|
386
|
+
* rendered cell - including values already present in `data` on load, not
|
|
387
|
+
* just on edit - so bad data is flagged immediately. Invalid cells get the
|
|
388
|
+
* `sv-grid-cell-invalid` class (red highlight) and the returned message as
|
|
389
|
+
* their tooltip.
|
|
390
|
+
*
|
|
391
|
+
* Return value:
|
|
392
|
+
* - `null` / `undefined` / `true` → valid (no highlight)
|
|
393
|
+
* - `false` → invalid, no message
|
|
394
|
+
* - a non-empty `string` → invalid, string is the tooltip
|
|
395
|
+
*
|
|
396
|
+
* The value keeps rendering as-is (the grid does NOT roll it back); pair
|
|
397
|
+
* with `onCellValueChange` if you also want to reject the commit.
|
|
398
|
+
*/
|
|
399
|
+
validate?: (params: {
|
|
400
|
+
value: unknown
|
|
401
|
+
row: TData
|
|
402
|
+
rowIndex: number
|
|
403
|
+
column: Column<TData>
|
|
404
|
+
}) => string | boolean | null | undefined
|
|
405
|
+
/**
|
|
406
|
+
* Gate editing per column or per cell.
|
|
407
|
+
*
|
|
408
|
+
* - `true` (or omitted): the column is fully editable.
|
|
409
|
+
* - `false`: the column is read-only - double-click, type-to-edit,
|
|
410
|
+
* fill-handle drag, Delete, and clipboard paste all skip it.
|
|
411
|
+
* - `(ctx) => boolean`: evaluated for each cell, so you can lock
|
|
412
|
+
* individual rows (e.g. by role, status, ownership). Returning
|
|
413
|
+
* `false` opts the cell out of every editing path, identical to
|
|
414
|
+
* setting `editable: false` on the whole column for that row.
|
|
415
|
+
*
|
|
416
|
+
* The grid-wide `enableInlineEditing` prop still wins when set to
|
|
417
|
+
* `false`.
|
|
418
|
+
*/
|
|
419
|
+
editable?: boolean | ((context: CellContext<TData>) => boolean)
|
|
420
|
+
/**
|
|
421
|
+
* Transform the committed edit value before it is written to the row.
|
|
422
|
+
* Runs after the built-in per-`editorType` coercion, so `newValue` is
|
|
423
|
+
* already type-parsed; return the final value to store (e.g. round a
|
|
424
|
+
* number, uppercase a code, look up an id). A `valueParser` hook.
|
|
425
|
+
*/
|
|
426
|
+
valueParser?: (params: ValueParserParams<TData>) => unknown
|
|
427
|
+
/**
|
|
428
|
+
* Briefly flash / highlight this column's cell when its value changes
|
|
429
|
+
* (streaming feeds, edits, server pushes). `true` uses the default flash;
|
|
430
|
+
* pass `{ className }` to apply your own animation class instead.
|
|
431
|
+
*/
|
|
432
|
+
cellFlash?: boolean | { className?: string }
|
|
433
|
+
/**
|
|
434
|
+
* When `false`, this column never shows a sort indicator and clicking
|
|
435
|
+
* its header is a no-op - `api.setSort(thisColumn, ...)` is also
|
|
436
|
+
* ignored. Defaults to `true` (the column participates in sorting as
|
|
437
|
+
* long as `rowSortingFeature` is registered).
|
|
438
|
+
*/
|
|
439
|
+
sortable?: boolean
|
|
440
|
+
/**
|
|
441
|
+
* When `false`, this column never shows a filter funnel / menu and
|
|
442
|
+
* `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the
|
|
443
|
+
* column is filterable as long as `columnFilteringFeature` is
|
|
444
|
+
* registered).
|
|
445
|
+
*/
|
|
446
|
+
filterable?: boolean
|
|
447
|
+
/**
|
|
448
|
+
* Options for `editorType: 'list' | 'chips'`. Either bare values (the
|
|
449
|
+
* string is both value and label) or `{ value, label }` objects.
|
|
450
|
+
* For `chips` this is optional - when omitted, the chips editor becomes
|
|
451
|
+
* free-form (user types and presses Enter to commit a chip).
|
|
452
|
+
*
|
|
453
|
+
* Pass a function `(row) => options` for row-dependent (cascading)
|
|
454
|
+
* options - e.g. City options that depend on Country in the same row.
|
|
455
|
+
*
|
|
456
|
+
* Either form may return a **Promise**, for options that come from the
|
|
457
|
+
* server. While it resolves, the editor shows a loading state and the cell
|
|
458
|
+
* renders its raw value.
|
|
459
|
+
*
|
|
460
|
+
* Results are cached so reopening an editor does not refetch: a static source
|
|
461
|
+
* per column, a per-row source per row AND per that row's data - so a cascade
|
|
462
|
+
* reloads by itself when the cell it depends on is edited. Call
|
|
463
|
+
* `api.refreshEditorOptions(columnId?)` when the list changes server-side.
|
|
464
|
+
*/
|
|
465
|
+
editorOptions?:
|
|
466
|
+
| EditorOptionSource
|
|
467
|
+
| Promise<EditorOptionSource>
|
|
468
|
+
| ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
|
|
469
|
+
/** When true, list/chips allow multiple selections. Cell value becomes an array. */
|
|
470
|
+
editorMultiple?: boolean
|
|
471
|
+
/** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
|
|
472
|
+
editorSeparator?: string
|
|
473
|
+
format?: CellFormatConfig
|
|
474
|
+
formatter?: CellFormatter<TData>
|
|
475
|
+
/**
|
|
476
|
+
* Aggregate this column's values into the group row when grouping is
|
|
477
|
+
* active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |
|
|
478
|
+
* 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result
|
|
479
|
+
* is formatted with this column's `format` and shown in the group header.
|
|
480
|
+
*/
|
|
481
|
+
aggregate?: GroupAggregator<TData>
|
|
482
|
+
/**
|
|
483
|
+
* What this column contributes to the grid's footer summary row (the one
|
|
484
|
+
* turned on with `summary` / `enableRowSummaries`). Takes the same
|
|
485
|
+
* aggregators as {@link aggregate}, and the result is formatted with this
|
|
486
|
+
* column's `format`.
|
|
487
|
+
*
|
|
488
|
+
* Without it the footer falls back to its default: the sum of a numeric
|
|
489
|
+
* column, `Count: N` otherwise. Set `false` to leave the cell blank, which is
|
|
490
|
+
* usually what an actions or checkbox column wants.
|
|
491
|
+
*
|
|
492
|
+
* { field: 'amount', summary: 'avg' }
|
|
493
|
+
* { id: 'actions', summary: false }
|
|
494
|
+
*/
|
|
495
|
+
summary?: GroupAggregator<TData> | false
|
|
496
|
+
/**
|
|
497
|
+
* Render the cell as an in-cell sparkline chart. The cell value should be
|
|
498
|
+
* an array of numbers (or a comma/space separated string). Mutually
|
|
499
|
+
* exclusive with a custom `cell` renderer (a `cell` wins if both are set).
|
|
500
|
+
*
|
|
501
|
+
* { sparkline: { type: 'line' } } // default line
|
|
502
|
+
* { sparkline: { type: 'bar', color: '#16a34a' } }
|
|
503
|
+
* { sparkline: { type: 'winloss' } } // sign-only up/down
|
|
504
|
+
*
|
|
505
|
+
* See `SparklineConfig` for the full option set (type, color,
|
|
506
|
+
* negativeColor, width, height, fixed min/max).
|
|
507
|
+
*/
|
|
508
|
+
sparkline?: SparklineConfig
|
|
509
|
+
/** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
|
|
510
|
+
width?: number
|
|
511
|
+
/**
|
|
512
|
+
* Whether the user may resize this column. Only consulted when the grid has
|
|
513
|
+
* `columnResize` on - it narrows that, it does not enable anything.
|
|
514
|
+
*
|
|
515
|
+
* `false` removes the column's drag handle entirely, so pointer drag, the
|
|
516
|
+
* keyboard arrows and double-click-to-autosize are all gone with it, and the
|
|
517
|
+
* column menu drops its Autosize item. Use it for the columns whose width is
|
|
518
|
+
* part of the layout rather than a preference: a row-number gutter, a
|
|
519
|
+
* checkbox column, a fixed icon column.
|
|
520
|
+
*
|
|
521
|
+
* Programmatic sizing is unaffected - `api.autosizeColumn()`,
|
|
522
|
+
* `api.setColumnWidth()` and `fitColumns` all still apply, the same way they
|
|
523
|
+
* do when `columnResize` is off. This governs the user affordance only.
|
|
524
|
+
*/
|
|
525
|
+
resizable?: boolean
|
|
526
|
+
/**
|
|
527
|
+
* Initial visibility. Set `false` to start the column hidden while still
|
|
528
|
+
* listing it in the Choose Columns UI for the user to re-enable. Applied
|
|
529
|
+
* once at mount; after that `api.setColumnVisible` / user toggles win.
|
|
530
|
+
* On a group column, `false` hides the whole group's leaf columns.
|
|
531
|
+
*/
|
|
532
|
+
visible?: boolean
|
|
533
|
+
/**
|
|
534
|
+
* Horizontal alignment for header and body cells. When omitted, the
|
|
535
|
+
* default is inferred from `editorType`:
|
|
536
|
+
* - `'number' | 'date' | 'datetime'` → `'right'`
|
|
537
|
+
* - `'checkbox'` → `'center'`
|
|
538
|
+
* - everything else → `'left'`
|
|
539
|
+
*/
|
|
540
|
+
align?: 'left' | 'center' | 'right'
|
|
541
|
+
/**
|
|
542
|
+
* Per-cell conditional CSS. Two shapes:
|
|
543
|
+
*
|
|
544
|
+
* - **String** (or array of strings): class name(s) added to the
|
|
545
|
+
* cell's `<td>` for every row in this column.
|
|
546
|
+
* - **Function**: invoked per cell with the same `CellContext` shape
|
|
547
|
+
* the `cell` renderer receives. Return a string, an array of
|
|
548
|
+
* strings, or an object mapping class names to booleans.
|
|
549
|
+
*
|
|
550
|
+
* Use it for status tinting, conditional bold, "negative number"
|
|
551
|
+
* coloring - anything that's a function of the row's value. Cells
|
|
552
|
+
* still receive their format / cell renderer; the class just
|
|
553
|
+
* augments the rendered `<td>`.
|
|
554
|
+
*/
|
|
555
|
+
cellClass?:
|
|
556
|
+
| string
|
|
557
|
+
| ReadonlyArray<string>
|
|
558
|
+
| ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* A column definition keyed only by your row type - the ergonomic form of
|
|
563
|
+
* {@link ColumnDef}, whose first parameter is a phantom feature bag that is
|
|
564
|
+
* almost always `{}`.
|
|
565
|
+
*
|
|
566
|
+
* ```ts
|
|
567
|
+
* const columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]
|
|
568
|
+
* ```
|
|
569
|
+
*
|
|
570
|
+
* Interchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,
|
|
571
|
+
* TData>` in both directions, so it mixes freely with existing code.
|
|
572
|
+
*/
|
|
573
|
+
export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>
|
|
574
|
+
|
|
575
|
+
/** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
|
|
576
|
+
export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* A resolved column: your {@link ColumnDef} plus everything the grid computed
|
|
580
|
+
* from it - its id, its depth under any group header, and the sort handlers a
|
|
581
|
+
* header needs. This is what you receive in render contexts; the `ColumnDef`
|
|
582
|
+
* is what you wrote.
|
|
583
|
+
*/
|
|
584
|
+
export type Column<TData extends RowData> = {
|
|
585
|
+
id: string
|
|
586
|
+
columnDef: ColumnDef<any, TData>
|
|
587
|
+
depth: number
|
|
588
|
+
parentId?: string
|
|
589
|
+
getCanSort: () => boolean
|
|
590
|
+
getCanFilter: () => boolean
|
|
591
|
+
getIsSorted: () => false | 'asc' | 'desc'
|
|
592
|
+
getToggleSortingHandler: () => () => void
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* One header cell. `colSpan` is how many leaf columns it covers, and
|
|
597
|
+
* `isPlaceholder` marks the empty cells that pad a group-header row so the
|
|
598
|
+
* levels line up.
|
|
599
|
+
*/
|
|
600
|
+
export type Header<TData extends RowData> = {
|
|
601
|
+
id: string
|
|
602
|
+
isPlaceholder: boolean
|
|
603
|
+
colSpan: number
|
|
604
|
+
column: Column<TData>
|
|
605
|
+
getContext: () => HeaderContext<TData>
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** One row of header cells. A grid with grouped columns has several, outermost first. */
|
|
609
|
+
export type HeaderGroup<TData extends RowData> = {
|
|
610
|
+
id: string
|
|
611
|
+
headers: Array<Header<TData>>
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** One cell: the intersection of a {@link Row} and a {@link Column}. */
|
|
615
|
+
export type Cell<TData extends RowData> = {
|
|
616
|
+
id: string
|
|
617
|
+
row: Row<TData>
|
|
618
|
+
column: Column<TData>
|
|
619
|
+
getValue: () => unknown
|
|
620
|
+
getContext: () => CellContext<TData>
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* A row in the display model. `original` is your untouched data object;
|
|
625
|
+
* everything else is grid-computed. `index` is the position in the displayed
|
|
626
|
+
* set, so it shifts as sorting and filtering change - key on `id`, not index.
|
|
627
|
+
*
|
|
628
|
+
* Group rows and tree parents carry `subRows`; a plain data row does not.
|
|
629
|
+
*/
|
|
630
|
+
export type Row<TData extends RowData> = {
|
|
631
|
+
id: string
|
|
632
|
+
index: number
|
|
633
|
+
original: TData
|
|
634
|
+
depth: number
|
|
635
|
+
subRows?: Array<Row<TData>>
|
|
636
|
+
/** Total leaf (data) rows under this group row. Undefined for data rows. */
|
|
637
|
+
leafCount?: number
|
|
638
|
+
getCanExpand: () => boolean
|
|
639
|
+
getIsExpanded: () => boolean
|
|
640
|
+
toggleExpanded: () => void
|
|
641
|
+
getIsSelected: () => boolean
|
|
642
|
+
toggleSelected: () => void
|
|
643
|
+
getAllCells: () => Array<Cell<TData>>
|
|
644
|
+
getCellValueByColumnId: (columnId: string) => unknown
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** The output of the row pipeline: the rows to display, in order. */
|
|
648
|
+
export type RowModel<TData extends RowData> = {
|
|
649
|
+
rows: Array<Row<TData>>
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The minimal reactive store behind the headless core - read `state`, write
|
|
654
|
+
* through `setState`, and `subscribe` for changes. Deliberately framework
|
|
655
|
+
* free, which is what lets the core run under plain Node.
|
|
656
|
+
*
|
|
657
|
+
* In Svelte you rarely touch this: `subscribeGrid` wraps it with fine-grained
|
|
658
|
+
* selectors so a component only re-runs for the slice it read.
|
|
659
|
+
*/
|
|
660
|
+
export type Store<T> = {
|
|
661
|
+
readonly state: T
|
|
662
|
+
setState: (updater: (prev: T) => T) => void
|
|
663
|
+
subscribe: (listener: () => void) => () => void
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function createStore<T>(initial: T): Store<T> {
|
|
667
|
+
let value = initial
|
|
668
|
+
const listeners = new Set<() => void>()
|
|
669
|
+
return {
|
|
670
|
+
get state() {
|
|
671
|
+
return value
|
|
672
|
+
},
|
|
673
|
+
setState(updater) {
|
|
674
|
+
value = updater(value)
|
|
675
|
+
listeners.forEach((listener) => listener())
|
|
676
|
+
},
|
|
677
|
+
subscribe(listener) {
|
|
678
|
+
listeners.add(listener)
|
|
679
|
+
return () => listeners.delete(listener)
|
|
680
|
+
},
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Click-to-sort. Injected by the `sortable` shortcut.
|
|
686
|
+
*
|
|
687
|
+
* This and the five features below are opaque markers: pass the ones you want
|
|
688
|
+
* to {@link tableFeatures} and the grid wires up the matching row model. With
|
|
689
|
+
* `<SvGrid>` you rarely name them - the boolean shortcuts (`sortable`,
|
|
690
|
+
* `filterable`, `pageable`, `groupable`) inject them for you. Reach for them
|
|
691
|
+
* directly when driving the headless core, or when you want a feature on
|
|
692
|
+
* without its UI.
|
|
693
|
+
*
|
|
694
|
+
* The names match TanStack Table v9, so a features object written for it works
|
|
695
|
+
* here unchanged.
|
|
696
|
+
*/
|
|
697
|
+
export const rowSortingFeature = { key: 'rowSortingFeature' }
|
|
698
|
+
/** Per-column filtering. Injected by the `filterable` shortcut. */
|
|
699
|
+
export const columnFilteringFeature = { key: 'columnFilteringFeature' }
|
|
700
|
+
/** Paging of the row model. Injected by the `pageable` shortcut. */
|
|
701
|
+
export const rowPaginationFeature = { key: 'rowPaginationFeature' }
|
|
702
|
+
/** Row grouping with aggregation. Injected by the `groupable` shortcut. */
|
|
703
|
+
export const columnGroupingFeature = { key: 'columnGroupingFeature' }
|
|
704
|
+
/** Row selection state (the checkbox column reads it). */
|
|
705
|
+
export const rowSelectionFeature = { key: 'rowSelectionFeature' }
|
|
706
|
+
/** Expand / collapse, for tree rows and master-detail. */
|
|
707
|
+
export const rowExpandingFeature = { key: 'rowExpandingFeature' }
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Declare which features a grid uses. Identity at runtime - its whole job is to
|
|
711
|
+
* capture the exact set in the type, so `ColumnDef<typeof features, Row>` knows
|
|
712
|
+
* what is registered and anything you did not register is tree-shaken out.
|
|
713
|
+
*
|
|
714
|
+
* ```ts
|
|
715
|
+
* const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
|
|
716
|
+
* ```
|
|
717
|
+
*
|
|
718
|
+
* Same call signature as TanStack Table v9, so a features object written for it
|
|
719
|
+
* transfers unchanged.
|
|
720
|
+
*/
|
|
721
|
+
export function tableFeatures<T extends TableFeatures>(features: T): T {
|
|
722
|
+
return features
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Built-in comparators, chosen per column by its data type. `auto` compares as
|
|
727
|
+
* text; set a column's type or supply your own comparator to override.
|
|
728
|
+
*/
|
|
729
|
+
export const sortFns = {
|
|
730
|
+
auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
|
|
731
|
+
number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
|
|
732
|
+
date: (a: unknown, b: unknown) => {
|
|
733
|
+
const aa = new Date(a as any).getTime()
|
|
734
|
+
const bb = new Date(b as any).getTime()
|
|
735
|
+
return aa - bb
|
|
736
|
+
},
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Built-in match functions, named by {@link ColumnFilter}'s `fn`.
|
|
741
|
+
* `includesString` is case-insensitive substring; `equals` is strict identity.
|
|
742
|
+
*/
|
|
743
|
+
export const filterFns = {
|
|
744
|
+
includesString: (value: unknown, query: string) =>
|
|
745
|
+
String(value).toLowerCase().includes(query.toLowerCase()),
|
|
746
|
+
equals: (value: unknown, query: unknown) => value === query,
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Everything a base row needs that is the same for every row in the table.
|
|
751
|
+
*
|
|
752
|
+
* One object per table, referenced by every row, instead of one closure scope
|
|
753
|
+
* per row. See {@link BASE_ROW_METHODS}.
|
|
754
|
+
*/
|
|
755
|
+
type BaseRowCtx<TData extends RowData> = {
|
|
756
|
+
grid: SvGrid<TData>
|
|
757
|
+
store: { state: Record<string, any> }
|
|
758
|
+
columns: Array<Column<TData>>
|
|
759
|
+
columnCount: number
|
|
760
|
+
columnIndexById: Map<string, number>
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Keys for a base row's private fields.
|
|
765
|
+
*
|
|
766
|
+
* Symbols, not string keys, and that is load-bearing. A row's shared methods
|
|
767
|
+
* need a pointer back to the table, but `_ctx` as a normal property made every
|
|
768
|
+
* row serialise the entire grid: `JSON.stringify(oneRow)` grew with the dataset
|
|
769
|
+
* (981 chars at 3 rows, 67,719 at 3,000) because `options.data` is reachable
|
|
770
|
+
* through it, so stringifying a row model was quadratic. Rows used to serialise
|
|
771
|
+
* to a small constant and must again.
|
|
772
|
+
*
|
|
773
|
+
* A symbol key is invisible to `JSON.stringify`, `Object.keys` and `for...in`,
|
|
774
|
+
* yet IS copied by object spread - which matters because several row models
|
|
775
|
+
* legitimately do `{ ...row, depth }` and the clone needs these to work.
|
|
776
|
+
* Non-enumerable string keys would have hidden them from JSON but also from the
|
|
777
|
+
* spread, silently breaking every cloned row.
|
|
778
|
+
*/
|
|
779
|
+
const ROW_CTX = Symbol('svgrid.row.ctx')
|
|
780
|
+
const ROW_VALUES = Symbol('svgrid.row.values')
|
|
781
|
+
const ROW_CELLS = Symbol('svgrid.row.cells')
|
|
782
|
+
|
|
783
|
+
/** A base row's private fields, on top of the public {@link Row} surface. */
|
|
784
|
+
type BaseRowState<TData extends RowData> = Row<TData> & {
|
|
785
|
+
[ROW_CTX]: BaseRowCtx<TData>
|
|
786
|
+
[ROW_VALUES]: Array<unknown> | null
|
|
787
|
+
[ROW_CELLS]: Array<Cell<TData>> | null
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The methods every base row carries, defined ONCE and assigned by reference.
|
|
792
|
+
*
|
|
793
|
+
* Rows used to be built as object literals whose methods were closures, which
|
|
794
|
+
* meant a 100k-row grid allocated 700k closures and a closure scope per row
|
|
795
|
+
* before painting anything. Measured at 100k x 9: 13.8 ms and 56.5 MB to build,
|
|
796
|
+
* against 2.0 ms and 14.5 MB for this shape - the single largest cost in
|
|
797
|
+
* mounting a large grid.
|
|
798
|
+
*
|
|
799
|
+
* They read their row through `this` rather than a captured variable, which is
|
|
800
|
+
* why they can be shared. Note they are assigned as OWN properties rather than
|
|
801
|
+
* put on a prototype: `Row` is public, several row models legitimately do
|
|
802
|
+
* `{ ...row, depth }`, and a spread copies own properties but not a prototype.
|
|
803
|
+
* A class here would silently strip every method off a cloned row.
|
|
804
|
+
*/
|
|
805
|
+
const BASE_ROW_METHODS = {
|
|
806
|
+
getCanExpand(this: BaseRowState<RowData>) {
|
|
807
|
+
return false
|
|
808
|
+
},
|
|
809
|
+
getIsExpanded(this: BaseRowState<RowData>) {
|
|
810
|
+
return Boolean((this[ROW_CTX].store.state.expanded ?? {})[this.id])
|
|
811
|
+
},
|
|
812
|
+
toggleExpanded(this: BaseRowState<RowData>) {
|
|
813
|
+
const id = this.id
|
|
814
|
+
this[ROW_CTX].grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
815
|
+
},
|
|
816
|
+
getIsSelected(this: BaseRowState<RowData>) {
|
|
817
|
+
return Boolean((this[ROW_CTX].store.state.rowSelection ?? {})[this.id])
|
|
818
|
+
},
|
|
819
|
+
toggleSelected(this: BaseRowState<RowData>) {
|
|
820
|
+
const id = this.id
|
|
821
|
+
this[ROW_CTX].grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
822
|
+
},
|
|
823
|
+
getAllCells(this: BaseRowState<RowData>) {
|
|
824
|
+
return this[ROW_CELLS] ?? buildBaseRowCells(this)
|
|
825
|
+
},
|
|
826
|
+
getCellValueByColumnId(this: BaseRowState<RowData>, columnId: string) {
|
|
827
|
+
const idx = this[ROW_CTX].columnIndexById.get(columnId)
|
|
828
|
+
if (idx === undefined) return undefined
|
|
829
|
+
if (!this[ROW_VALUES]) this[ROW_VALUES] = baseRowValues(this)
|
|
830
|
+
return this[ROW_VALUES][idx]
|
|
831
|
+
},
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Materialise one row's `Cell[]`, memoised on the row.
|
|
836
|
+
*
|
|
837
|
+
* A free function taking the row rather than a method using `this`, because the
|
|
838
|
+
* cell closures need a stable reference to it and aliasing `this` inside a
|
|
839
|
+
* method is exactly the pattern that produces `self`/`that` bugs.
|
|
840
|
+
*/
|
|
841
|
+
function buildBaseRowCells<TData extends RowData>(row: BaseRowState<TData>): Array<Cell<TData>> {
|
|
842
|
+
const { columns, columnCount, grid } = row[ROW_CTX]
|
|
843
|
+
const built = new Array<Cell<TData>>(columnCount)
|
|
844
|
+
for (let i = 0; i < columnCount; i++) {
|
|
845
|
+
const column = columns[i]!
|
|
846
|
+
const colIndex = i
|
|
847
|
+
const cell: Cell<TData> = {
|
|
848
|
+
id: `${row.id}_${column.id}`,
|
|
849
|
+
row,
|
|
850
|
+
column,
|
|
851
|
+
getValue: () => {
|
|
852
|
+
if (!row[ROW_VALUES]) row[ROW_VALUES] = baseRowValues(row)
|
|
853
|
+
return row[ROW_VALUES][colIndex]
|
|
854
|
+
},
|
|
855
|
+
getContext: () => ({
|
|
856
|
+
cell,
|
|
857
|
+
row,
|
|
858
|
+
column,
|
|
859
|
+
table: grid,
|
|
860
|
+
getValue: () => cell.getValue(),
|
|
861
|
+
}),
|
|
862
|
+
}
|
|
863
|
+
built[i] = cell
|
|
864
|
+
}
|
|
865
|
+
row[ROW_CELLS] = built
|
|
866
|
+
return built
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Resolve every column's value for one row. Kept lazy: a 100k-row grid showing
|
|
871
|
+
* twenty rows must not materialise 900k values to paint.
|
|
872
|
+
*/
|
|
873
|
+
function baseRowValues<TData extends RowData>(row: BaseRowState<TData>): Array<unknown> {
|
|
874
|
+
const { columns, columnCount } = row[ROW_CTX]
|
|
875
|
+
const original = row.original as Record<string, unknown>
|
|
876
|
+
const values = new Array<unknown>(columnCount)
|
|
877
|
+
for (let i = 0; i < columnCount; i++) {
|
|
878
|
+
const def = columns[i]!.columnDef
|
|
879
|
+
if (def.fieldFn) values[i] = def.fieldFn(original as TData)
|
|
880
|
+
else if (def.field) values[i] = original[def.field]
|
|
881
|
+
else values[i] = undefined
|
|
882
|
+
}
|
|
883
|
+
return values
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* One stage of the row pipeline: takes the rows produced so far and returns the
|
|
888
|
+
* next set. Stages compose in the order given to `_rowModels`, so filtering
|
|
889
|
+
* before sorting sorts only what survived the filter.
|
|
890
|
+
*/
|
|
891
|
+
export type RowModelFactory<TData extends RowData> = (args: {
|
|
892
|
+
table: SvGrid<TData>
|
|
893
|
+
rows: Array<Row<TData>>
|
|
894
|
+
}) => Array<Row<TData>>
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* The identity stage that starts every pipeline. Always required, even when no
|
|
898
|
+
* other stage is: it is what turns your data into rows.
|
|
899
|
+
*/
|
|
900
|
+
export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
901
|
+
return ({ rows }) => rows
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Drops rows that fail the active {@link ColumnFiltersState}. Pairs with
|
|
905
|
+
* `columnFilteringFeature`; without it there are no filters to apply.
|
|
906
|
+
*/
|
|
907
|
+
export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
908
|
+
return ({ table, rows }) => {
|
|
909
|
+
const filters: ColumnFiltersState = table.getState().columnFilters ?? []
|
|
910
|
+
if (!filters.length) return rows
|
|
911
|
+
|
|
912
|
+
// Resolve each filter's match function once, outside the row loop.
|
|
913
|
+
const compiled = filters.map((filter) => ({
|
|
914
|
+
id: filter.id,
|
|
915
|
+
value: filter.value,
|
|
916
|
+
fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
|
|
917
|
+
}))
|
|
918
|
+
|
|
919
|
+
return rows.filter((row) => {
|
|
920
|
+
for (let i = 0; i < compiled.length; i++) {
|
|
921
|
+
const filter = compiled[i]!
|
|
922
|
+
// `getCellValueByColumnId` rather than `getAllCells().find(...)`.
|
|
923
|
+
// Both read the same lazily-built `cachedValues` array, but the latter
|
|
924
|
+
// also builds and caches the row's whole `Cell[]` - one object per
|
|
925
|
+
// column - purely to reach one field. On a 100k-row grid that is
|
|
926
|
+
// 100,000 cell arrays the filter never looks at again, and it defeats
|
|
927
|
+
// the laziness the row factory exists to provide.
|
|
928
|
+
if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false
|
|
929
|
+
}
|
|
930
|
+
return true
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Narrows the rows to the current page. Put it LAST: anything after it would
|
|
936
|
+
* only ever see one page of data.
|
|
937
|
+
*/
|
|
938
|
+
export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
939
|
+
return ({ table, rows }) => {
|
|
940
|
+
const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
|
|
941
|
+
const start = pagination.pageIndex * pagination.pageSize
|
|
942
|
+
return rows.slice(start, start + pagination.pageSize)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Buckets rows by the active {@link GroupingState} and inserts a group row
|
|
947
|
+
* ahead of each bucket, carrying that bucket's aggregates.
|
|
948
|
+
*/
|
|
949
|
+
export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
950
|
+
return ({ table, rows }) => {
|
|
951
|
+
const grouping: GroupingState = table.getState().grouping ?? []
|
|
952
|
+
if (!grouping.length) return rows
|
|
953
|
+
const columns = table.getAllColumns()
|
|
954
|
+
|
|
955
|
+
// Recursively bucket rows by each grouping column in turn. At every level a
|
|
956
|
+
// group row is built that stands in for its children - a non-group column
|
|
957
|
+
// resolves to the value shared by every leaf row, or to undefined when the
|
|
958
|
+
// leaves disagree.
|
|
959
|
+
function buildGroups(
|
|
960
|
+
input: Array<Row<TData>>,
|
|
961
|
+
levelIndex: number,
|
|
962
|
+
depth: number,
|
|
963
|
+
idPrefix: string,
|
|
964
|
+
/** Grouping columns already fixed by an ancestor bucket, and their raw
|
|
965
|
+
* values - `undefined` where that bucket mixed several. */
|
|
966
|
+
fixedValues: ReadonlyMap<string, unknown>,
|
|
967
|
+
): Array<Row<TData>> {
|
|
968
|
+
if (levelIndex >= grouping.length) {
|
|
969
|
+
// Leaves: actual data rows, with their nesting depth recorded.
|
|
970
|
+
return input.map((row) => ({ ...row, depth }))
|
|
971
|
+
}
|
|
972
|
+
const groupKey = grouping[levelIndex]
|
|
973
|
+
if (!groupKey) return input
|
|
974
|
+
|
|
975
|
+
// Buckets carry the RAW grouping value alongside the rows, plus whether
|
|
976
|
+
// the bucket saw more than one distinct raw value. Both are needed to let
|
|
977
|
+
// deeper levels skip re-scanning this column: buckets are keyed by
|
|
978
|
+
// `String(value ?? '')`, so `null`, `undefined` and `''` collapse into one
|
|
979
|
+
// bucket, and a scan of such a bucket would report disagreement. Tracking
|
|
980
|
+
// it here costs one comparison per row and keeps the shortcut honest.
|
|
981
|
+
type Bucket = { rows: Array<Row<TData>>; raw: unknown; mixed: boolean }
|
|
982
|
+
const buckets = new Map<string, Bucket>()
|
|
983
|
+
for (const row of input) {
|
|
984
|
+
const value = row.getCellValueByColumnId(groupKey)
|
|
985
|
+
const key = String(value ?? '')
|
|
986
|
+
const bucket = buckets.get(key)
|
|
987
|
+
if (bucket) {
|
|
988
|
+
bucket.rows.push(row)
|
|
989
|
+
if (!bucket.mixed && bucket.raw !== value) bucket.mixed = true
|
|
990
|
+
} else {
|
|
991
|
+
buckets.set(key, { rows: [row], raw: value, mixed: false })
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const groupRows: Array<Row<TData>> = []
|
|
996
|
+
let index = 0
|
|
997
|
+
buckets.forEach((bucket, key) => {
|
|
998
|
+
const children = bucket.rows
|
|
999
|
+
const id = `${idPrefix}_${groupKey}_${key}`
|
|
1000
|
+
// Record this column as fixed for deeper levels ONLY when the bucket is
|
|
1001
|
+
// homogeneous. If all rows here share a raw value, so does every subset
|
|
1002
|
+
// of them, which is what makes the shortcut sound.
|
|
1003
|
+
//
|
|
1004
|
+
// A MIXED bucket must not be recorded at all - not even as "undefined".
|
|
1005
|
+
// `null`, `undefined` and `''` share a bucket key, so a mixed bucket can
|
|
1006
|
+
// still split into homogeneous children one level down, and those
|
|
1007
|
+
// children have a real shared value that a scan would find. Marking the
|
|
1008
|
+
// column resolved here would hand them the parent's disagreement.
|
|
1009
|
+
const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw)
|
|
1010
|
+
const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed)
|
|
1011
|
+
const isDeepest = levelIndex + 1 >= grouping.length
|
|
1012
|
+
const leafCount = isDeepest
|
|
1013
|
+
? subRows.length
|
|
1014
|
+
: subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
|
|
1015
|
+
|
|
1016
|
+
// Every grouping column ABOVE this level is already resolved: bucketing
|
|
1017
|
+
// by it is what made it constant, so scanning the children to rediscover
|
|
1018
|
+
// it is pure waste. Only the current level's key short-circuited before,
|
|
1019
|
+
// so a second-level group walked all of its children to re-derive the
|
|
1020
|
+
// first level's value - about 100,000 reads on the 100k x 9 two-level
|
|
1021
|
+
// case, for an answer already in hand.
|
|
1022
|
+
//
|
|
1023
|
+
// The current level still returns the stringified bucket key rather than
|
|
1024
|
+
// the raw value, because that is what it has always returned and the
|
|
1025
|
+
// group row's display depends on it.
|
|
1026
|
+
const resolveColumnValue = (columnId: string): unknown => {
|
|
1027
|
+
if (columnId === groupKey) return key
|
|
1028
|
+
if (fixedValues.has(columnId)) return fixedValues.get(columnId)
|
|
1029
|
+
let resolved: unknown
|
|
1030
|
+
let hasResolved = false
|
|
1031
|
+
for (const child of children) {
|
|
1032
|
+
const childValue = child.getCellValueByColumnId(columnId)
|
|
1033
|
+
if (!hasResolved) {
|
|
1034
|
+
resolved = childValue
|
|
1035
|
+
hasResolved = true
|
|
1036
|
+
} else if (childValue !== resolved) {
|
|
1037
|
+
return undefined
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return resolved
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const groupOriginal: Record<string, unknown> = {}
|
|
1044
|
+
columns.forEach((column) => {
|
|
1045
|
+
const field = column.columnDef.field
|
|
1046
|
+
if (!field) return
|
|
1047
|
+
const agg = column.columnDef.aggregate
|
|
1048
|
+
groupOriginal[field] = agg
|
|
1049
|
+
? applyGroupAggregate(agg, column.id, children)
|
|
1050
|
+
: resolveColumnValue(column.id)
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
const groupRow: Row<TData> = {
|
|
1054
|
+
id,
|
|
1055
|
+
index: index++,
|
|
1056
|
+
original: groupOriginal as TData,
|
|
1057
|
+
depth,
|
|
1058
|
+
subRows,
|
|
1059
|
+
leafCount,
|
|
1060
|
+
getCanExpand: () => true,
|
|
1061
|
+
getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
|
|
1062
|
+
toggleExpanded: () => {
|
|
1063
|
+
table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
1064
|
+
},
|
|
1065
|
+
getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),
|
|
1066
|
+
toggleSelected: () => {
|
|
1067
|
+
table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
1068
|
+
},
|
|
1069
|
+
getAllCells: () => [],
|
|
1070
|
+
// Prefer the precomputed group value (which carries aggregates)
|
|
1071
|
+
// and fall back to the shared-value resolver for columns without
|
|
1072
|
+
// a field.
|
|
1073
|
+
getCellValueByColumnId: (columnId: string) => {
|
|
1074
|
+
const col = columns.find((c) => c.id === columnId)
|
|
1075
|
+
const field = col?.columnDef.field
|
|
1076
|
+
if (field && field in groupOriginal) return groupOriginal[field]
|
|
1077
|
+
return resolveColumnValue(columnId)
|
|
1078
|
+
},
|
|
1079
|
+
}
|
|
1080
|
+
groupRows.push(groupRow)
|
|
1081
|
+
})
|
|
1082
|
+
return groupRows
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return buildGroups(rows, 0, 0, 'group', new Map())
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* How to read a hierarchy out of FLAT rows: each row names its parent, and the
|
|
1090
|
+
* grid reconstructs the tree. Rows whose parent id matches nothing become roots
|
|
1091
|
+
* rather than disappearing.
|
|
1092
|
+
*
|
|
1093
|
+
* For nested source data (`children: [...]`), flatten it first with
|
|
1094
|
+
* {@link flattenTreeData}.
|
|
1095
|
+
*/
|
|
1096
|
+
export type TreeRowModelOptions = {
|
|
1097
|
+
/** Field holding each row's parent id. Rows with no parent are roots. */
|
|
1098
|
+
parentField: string
|
|
1099
|
+
/** Field holding the row's own id. Defaults to `'id'`. */
|
|
1100
|
+
idField?: string
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Client-side tree data: nest the grid's own flat rows into a parent/child
|
|
1105
|
+
* hierarchy that `createExpandedRowModel` then walks.
|
|
1106
|
+
*
|
|
1107
|
+
* This works on the rows the grid already built rather than on raw data, so
|
|
1108
|
+
* tree rows keep their cells, editing, selection and formatting - they are real
|
|
1109
|
+
* data rows that happen to have children, not synthetic banners like grouping's.
|
|
1110
|
+
* That is also why the model is parent-id based: nested source arrays never
|
|
1111
|
+
* become rows (the grid only builds rows for `data`), so nested input is
|
|
1112
|
+
* flattened first with {@link flattenTreeData}. One code path, no duplicated
|
|
1113
|
+
* row construction.
|
|
1114
|
+
*
|
|
1115
|
+
* Rows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable
|
|
1116
|
+
* data row for a full-width group banner.
|
|
1117
|
+
*/
|
|
1118
|
+
export function createTreeRowModel<TData extends RowData>(
|
|
1119
|
+
options: TreeRowModelOptions,
|
|
1120
|
+
): RowModelFactory<TData> {
|
|
1121
|
+
const { parentField, idField = 'id' } = options
|
|
1122
|
+
return ({ table, rows }) => {
|
|
1123
|
+
if (!rows.length) return rows
|
|
1124
|
+
const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]
|
|
1125
|
+
const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]
|
|
1126
|
+
|
|
1127
|
+
const present = new Set<unknown>()
|
|
1128
|
+
for (const row of rows) present.add(keyOf(row))
|
|
1129
|
+
|
|
1130
|
+
const childrenByParent = new Map<unknown, Array<Row<TData>>>()
|
|
1131
|
+
const roots: Array<Row<TData>> = []
|
|
1132
|
+
for (const row of rows) {
|
|
1133
|
+
const parent = parentOf(row)
|
|
1134
|
+
// A row whose parent is absent (filtered out, or never existed) becomes a
|
|
1135
|
+
// root rather than disappearing - silently dropping rows is worse than a
|
|
1136
|
+
// shallower tree. Self-parenting is treated the same way.
|
|
1137
|
+
if (parent == null || parent === keyOf(row) || !present.has(parent)) {
|
|
1138
|
+
roots.push(row)
|
|
1139
|
+
continue
|
|
1140
|
+
}
|
|
1141
|
+
const list = childrenByParent.get(parent) ?? []
|
|
1142
|
+
list.push(row)
|
|
1143
|
+
childrenByParent.set(parent, list)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Guards a cycle in the parent chain from recursing forever.
|
|
1147
|
+
const seen = new Set<unknown>()
|
|
1148
|
+
const build = (row: Row<TData>, depth: number): Row<TData> => {
|
|
1149
|
+
const key = keyOf(row)
|
|
1150
|
+
const id = row.id
|
|
1151
|
+
if (seen.has(key)) {
|
|
1152
|
+
return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>
|
|
1153
|
+
}
|
|
1154
|
+
seen.add(key)
|
|
1155
|
+
const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))
|
|
1156
|
+
return {
|
|
1157
|
+
...row,
|
|
1158
|
+
depth,
|
|
1159
|
+
subRows,
|
|
1160
|
+
leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),
|
|
1161
|
+
__treeRow: true,
|
|
1162
|
+
getCanExpand: () => subRows.length > 0,
|
|
1163
|
+
getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
|
|
1164
|
+
toggleExpanded: () => {
|
|
1165
|
+
table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
1166
|
+
},
|
|
1167
|
+
} as Row<TData>
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return roots.map((root) => build(root, 0))
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* How to flatten NESTED source data into the parent-id shape tree rows need.
|
|
1176
|
+
* `parentField` is written onto each row, so point `treeData.parentField` at
|
|
1177
|
+
* the same name afterwards.
|
|
1178
|
+
*/
|
|
1179
|
+
export type FlattenTreeOptions = {
|
|
1180
|
+
/** Field holding an array of child objects. */
|
|
1181
|
+
childrenField: string
|
|
1182
|
+
/** Field holding each object's id. Defaults to `'id'`. */
|
|
1183
|
+
idField?: string
|
|
1184
|
+
/** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */
|
|
1185
|
+
parentField?: string
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Flatten nested tree data into the flat parent-id shape `createTreeRowModel`
|
|
1190
|
+
* consumes, stamping each child with its parent's id.
|
|
1191
|
+
*
|
|
1192
|
+
* Children are emitted directly after their parent so the natural order already
|
|
1193
|
+
* matches the rendered tree. The `childrenField` array is left on the objects
|
|
1194
|
+
* (harmless, and callers often still want it); only the parent link is added.
|
|
1195
|
+
*/
|
|
1196
|
+
export function flattenTreeData<T extends RowData>(
|
|
1197
|
+
data: ReadonlyArray<T>,
|
|
1198
|
+
options: FlattenTreeOptions,
|
|
1199
|
+
): T[] {
|
|
1200
|
+
const { childrenField, idField = 'id', parentField = '__parentId' } = options
|
|
1201
|
+
const out: T[] = []
|
|
1202
|
+
const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {
|
|
1203
|
+
for (const node of nodes) {
|
|
1204
|
+
const flat = { ...node, [parentField]: parentId } as T
|
|
1205
|
+
out.push(flat)
|
|
1206
|
+
const kids = (node as any)[childrenField]
|
|
1207
|
+
if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
walk(data, null)
|
|
1211
|
+
return out
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Hides the descendants of collapsed rows. Needed for grouping, tree data and
|
|
1216
|
+
* master-detail alike - all three are the same expand/collapse mechanism.
|
|
1217
|
+
*/
|
|
1218
|
+
export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
1219
|
+
return ({ table, rows }) => {
|
|
1220
|
+
const expanded: ExpandedState = table.getState().expanded ?? {}
|
|
1221
|
+
const flattened: Array<Row<TData>> = []
|
|
1222
|
+
const visit = (row: Row<TData>) => {
|
|
1223
|
+
flattened.push(row)
|
|
1224
|
+
if (row.subRows?.length && expanded[row.id]) {
|
|
1225
|
+
for (const sub of row.subRows) visit(sub)
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
for (const row of rows) visit(row)
|
|
1229
|
+
return flattened
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Orders rows by the active {@link SortingState}. Pass your own comparators to
|
|
1234
|
+
* override the built-in {@link sortFns} - useful for locale-aware or
|
|
1235
|
+
* domain-specific ordering.
|
|
1236
|
+
*/
|
|
1237
|
+
export function createSortedRowModel<TData extends RowData>(
|
|
1238
|
+
localSortFns: typeof sortFns = sortFns,
|
|
1239
|
+
): RowModelFactory<TData> {
|
|
1240
|
+
return function sortedRowModelStage({ table, rows }) {
|
|
1241
|
+
const sorting = table.getState().sorting ?? []
|
|
1242
|
+
if (!sorting.length) return rows
|
|
1243
|
+
|
|
1244
|
+
// Resolve every clause ONCE, before sorting.
|
|
1245
|
+
//
|
|
1246
|
+
// This used to live inside the comparator, so `getAllColumns().find(...)`
|
|
1247
|
+
// ran per comparison per clause: a single-clause sort of 100k rows made
|
|
1248
|
+
// 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
|
|
1249
|
+
// `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
|
|
1250
|
+
// times, so anything inside it that is not O(1) sets the cost of the sort.
|
|
1251
|
+
const allColumns = table.getAllColumns()
|
|
1252
|
+
const clauses: Array<{
|
|
1253
|
+
keys: Array<any>
|
|
1254
|
+
desc: boolean
|
|
1255
|
+
compare: (a: any, b: any) => number
|
|
1256
|
+
}> = []
|
|
1257
|
+
|
|
1258
|
+
for (const clause of sorting) {
|
|
1259
|
+
const column = allColumns.find((col) => col.id === clause.id)
|
|
1260
|
+
if (!column) continue
|
|
1261
|
+
const editorType = column.columnDef.editorType
|
|
1262
|
+
const comparator =
|
|
1263
|
+
editorType === 'number'
|
|
1264
|
+
? localSortFns.number
|
|
1265
|
+
: editorType === 'date' || editorType === 'datetime'
|
|
1266
|
+
? localSortFns.date
|
|
1267
|
+
: localSortFns.auto
|
|
1268
|
+
|
|
1269
|
+
// Precompute one sort key per row, so the comparator reads an array slot
|
|
1270
|
+
// instead of walking the row's column index on every comparison. For the
|
|
1271
|
+
// three built-in comparators the key is also cheaper to compare than the
|
|
1272
|
+
// raw value: a timestamp rather than two `new Date()` allocations, a
|
|
1273
|
+
// number rather than two `Number()` coercions, a collator rather than a
|
|
1274
|
+
// fresh one per `localeCompare` call.
|
|
1275
|
+
//
|
|
1276
|
+
// The identity checks against `sortFns` matter: `localSortFns` is a
|
|
1277
|
+
// public parameter, so a caller can substitute their own comparators.
|
|
1278
|
+
// When they have, we fall through to calling their function with the raw
|
|
1279
|
+
// values - still hoisted, just not specialised.
|
|
1280
|
+
const columnId = column.id
|
|
1281
|
+
const n = rows.length
|
|
1282
|
+
let keys: Array<any> = new Array(n)
|
|
1283
|
+
let compare: (a: any, b: any) => number
|
|
1284
|
+
|
|
1285
|
+
if (comparator === sortFns.number) {
|
|
1286
|
+
for (let i = 0; i < n; i++) keys[i] = Number(rows[i]!.getCellValueByColumnId(columnId) ?? 0)
|
|
1287
|
+
compare = compareNumericKeys
|
|
1288
|
+
} else if (comparator === sortFns.date) {
|
|
1289
|
+
for (let i = 0; i < n; i++) {
|
|
1290
|
+
keys[i] = new Date(rows[i]!.getCellValueByColumnId(columnId) as any).getTime()
|
|
1291
|
+
}
|
|
1292
|
+
compare = compareNumericKeys
|
|
1293
|
+
} else if (comparator === sortFns.auto) {
|
|
1294
|
+
const strings: string[] = new Array(n)
|
|
1295
|
+
// Decide whether ranking is worth attempting BEFORE paying for it.
|
|
1296
|
+
//
|
|
1297
|
+
// Building the distinct set and then discarding it costs about 9 ms on
|
|
1298
|
+
// a 100k-row column where nearly every value is unique, and ranking
|
|
1299
|
+
// saves about 19 ms where they repeat - so guessing wrong in either
|
|
1300
|
+
// direction is measurable. A small stride sample answers it for well
|
|
1301
|
+
// under a millisecond.
|
|
1302
|
+
//
|
|
1303
|
+
// Strided rather than the first N rows: data arrives sorted or
|
|
1304
|
+
// clustered often enough that a prefix is a bad estimator of the whole
|
|
1305
|
+
// column. Reading every k-th row is no more expensive and does not care
|
|
1306
|
+
// how the rows are arranged.
|
|
1307
|
+
const rankLimit = n >> 1
|
|
1308
|
+
let distinct: Set<string> | null = null
|
|
1309
|
+
if (n > 0) {
|
|
1310
|
+
const sampleTarget = Math.min(n, 256)
|
|
1311
|
+
const stride = Math.max(1, Math.floor(n / sampleTarget))
|
|
1312
|
+
const sample = new Set<string>()
|
|
1313
|
+
let sampled = 0
|
|
1314
|
+
for (let i = 0; i < n; i += stride) {
|
|
1315
|
+
sample.add(String(rows[i]!.getCellValueByColumnId(columnId)))
|
|
1316
|
+
sampled++
|
|
1317
|
+
}
|
|
1318
|
+
// Only attempt ranking when the sample suggests real repetition.
|
|
1319
|
+
if (sample.size * 2 <= sampled) distinct = new Set()
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
for (let i = 0; i < n; i++) {
|
|
1323
|
+
const s = String(rows[i]!.getCellValueByColumnId(columnId))
|
|
1324
|
+
strings[i] = s
|
|
1325
|
+
if (distinct) {
|
|
1326
|
+
distinct.add(s)
|
|
1327
|
+
if (distinct.size > rankLimit) distinct = null
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Collation is by far the most expensive comparison we do - a CPU
|
|
1332
|
+
// profile of a 100k text sort put 65% of the whole operation inside the
|
|
1333
|
+
// collator. But a column's DISTINCT values are usually far fewer than
|
|
1334
|
+
// its rows (statuses, regions, categories, owners), so rank the
|
|
1335
|
+
// distinct values once and sort by rank afterwards. That turns
|
|
1336
|
+
// O(n log n) collator calls into O(u log u), where u is the number of
|
|
1337
|
+
// distinct values, and the resulting order is identical because rank is
|
|
1338
|
+
// a monotone relabelling of the collated order - equal strings share a
|
|
1339
|
+
// rank, so ties still fall through to the stable sort exactly as before.
|
|
1340
|
+
//
|
|
1341
|
+
// Guarded on the uniqueness ratio: when nearly every value is distinct
|
|
1342
|
+
// the ranking pass cannot save any collator calls and would just add an
|
|
1343
|
+
// O(n) Map build, so that case keeps comparing directly.
|
|
1344
|
+
if (distinct) {
|
|
1345
|
+
const ordered = Array.from(distinct).sort(compareCollatedKeys)
|
|
1346
|
+
const rankOf = new Map<string, number>()
|
|
1347
|
+
for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
|
|
1348
|
+
for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
|
|
1349
|
+
compare = compareNumericKeys
|
|
1350
|
+
} else {
|
|
1351
|
+
keys = strings
|
|
1352
|
+
compare = compareCollatedKeys
|
|
1353
|
+
}
|
|
1354
|
+
} else {
|
|
1355
|
+
for (let i = 0; i < n; i++) keys[i] = rows[i]!.getCellValueByColumnId(columnId)
|
|
1356
|
+
compare = comparator
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
clauses.push({ keys, desc: clause.desc, compare })
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (!clauses.length) return rows
|
|
1363
|
+
|
|
1364
|
+
// Sort an index array, then materialise. `Array.prototype.sort` is stable,
|
|
1365
|
+
// so equal keys keep their original relative order exactly as the previous
|
|
1366
|
+
// `[...rows].sort(...)` did.
|
|
1367
|
+
const order = new Array<number>(rows.length)
|
|
1368
|
+
for (let i = 0; i < order.length; i++) order[i] = i
|
|
1369
|
+
|
|
1370
|
+
// Single-clause sorts get a specialised comparator.
|
|
1371
|
+
//
|
|
1372
|
+
// Most sorts are one column, and that path runs O(n log n) times - 1.66
|
|
1373
|
+
// million comparisons for 100k rows. The general loop pays a clause-array
|
|
1374
|
+
// index, three property loads and an indirect call on every one of them,
|
|
1375
|
+
// none of which vary once the clause list is fixed. Hoisting them into a
|
|
1376
|
+
// closure and, for the numeric comparator, inlining the subtraction removes
|
|
1377
|
+
// the call entirely.
|
|
1378
|
+
//
|
|
1379
|
+
// `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`
|
|
1380
|
+
// as far as sorting is concerned: both are NaN for unorderable values,
|
|
1381
|
+
// which the spec coerces to 0, and they differ only in producing 0 versus
|
|
1382
|
+
// -0 for equal keys, which sorts identically.
|
|
1383
|
+
if (clauses.length === 1) {
|
|
1384
|
+
const { keys, compare, desc } = clauses[0]!
|
|
1385
|
+
if (compare === compareNumericKeys) {
|
|
1386
|
+
order.sort(
|
|
1387
|
+
desc
|
|
1388
|
+
? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }
|
|
1389
|
+
: function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },
|
|
1390
|
+
)
|
|
1391
|
+
} else {
|
|
1392
|
+
order.sort(
|
|
1393
|
+
desc
|
|
1394
|
+
? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }
|
|
1395
|
+
: function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },
|
|
1396
|
+
)
|
|
1397
|
+
}
|
|
1398
|
+
} else {
|
|
1399
|
+
order.sort(function compareRowsByClauses(ia, ib) {
|
|
1400
|
+
for (let k = 0; k < clauses.length; k++) {
|
|
1401
|
+
const clause = clauses[k]!
|
|
1402
|
+
const result = clause.compare(clause.keys[ia], clause.keys[ib])
|
|
1403
|
+
if (result !== 0) return clause.desc ? -result : result
|
|
1404
|
+
}
|
|
1405
|
+
return 0
|
|
1406
|
+
})
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const sorted = new Array<Row<TData>>(rows.length)
|
|
1410
|
+
for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!
|
|
1411
|
+
return sorted
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Numeric key comparison for the built-in `number` and `date` comparators.
|
|
1417
|
+
* Subtraction rather than `<`/`>` on purpose: it reproduces the originals
|
|
1418
|
+
* exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
|
|
1419
|
+
* and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
|
|
1420
|
+
* it), which is the behaviour callers already depend on.
|
|
1421
|
+
*/
|
|
1422
|
+
function compareNumericKeys(a: number, b: number): number {
|
|
1423
|
+
return a - b
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Text key comparison for the built-in `auto` comparator.
|
|
1428
|
+
*
|
|
1429
|
+
* `localeCompare`, NOT a hoisted `Intl.Collator`. The specification defines
|
|
1430
|
+
* `localeCompare` with no locale or options as constructing a default collator
|
|
1431
|
+
* per call, so hoisting one looks like the obvious optimisation - and it is
|
|
1432
|
+
* measurably slower. V8 fast-paths `String.prototype.localeCompare` for the
|
|
1433
|
+
* default locale; going through a collator object misses that path. Measured
|
|
1434
|
+
* sorting 100k strings: 33 ms via `localeCompare` against 83 ms via a cached
|
|
1435
|
+
* collator on ASCII, 58 ms against 99 ms with accents mixed in, and the two
|
|
1436
|
+
* produce byte-identical orderings across all 100k positions.
|
|
1437
|
+
*
|
|
1438
|
+
* Left as its own function so the sort path has one place to change if that
|
|
1439
|
+
* ever stops being true. Re-measure before "optimising" this again.
|
|
1440
|
+
*/
|
|
1441
|
+
function compareCollatedKeys(a: string, b: string): number {
|
|
1442
|
+
return a.localeCompare(b)
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Everything {@link createSvGridCore} accepts: the data and columns, the
|
|
1447
|
+
* features and row models that make up the pipeline, and an `on*Change`
|
|
1448
|
+
* callback per piece of state for controlled use.
|
|
1449
|
+
*
|
|
1450
|
+
* `<SvGrid>` builds this for you from its props - you only construct it
|
|
1451
|
+
* directly when driving the headless core.
|
|
1452
|
+
*/
|
|
1453
|
+
export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
|
|
1454
|
+
_features: TFeatures
|
|
1455
|
+
_rowModels?: {
|
|
1456
|
+
coreRowModel?: RowModelFactory<TData>
|
|
1457
|
+
filteredRowModel?: RowModelFactory<TData>
|
|
1458
|
+
sortedRowModel?: RowModelFactory<TData>
|
|
1459
|
+
paginatedRowModel?: RowModelFactory<TData>
|
|
1460
|
+
groupedRowModel?: RowModelFactory<TData>
|
|
1461
|
+
expandedRowModel?: RowModelFactory<TData>
|
|
1462
|
+
}
|
|
1463
|
+
columns: Array<ColumnDef<TFeatures, TData>>
|
|
1464
|
+
data: ReadonlyArray<TData>
|
|
1465
|
+
/**
|
|
1466
|
+
* Optional row-id resolver. When set, the value it returns becomes
|
|
1467
|
+
* `row.id` (and therefore the selection / expansion / edit key). When
|
|
1468
|
+
* omitted, ids fall back to the row's array index as a string. Use a
|
|
1469
|
+
* stable id (database PK, UUID, etc.) so selection survives reorders.
|
|
1470
|
+
*/
|
|
1471
|
+
getRowId?: (row: TData, index: number) => string
|
|
1472
|
+
state?: Partial<Record<string, any>>
|
|
1473
|
+
onSortingChange?: (updater: Updater<SortingState>) => void
|
|
1474
|
+
onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
|
|
1475
|
+
onPaginationChange?: (updater: Updater<PaginationState>) => void
|
|
1476
|
+
onGroupingChange?: (updater: Updater<GroupingState>) => void
|
|
1477
|
+
onExpandedChange?: (updater: Updater<ExpandedState>) => void
|
|
1478
|
+
onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
|
|
1479
|
+
onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* The headless grid instance: the state stores plus the read methods a renderer
|
|
1484
|
+
* needs (`getHeaderGroups()`, `getRowModel()`, the `set*` writers).
|
|
1485
|
+
*
|
|
1486
|
+
* Framework free by design - `<SvGrid>` is one renderer over this, and you can
|
|
1487
|
+
* write another. See the "Why headless?" guide.
|
|
1488
|
+
*/
|
|
1489
|
+
export type SvGrid<TData extends RowData> = {
|
|
1490
|
+
store: Store<Record<string, any>>
|
|
1491
|
+
optionsStore: Store<Record<string, any>>
|
|
1492
|
+
state: Record<string, any>
|
|
1493
|
+
getState: () => Record<string, any>
|
|
1494
|
+
setOptions: (updater: Updater<Record<string, any>>) => void
|
|
1495
|
+
setColumnFilters: (updater: Updater<ColumnFiltersState>) => void
|
|
1496
|
+
setPagination: (updater: Updater<PaginationState>) => void
|
|
1497
|
+
setGrouping: (updater: Updater<GroupingState>) => void
|
|
1498
|
+
setExpanded: (updater: Updater<ExpandedState>) => void
|
|
1499
|
+
setRowSelection: (updater: Updater<RowSelectionState>) => void
|
|
1500
|
+
setActiveCell: (updater: Updater<ActiveCellState>) => void
|
|
1501
|
+
moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void
|
|
1502
|
+
getAllColumns: () => Array<Column<TData>>
|
|
1503
|
+
getHeaderGroups: () => Array<HeaderGroup<TData>>
|
|
1504
|
+
getFooterGroups: () => Array<HeaderGroup<TData>>
|
|
1505
|
+
getRowModel: () => RowModel<TData>
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
type InternalGrid<TData extends RowData> = SvGrid<TData> & {
|
|
1509
|
+
getAllColumns: () => Array<Column<TData>>
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Build a headless grid: state, the row pipeline, and the read methods, with no
|
|
1514
|
+
* DOM and no Svelte. This is the engine `<SvGrid>` renders.
|
|
1515
|
+
*
|
|
1516
|
+
* Most callers want `createSvGrid` (the runes-aware wrapper) or the component
|
|
1517
|
+
* itself; reach for this when you are writing your own renderer or running the
|
|
1518
|
+
* pipeline outside a browser.
|
|
1519
|
+
*/
|
|
1520
|
+
export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
|
|
1521
|
+
options: SvGridOptions<TFeatures, TData>,
|
|
1522
|
+
): SvGrid<TData> {
|
|
1523
|
+
const internalState: Record<string, any> = {
|
|
1524
|
+
sorting: [],
|
|
1525
|
+
columnFilters: [],
|
|
1526
|
+
pagination: { pageIndex: 0, pageSize: options.data.length || 10 },
|
|
1527
|
+
grouping: [],
|
|
1528
|
+
expanded: {},
|
|
1529
|
+
rowSelection: {},
|
|
1530
|
+
activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
|
|
1531
|
+
...(options.state ?? {}),
|
|
1532
|
+
}
|
|
1533
|
+
const store = createStore(internalState)
|
|
1534
|
+
const optionsStore = createStore(options as Record<string, any>)
|
|
1535
|
+
let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null
|
|
1536
|
+
let cachedColumns: Array<Column<TData>> = []
|
|
1537
|
+
let cachedHeaderGroups: Array<HeaderGroup<TData>> = []
|
|
1538
|
+
let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
|
|
1539
|
+
let cachedBaseRowsColumns: Array<Column<TData>> | null = null
|
|
1540
|
+
let cachedBaseRows: Array<Row<TData>> = []
|
|
1541
|
+
let cachedRowModel: RowModel<TData> | null = null
|
|
1542
|
+
let cachedRowModelBaseRows: Array<Row<TData>> | null = null
|
|
1543
|
+
let cachedPipeline = options._rowModels
|
|
1544
|
+
let cachedSlices: {
|
|
1545
|
+
sorting: SortingState | undefined
|
|
1546
|
+
columnFilters: ColumnFiltersState | undefined
|
|
1547
|
+
pagination: PaginationState | undefined
|
|
1548
|
+
grouping: GroupingState | undefined
|
|
1549
|
+
expanded: ExpandedState | undefined
|
|
1550
|
+
} | null = null
|
|
1551
|
+
|
|
1552
|
+
const grid = {
|
|
1553
|
+
store,
|
|
1554
|
+
optionsStore,
|
|
1555
|
+
get state() {
|
|
1556
|
+
return store.state
|
|
1557
|
+
},
|
|
1558
|
+
getState() {
|
|
1559
|
+
return store.state
|
|
1560
|
+
},
|
|
1561
|
+
setOptions(updater: Updater<Record<string, any>>) {
|
|
1562
|
+
optionsStore.setState((prev) =>
|
|
1563
|
+
typeof updater === 'function' ? (updater as any)(prev) : updater,
|
|
1564
|
+
)
|
|
1565
|
+
},
|
|
1566
|
+
setColumnFilters(updater: Updater<ColumnFiltersState>) {
|
|
1567
|
+
store.setState((prev) => ({
|
|
1568
|
+
...prev,
|
|
1569
|
+
columnFilters:
|
|
1570
|
+
typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,
|
|
1571
|
+
}))
|
|
1572
|
+
options.onColumnFiltersChange?.(updater)
|
|
1573
|
+
},
|
|
1574
|
+
setPagination(updater: Updater<PaginationState>) {
|
|
1575
|
+
store.setState((prev) => ({
|
|
1576
|
+
...prev,
|
|
1577
|
+
pagination:
|
|
1578
|
+
typeof updater === 'function'
|
|
1579
|
+
? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })
|
|
1580
|
+
: updater,
|
|
1581
|
+
}))
|
|
1582
|
+
options.onPaginationChange?.(updater)
|
|
1583
|
+
},
|
|
1584
|
+
setGrouping(updater: Updater<GroupingState>) {
|
|
1585
|
+
store.setState((prev) => ({
|
|
1586
|
+
...prev,
|
|
1587
|
+
grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,
|
|
1588
|
+
}))
|
|
1589
|
+
options.onGroupingChange?.(updater)
|
|
1590
|
+
},
|
|
1591
|
+
setExpanded(updater: Updater<ExpandedState>) {
|
|
1592
|
+
store.setState((prev) => ({
|
|
1593
|
+
...prev,
|
|
1594
|
+
expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,
|
|
1595
|
+
}))
|
|
1596
|
+
options.onExpandedChange?.(updater)
|
|
1597
|
+
},
|
|
1598
|
+
setRowSelection(updater: Updater<RowSelectionState>) {
|
|
1599
|
+
store.setState((prev) => ({
|
|
1600
|
+
...prev,
|
|
1601
|
+
rowSelection:
|
|
1602
|
+
typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,
|
|
1603
|
+
}))
|
|
1604
|
+
options.onRowSelectionChange?.(updater)
|
|
1605
|
+
},
|
|
1606
|
+
setActiveCell(updater: Updater<ActiveCellState>) {
|
|
1607
|
+
store.setState((prev) => {
|
|
1608
|
+
const previous: ActiveCellState = prev.activeCell ?? {
|
|
1609
|
+
rowIndex: 0,
|
|
1610
|
+
colIndex: 0,
|
|
1611
|
+
cellId: null,
|
|
1612
|
+
}
|
|
1613
|
+
const nextActive =
|
|
1614
|
+
typeof updater === 'function' ? updater(previous) : updater
|
|
1615
|
+
return {
|
|
1616
|
+
...prev,
|
|
1617
|
+
activeCell: nextActive,
|
|
1618
|
+
}
|
|
1619
|
+
})
|
|
1620
|
+
options.onActiveCellChange?.(updater)
|
|
1621
|
+
},
|
|
1622
|
+
moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {
|
|
1623
|
+
const rows = grid.getRowModel().rows
|
|
1624
|
+
const columns = grid.getAllColumns()
|
|
1625
|
+
const maxRow = Math.max(rows.length - 1, 0)
|
|
1626
|
+
const maxCol = Math.max(columns.length - 1, 0)
|
|
1627
|
+
const current: ActiveCellState = grid.getState().activeCell ?? {
|
|
1628
|
+
rowIndex: 0,
|
|
1629
|
+
colIndex: 0,
|
|
1630
|
+
cellId: null,
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const rowIndex = Math.min(
|
|
1634
|
+
Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),
|
|
1635
|
+
maxRow,
|
|
1636
|
+
)
|
|
1637
|
+
const colIndex = Math.min(
|
|
1638
|
+
Math.max(current.colIndex + (next.colDelta ?? 0), 0),
|
|
1639
|
+
maxCol,
|
|
1640
|
+
)
|
|
1641
|
+
const columnId = columns[colIndex]?.id ?? 'col_0'
|
|
1642
|
+
grid.setActiveCell({
|
|
1643
|
+
rowIndex,
|
|
1644
|
+
colIndex,
|
|
1645
|
+
cellId: `${rowIndex}_${columnId}`,
|
|
1646
|
+
})
|
|
1647
|
+
},
|
|
1648
|
+
getAllColumns() {
|
|
1649
|
+
// Cache hit: referentially identical columns array.
|
|
1650
|
+
if (cachedColumnsInput === options.columns && cachedColumns.length) {
|
|
1651
|
+
return cachedColumns
|
|
1652
|
+
}
|
|
1653
|
+
// Soft cache hit: consumers commonly recreate the columns array
|
|
1654
|
+
// inline on every render (e.g. `columns={[...]}`). If the new
|
|
1655
|
+
// array has the same length AND each entry has the same `field` /
|
|
1656
|
+
// `id` / `header` (the visibility-affecting structure of a
|
|
1657
|
+
// column), trust the previous build. Mutable inner fields like
|
|
1658
|
+
// `cell` and `editorOptions` are still picked up on the next real
|
|
1659
|
+
// render that bumps an actual data dep - they're read at cell-
|
|
1660
|
+
// render time, not at this top-level cache.
|
|
1661
|
+
if (
|
|
1662
|
+
cachedColumnsInput &&
|
|
1663
|
+
options.columns.length === cachedColumnsInput.length &&
|
|
1664
|
+
cachedColumns.length === options.columns.length &&
|
|
1665
|
+
options.columns.every((c, i) => {
|
|
1666
|
+
const prev = cachedColumnsInput![i]!
|
|
1667
|
+
return (
|
|
1668
|
+
c.field === prev.field &&
|
|
1669
|
+
c.id === prev.id &&
|
|
1670
|
+
c.header === prev.header &&
|
|
1671
|
+
c.editorType === prev.editorType
|
|
1672
|
+
)
|
|
1673
|
+
})
|
|
1674
|
+
) {
|
|
1675
|
+
// Update the stored input reference so the strict check hits
|
|
1676
|
+
// next time, but reuse the built column model.
|
|
1677
|
+
cachedColumnsInput = options.columns
|
|
1678
|
+
return cachedColumns
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
cachedColumnsInput = options.columns
|
|
1682
|
+
cachedHeaderGroups = []
|
|
1683
|
+
const build = (
|
|
1684
|
+
defs: Array<ColumnDef<TFeatures, TData>>,
|
|
1685
|
+
depth: number,
|
|
1686
|
+
parentId?: string,
|
|
1687
|
+
): Array<Column<TData>> => {
|
|
1688
|
+
const leaves: Array<Column<TData>> = []
|
|
1689
|
+
defs.forEach((columnDef, index) => {
|
|
1690
|
+
const id = resolveColumnId(columnDef, parentId, depth, index)
|
|
1691
|
+
if (columnDef.columns?.length) {
|
|
1692
|
+
leaves.push(...build(columnDef.columns, depth + 1, id))
|
|
1693
|
+
return
|
|
1694
|
+
}
|
|
1695
|
+
leaves.push({
|
|
1696
|
+
id,
|
|
1697
|
+
depth,
|
|
1698
|
+
parentId,
|
|
1699
|
+
columnDef,
|
|
1700
|
+
getCanSort: () =>
|
|
1701
|
+
Boolean((options._features as any).rowSortingFeature) &&
|
|
1702
|
+
columnDef.sortable !== false,
|
|
1703
|
+
getCanFilter: () =>
|
|
1704
|
+
Boolean((options._features as any).columnFilteringFeature) &&
|
|
1705
|
+
columnDef.filterable !== false,
|
|
1706
|
+
getIsSorted: () => {
|
|
1707
|
+
const entry = store.state.sorting?.find((s: any) => s.id === id)
|
|
1708
|
+
if (!entry) return false
|
|
1709
|
+
return entry.desc ? 'desc' : 'asc'
|
|
1710
|
+
},
|
|
1711
|
+
getToggleSortingHandler: () => () => {
|
|
1712
|
+
const clauses: SortingState = store.state.sorting ?? []
|
|
1713
|
+
const current = clauses.find((s: any) => s.id === id)
|
|
1714
|
+
const nextClause: SortingState = !current
|
|
1715
|
+
? [...clauses, { id, desc: false }]
|
|
1716
|
+
: current.desc
|
|
1717
|
+
? clauses.filter((s) => s.id !== id)
|
|
1718
|
+
: clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))
|
|
1719
|
+
store.setState((prev) => ({ ...prev, sorting: nextClause }))
|
|
1720
|
+
options.onSortingChange?.(nextClause)
|
|
1721
|
+
},
|
|
1722
|
+
})
|
|
1723
|
+
})
|
|
1724
|
+
return leaves
|
|
1725
|
+
}
|
|
1726
|
+
cachedColumns = build(options.columns, 0)
|
|
1727
|
+
return cachedColumns
|
|
1728
|
+
},
|
|
1729
|
+
getHeaderGroups() {
|
|
1730
|
+
if (cachedHeaderGroups.length) return cachedHeaderGroups
|
|
1731
|
+
const headers = grid.getAllColumns().map((column) => {
|
|
1732
|
+
const header: Header<TData> = {
|
|
1733
|
+
id: column.id,
|
|
1734
|
+
isPlaceholder: false,
|
|
1735
|
+
colSpan: 1,
|
|
1736
|
+
column,
|
|
1737
|
+
getContext: () => ({ header, column, table: grid }),
|
|
1738
|
+
}
|
|
1739
|
+
return header
|
|
1740
|
+
})
|
|
1741
|
+
cachedHeaderGroups = [{ id: 'header_group_0', headers }]
|
|
1742
|
+
return cachedHeaderGroups
|
|
1743
|
+
},
|
|
1744
|
+
getFooterGroups() {
|
|
1745
|
+
return grid.getHeaderGroups()
|
|
1746
|
+
},
|
|
1747
|
+
getRowModel() {
|
|
1748
|
+
const columns = grid.getAllColumns()
|
|
1749
|
+
if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
|
|
1750
|
+
cachedBaseRowsInput = options.data
|
|
1751
|
+
cachedBaseRowsColumns = columns
|
|
1752
|
+
// O(1) column-id → index lookup so getCellValueByColumnId doesn't do
|
|
1753
|
+
// a linear `findIndex` on every cell read (was O(rows × cells × cols)).
|
|
1754
|
+
const columnIndexById = new Map<string, number>()
|
|
1755
|
+
for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
|
|
1756
|
+
const columnCount = columns.length
|
|
1757
|
+
|
|
1758
|
+
// One shared context for every row in this table, so a row carries a
|
|
1759
|
+
// pointer rather than a closure scope. See BASE_ROW_METHODS.
|
|
1760
|
+
const rowCtx: BaseRowCtx<TData> = {
|
|
1761
|
+
grid: grid as SvGrid<TData>,
|
|
1762
|
+
store,
|
|
1763
|
+
columns,
|
|
1764
|
+
columnCount,
|
|
1765
|
+
columnIndexById,
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
cachedBaseRows = new Array(options.data.length)
|
|
1769
|
+
const getRowId = options.getRowId
|
|
1770
|
+
const m = BASE_ROW_METHODS as unknown as {
|
|
1771
|
+
getCanExpand: Row<TData>['getCanExpand']
|
|
1772
|
+
getIsExpanded: Row<TData>['getIsExpanded']
|
|
1773
|
+
toggleExpanded: Row<TData>['toggleExpanded']
|
|
1774
|
+
getIsSelected: Row<TData>['getIsSelected']
|
|
1775
|
+
toggleSelected: Row<TData>['toggleSelected']
|
|
1776
|
+
getAllCells: Row<TData>['getAllCells']
|
|
1777
|
+
getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
|
|
1778
|
+
}
|
|
1779
|
+
for (let index = 0; index < options.data.length; index++) {
|
|
1780
|
+
const original = options.data[index]!
|
|
1781
|
+
// `_values` and `_cells` stay null until something reads them - a
|
|
1782
|
+
// 100k-row grid showing twenty rows must not materialise every row's
|
|
1783
|
+
// values or cell objects to paint.
|
|
1784
|
+
const row: BaseRowState<TData> = {
|
|
1785
|
+
id: getRowId ? getRowId(original, index) : String(index),
|
|
1786
|
+
index,
|
|
1787
|
+
original,
|
|
1788
|
+
depth: 0,
|
|
1789
|
+
[ROW_CTX]: rowCtx,
|
|
1790
|
+
[ROW_VALUES]: null,
|
|
1791
|
+
[ROW_CELLS]: null,
|
|
1792
|
+
getCanExpand: m.getCanExpand,
|
|
1793
|
+
getIsExpanded: m.getIsExpanded,
|
|
1794
|
+
toggleExpanded: m.toggleExpanded,
|
|
1795
|
+
getIsSelected: m.getIsSelected,
|
|
1796
|
+
toggleSelected: m.toggleSelected,
|
|
1797
|
+
getAllCells: m.getAllCells,
|
|
1798
|
+
getCellValueByColumnId: m.getCellValueByColumnId,
|
|
1799
|
+
}
|
|
1800
|
+
cachedBaseRows[index] = row
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Only the slices a pipeline stage actually READS belong in this key.
|
|
1805
|
+
//
|
|
1806
|
+
// `rowSelection` used to be here, which meant ticking one checkbox on a
|
|
1807
|
+
// 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a
|
|
1808
|
+
// row array that was identical by construction. Nothing reads it: the two
|
|
1809
|
+
// consumers are `getIsSelected` closures (on data rows and on group rows)
|
|
1810
|
+
// that read `store.state` when called, so they observe a selection change
|
|
1811
|
+
// without the model being rebuilt.
|
|
1812
|
+
//
|
|
1813
|
+
// `_rowModels` is a closed set of six named slots, so no consumer stage
|
|
1814
|
+
// can be inserted that might read selection. A caller CAN supply a custom
|
|
1815
|
+
// function for one of those slots; if one ever needs a slice that is not
|
|
1816
|
+
// listed here, add it here rather than reinstating all of them.
|
|
1817
|
+
const currentSlices = {
|
|
1818
|
+
sorting: store.state.sorting,
|
|
1819
|
+
columnFilters: store.state.columnFilters,
|
|
1820
|
+
pagination: store.state.pagination,
|
|
1821
|
+
grouping: store.state.grouping,
|
|
1822
|
+
expanded: store.state.expanded,
|
|
1823
|
+
}
|
|
1824
|
+
if (
|
|
1825
|
+
cachedRowModel &&
|
|
1826
|
+
cachedRowModelBaseRows === cachedBaseRows &&
|
|
1827
|
+
cachedPipeline === options._rowModels &&
|
|
1828
|
+
cachedSlices?.sorting === currentSlices.sorting &&
|
|
1829
|
+
cachedSlices?.columnFilters === currentSlices.columnFilters &&
|
|
1830
|
+
cachedSlices?.pagination === currentSlices.pagination &&
|
|
1831
|
+
cachedSlices?.grouping === currentSlices.grouping &&
|
|
1832
|
+
cachedSlices?.expanded === currentSlices.expanded
|
|
1833
|
+
) {
|
|
1834
|
+
return cachedRowModel
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
let rows: Array<Row<TData>> = cachedBaseRows
|
|
1838
|
+
|
|
1839
|
+
const pipeline = options._rowModels ?? {}
|
|
1840
|
+
const ordered: Array<RowModelFactory<TData> | undefined> = [
|
|
1841
|
+
pipeline.coreRowModel,
|
|
1842
|
+
pipeline.filteredRowModel,
|
|
1843
|
+
pipeline.sortedRowModel,
|
|
1844
|
+
pipeline.groupedRowModel,
|
|
1845
|
+
pipeline.expandedRowModel,
|
|
1846
|
+
pipeline.paginatedRowModel,
|
|
1847
|
+
]
|
|
1848
|
+
ordered.forEach((fn) => {
|
|
1849
|
+
if (fn) rows = fn({ table: grid, rows })
|
|
1850
|
+
})
|
|
1851
|
+
cachedPipeline = options._rowModels
|
|
1852
|
+
cachedSlices = currentSlices
|
|
1853
|
+
cachedRowModelBaseRows = cachedBaseRows
|
|
1854
|
+
cachedRowModel = { rows }
|
|
1855
|
+
return cachedRowModel
|
|
1856
|
+
},
|
|
1857
|
+
} as InternalGrid<TData>
|
|
1858
|
+
|
|
1859
|
+
return grid
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** Narrowing helper for the many options that accept a value or a function. */
|
|
1863
|
+
export function isFunction(value: unknown): value is (...args: Array<any>) => any {
|
|
1864
|
+
return typeof value === 'function'
|
|
1865
|
+
}
|