@opencorestack/opengridx 3.7.1 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/ai.cjs.map +1 -1
  3. package/dist/ai.es.js.map +1 -1
  4. package/dist/index.d.ts +2 -2
  5. package/dist/opengridx.css +1 -1
  6. package/dist/opengridx.es.js +6043 -5979
  7. package/dist/opengridx.es.js.map +1 -1
  8. package/dist/opengridx.umd.js +16 -16
  9. package/dist/opengridx.umd.js.map +1 -1
  10. package/docs/API_REFERENCE.md +28 -0
  11. package/docs/features/filtering.md +2 -0
  12. package/lib/components/AiAssistant/GridAiAssistantPanel.css +2 -2
  13. package/lib/components/Cell/Cell.css +1 -3
  14. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +1 -2
  15. package/lib/components/DataGrid/DataGrid.css +1 -28
  16. package/lib/components/DataGrid/DataGrid.tsx +2 -4
  17. package/lib/components/DataGrid/GridListView.tsx +1 -1
  18. package/lib/components/DataGrid/GridPinnedRows.tsx +1 -1
  19. package/lib/components/DataGrid/GridVirtualRows.tsx +3 -2
  20. package/lib/components/FilterPanel/FilterPanel.tsx +7 -39
  21. package/lib/components/Header/Header.css +2 -11
  22. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +6 -37
  23. package/lib/components/ListView/ListView.css +0 -16
  24. package/lib/components/Row/Row.tsx +4 -1
  25. package/lib/hooks/core/useDataGrid.ts +40 -35
  26. package/lib/hooks/core/useGridAggregationApi.ts +1 -1
  27. package/lib/hooks/core/useGridCellSelection.ts +3 -4
  28. package/lib/hooks/core/useGridClipboardPaste.ts +1 -1
  29. package/lib/hooks/core/useGridDetailPanel.ts +1 -1
  30. package/lib/hooks/core/useGridFillHandle.ts +1 -1
  31. package/lib/hooks/core/useGridHierarchy.ts +2 -2
  32. package/lib/hooks/core/useGridKeyboardNavigation.ts +1 -1
  33. package/lib/hooks/core/useGridLiveRowSelection.ts +9 -2
  34. package/lib/hooks/core/useGridRangeEditing.ts +1 -1
  35. package/lib/hooks/core/useGridRowInteractions.ts +3 -2
  36. package/lib/hooks/features/useGridSpanning.ts +4 -4
  37. package/lib/hooks/useRowGrouping.ts +19 -10
  38. package/lib/hooks/useRowReorder.ts +9 -3
  39. package/lib/styles/base/reset.css +6 -7
  40. package/lib/theme/__baseline__/default-theme.darwin.chromium.json +1 -1503
  41. package/lib/theme/__baseline__/default-theme.darwin.firefox.json +1 -1503
  42. package/lib/theme/__baseline__/default-theme.darwin.webkit.json +1 -1503
  43. package/lib/types/rows.ts +0 -2
  44. package/lib/types/state.ts +9 -0
  45. package/lib/utils/cellSelection.ts +0 -2
  46. package/lib/utils/editing/index.ts +1 -1
  47. package/lib/utils/editing/targets.ts +2 -3
  48. package/lib/utils/filtering/filterInput.ts +45 -0
  49. package/lib/utils/filtering/index.ts +0 -28
  50. package/lib/utils/gridApiRows.ts +1 -1
  51. package/lib/utils/grouping/groupedExportRows.ts +1 -1
  52. package/lib/utils/grouping/layeredMap.ts +52 -0
  53. package/lib/utils/pinning/index.ts +0 -107
  54. package/lib/utils/rowIndex.ts +67 -0
  55. package/lib/utils/sorting/index.ts +42 -33
  56. package/lib/utils/values.ts +5 -1
  57. package/llms.txt +7 -3
  58. package/package.json +1 -1
  59. package/lib/theme/defaultStyles.ts +0 -0
@@ -546,6 +546,21 @@ In every exporter, a non-empty `selectedRows` takes precedence over `groupedRows
546
546
 
547
547
  Every exporter takes `columns` typed for your rows (`GridColDef<R>[]`) or untyped (`GridColDef[]`), and its options also accept `getRowId?: (row: R) => GridRowId`. Pass the grid's `getRowId` when you use one: `selectedRows` holds those ids, and since v3.0 the grid no longer copies them onto `row.id`.
548
548
 
549
+ ### Export option types
550
+
551
+ Each exporter's options are exported as a type. Fields common to all of them: `fileName`, `selectedRows`, `getRowId`, `aggregationResult`, `aggregationModel`, `groupedRows`.
552
+
553
+ | Type | For | Its own fields |
554
+ | :--- | :--- | :--- |
555
+ | `CsvExportOptions<R>` | `exportToCsv` | `includeHeaders`, `delimiter`, `escapeFormulas`, `bom` |
556
+ | `JsonExportOptions<R>` | `exportToJson` | `pretty` |
557
+ | `ExcelExportOptions<R>` | `exportToExcel` (HTML `.xls`) | `sheetName`, `includeHeaders`, `escapeFormulas` |
558
+ | `ExcelAdvancedExportOptions<R>` | `exportToExcelAdvanced` (`.xlsx`) | `sheets`, `columnStyles`, `headerFillColor`, `headerTextColor`, `headerFontSize`, `bodyFontSize` |
559
+ | `ExcelSheetDefinition` | one entry of `sheets` | `name`, `rows` (`'all'` / `'selected'`), `includeHeaders`, `includeSummary`, `autoFilter`, `frozenHeader`, `alternateRowColor`; `{ type: 'summary', name? }` adds a totals sheet |
560
+ | `ExcelColumnStyle` | a value of `columnStyles` (by field) | `numFmt` (e.g. `'$#,##0.00'`), `width`, `alignment`, `embedImage`, `imageWidth`, `imageHeight` |
561
+
562
+ `PdfExportOptions` and `PrintOptions` are described in [PDF Export](features/pdf-export.md) and the [Export Guide](features/export-guide.md).
563
+
549
564
  ---
550
565
 
551
566
  ## 🤖 AI Toolkit (`@opencorestack/opengridx/ai`)
@@ -652,6 +667,10 @@ Context provider for overriding the grid's visual system.
652
667
  - **`overlays`**: colours (`background`, `text`, `border`, `shadow`, `item*`) and, v3.7, `fontFamily`, `fontWeight`, `fontSize` for menus and panels.
653
668
  - **`toolbar`**, **`scrollbar`** (`thumbColor`, `trackColor`, `size`), **`skeleton`** (`baseColor`, `highlightColor`), **`transitions`**.
654
669
 
670
+ #### Theme types
671
+
672
+ `DataGridThemeProviderProps`: `theme: GridTheme`, `children`, `className?`, `style?` (both on the wrapper element). Each key of `GridTheme` has its own exported type, for building a theme in parts: `GridThemeColors` (with `GridThemeGrayScale` for `gray`), `GridThemeTypography`, `GridThemeSpacing`, `GridThemeBorders`, `GridThemeShadows`, `GridThemeGrid`, `GridThemeOverlays`, `GridThemeToolbar`, `GridThemeScrollbar`, `GridThemeSkeleton`, `GridThemeTransitions`. Every field is optional.
673
+
655
674
  Presets: `darkTheme`, `roseTheme`, `emeraldTheme`, `amberTheme`, `compactTheme`. See [Theming](customization/theming.md).
656
675
 
657
676
  ---
@@ -836,6 +855,15 @@ If the key can change while the grid stays mounted, remount the grid with it (`<
836
855
 
837
856
  ## ⚙️ Interfaces & Types
838
857
 
858
+ ### Small shared types
859
+
860
+ | Type | Definition | Used by |
861
+ | :--- | :--- | :--- |
862
+ | `GridAlignment` | `'left' \| 'center' \| 'right'` | `GridColDef.align`, `headerAlign` |
863
+ | `GridSortDirection` | `'asc' \| 'desc' \| null` | a column's sort state (`null`: unsorted) |
864
+ | `GridSortModel` | `{ field: string; sort: GridSortDirection }`: like `GridSortItem`, but `sort` may be `null` | a single column's sort state |
865
+ | `GridPinnedPosition` | `'left' \| 'right'` (logical: inline start / end in RTL) | column pinning |
866
+
839
867
  ### `GridSortItem`
840
868
  ```typescript
841
869
  interface GridSortItem {
@@ -104,7 +104,9 @@ Every operator in `GridFilterOperator` also works in a programmatic `filterModel
104
104
 
105
105
  - Only **visible** columns with `filterable !== false` are searched. The row `id` and fields that are not columns are not searched.
106
106
  - Each column is searched by its value after `valueGetter` and, when the column has a `valueFormatter`, by the formatted text as well. `Date` values are searched as `YYYY-MM-DD`; objects are skipped.
107
+ - Without a `valueFormatter`, `singleSelect` columns are also searched by their option label, `boolean` columns by Yes / No and `date` columns by the locale date the cell shows (since v3.7.1).
107
108
  - The toolbar search box splits what the user types on whitespace, so `john london` becomes `['john', 'london']` and matches a row with first name John and city London.
109
+ - The searched text is cached per row object and column set, so typing does not re-read every cell. Two consequences: change a row by passing a **new** row object (mutating it in place keeps the old text), and when a `valueFormatter` reads state outside the row, pass new column definitions when that state changes.
108
110
 
109
111
  ---
110
112
 
@@ -71,7 +71,7 @@
71
71
  .ogx-ai-panel__suggestion {
72
72
  padding: 3px 10px;
73
73
  border: 1px solid var(--ogx-border-color, var(--ogx-grid-border-color));
74
- border-radius: var(--ogx-border-radius-round, 999px);
74
+ border-radius: 999px;
75
75
  background: var(--ogx-grid-header-background);
76
76
  color: inherit;
77
77
  font: inherit;
@@ -119,7 +119,7 @@
119
119
  align-items: center;
120
120
  gap: 4px;
121
121
  padding: 2px 4px 2px 10px;
122
- border-radius: var(--ogx-border-radius-round, 999px);
122
+ border-radius: 999px;
123
123
  background: var(--ogx-color-primary-light, #e0e7ff);
124
124
  color: var(--ogx-color-primary-dark, #1e3a8a);
125
125
  }
@@ -27,9 +27,7 @@
27
27
  outline: 2px solid var(--ogx-grid-cell-focus-border, var(--ogx-color-primary, #2563eb));
28
28
  outline-offset: -2px;
29
29
  }
30
- .ogx__cell--editable {
31
- cursor: text;
32
- }
30
+
33
31
  /*
34
32
  * Cell range selection (cellSelection, v3.3). The tint is a background image, so it also covers
35
33
  * pinned cells' own background; the outline is an inset box-shadow, so the layout never shifts.
@@ -67,7 +67,6 @@
67
67
  }
68
68
 
69
69
  /* ─── Dark-mode overrides ────────────────────────────────────────────────────*/
70
- [data-theme="dark"] .ogx-col-group-cell__label,
71
- .ogx--dark .ogx-col-group-cell__label {
70
+ [data-theme="dark"] .ogx-col-group-cell__label {
72
71
  color: var(--ogx-color-gray-300, #cbd5e1);
73
72
  }
@@ -67,24 +67,7 @@
67
67
  background: var(--ogx-grid-background);
68
68
  box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.05);
69
69
  }
70
- .ogx--loading {
71
- opacity: 0.6;
72
- pointer-events: none;
73
- }
74
- .ogx--loading::after {
75
- content: '';
76
- position: absolute;
77
- top: 0;
78
- left: 0;
79
- right: 0;
80
- bottom: 0;
81
- background: color-mix(in srgb, var(--ogx-grid-background) 60%, transparent);
82
- backdrop-filter: blur(2px);
83
- display: flex;
84
- align-items: center;
85
- justify-content: center;
86
- z-index: var(--ogx-z-index-modal);
87
- }
70
+
88
71
  /* No height prop: fill the container, as a block child of a sized box (height: 100%) or as a
89
72
  flex item (flex: 1 with min-height: 0). In an auto-height parent 100% resolves to auto. */
90
73
  .ogx--fill {
@@ -312,16 +295,6 @@
312
295
  text-overflow: ellipsis;
313
296
  }
314
297
 
315
- .ogx--density-compact .ogx__cell,
316
- .ogx--density-compact .ogx__header-cell {
317
- padding: var(--ogx-spacing-xs) var(--ogx-spacing-sm);
318
- font-size: var(--ogx-font-size-xs);
319
- }
320
- .ogx--density-comfortable .ogx__cell,
321
- .ogx--density-comfortable .ogx__header-cell {
322
- padding: var(--ogx-spacing-md) var(--ogx-spacing-lg);
323
- font-size: var(--ogx-font-size-md);
324
- }
325
298
  .ogx__group-cell-content {
326
299
  font-weight: 500;
327
300
  text-align: start;
@@ -208,9 +208,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
208
208
  const isInternalLoading = state.dataSource.loading;
209
209
  const effectiveLoading = loading || isInternalLoading;
210
210
 
211
- const effectiveRows = useMemo(() => {
212
- return state.rows.allRows.map(id => state.rows.idRowsLookup.get(id)!) as R[];
213
- }, [state.rows]);
211
+ const effectiveRows = state.rows.rows as R[];
214
212
 
215
213
  const { treeDataHandlers, rowGroupingHandlers, activeHierarchyHandlers, rowMetaMap } = useGridHierarchy<R>({
216
214
  rows: effectiveRows,
@@ -354,7 +352,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
354
352
  // Indices address the consumer's own rows, whatever the sort, filter, page or pinning.
355
353
  const rowReorderHandlers = useRowReorder({
356
354
  rows: effectiveRows,
357
- getRowId: getRowIdOf,
355
+ indexById: state.rows.indexById,
358
356
  onRowOrderChange,
359
357
  rowReordering
360
358
  });
@@ -38,7 +38,7 @@ export interface GridListViewProps<R extends GridRowModel> {
38
38
  listViewColumn: GridListViewColDef<R>;
39
39
  /** Grid columns by field; a list field that names one gets its valueGetter / valueFormatter. */
40
40
  columnsByField: ReadonlyMap<string, GridColDef>;
41
- rowMetaMap: Map<GridRowId, GridRowMeta>;
41
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
42
42
  noRowsLabel: string;
43
43
  rowHeight: number;
44
44
  checkboxSelection: boolean;
@@ -53,7 +53,7 @@ export interface GridPinnedRowsProps<R extends GridRowModel> {
53
53
  colspanMap?: Map<GridRowId, Record<string, CellColSpanInfo>>;
54
54
  rowSpanningCaches?: RowSpanningCaches;
55
55
  rowHeight: number;
56
- rowMetaMap: Map<GridRowId, GridRowMeta>;
56
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
57
57
  /** Resolves a row's id; defaults to `row.id`. */
58
58
  getRowId?: (row: R) => GridRowId;
59
59
  /** Index of the first of these rows in the grid's renderable rows (top-pinned + centre + bottom-pinned). */
@@ -69,7 +69,7 @@ export interface GridVirtualRowsProps<R extends GridRowModel> {
69
69
  unpinnedRowsLength: number;
70
70
  /** Last center row index in the render window: skeleton rows render only once it reaches the end of the data. */
71
71
  lastRenderedRowIndex: number;
72
- rowMetaMap: Map<GridRowId, GridRowMeta>;
72
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
73
73
  loadingOverlay?: React.ReactNode;
74
74
  /** Added to a row's rowIndex to get its `aria-rowindex` (header rows and earlier pages included). */
75
75
  ariaRowIndexOffset?: number;
@@ -228,7 +228,8 @@ export function GridVirtualRows<R extends GridRowModel>({
228
228
  {Array.from({ length: infiniteScrollSkeletonCount }).map((_, i) => (
229
229
  <Row<R>
230
230
  key={`__skeleton_${i}__`}
231
- row={{ id: `__skeleton_${i}__`, _isSkeleton: true } as unknown as R}
231
+ row={{ id: `__skeleton_${i}__` } as unknown as R}
232
+ skeleton
232
233
  columns={virtualColumns}
233
234
  rowIndex={unpinnedRowsLength + i}
234
235
  rowHeight={rowHeight}
@@ -7,9 +7,9 @@ import type {
7
7
  GridFilterOperator
8
8
  } from '../../types';
9
9
  import { filterValueToInputText, getOperatorsForType, NO_VALUE_OPERATORS } from '../../utils/filtering';
10
+ import { filterValueFromInputText, getFilterValueOptions, isSameFilterValue, matchesFilterValueOption } from '../../utils/filtering/filterInput';
10
11
  import { Input } from '../ui/Input';
11
12
 
12
-
13
13
  export interface FilterPanelProps {
14
14
  filterModel: GridFilterModel;
15
15
  columns: GridColDef[];
@@ -29,17 +29,10 @@ function getActiveFilter(items: readonly FilterEntry[], field: string): GridFilt
29
29
  return items.find((entry): entry is GridFilterItem => isFilterItem(entry) && entry.field === field);
30
30
  }
31
31
 
32
- function sameValue(a: unknown, b: unknown): boolean {
33
- if (Object.is(a, b)) return true;
34
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
35
- if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
36
- return false;
37
- }
38
-
39
32
  function sameEntry(a: FilterEntry, b: FilterEntry): boolean {
40
33
  if (a === b) return true;
41
34
  if (isFilterItem(a) && isFilterItem(b)) {
42
- return a.id === b.id && a.field === b.field && a.operator === b.operator && sameValue(a.value, b.value);
35
+ return a.id === b.id && a.field === b.field && a.operator === b.operator && isSameFilterValue(a.value, b.value);
43
36
  }
44
37
  if (!isFilterItem(a) && !isFilterItem(b)) {
45
38
  return a.id === b.id && a.logicOperator === b.logicOperator && sameEntries(a.items, b.items);
@@ -52,36 +45,11 @@ function sameEntries(a: readonly FilterEntry[], b: readonly FilterEntry[]): bool
52
45
  return a.length === b.length && a.every((entry, i) => sameEntry(entry, b[i]));
53
46
  }
54
47
 
55
- interface ValueOption {
56
- key: string;
57
- value: unknown;
58
- label: string;
59
- }
60
-
61
- function getValueOptions(col: GridColDef): ValueOption[] {
62
- return (col.valueOptions ?? []).map((option, index) => (
63
- option !== null && typeof option === 'object'
64
- ? { key: String(index), value: option.value, label: option.label }
65
- : { key: String(index), value: option, label: String(option) }
66
- ));
67
- }
68
-
69
- function matchesOption(option: ValueOption, value: unknown): boolean {
70
- if (Object.is(option.value, value)) return true;
71
- return value != null && String(option.value).toLowerCase() === String(value).toLowerCase();
72
- }
73
-
74
48
  /** Text shown in the value input for a committed value. Never used to emit anything. */
75
49
  function toInputText(value: unknown, col: GridColDef): string {
76
50
  return filterValueToInputText(value, col.type);
77
51
  }
78
52
 
79
- /** The item value for typed text. isAnyOf takes a comma-separated list. */
80
- function fromInputText(text: string, operator: GridFilterOperator): unknown {
81
- if (operator === 'isAnyOf') return text.split(',').map(part => part.trim()).filter(part => part !== '');
82
- return text;
83
- }
84
-
85
53
  /** Carry a value over to a newly picked operator: lists for isAnyOf, scalars for the rest. */
86
54
  function convertValueForOperator(value: unknown, operator: GridFilterOperator): unknown {
87
55
  if (NO_VALUE_OPERATORS.has(operator)) return undefined;
@@ -144,7 +112,7 @@ const FilterRow: React.FC<{
144
112
  const unchanged = current ? text === toInputText(current.value, colRef.current) : text.trim() === '';
145
113
  if (unchanged) return;
146
114
  const operator = current?.operator ?? defaultOperator;
147
- onChangeRef.current(buildItem(operator, fromInputText(text, operator)));
115
+ onChangeRef.current(buildItem(operator, filterValueFromInputText(text, operator)));
148
116
  }, [defaultOperator, buildItem]);
149
117
 
150
118
  // Text still waiting for the debounce; flushed when the panel closes (unmounts) meanwhile.
@@ -172,7 +140,7 @@ const FilterRow: React.FC<{
172
140
 
173
141
  const handleOperatorChange = (op: GridFilterOperator) => {
174
142
  // Keep what the user typed even if the debounce has not committed it yet.
175
- const value = draft !== null ? fromInputText(draft, op) : item?.value;
143
+ const value = draft !== null ? filterValueFromInputText(draft, op) : item?.value;
176
144
  setDraft(null);
177
145
  onChange(buildItem(op, convertValueForOperator(value, op)));
178
146
  };
@@ -183,7 +151,7 @@ const FilterRow: React.FC<{
183
151
  };
184
152
 
185
153
  const showValue = !NO_VALUE_OPERATORS.has(currentOperator);
186
- const valueOptions = getValueOptions(col);
154
+ const valueOptions = getFilterValueOptions(col);
187
155
  const valueClassName = `ogx-filter__value-input${isActive ? ' ogx-filter__value-input--active' : ''}`;
188
156
  const valueLabel = `Filter value for ${label}`;
189
157
 
@@ -215,7 +183,7 @@ const FilterRow: React.FC<{
215
183
  className={`${valueClassName} ogx-filter__value-multiselect`}
216
184
  multiple
217
185
  size={Math.min(valueOptions.length, 4)}
218
- value={valueOptions.filter(o => selected.some(v => matchesOption(o, v))).map(o => o.key)}
186
+ value={valueOptions.filter(o => selected.some(v => matchesFilterValueOption(o, v))).map(o => o.key)}
219
187
  onChange={(e) => {
220
188
  const keys = new Set(Array.from(e.target.selectedOptions, option => option.value));
221
189
  onChange(buildItem('isAnyOf', valueOptions.filter(o => keys.has(o.key)).map(o => o.value)));
@@ -226,7 +194,7 @@ const FilterRow: React.FC<{
226
194
  </select>
227
195
  );
228
196
  } else if (showValue && col.type === 'singleSelect' && valueOptions.length > 0 && (currentOperator === 'is' || currentOperator === 'not')) {
229
- const current = valueOptions.find(o => matchesOption(o, item?.value));
197
+ const current = valueOptions.find(o => matchesFilterValueOption(o, item?.value));
230
198
  valueControl = (
231
199
  <select
232
200
  id={`${filterId}-val`}
@@ -207,12 +207,7 @@
207
207
  background-color: var(--ogx-grid-header-hover-background);
208
208
  z-index: 6;
209
209
  }
210
- .ogx__header-cell--focus-visible {
211
- outline: 2px solid var(--ogx-grid-cell-focus-border, var(--ogx-color-primary));
212
- outline-offset: -2px;
213
- background-color: var(--ogx-grid-header-hover-background);
214
- z-index: 6;
215
- }
210
+
216
211
  .ogx__header-cell:focus, .ogx__header-cell:focus-visible {
217
212
  outline: none !important;
218
213
  }
@@ -241,8 +236,4 @@
241
236
  background: var(--ogx-grid-header-hover-background);
242
237
  color: var(--ogx-grid-header-text);
243
238
  }
244
- .ogx__menu-icon-btn--active {
245
- opacity: 1;
246
- background: var(--ogx-color-primary-light);
247
- color: var(--ogx-color-primary);
248
- }
239
+
@@ -10,6 +10,7 @@ import {
10
10
  getHeaderFilterOperators,
11
11
  type GridHeaderFilterState,
12
12
  } from '../../utils/headerFilters';
13
+ import { filterValueFromInputText, getFilterValueOptions, isSameFilterValue, matchesFilterValueOption } from '../../utils/filtering/filterInput';
13
14
  import { Input } from '../ui/Input';
14
15
  import { HeaderFilterOperatorMenu } from './HeaderFilterOperatorMenu';
15
16
  import type { HeaderFilterLocale } from '../../utils/headerFilterLocale';
@@ -29,42 +30,10 @@ export interface HeaderFilterCellProps {
29
30
  locale: HeaderFilterLocale;
30
31
  }
31
32
 
32
- interface ValueOption {
33
- key: string;
34
- value: unknown;
35
- label: string;
36
- }
37
-
38
- function getValueOptions(col: GridColDef): ValueOption[] {
39
- return (col.valueOptions ?? []).map((option, index) => (
40
- option !== null && typeof option === 'object'
41
- ? { key: String(index), value: option.value, label: option.label }
42
- : { key: String(index), value: option, label: String(option) }
43
- ));
44
- }
45
-
46
- function matchesOption(option: ValueOption, value: unknown): boolean {
47
- const single = Array.isArray(value) ? value[0] : value;
48
- if (Object.is(option.value, single)) return true;
49
- return single != null && String(option.value).toLowerCase() === String(single).toLowerCase();
50
- }
51
-
52
- /** The item value for typed text. isAnyOf takes a comma-separated list. */
53
- function fromInputText(text: string, operator: GridFilterOperator): unknown {
54
- if (operator === 'isAnyOf') return text.split(',').map(part => part.trim()).filter(part => part !== '');
55
- return text;
56
- }
57
-
58
- function sameValue(a: unknown, b: unknown): boolean {
59
- if (Object.is(a, b)) return true;
60
- if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
61
- return false;
62
- }
63
-
64
33
  function sameItem(a: GridFilterItem | undefined, b: GridFilterItem | undefined): boolean {
65
34
  if (a === b) return true;
66
35
  if (!a || !b) return false;
67
- return a.operator === b.operator && sameValue(a.value, b.value);
36
+ return a.operator === b.operator && isSameFilterValue(a.value, b.value);
68
37
  }
69
38
 
70
39
  function ClearIcon() {
@@ -81,7 +50,7 @@ export function HeaderFilterCell({ colDef, state, style, className, ariaColIndex
81
50
  const label = colDef.headerName || colDef.field;
82
51
  const item = state.kind === 'editable' ? state.item : undefined;
83
52
  const operator: GridFilterOperator = item?.operator ?? getHeaderFilterDefaultOperator(colDef);
84
- const valueOptions = getValueOptions(colDef);
53
+ const valueOptions = getFilterValueOptions(colDef);
85
54
  const usesSelect = colDef.type === 'boolean' || (colDef.type === 'singleSelect' && valueOptions.length > 0);
86
55
  const showsValue = !NO_VALUE_OPERATORS.has(operator);
87
56
 
@@ -117,7 +86,7 @@ export function HeaderFilterCell({ colDef, state, style, className, ariaColIndex
117
86
  const unchanged = current ? text === filterValueToInputText(current.value, col.type) : text.trim() === '';
118
87
  if (unchanged) return;
119
88
  const op = current?.operator ?? getHeaderFilterDefaultOperator(col);
120
- const next = buildHeaderFilterItem(col, op, fromInputText(text, op));
89
+ const next = buildHeaderFilterItem(col, op, filterValueFromInputText(text, op));
121
90
  setLastEmitted(next);
122
91
  emit(col.field, next);
123
92
  }, []);
@@ -173,7 +142,7 @@ export function HeaderFilterCell({ colDef, state, style, className, ariaColIndex
173
142
 
174
143
  const selectOperator = (op: GridFilterOperator) => {
175
144
  // Keep what was typed even if the debounce has not committed it yet.
176
- const value = draft !== null ? fromInputText(draft, op) : item?.value;
145
+ const value = draft !== null ? filterValueFromInputText(draft, op) : item?.value;
177
146
  setDraft(null);
178
147
  setMenuAnchor(null);
179
148
  report(buildHeaderFilterItem(colDef, op, convertHeaderFilterValue(value, op)));
@@ -271,7 +240,7 @@ export function HeaderFilterCell({ colDef, state, style, className, ariaColIndex
271
240
  </select>
272
241
  );
273
242
  } else if (usesSelect) {
274
- const selected = valueOptions.find(o => matchesOption(o, item?.value));
243
+ const selected = valueOptions.find(o => matchesFilterValueOption(o, item?.value));
275
244
  valueControl = (
276
245
  <select
277
246
  ref={selectRef}
@@ -109,22 +109,6 @@
109
109
  color: var(--ogx-text-secondary, #9ca3af);
110
110
  }
111
111
 
112
- /* ─── Responsive: auto switch at ≤768px when using ogx--list-auto class ─── */
113
- @media (max-width: 768px) {
114
- .ogx--list-auto .ogx__viewport {
115
- display: none;
116
- }
117
- .ogx--list-auto .ogx-list-view {
118
- display: flex;
119
- }
120
- }
121
-
122
- @media (min-width: 769px) {
123
- .ogx--list-auto .ogx-list-view {
124
- display: none;
125
- }
126
- }
127
-
128
112
  /* Loading with no rows yet: a progress bar, or slots.loadingOverlay centred. */
129
113
  .ogx-list-view__loading {
130
114
  position: relative;
@@ -46,6 +46,8 @@ function mergeColSpan<R extends GridRowModel>(
46
46
 
47
47
  export interface RowProps<R extends GridRowModel = GridRowModel> {
48
48
  row: R;
49
+ /** A shimmer placeholder shown while infinite scroll loads more rows (`row` is unused). */
50
+ skeleton?: boolean;
49
51
  /** The row's id (from getRowId). Defaults to `row.id`; the grid always passes it. */
50
52
  rowId?: GridRowId;
51
53
  columns: GridColDef<R>[];
@@ -108,6 +110,7 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
108
110
  function RowImpl<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
109
111
  const {
110
112
  row,
113
+ skeleton = false,
111
114
  rowId: rowIdProp,
112
115
  columns,
113
116
  rowIndex,
@@ -197,7 +200,7 @@ function RowImpl<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
197
200
  }, [onEditStop, id]);
198
201
 
199
202
  // ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
200
- if (row._isSkeleton) {
203
+ if (skeleton) {
201
204
  // Render shimmer placeholders — one per visible column, matching row height
202
205
  const cellCount = Math.max(columns.length, 5);
203
206
  return (
@@ -41,7 +41,9 @@ let warnedDuplicateIds = false;
41
41
  function buildRowsState(rows: GridRowModel[], getRowId: GetRowId, knownIds?: Map<GridRowModel, GridRowId>): GridState['rows'] {
42
42
  const idRowsLookup = new Map<GridRowId, GridRowModel>();
43
43
  const idByRow = new Map<GridRowModel, GridRowId>();
44
+ const indexById = new Map<GridRowId, number>();
44
45
  const allRows: GridRowId[] = [];
46
+ const stored: GridRowModel[] = [];
45
47
  const duplicates: GridRowId[] = [];
46
48
 
47
49
  rows.forEach(row => {
@@ -52,7 +54,9 @@ function buildRowsState(rows: GridRowModel[], getRowId: GetRowId, knownIds?: Map
52
54
  }
53
55
  idRowsLookup.set(id, row);
54
56
  idByRow.set(row, id);
57
+ indexById.set(id, allRows.length);
55
58
  allRows.push(id);
59
+ stored.push(row);
56
60
  });
57
61
 
58
62
  if (duplicates.length > 0 && !warnedDuplicateIds && process.env.NODE_ENV !== 'production') {
@@ -64,7 +68,7 @@ function buildRowsState(rows: GridRowModel[], getRowId: GetRowId, knownIds?: Map
64
68
  );
65
69
  }
66
70
 
67
- return { idRowsLookup, allRows, idByRow };
71
+ return { idRowsLookup, allRows, idByRow, rows: stored, indexById };
68
72
  }
69
73
 
70
74
  function createInitialState<R extends GridRowModel>(rows: R[], getRowId: GetRowId, columns: GridColDef<R>[], columnVisibilityModel: GridColumnVisibilityModel = {}): GridState {
@@ -104,10 +108,39 @@ function createInitialState<R extends GridRowModel>(rows: R[], getRowId: GetRowI
104
108
  };
105
109
  }
106
110
 
111
+ /**
112
+ * Stores committed edits under the ids of the rows that were edited. Edits never add, remove or
113
+ * reorder rows, so the id maps are updated in place (copying them cost ~100 ms each at 1M rows)
114
+ * and keep their identity: `getRowIdOf` and everything memoized on it stay valid. Only the row
115
+ * array is copied, with the edited positions patched. Writing the same entries again is a no-op,
116
+ * so React re-running the reducer (StrictMode, rebasing) gives the same result.
117
+ */
118
+ function replaceStoredRows(
119
+ state: GridState,
120
+ edits: Iterable<readonly [GridRowId, GridRowModel]>,
121
+ ): GridState {
122
+ const { idRowsLookup, idByRow, indexById } = state.rows;
123
+ let rows: GridRowModel[] | null = null;
124
+ for (const [id, row] of edits) {
125
+ const index = indexById.get(id);
126
+ if (index === undefined) continue;
127
+ const previous = idRowsLookup.get(id);
128
+ if (previous !== undefined && previous !== row) idByRow.delete(previous);
129
+ idRowsLookup.set(id, row);
130
+ idByRow.set(row, id);
131
+ if (state.rows.rows[index] !== row) {
132
+ rows ??= state.rows.rows.slice();
133
+ rows[index] = row;
134
+ }
135
+ }
136
+ if (!rows) return state;
137
+ return { ...state, rows: { ...state.rows, rows } };
138
+ }
139
+
107
140
  function gridReducer(state: GridState, action: GridAction): GridState {
108
141
  switch (action.type) {
109
142
  case 'SET_ROWS': {
110
- const currentRows = state.rows.allRows.map(id => state.rows.idRowsLookup.get(id)!);
143
+ const currentRows = state.rows.rows;
111
144
  const newRows = typeof action.payload === 'function'
112
145
  ? action.payload(currentRows)
113
146
  : action.payload;
@@ -120,40 +153,12 @@ function gridReducer(state: GridState, action: GridAction): GridState {
120
153
  };
121
154
  }
122
155
 
123
- case 'REPLACE_ROW': {
124
- // Stores a committed edit under the id of the row that was edited, in the current store.
125
- const previous = state.rows.idRowsLookup.get(action.id);
126
- if (previous === undefined) return state;
127
- const idRowsLookup = new Map(state.rows.idRowsLookup);
128
- idRowsLookup.set(action.id, action.row);
129
- const idByRow = new Map(state.rows.idByRow);
130
- idByRow.delete(previous);
131
- idByRow.set(action.row, action.id);
132
- return {
133
- ...state,
134
- rows: { ...state.rows, idRowsLookup, idByRow },
135
- };
136
- }
156
+ case 'REPLACE_ROW':
157
+ return replaceStoredRows(state, [[action.id, action.row]]);
137
158
 
138
- case 'REPLACE_ROWS': {
159
+ case 'REPLACE_ROWS':
139
160
  // A batch edit (paste, range clear, undo, redo): every stored row in one update.
140
- let changed = false;
141
- const idRowsLookup = new Map(state.rows.idRowsLookup);
142
- const idByRow = new Map(state.rows.idByRow);
143
- for (const [id, row] of action.rows) {
144
- const previous = idRowsLookup.get(id);
145
- if (previous === undefined) continue;
146
- changed = true;
147
- idRowsLookup.set(id, row);
148
- idByRow.delete(previous);
149
- idByRow.set(row, id);
150
- }
151
- if (!changed) return state;
152
- return {
153
- ...state,
154
- rows: { ...state.rows, idRowsLookup, idByRow },
155
- };
156
- }
161
+ return replaceStoredRows(state, action.rows);
157
162
 
158
163
  case 'SET_COLUMNS': {
159
164
  const columnLookup = new Map<string, GridColDef>();
@@ -321,7 +326,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
321
326
  const [initialApi] = useState<GridApi>(() => ({
322
327
  ...createGridApiPlaceholder(),
323
328
  getRow: (id: GridRowId) => stateRef.current.rows.idRowsLookup.get(id) || null,
324
- getAllRows: () => stateRef.current.rows.allRows.map(id => stateRef.current.rows.idRowsLookup.get(id)!),
329
+ getAllRows: () => stateRef.current.rows.rows.slice(),
325
330
  getColumn: (field: string) => stateRef.current.columns.lookup.get(field) || null,
326
331
  getAllColumns: () => stateRef.current.columns.all,
327
332
  }));
@@ -25,7 +25,7 @@ export interface UseGridAggregationApiParams<R extends GridRowModel> {
25
25
  rootAggregationPosition: GridGroupAggregationPosition;
26
26
  aggregationPositions: Map<GridRowId, GridGroupAggregationPosition>;
27
27
  };
28
- rowMetaMap: Map<GridRowId, GridRowMeta>;
28
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
29
29
  columns: GridColDef<R>[];
30
30
  getRowId: (row: GridRowModel) => GridRowId;
31
31
  /** The column that shows the group labels, so export order matches the screen when sorting by it. */
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { RefObject } from 'react';
3
+ import { getRowIndex } from '../../utils/rowIndex';
3
4
  import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
4
5
  import { isRtl } from '../../utils/direction';
5
6
  import { isSyntheticRowId } from '../../utils/syntheticRows';
@@ -76,7 +77,7 @@ export interface UseGridCellSelectionParams<R extends GridRowModel> {
76
77
  navigationColumns: ReadonlyArray<RangeColumn<R>>;
77
78
  columnIndexMap: Map<string, number>;
78
79
  spanning: GridSpanningResult;
79
- rowMetaMap: Map<GridRowId, GridRowMeta>;
80
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
80
81
  layout: LayoutResult<R>;
81
82
  rowHeight: number;
82
83
  pinnedTopRowCount: number;
@@ -223,9 +224,7 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
223
224
 
224
225
  const context = useMemo<CellRangeContext | null>(() => {
225
226
  if (!enabled) return null;
226
- const rowIds = allRenderableRows.map(getRowId);
227
- const rowIndexById = new Map<GridRowId, number>();
228
- rowIds.forEach((id, index) => { if (!rowIndexById.has(id)) rowIndexById.set(id, index); });
227
+ const { rowIds, rowIndexById } = getRowIndex(allRenderableRows, getRowId, 'renderable');
229
228
  const fields = rangeColumns.map(c => c.field);
230
229
  return {
231
230
  rowIds,
@@ -49,7 +49,7 @@ export interface UseGridClipboardPasteParams<R extends GridRowModel> {
49
49
  /** System columns and the rendered data columns, in render order. */
50
50
  navigationColumns: ReadonlyArray<GridColDef<R>>;
51
51
  isCellEditable?: (params: GridCellParams<R>) => boolean;
52
- rowMetaMap: Map<GridRowId, GridRowMeta>;
52
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
53
53
  getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
54
54
  /** Whether a cell editor is open: paste and Delete then belong to it. */
55
55
  isEditing: boolean;
@@ -9,7 +9,7 @@ export interface UseGridDetailPanelParams {
9
9
  /** `detailPanelExpandedRowIds` prop (controlled when defined). */
10
10
  controlledExpandedRowIds: Set<GridRowId> | undefined;
11
11
  onDetailPanelExpandedRowIdsChange: ((expandedRowIds: Set<GridRowId>) => void) | undefined;
12
- rowMetaMap: Map<GridRowId, GridRowMeta>;
12
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
13
13
  }
14
14
 
15
15
  export interface UseGridDetailPanelResult {