@ornery/ui-grid-vanilla 0.1.10 → 1.0.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 (63) hide show
  1. package/AGENTS.md +70 -0
  2. package/README.md +196 -0
  3. package/demo/main.ts +0 -1
  4. package/dist/index.d.mts +582 -55
  5. package/dist/index.d.ts +582 -55
  6. package/dist/index.js +4959 -1429
  7. package/dist/index.mjs +5137 -1560
  8. package/package.json +11 -8
  9. package/scss-plugin.ts +65 -0
  10. package/src/adopt-styles.ts +39 -0
  11. package/src/attribute-bridge.ts +291 -0
  12. package/src/auto-height.test.ts +142 -0
  13. package/src/auto-resize.test.ts +207 -0
  14. package/src/cell-nav.test.ts +262 -0
  15. package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
  16. package/src/components/grid-body-cell/index.ts +1 -0
  17. package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
  18. package/src/components/grid-cell-editor/index.ts +1 -0
  19. package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
  20. package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
  21. package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
  22. package/src/components/grid-filter-cell/index.ts +1 -0
  23. package/src/components/grid-group-row/grid-group-row.scss +24 -0
  24. package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
  25. package/src/components/grid-group-row/grid-group-row.ts +94 -0
  26. package/src/components/grid-group-row/index.ts +1 -0
  27. package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
  28. package/src/components/grid-header-cell/index.ts +1 -0
  29. package/src/components/grid-pagination/grid-pagination.scss +39 -0
  30. package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
  31. package/src/components/grid-pagination/grid-pagination.ts +204 -0
  32. package/src/components/grid-pagination/index.ts +1 -0
  33. package/src/components/grid-template/grid-template.ts +166 -0
  34. package/src/components/grid-template/index.ts +1 -0
  35. package/src/events.ts +824 -0
  36. package/src/exporter.test.ts +186 -0
  37. package/src/focus.ts +379 -0
  38. package/src/framework-slots.ts +440 -0
  39. package/src/grid-controller.ts +1532 -21
  40. package/src/icons.ts +88 -0
  41. package/src/importer.test.ts +147 -0
  42. package/src/index.integration.test.ts +2 -2
  43. package/src/index.ts +9 -0
  44. package/src/infinite-scroll.test.ts +220 -0
  45. package/src/keyboard-navigation.test.ts +579 -0
  46. package/src/patch.ts +722 -0
  47. package/src/render.ts +465 -0
  48. package/src/row-edit.test.ts +191 -0
  49. package/src/row-selection.test.ts +550 -0
  50. package/src/save-state.test.ts +235 -0
  51. package/src/scss.d.ts +4 -0
  52. package/src/templates/index.ts +62 -0
  53. package/src/test-setup.ts +23 -0
  54. package/src/ui-grid-empty.template.ts +23 -0
  55. package/src/ui-grid-shell.scss +25 -0
  56. package/src/ui-grid-shell.template.ts +45 -0
  57. package/src/ui-grid-standalone.element.ts +751 -1112
  58. package/src/utils/cell-class.ts +70 -0
  59. package/src/utils/display-items.ts +15 -0
  60. package/src/utils/dom.ts +64 -0
  61. package/src/validate.test.ts +170 -0
  62. package/tsup.config.ts +10 -0
  63. package/vite.config.ts +10 -1
@@ -3,6 +3,7 @@ import {
3
3
  beginGridCellEditCommand,
4
4
  buildGridRows,
5
5
  buildGridCellContext,
6
+ buildInitialPinnedState,
6
7
  cancelGridCellEditCommand,
7
8
  canGridExpandRows,
8
9
  canGridMoveColumns,
@@ -48,6 +49,64 @@ import {
48
49
  pinGridColumnCommand,
49
50
  resolveGridLabels,
50
51
  resolveGridRowId as coreResolveGridRowId,
52
+ resolveGridSelectionOptions,
53
+ createGridSelectionState,
54
+ toggleGridRowSelection as coreToggleGridRowSelection,
55
+ shiftGridRowSelection as coreShiftGridRowSelection,
56
+ selectAllGridRows as coreSelectAllGridRows,
57
+ selectAllVisibleGridRows as coreSelectAllVisibleGridRows,
58
+ clearAllGridSelection as coreClearAllGridSelection,
59
+ findGridRowByKey as coreFindGridRowByKey,
60
+ reconcileGridSelection as coreReconcileGridSelection,
61
+ mapSelectedRowsToEntities as coreMapSelectedRowsToEntities,
62
+ buildGridCsv,
63
+ buildGridExcelSheetData,
64
+ buildGridPdfDocDefinition,
65
+ buildGridExporterMenuItems,
66
+ buildGridImporterMenuItems,
67
+ buildGridRowEditMenuItems,
68
+ type GridMenuItem,
69
+ resolveGridExporterExcelOptions,
70
+ resolveGridExporterOptions,
71
+ resolveGridExporterPdfOptions,
72
+ resolveExporterFilename,
73
+ downloadGridCsvFile,
74
+ sanitizeDownloadFilename,
75
+ createGridRowEditState,
76
+ markGridRowDirty,
77
+ markGridRowClean,
78
+ markGridRowSaving,
79
+ markGridRowError,
80
+ isGridRowEditTimerEnabled,
81
+ resolveGridRowEditWaitInterval,
82
+ resolveGridImporterOptions,
83
+ parseGridImporterJson,
84
+ parseGridImporterCsv,
85
+ buildGridImporterObjectsFromCsv,
86
+ buildGridImporterObjectsFromJson,
87
+ createGridValidatorRegistry,
88
+ runGridCellValidators,
89
+ validateAllGridRows,
90
+ isGridCellInvalid,
91
+ getGridCellErrorMessages,
92
+ GridValidatorRegistry,
93
+ type GridValidatorFactory,
94
+ type GridValidatorMessageFn,
95
+ type GridRowEditState,
96
+ type GridImporterOptions,
97
+ type GridExporterExcelSheetData,
98
+ type GridExporterMenuItem,
99
+ type GridExporterOptions,
100
+ type GridExporterColumnType,
101
+ type GridExporterPdfDocDefinition,
102
+ type GridExporterRowType,
103
+ type GridSelectionState,
104
+ type GridInfiniteScrollState,
105
+ maybeRequestInfiniteScrollData,
106
+ completeInfiniteScrollDataLoad,
107
+ resetInfiniteScrollState,
108
+ saveInfiniteScrollPercentage,
109
+ setInfiniteScrollDirectionsState,
51
110
  seekGridPaginationCommand,
52
111
  setGridPaginationPageSizeCommand,
53
112
  setPathValue,
@@ -62,10 +121,12 @@ import {
62
121
  type DisplayItem,
63
122
  type GridCellPosition,
64
123
  type GridColumnDef,
124
+ type GridRowColumn,
65
125
  type GridLabels,
66
126
  type GridOptions,
67
127
  type GridRecord,
68
128
  type GridRow,
129
+ type GridSavedState,
69
130
  type PipelineResult,
70
131
  type PinDirection,
71
132
  type PinnedColumnState,
@@ -75,14 +136,31 @@ import {
75
136
  type UiGridApi,
76
137
  } from '@ornery/ui-grid-core';
77
138
 
139
+ /**
140
+ * Complete grid save state — parity with the old `ui.grid.saveState`
141
+ * module. Every field is optional so partial saves / restores work too.
142
+ *
143
+ * Opt-in/out behaviour mirrors the old module: by default save() serialises
144
+ * everything; consumers can disable per-field capture via
145
+ * `saveSort`/`saveFilter`/... options on `GridOptions`. Restore tolerates
146
+ * missing fields.
147
+ */
78
148
  export interface GridSaveState {
79
- sortState: SortState;
80
- activeFilters: Record<string, string>;
81
- groupByColumns: string[];
82
- pinnedColumns: PinnedColumnState;
83
- columnOrder: string[];
84
- currentPage: number;
85
- pageSize: number;
149
+ sortState?: SortState;
150
+ activeFilters?: Record<string, string>;
151
+ groupByColumns?: string[];
152
+ collapsedGroups?: Record<string, boolean>;
153
+ pinnedColumns?: PinnedColumnState;
154
+ columnOrder?: string[];
155
+ columnWidthOverrides?: Record<string, string>;
156
+ currentPage?: number;
157
+ pageSize?: number;
158
+ selectedRowIds?: string[];
159
+ focusedCell?: GridCellPosition | null;
160
+ expandedRows?: Record<string, boolean>;
161
+ expandedTreeRows?: Record<string, boolean>;
162
+ scrollTop?: number;
163
+ scrollLeft?: number;
86
164
  }
87
165
 
88
166
  export interface GridControllerSnapshot {
@@ -103,6 +181,13 @@ export interface GridControllerSnapshot {
103
181
  pageSize: number;
104
182
  editingCell: GridCellPosition | null;
105
183
  editingValue: string;
184
+ /** Row ids currently selected. A ReadonlySet for surgical DOM updates —
185
+ * the renderer toggles the .ui-grid-row-selected class on every cell
186
+ * whose data-row matches one of these ids. */
187
+ selectedRowIds: ReadonlySet<string>;
188
+ selectAll: boolean;
189
+ focusedRowId: string | null;
190
+ selectedCount: number;
106
191
  }
107
192
 
108
193
  export type GridControllerSubscriber = (snapshot: GridControllerSnapshot) => void;
@@ -116,8 +201,94 @@ function orderVisibleColumns(
116
201
  .sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
117
202
  }
118
203
 
119
- function buildGridTemplateColumns(columns: readonly GridColumnDef[]): string {
120
- return columns.map((column) => gridColumnWidth(column)).join(' ');
204
+ const DEFAULT_COL_MIN_WIDTH = 176;
205
+
206
+ /**
207
+ * Resolve column widths to fixed px values, mirroring the old ui-grid's
208
+ * `updateColumnWidths` / `drawnWidth` approach. Every cell (header, filter,
209
+ * body) gets the exact same px track size — alignment guaranteed regardless
210
+ * of container width or scrollbar presence.
211
+ *
212
+ * Algorithm (ported from GridRenderContainer.prototype.updateColumnWidths):
213
+ * 1. Fixed-px columns: use their declared width directly.
214
+ * 2. Percentage columns: resolve against availableWidth.
215
+ * 3. Flex columns (*, 1fr, minmax): divide remaining space equally.
216
+ * 4. Leftover/excess pixels distributed 1px at a time for pixel-perfect fit.
217
+ */
218
+ function buildGridTemplateColumns(columns: readonly GridColumnDef[], viewportWidth?: number): string {
219
+ if (!viewportWidth || viewportWidth <= 0) {
220
+ return columns.map((column) => gridColumnWidth(column)).join(' ');
221
+ }
222
+
223
+ const availableWidth = viewportWidth;
224
+ const resolved: number[] = new Array(columns.length);
225
+ const flexIndices: number[] = [];
226
+ let usedWidth = 0;
227
+
228
+ for (let i = 0; i < columns.length; i++) {
229
+ const raw = columns[i].width;
230
+ const minWidth = columns[i].minWidth;
231
+ const maxWidth = columns[i].maxWidth ?? Infinity;
232
+
233
+ if (raw && raw.endsWith('px')) {
234
+ let px = parseInt(raw, 10);
235
+ if (minWidth != null) px = Math.max(minWidth, px);
236
+ px = Math.min(maxWidth, px);
237
+ resolved[i] = px;
238
+ usedWidth += px;
239
+ } else if (raw && raw.endsWith('%')) {
240
+ const pct = parseInt(raw.replace(/%/g, ''), 10);
241
+ let w = Math.round((pct / 100) * availableWidth);
242
+ if (minWidth != null) w = Math.max(minWidth, w);
243
+ w = Math.min(maxWidth, w);
244
+ resolved[i] = w;
245
+ usedWidth += w;
246
+ } else {
247
+ flexIndices.push(i);
248
+ resolved[i] = 0;
249
+ }
250
+ }
251
+
252
+ if (flexIndices.length > 0) {
253
+ const remaining = Math.max(0, availableWidth - usedWidth);
254
+ const perFlex = Math.floor(remaining / flexIndices.length);
255
+
256
+ for (const idx of flexIndices) {
257
+ const minWidth = columns[idx].minWidth ?? DEFAULT_COL_MIN_WIDTH;
258
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
259
+ const w = Math.max(minWidth, Math.min(maxWidth, perFlex));
260
+ resolved[idx] = w;
261
+ usedWidth += w;
262
+ }
263
+
264
+ // Distribute leftover pixels one at a time (old grid's processColumnUpwards)
265
+ let leftover = availableWidth - usedWidth;
266
+ let changed = true;
267
+ while (leftover > 0 && changed) {
268
+ changed = false;
269
+ for (const idx of flexIndices) {
270
+ if (leftover <= 0) break;
271
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
272
+ if (resolved[idx] < maxWidth) {
273
+ resolved[idx]++;
274
+ leftover--;
275
+ changed = true;
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ return resolved.map((w) => `${w}px`).join(' ');
282
+ }
283
+
284
+ function pinnedStatesEqual(a: PinnedColumnState, b: PinnedColumnState): boolean {
285
+ const keysA = Object.keys(a);
286
+ const keysB = Object.keys(b);
287
+ if (keysA.length !== keysB.length) return false;
288
+ for (const key of keysA) {
289
+ if (a[key] !== b[key]) return false;
290
+ }
291
+ return true;
121
292
  }
122
293
 
123
294
  export class VanillaGridController {
@@ -128,6 +299,10 @@ export class VanillaGridController {
128
299
  direction: SORT_DIRECTIONS.none,
129
300
  };
130
301
  private groupByColumns: string[] = [];
302
+ private lastOptionsGroupBy: string[] | undefined = undefined;
303
+ private lastOptionsPinnedState: PinnedColumnState | undefined = undefined;
304
+ private lastOptionsInfiniteScrollUp: boolean | undefined = undefined;
305
+ private lastOptionsInfiniteScrollDown: boolean | undefined = undefined;
131
306
  private collapsedGroups: Record<string, boolean> = {};
132
307
  private hiddenRowReasons: Record<string, string[]> = {};
133
308
  private expandedRows: Record<string, boolean> = {};
@@ -135,10 +310,31 @@ export class VanillaGridController {
135
310
  private pinnedColumns: PinnedColumnState = {};
136
311
  private columnOrder: string[] = [];
137
312
  private columnWidthOverrides: Record<string, string> = {};
313
+ private viewportWidth = 0;
138
314
  private currentPage = 1;
139
315
  private pageSize = 0;
140
316
  private editingCell: GridCellPosition | null = null;
141
317
  private editingValue = '';
318
+ private exporterOverrides: GridExporterOptions = {};
319
+ private validatorRegistry: GridValidatorRegistry;
320
+ private rowEditState: GridRowEditState = createGridRowEditState();
321
+ private rowEditTimers = new Map<string, ReturnType<typeof setTimeout>>();
322
+ private rowEditSavePromiseOverrides = new Map<string, Promise<void>>();
323
+ private selectionState: GridSelectionState = createGridSelectionState();
324
+ /** cellNav state. Persists the last focused cell so the public API's
325
+ * getFocusedCell returns the right value across re-renders, and tracks
326
+ * the focused-cells history for getCurrentSelection. */
327
+ private cellNavLastRowCol: { rowId: string; columnName: string } | null = null;
328
+ private cellNavFocusedCells: { rowId: string; columnName: string }[] = [];
329
+ /** Infinite-scroll state. Direction flags seed from options; dataLoading
330
+ * + previousVisibleRows are runtime bookkeeping for saveScrollPercentage /
331
+ * dataRemovedTop / dataRemovedBottom. */
332
+ private infiniteScrollState: GridInfiniteScrollState = {
333
+ scrollUp: false,
334
+ scrollDown: true,
335
+ dataLoading: false,
336
+ previousVisibleRows: 0,
337
+ };
142
338
  private pipeline: PipelineResult;
143
339
  private labels: GridLabels;
144
340
  private visibleColumns: GridColumnDef[] = [];
@@ -151,7 +347,23 @@ export class VanillaGridController {
151
347
  constructor(options: GridOptions) {
152
348
  this.options = options;
153
349
  this.labels = resolveGridLabels(options.labels);
350
+ // Validator registry is built from the resolved labels so default
351
+ // validator messages ("Field required", etc.) come out localized
352
+ // without the consumer wiring anything up.
353
+ this.validatorRegistry = createGridValidatorRegistry(this.labels);
154
354
  this.columnOrder = options.columnDefs.map((column) => column.name);
355
+ this.groupByColumns = options.grouping?.groupBy ? [...options.grouping.groupBy] : [];
356
+ this.lastOptionsGroupBy = options.grouping?.groupBy ? [...options.grouping.groupBy] : undefined;
357
+ this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
358
+ const declaresPin = options.columnDefs.some((col) => col.pinnedLeft === true || col.pinnedRight === true);
359
+ this.lastOptionsPinnedState = declaresPin ? { ...this.pinnedColumns } : undefined;
360
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
361
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
362
+
363
+ this.infiniteScrollState = resetInfiniteScrollState(
364
+ options.infiniteScrollUp === true,
365
+ options.infiniteScrollDown !== false,
366
+ );
155
367
 
156
368
  this.pipeline = {
157
369
  visibleRows: [],
@@ -181,8 +393,17 @@ export class VanillaGridController {
181
393
  toggleGrouping: (columnName) => this.toggleGrouping(columnName),
182
394
  clearGrouping: () => this.clearGrouping(),
183
395
  benchmark: (iterations) => this.benchmark(iterations),
184
- exportCsv: () => {
185
- // Export remains hosted by framework wrappers for now.
396
+ exportCsv: (rowType, colType) => this.exportCsv(rowType, colType),
397
+ buildCsv: (rowType, colType) => this.buildCsv(rowType, colType),
398
+ pdfExport: (rowType, colType) => this.exportPdf(rowType, colType),
399
+ buildPdfDocDefinition: (rowType, colType) =>
400
+ this.buildPdfDocDefinition(rowType, colType),
401
+ excelExport: (rowType, colType) => this.exportExcel(rowType, colType),
402
+ buildExcelSheetData: (rowType, colType) => this.buildExcelSheetData(rowType, colType),
403
+ getExporterMenuItems: () => this.buildExporterMenuItems(),
404
+ getExporterOptions: () => ({ ...this.exporterOverrides, ...resolveGridExporterOptions(this.options) }),
405
+ setExporterOptions: (overrides) => {
406
+ this.exporterOverrides = { ...this.exporterOverrides, ...overrides };
186
407
  },
187
408
  paginationGetPage: () => this.getCurrentPage(),
188
409
  paginationGetTotalPages: () => this.getTotalPages(),
@@ -225,14 +446,163 @@ export class VanillaGridController {
225
446
  this.cancelCellEdit();
226
447
  },
227
448
  getEditingCell: () => this.editingCell,
449
+ toggleRowSelection: (rowEntity, evt) => this.toggleRowSelectionByEntity(rowEntity, evt),
450
+ selectRow: (rowEntity, evt) => this.selectRow(rowEntity, evt),
451
+ selectRowByVisibleIndex: (rowNum, evt) => this.selectRowByVisibleIndex(rowNum, evt),
452
+ selectRowByKey: (isInEntity, key, comparator, evt, lookInRows) =>
453
+ this.selectRowByKey(isInEntity, key, comparator, evt, lookInRows),
454
+ unSelectRow: (rowEntity, evt) => this.unSelectRow(rowEntity, evt),
455
+ unSelectRowByVisibleIndex: (rowNum, evt) => this.unSelectRowByVisibleIndex(rowNum, evt),
456
+ unSelectRowByKey: (isInEntity, key, comparator, evt, lookInRows) =>
457
+ this.unSelectRowByKey(isInEntity, key, comparator, evt, lookInRows),
458
+ selectAllRows: (evt) => this.selectAllRows(evt),
459
+ selectAllVisibleRows: (evt) => this.selectAllVisibleRows(evt),
460
+ clearSelectedRows: (evt) => this.clearSelectedRows(evt),
461
+ getSelectedRows: () => this.getSelectedRows(),
462
+ getUnSelectedRows: () => this.getUnSelectedRows(),
463
+ getSelectedGridRows: () => this.getSelectedGridRows(),
464
+ getUnSelectedGridRows: () => this.getUnSelectedGridRows(),
465
+ getSelectedCount: () => this.selectionState.selectedRowIds.size,
466
+ setMultiSelect: (multiSelect) => this.setMultiSelect(multiSelect),
467
+ setModifierKeysToMultiSelect: (value) => this.setModifierKeysToMultiSelect(value),
468
+ getSelectAllState: () => this.selectionState.selectAll,
469
+ shiftSelectRow: (rowEntity, evt) => this.shiftSelectRow(rowEntity, evt),
470
+ // cellNav bindings — thin wrappers over the same focus state the
471
+ // element uses. The element raises `cellNav.navigate` via
472
+ // controller.setCellNavFocus(); these methods let consumers inspect
473
+ // and re-drive that state.
474
+ scrollToFocus: (rowEntity, colDef) => this.cellNavScrollToFocus(rowEntity, colDef),
475
+ getFocusedCell: () => this.cellNavGetFocusedCell(),
476
+ getCurrentSelection: () => this.cellNavGetCurrentSelection(),
477
+ rowColSelectIndex: (rowCol) => this.cellNavRowColSelectIndex(rowCol),
478
+ // SaveState bindings — ports ui.grid.saveState public API.
479
+ saveState: () => this.getState() as GridSavedState,
480
+ restoreState: (state) => this.setState(state as Partial<GridSaveState>),
481
+ // Infinite-scroll bindings — ports ui.grid.infiniteScroll public API.
482
+ infiniteScrollDataLoaded: (scrollUp, scrollDown) =>
483
+ this.infiniteScrollDataLoaded(scrollUp, scrollDown),
484
+ infiniteScrollReset: (scrollUp, scrollDown) =>
485
+ this.infiniteScrollResetScroll(scrollUp, scrollDown),
486
+ infiniteScrollSaveScrollPercentage: () => this.infiniteScrollSavePercentage(),
487
+ infiniteScrollDataRemovedTop: (scrollUp, scrollDown) =>
488
+ this.infiniteScrollDataRemovedTop(scrollUp, scrollDown),
489
+ infiniteScrollDataRemovedBottom: (scrollUp, scrollDown) =>
490
+ this.infiniteScrollDataRemovedBottom(scrollUp, scrollDown),
491
+ infiniteScrollSetDirections: (scrollUp, scrollDown) =>
492
+ this.infiniteScrollSetDirections(scrollUp, scrollDown),
493
+ // Row-edit bindings — port of ui.grid.rowEdit public API.
494
+ rowEditSetSavePromise: (rowEntity, promise) =>
495
+ this.rowEditSetSavePromise(rowEntity, promise),
496
+ rowEditGetDirtyRows: () => this.rowEditGetDirtyRows(),
497
+ rowEditGetErrorRows: () => this.rowEditGetErrorRows(),
498
+ rowEditFlushDirtyRows: () => this.rowEditFlushDirtyRows(),
499
+ rowEditRetryErroredRows: () => this.rowEditRetryErroredRows(),
500
+ rowEditSetRowsDirty: (rowEntities) => this.rowEditSetRowsDirty(rowEntities),
501
+ rowEditSetRowsClean: (rowEntities) => this.rowEditSetRowsClean(rowEntities),
502
+ rowEditGetMenuItems: () => this.buildRowEditMenuItems(),
503
+ // Importer bindings — port of ui.grid.importer public API.
504
+ importerImportAFile: () => this.importerRequestFile(),
505
+ importerImportThisFile: (file) => this.importerImportThisFile(file),
506
+ importerImportText: (text, type) => this.importerImportText(text, type),
507
+ importerGetMenuItems: () => this.buildImporterMenuItems(),
508
+ // Validate bindings — port of ui.grid.validate public API.
509
+ validateIsInvalid: (rowEntity, colDef) => isGridCellInvalid(rowEntity, colDef),
510
+ validateGetErrorMessages: (rowEntity, colDef) =>
511
+ getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry),
512
+ validateGetFormattedErrors: (rowEntity, colDef) =>
513
+ this.validateGetFormattedErrors(rowEntity, colDef),
514
+ validateGetTitleFormattedErrors: (rowEntity, colDef) =>
515
+ this.validateGetTitleFormattedErrors(rowEntity, colDef),
516
+ validateRunValidators: (rowEntity, colDef, newValue, oldValue) =>
517
+ this.validateRunValidators(rowEntity, colDef, newValue, oldValue),
518
+ validateSetValidator: (name, factory, messageFn) =>
519
+ this.validatorRegistry.setValidator(name, factory, messageFn),
520
+ validateGetInvalidRows: () => this.validateGetInvalidRows(),
521
+ });
522
+
523
+ // Subscribe row-edit to the edit events so a committed change flips the
524
+ // row into dirty state + schedules a save, and cancelling restarts the
525
+ // timer when the row was already dirty.
526
+ this.gridApi.edit.on.afterCellEdit((rowEntity, col, newValue, previousValue) => {
527
+ const rowId = this.resolveRowId(rowEntity);
528
+ const gridRow = this.findRowById(rowId);
529
+ if (!gridRow) return;
530
+ // Auto-run validators when a column declares them. Ports the old
531
+ // ui.grid.validate → edit integration where afterCellEdit kicked
532
+ // runValidators on the same cell. Async validators settle later;
533
+ // the re-emit makes sure the UI picks up the resulting invalid
534
+ // flag once they do.
535
+ if (col?.validators) {
536
+ void this.validateRunValidators(rowEntity, col, newValue, previousValue).then(() => {
537
+ this.emit();
538
+ });
539
+ }
540
+ if (newValue === previousValue && !gridRow.isDirty) return;
541
+ markGridRowDirty(this.rowEditState, gridRow);
542
+ this.considerSetRowEditTimer(gridRow);
543
+ this.emit();
544
+ });
545
+ this.gridApi.edit.on.beginCellEdit((rowEntity) => {
546
+ const rowId = this.resolveRowId(rowEntity);
547
+ const gridRow = this.findRowById(rowId);
548
+ if (gridRow) this.cancelRowEditTimer(gridRow);
549
+ });
550
+ this.gridApi.edit.on.cancelCellEdit((rowEntity) => {
551
+ const rowId = this.resolveRowId(rowEntity);
552
+ const gridRow = this.findRowById(rowId);
553
+ if (gridRow) this.considerSetRowEditTimer(gridRow);
554
+ });
555
+ // Cellnav navigate — if the user leaves a dirty row, start its timer
556
+ // (matches old grid: a row only autosaves when focus moves off it or
557
+ // the debounce elapses).
558
+ this.gridApi.cellNav.on.navigate((_newRowCol, oldRowCol) => {
559
+ if (!oldRowCol) return;
560
+ this.considerSetRowEditTimer(oldRowCol.row);
561
+ });
562
+
563
+ // When the i18n service swaps the active language, re-resolve labels
564
+ // + re-register the default validators (their messages are locale-
565
+ // driven) + re-render so every cell/tooltip picks up the new strings.
566
+ this.disposeLanguageListener = this.gridApi.i18n.on.languageChanged(() => {
567
+ this.labels = resolveGridLabels(this.options.labels);
568
+ const rebuilt = createGridValidatorRegistry(this.labels);
569
+ for (const name of ['required', 'minLength', 'maxLength']) {
570
+ this.validatorRegistry.setValidator(
571
+ name,
572
+ (arg: unknown) => rebuilt.getValidator(name, arg),
573
+ (arg: unknown) => rebuilt.getMessage(name, arg),
574
+ );
575
+ }
576
+ this.refresh();
228
577
  });
229
578
 
230
579
  this.refresh();
231
580
  }
232
581
 
582
+ /** Disposer for the i18n language-change listener — cleared when the
583
+ * controller is torn down (matches the pattern used for other global
584
+ * subscriptions). */
585
+ private disposeLanguageListener: (() => void) | null = null;
586
+ dispose(): void {
587
+ this.disposeLanguageListener?.();
588
+ this.disposeLanguageListener = null;
589
+ this.subscribers.clear();
590
+ }
591
+
233
592
  setOptions(options: GridOptions): void {
234
593
  this.options = options;
235
594
  this.labels = resolveGridLabels(options.labels);
595
+ // Re-register the built-in validators so their messages track the
596
+ // refreshed locale. Consumer-registered validators survive untouched
597
+ // because we write them onto the same registry instance.
598
+ const rebuiltDefaults = createGridValidatorRegistry(this.labels);
599
+ for (const name of ['required', 'minLength', 'maxLength']) {
600
+ this.validatorRegistry.setValidator(
601
+ name,
602
+ (arg: unknown) => rebuiltDefaults.getValidator(name, arg),
603
+ (arg: unknown) => rebuiltDefaults.getMessage(name, arg),
604
+ );
605
+ }
236
606
  if (this.columnOrder.length === 0) {
237
607
  this.columnOrder = options.columnDefs.map((column) => column.name);
238
608
  } else {
@@ -245,6 +615,55 @@ export class VanillaGridController {
245
615
  }
246
616
  }
247
617
 
618
+ // Apply declarative grouping only when the consumer's value structurally
619
+ // changed from what was last provided via options. This lets interactive
620
+ // toggles persist across re-renders that pass the same grouping config.
621
+ if (options.grouping?.groupBy !== undefined) {
622
+ const incoming = options.grouping.groupBy;
623
+ const prev = this.lastOptionsGroupBy;
624
+ const changed = !prev || prev.length !== incoming.length || prev.some((col, i) => col !== incoming[i]);
625
+ if (changed) {
626
+ this.groupByColumns = [...incoming];
627
+ this.lastOptionsGroupBy = [...incoming];
628
+ }
629
+ } else if (this.lastOptionsGroupBy !== undefined) {
630
+ this.lastOptionsGroupBy = undefined;
631
+ }
632
+
633
+ // Re-seed pinned state from columnDefs only when the declarative pin
634
+ // configuration structurally changed from the last options pass. This
635
+ // prevents interactive un-pin actions from being clobbered by re-renders
636
+ // that pass the same columnDefs.
637
+ const declaresPin = options.columnDefs.some(
638
+ (col) => col.pinnedLeft === true || col.pinnedRight === true,
639
+ );
640
+ if (declaresPin) {
641
+ const incoming = buildInitialPinnedState(options.columnDefs);
642
+ const prev = this.lastOptionsPinnedState;
643
+ if (!prev || !pinnedStatesEqual(prev, incoming)) {
644
+ this.pinnedColumns = incoming;
645
+ this.lastOptionsPinnedState = { ...incoming };
646
+ }
647
+ } else if (this.lastOptionsPinnedState !== undefined) {
648
+ this.lastOptionsPinnedState = undefined;
649
+ }
650
+
651
+ // Re-seed infinite-scroll directions only when the values structurally
652
+ // changed from the last options pass.
653
+ if (options.infiniteScrollUp !== undefined || options.infiniteScrollDown !== undefined) {
654
+ const upChanged = options.infiniteScrollUp !== undefined && options.infiniteScrollUp !== this.lastOptionsInfiniteScrollUp;
655
+ const downChanged = options.infiniteScrollDown !== undefined && options.infiniteScrollDown !== this.lastOptionsInfiniteScrollDown;
656
+ if (upChanged || downChanged) {
657
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
658
+ this.infiniteScrollState,
659
+ options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
660
+ options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown,
661
+ );
662
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
663
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
664
+ }
665
+ }
666
+
248
667
  this.apiRegistered = false;
249
668
  this.refresh();
250
669
  }
@@ -288,7 +707,7 @@ export class VanillaGridController {
288
707
  labels: this.labels,
289
708
  rowSize: this.getRowSize(),
290
709
  visibleColumns: this.visibleColumns,
291
- gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns),
710
+ gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns, this.viewportWidth),
292
711
  pipeline: this.pipeline,
293
712
  activeFilters: this.activeFilters,
294
713
  sortState: this.sortState,
@@ -301,6 +720,10 @@ export class VanillaGridController {
301
720
  pageSize: this.getEffectivePageSize(),
302
721
  editingCell: this.editingCell,
303
722
  editingValue: this.editingValue,
723
+ selectedRowIds: this.selectionState.selectedRowIds,
724
+ selectAll: this.selectionState.selectAll,
725
+ focusedRowId: this.selectionState.focusedRowId,
726
+ selectedCount: this.selectionState.selectedRowIds.size,
304
727
  };
305
728
  }
306
729
 
@@ -520,6 +943,7 @@ export class VanillaGridController {
520
943
  row: GridRow | GridRecord | string,
521
944
  columnName: string,
522
945
  triggerEvent?: Event | KeyboardEvent | null,
946
+ initialValue?: string,
523
947
  ): void {
524
948
  const rowId = this.resolveRowId(row);
525
949
  const gridRow = this.findRowById(rowId);
@@ -545,6 +969,7 @@ export class VanillaGridController {
545
969
  column,
546
970
  getCellValue(gridRow.entity, column),
547
971
  triggerEvent,
972
+ initialValue,
548
973
  );
549
974
 
550
975
  this.refresh();
@@ -556,6 +981,15 @@ export class VanillaGridController {
556
981
  }
557
982
 
558
983
  commitCellEdit(): void {
984
+ // Guard against re-entry: committing rebuilds the cell markup, which
985
+ // removes the focused <input> and fires blur — whose handler also tries
986
+ // to commit. Without this early-return, the nested render mutates DOM
987
+ // that the outer render hasn't finished unwinding yet, throwing
988
+ // "node to be removed is no longer a child of this node."
989
+ if (this.editingCell === null) {
990
+ return;
991
+ }
992
+
559
993
  commitGridCellEditCommand(this.gridApi, {
560
994
  getEditingCell: () => this.editingCell,
561
995
  getEditingValue: () => this.editingValue,
@@ -576,6 +1010,10 @@ export class VanillaGridController {
576
1010
  }
577
1011
 
578
1012
  cancelCellEdit(): void {
1013
+ if (this.editingCell === null) {
1014
+ return;
1015
+ }
1016
+
579
1017
  cancelGridCellEditCommand(this.gridApi, {
580
1018
  getEditingCell: () => this.editingCell,
581
1019
  setEditingCell: (next) => {
@@ -591,10 +1029,476 @@ export class VanillaGridController {
591
1029
  this.refresh();
592
1030
  }
593
1031
 
1032
+ // ---- Row selection -------------------------------------------------
1033
+ // Thin method layer on top of grid.core.selection. Each call mutates
1034
+ // this.selectionState + row instances in place, raises the appropriate
1035
+ // public-API event (single vs batch), then refreshes so the element
1036
+ // re-renders with the updated `isSelected` / `isFocused` flags.
1037
+
1038
+ /** Internal: run a core selection mutation, raise events, trigger a render. */
1039
+ private applySelectionChange(
1040
+ apply: () => { changed: GridRow[] },
1041
+ evt?: Event | null,
1042
+ ): void {
1043
+ const { changed } = apply();
1044
+ if (changed.length === 0) {
1045
+ return;
1046
+ }
1047
+ if (this.options.enableSelectionBatchEvent !== false && changed.length > 1) {
1048
+ this.gridApi.selection.raise.rowSelectionChangedBatch(changed, evt);
1049
+ } else {
1050
+ for (const row of changed) {
1051
+ this.gridApi.selection.raise.rowSelectionChanged(row, evt);
1052
+ }
1053
+ }
1054
+ this.refresh();
1055
+ }
1056
+
1057
+ private allGridRows(): GridRow[] {
1058
+ // Use the cached pipeline row list when possible — it already reflects
1059
+ // `options.data`. Fall back to rebuilding when the pipeline is empty
1060
+ // (e.g. before first refresh).
1061
+ return this.pipeline.visibleRows.length > 0
1062
+ ? this.pipeline.visibleRows
1063
+ : this.buildRowsFromData(this.options.data);
1064
+ }
1065
+
1066
+ toggleRowSelectionByEntity(rowEntity: GridRecord, evt?: Event | null): void {
1067
+ const row = this.findRowById(this.resolveRowId(rowEntity));
1068
+ if (!row) return;
1069
+ const resolved = resolveGridSelectionOptions(this.options);
1070
+ this.applySelectionChange(
1071
+ () =>
1072
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1073
+ multiSelect: resolved.multiSelect,
1074
+ noUnselect: resolved.noUnselect,
1075
+ }),
1076
+ evt,
1077
+ );
1078
+ }
1079
+
1080
+ selectRow(rowEntity: GridRecord, evt?: Event | null): void {
1081
+ const row = this.findRowById(this.resolveRowId(rowEntity));
1082
+ if (!row || row.isSelected) return;
1083
+ const resolved = resolveGridSelectionOptions(this.options);
1084
+ this.applySelectionChange(
1085
+ () =>
1086
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1087
+ multiSelect: resolved.multiSelect,
1088
+ noUnselect: resolved.noUnselect,
1089
+ }),
1090
+ evt,
1091
+ );
1092
+ }
1093
+
1094
+ selectRowByVisibleIndex(rowNum: number, evt?: Event | null): void {
1095
+ const rows = this.pipeline.visibleRows;
1096
+ const row = rows[rowNum];
1097
+ if (!row || row.isSelected) return;
1098
+ const resolved = resolveGridSelectionOptions(this.options);
1099
+ this.applySelectionChange(
1100
+ () =>
1101
+ coreToggleGridRowSelection(this.selectionState, rows, row, {
1102
+ multiSelect: resolved.multiSelect,
1103
+ noUnselect: resolved.noUnselect,
1104
+ canBeInvisible: false,
1105
+ }),
1106
+ evt,
1107
+ );
1108
+ }
1109
+
1110
+ selectRowByKey(
1111
+ isInEntity: boolean,
1112
+ key: string,
1113
+ comparator: unknown,
1114
+ evt?: Event | null,
1115
+ lookInRows?: readonly GridRow[],
1116
+ ): void {
1117
+ const rows = lookInRows ?? this.allGridRows();
1118
+ const row = coreFindGridRowByKey(rows, isInEntity, key, comparator);
1119
+ if (!row || row.isSelected) return;
1120
+ const resolved = resolveGridSelectionOptions(this.options);
1121
+ this.applySelectionChange(
1122
+ () =>
1123
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1124
+ multiSelect: resolved.multiSelect,
1125
+ noUnselect: resolved.noUnselect,
1126
+ canBeInvisible: false,
1127
+ }),
1128
+ evt,
1129
+ );
1130
+ }
1131
+
1132
+ unSelectRow(rowEntity: GridRecord, evt?: Event | null): void {
1133
+ const row = this.findRowById(this.resolveRowId(rowEntity));
1134
+ if (!row || !row.isSelected) return;
1135
+ const resolved = resolveGridSelectionOptions(this.options);
1136
+ this.applySelectionChange(
1137
+ () =>
1138
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1139
+ multiSelect: resolved.multiSelect,
1140
+ noUnselect: resolved.noUnselect,
1141
+ }),
1142
+ evt,
1143
+ );
1144
+ }
1145
+
1146
+ unSelectRowByVisibleIndex(rowNum: number, evt?: Event | null): void {
1147
+ const row = this.pipeline.visibleRows[rowNum];
1148
+ if (!row || !row.isSelected) return;
1149
+ const resolved = resolveGridSelectionOptions(this.options);
1150
+ this.applySelectionChange(
1151
+ () =>
1152
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1153
+ multiSelect: resolved.multiSelect,
1154
+ noUnselect: resolved.noUnselect,
1155
+ canBeInvisible: false,
1156
+ }),
1157
+ evt,
1158
+ );
1159
+ }
1160
+
1161
+ unSelectRowByKey(
1162
+ isInEntity: boolean,
1163
+ key: string,
1164
+ comparator: unknown,
1165
+ evt?: Event | null,
1166
+ lookInRows?: readonly GridRow[],
1167
+ ): void {
1168
+ const rows = lookInRows ?? this.allGridRows();
1169
+ const row = coreFindGridRowByKey(rows, isInEntity, key, comparator);
1170
+ if (!row || !row.isSelected) return;
1171
+ const resolved = resolveGridSelectionOptions(this.options);
1172
+ this.applySelectionChange(
1173
+ () =>
1174
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1175
+ multiSelect: resolved.multiSelect,
1176
+ noUnselect: resolved.noUnselect,
1177
+ canBeInvisible: false,
1178
+ }),
1179
+ evt,
1180
+ );
1181
+ }
1182
+
1183
+ selectAllRows(evt?: Event | null): void {
1184
+ const resolved = resolveGridSelectionOptions(this.options);
1185
+ this.applySelectionChange(
1186
+ () =>
1187
+ coreSelectAllGridRows(this.selectionState, this.allGridRows(), {
1188
+ multiSelect: resolved.multiSelect,
1189
+ isRowSelectable: resolved.isRowSelectable,
1190
+ }),
1191
+ evt,
1192
+ );
1193
+ }
1194
+
1195
+ selectAllVisibleRows(evt?: Event | null): void {
1196
+ const resolved = resolveGridSelectionOptions(this.options);
1197
+ this.applySelectionChange(
1198
+ () =>
1199
+ coreSelectAllVisibleGridRows(this.selectionState, this.allGridRows(), {
1200
+ multiSelect: resolved.multiSelect,
1201
+ isRowSelectable: resolved.isRowSelectable,
1202
+ }),
1203
+ evt,
1204
+ );
1205
+ }
1206
+
1207
+ clearSelectedRows(evt?: Event | null): void {
1208
+ this.applySelectionChange(
1209
+ () => coreClearAllGridSelection(this.selectionState, this.allGridRows()),
1210
+ evt,
1211
+ );
1212
+ }
1213
+
1214
+ shiftSelectRow(rowEntity: GridRecord, evt?: Event | null): void {
1215
+ const row = this.findRowById(this.resolveRowId(rowEntity));
1216
+ if (!row) return;
1217
+ const resolved = resolveGridSelectionOptions(this.options);
1218
+ this.applySelectionChange(
1219
+ () =>
1220
+ coreShiftGridRowSelection(this.selectionState, this.pipeline.visibleRows, row, {
1221
+ multiSelect: resolved.multiSelect,
1222
+ }),
1223
+ evt,
1224
+ );
1225
+ }
1226
+
1227
+ getSelectedRows(): GridRecord[] {
1228
+ return coreMapSelectedRowsToEntities(this.getSelectedGridRows());
1229
+ }
1230
+
1231
+ getUnSelectedRows(): GridRecord[] {
1232
+ return coreMapSelectedRowsToEntities(this.getUnSelectedGridRows());
1233
+ }
1234
+
1235
+ getSelectedGridRows(): GridRow[] {
1236
+ return this.allGridRows().filter((row) => row.isSelected);
1237
+ }
1238
+
1239
+ getUnSelectedGridRows(): GridRow[] {
1240
+ return this.allGridRows().filter((row) => !row.isSelected);
1241
+ }
1242
+
1243
+ setMultiSelect(multiSelect: boolean): void {
1244
+ this.options = { ...this.options, multiSelect };
1245
+ }
1246
+
1247
+ setModifierKeysToMultiSelect(value: boolean): void {
1248
+ this.options = { ...this.options, modifierKeysToMultiSelect: value };
1249
+ }
1250
+
1251
+ /** Focus a row by id — the old module's `row.setFocused(true)` with all
1252
+ * the book-keeping. Raises rowFocusChanged and refreshes. */
1253
+ setRowFocused(rowId: string, focused: boolean, evt?: Event | null): void {
1254
+ const row = this.findRowById(rowId);
1255
+ if (!row) return;
1256
+ const previousFocusedId = this.selectionState.focusedRowId;
1257
+ if (focused) {
1258
+ if (previousFocusedId && previousFocusedId !== rowId) {
1259
+ const previous = this.findRowById(previousFocusedId);
1260
+ previous?.setFocused(false);
1261
+ }
1262
+ row.setFocused(true);
1263
+ this.selectionState.focusedRowId = rowId;
1264
+ } else {
1265
+ row.setFocused(false);
1266
+ if (previousFocusedId === rowId) this.selectionState.focusedRowId = null;
1267
+ }
1268
+ this.gridApi.selection.raise.rowFocusChanged(row, evt);
1269
+ this.refresh();
1270
+ }
1271
+
1272
+ getRowSelectionState(): Readonly<GridSelectionState> {
1273
+ return this.selectionState;
1274
+ }
1275
+
1276
+ /** Resolved/defaulted selection options — exposed for the element layer
1277
+ * so it can decide whether to wire pointer handlers without duplicating
1278
+ * default logic. */
1279
+ getResolvedSelectionOptions(): ReturnType<typeof resolveGridSelectionOptions> {
1280
+ return resolveGridSelectionOptions(this.options);
1281
+ }
1282
+
1283
+ /** Public analogue of findRowById — the element uses this during drag
1284
+ * painting to resolve a row-id back to its entity without calling the
1285
+ * selection API methods that would double-raise events. */
1286
+ findRowByIdPublic(rowId: string): GridRow | null {
1287
+ return this.findRowById(rowId);
1288
+ }
1289
+
1290
+ // ---- End row selection ---------------------------------------------
1291
+
1292
+ // ---- cellNav -------------------------------------------------------
1293
+ // State + accessors for the cellNav public API. Element owns the DOM
1294
+ // focus; controller owns the logical "last focused cell" and the
1295
+ // focused-cells history. Element calls setCellNavFocus() whenever the
1296
+ // active cell changes.
1297
+
1298
+ /** Record a navigation event. Pushes to the focused-cells history,
1299
+ * updates lastRowCol, and raises cellNav.navigate on the public API. */
1300
+ setCellNavFocus(
1301
+ rowId: string | null,
1302
+ columnName: string | null,
1303
+ opts: { appendToSelection?: boolean } = {},
1304
+ ): void {
1305
+ const previous = this.cellNavLastRowCol;
1306
+ const previousRowCol = this.resolveCellNavRowCol(previous);
1307
+ if (rowId && columnName) {
1308
+ this.cellNavLastRowCol = { rowId, columnName };
1309
+ if (opts.appendToSelection) {
1310
+ this.cellNavFocusedCells = [
1311
+ ...this.cellNavFocusedCells.filter(
1312
+ (entry) => !(entry.rowId === rowId && entry.columnName === columnName),
1313
+ ),
1314
+ { rowId, columnName },
1315
+ ];
1316
+ } else {
1317
+ this.cellNavFocusedCells = [{ rowId, columnName }];
1318
+ }
1319
+ } else {
1320
+ this.cellNavLastRowCol = null;
1321
+ this.cellNavFocusedCells = [];
1322
+ }
1323
+ const nextRowCol = this.resolveCellNavRowCol(this.cellNavLastRowCol);
1324
+ this.gridApi.cellNav.raise.navigate(nextRowCol, previousRowCol);
1325
+ }
1326
+
1327
+ /** Raise viewPortKeyDown / viewPortKeyPress on behalf of the element.
1328
+ * Element gates this with `options.keyDownOverrides`. */
1329
+ raiseCellNavKeyEvent(
1330
+ type: 'keydown' | 'keypress',
1331
+ event: KeyboardEvent,
1332
+ ): void {
1333
+ const rowCol = this.resolveCellNavRowCol(this.cellNavLastRowCol);
1334
+ if (type === 'keydown') this.gridApi.cellNav.raise.viewPortKeyDown(event, rowCol);
1335
+ else this.gridApi.cellNav.raise.viewPortKeyPress(event, rowCol);
1336
+ }
1337
+
1338
+ private cellNavGetFocusedCell(): GridRowColumn | null {
1339
+ return this.resolveCellNavRowCol(this.cellNavLastRowCol);
1340
+ }
1341
+
1342
+ private cellNavGetCurrentSelection(): GridRowColumn[] {
1343
+ const out: GridRowColumn[] = [];
1344
+ for (const entry of this.cellNavFocusedCells) {
1345
+ const rowCol = this.resolveCellNavRowCol(entry);
1346
+ if (rowCol) out.push(rowCol);
1347
+ }
1348
+ return out;
1349
+ }
1350
+
1351
+ private cellNavRowColSelectIndex(rowCol: GridRowColumn): number {
1352
+ for (let i = 0; i < this.cellNavFocusedCells.length; i++) {
1353
+ const entry = this.cellNavFocusedCells[i]!;
1354
+ if (entry.rowId === rowCol.row.id && entry.columnName === rowCol.col.name) {
1355
+ return i;
1356
+ }
1357
+ }
1358
+ return -1;
1359
+ }
1360
+
1361
+ private resolveCellNavRowCol(
1362
+ position: { rowId: string; columnName: string } | null,
1363
+ ): GridRowColumn | null {
1364
+ if (!position) return null;
1365
+ const row = this.findRowById(position.rowId);
1366
+ const col = this.findColumnByName(position.columnName);
1367
+ if (!row || !col) return null;
1368
+ return { row, col };
1369
+ }
1370
+
1371
+ /** cellNav.scrollToFocus — resolves rowEntity + colDef into a logical
1372
+ * focus and delegates to the element (if attached) for actual scrolling.
1373
+ * When the element isn't wired (e.g. bare controller in tests), the
1374
+ * state still updates and the promise resolves immediately. */
1375
+ private async cellNavScrollToFocus(
1376
+ rowEntity: GridRecord | null,
1377
+ colDef: GridColumnDef | null,
1378
+ ): Promise<void> {
1379
+ const rowId = rowEntity ? this.resolveRowId(rowEntity) : null;
1380
+ const columnName = colDef?.name ?? null;
1381
+ this.setCellNavFocus(rowId, columnName);
1382
+ this.cellNavScrollRequest?.(rowId, columnName);
1383
+ return Promise.resolve();
1384
+ }
1385
+
1386
+ /** Element registers its scroll-and-focus handler here so the API
1387
+ * binding above can delegate DOM work. Controller stays DOM-free. */
1388
+ private cellNavScrollRequest: ((rowId: string | null, columnName: string | null) => void) | null = null;
1389
+ setCellNavScrollHandler(handler: ((rowId: string | null, columnName: string | null) => void) | null): void {
1390
+ this.cellNavScrollRequest = handler;
1391
+ }
1392
+
1393
+ /** Read-only accessor for the current options. Used by the element to
1394
+ * inspect keyDownOverrides / modifierKeysToMultiSelectCells without
1395
+ * coupling it to a private field. */
1396
+ getOptions(): GridOptions {
1397
+ return this.options;
1398
+ }
1399
+
1400
+ // ---- End cellNav ---------------------------------------------------
1401
+
1402
+ // ---- Infinite scroll ----------------------------------------------
1403
+ // Thin wrappers over grid.core.infinite-scroll's pure helpers. The
1404
+ // element calls `evaluateInfiniteScroll` from its scroll-frame callback;
1405
+ // these helpers raise needLoadMoreData / needLoadMoreDataTop when the
1406
+ // user approaches the end/top of the dataset.
1407
+
1408
+ /** Evaluate whether the current scroll position should request more
1409
+ * data at the top or bottom. Called from the element's scroll frame. */
1410
+ evaluateInfiniteScroll(startIndex: number, visibleRows: number, viewportRows: number): void {
1411
+ if (this.options.enableInfiniteScroll === false) return;
1412
+ const threshold = this.options.infiniteScrollRowsFromEnd ?? 20;
1413
+ const { request, nextState } = maybeRequestInfiniteScrollData({
1414
+ state: this.infiniteScrollState,
1415
+ startIndex,
1416
+ visibleRows,
1417
+ viewportRows,
1418
+ threshold,
1419
+ });
1420
+ if (request === null) return;
1421
+ this.infiniteScrollState = nextState;
1422
+ if (request === 'top') this.gridApi.infiniteScroll.raise.needLoadMoreDataTop();
1423
+ else this.gridApi.infiniteScroll.raise.needLoadMoreData();
1424
+ }
1425
+
1426
+ private infiniteScrollDataLoaded(scrollUp?: boolean, scrollDown?: boolean): Promise<void> {
1427
+ this.infiniteScrollState = completeInfiniteScrollDataLoad(
1428
+ this.infiniteScrollState,
1429
+ scrollUp ?? this.infiniteScrollState.scrollUp,
1430
+ scrollDown ?? this.infiniteScrollState.scrollDown,
1431
+ );
1432
+ this.refresh();
1433
+ return Promise.resolve();
1434
+ }
1435
+
1436
+ private infiniteScrollResetScroll(scrollUp?: boolean, scrollDown?: boolean): void {
1437
+ this.infiniteScrollState = resetInfiniteScrollState(
1438
+ scrollUp ?? false,
1439
+ scrollDown ?? true,
1440
+ );
1441
+ this.infiniteScrollScrollToTopRequest?.();
1442
+ }
1443
+
1444
+ private infiniteScrollSavePercentage(): void {
1445
+ this.infiniteScrollState = saveInfiniteScrollPercentage(
1446
+ this.infiniteScrollState,
1447
+ this.pipeline.visibleRows.length,
1448
+ );
1449
+ }
1450
+
1451
+ private infiniteScrollDataRemovedTop(scrollUp?: boolean, scrollDown?: boolean): void {
1452
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
1453
+ this.infiniteScrollState,
1454
+ scrollUp ?? this.infiniteScrollState.scrollUp,
1455
+ scrollDown ?? this.infiniteScrollState.scrollDown,
1456
+ );
1457
+ this.refresh();
1458
+ }
1459
+
1460
+ private infiniteScrollDataRemovedBottom(scrollUp?: boolean, scrollDown?: boolean): void {
1461
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
1462
+ this.infiniteScrollState,
1463
+ scrollUp ?? this.infiniteScrollState.scrollUp,
1464
+ scrollDown ?? this.infiniteScrollState.scrollDown,
1465
+ );
1466
+ this.refresh();
1467
+ }
1468
+
1469
+ private infiniteScrollSetDirections(scrollUp: boolean, scrollDown: boolean): void {
1470
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
1471
+ this.infiniteScrollState,
1472
+ scrollUp,
1473
+ scrollDown,
1474
+ );
1475
+ }
1476
+
1477
+ /** Element registers a scroll-to-top handler here so resetScroll can
1478
+ * bring the viewport back to 0. Keeps the controller DOM-free. */
1479
+ private infiniteScrollScrollToTopRequest: (() => void) | null = null;
1480
+ setInfiniteScrollResetHandler(handler: (() => void) | null): void {
1481
+ this.infiniteScrollScrollToTopRequest = handler;
1482
+ }
1483
+
1484
+ getInfiniteScrollState(): Readonly<GridInfiniteScrollState> {
1485
+ return this.infiniteScrollState;
1486
+ }
1487
+
1488
+ // ---- End infinite scroll ------------------------------------------
1489
+
594
1490
  canResizeColumns(): boolean {
595
1491
  return this.options.enableColumnResizing !== false;
596
1492
  }
597
1493
 
1494
+ setViewportWidth(width: number): void {
1495
+ this.viewportWidth = Math.round(width);
1496
+ }
1497
+
1498
+ getViewportWidth(): number {
1499
+ return this.viewportWidth;
1500
+ }
1501
+
598
1502
  setColumnWidthOverride(columnName: string, widthPx: number): void {
599
1503
  const nextWidth = `${Math.max(88, Math.round(widthPx))}px`;
600
1504
  this.columnWidthOverrides = { ...this.columnWidthOverrides, [columnName]: nextWidth };
@@ -609,6 +1513,7 @@ export class VanillaGridController {
609
1513
  this.visibleColumns.map((c) =>
610
1514
  c.name === columnName ? { ...c, width: widthStr } : c,
611
1515
  ),
1516
+ this.viewportWidth,
612
1517
  );
613
1518
  }
614
1519
 
@@ -678,6 +1583,21 @@ export class VanillaGridController {
678
1583
  return isColumnPinnable(this.options, column);
679
1584
  }
680
1585
 
1586
+ /** True when the given column is editable in principle — does NOT evaluate
1587
+ * a per-row `cellEditableCondition`. Use isCellEditable for that. */
1588
+ isColumnEditable(column: GridColumnDef): boolean {
1589
+ return column.enableCellEdit ?? this.options.enableCellEdit ?? false;
1590
+ }
1591
+
1592
+ /** True when the (row, column) cell is editable, respecting both the
1593
+ * column's `enableCellEdit` flag and any `cellEditableCondition`. */
1594
+ isCellEditable(row: GridRow, column: GridColumnDef): boolean {
1595
+ if (!this.isColumnEditable(column)) return false;
1596
+ const condition = column.cellEditableCondition ?? this.options.cellEditableCondition ?? true;
1597
+ if (typeof condition === 'boolean') return condition;
1598
+ return condition({ row: row.entity, column, rowIndex: row.index });
1599
+ }
1600
+
681
1601
  isPinned(column: GridColumnDef): boolean {
682
1602
  return this.pinnedColumns[column.name] !== undefined;
683
1603
  }
@@ -740,27 +1660,121 @@ export class VanillaGridController {
740
1660
  return this.editingCell?.rowId === rowId && this.editingCell?.columnName === columnName;
741
1661
  }
742
1662
 
1663
+ /**
1664
+ * Serialise the current grid state. Mirrors `ui.grid.saveState.save`:
1665
+ * every field is gated by an `options.save*` flag with the old grid's
1666
+ * defaults. A missing flag keeps the matching field out of the result.
1667
+ */
743
1668
  getState(): GridSaveState {
744
- return {
745
- sortState: { ...this.sortState },
746
- activeFilters: { ...this.activeFilters },
747
- groupByColumns: [...this.groupByColumns],
748
- pinnedColumns: { ...this.pinnedColumns },
749
- columnOrder: [...this.columnOrder],
750
- currentPage: this.currentPage,
751
- pageSize: this.pageSize,
752
- };
1669
+ const opts = this.options;
1670
+ const saveWidths = opts.saveWidths !== false;
1671
+ const saveOrder = opts.saveOrder !== false;
1672
+ const saveVisible = opts.saveVisible !== false;
1673
+ const saveSort = opts.saveSort !== false;
1674
+ const saveFilter = opts.saveFilter !== false;
1675
+ const saveSelection = opts.saveSelection !== false;
1676
+ const saveGrouping = opts.saveGrouping !== false;
1677
+ const saveGroupingExpandedStates = opts.saveGroupingExpandedStates === true;
1678
+ const savePinning = opts.savePinning !== false;
1679
+ const saveTreeView = opts.saveTreeView !== false;
1680
+ const savePagination = opts.savePagination !== false;
1681
+ const saveScroll = opts.saveScroll === true;
1682
+ const saveFocus = saveScroll ? false : opts.saveFocus !== false;
1683
+ void saveVisible; // Visible column state is derived from columnOrder + defs;
1684
+ // kept as an opt-flag for forward compat.
1685
+
1686
+ const state: GridSaveState = {};
1687
+ if (saveSort) state.sortState = { ...this.sortState };
1688
+ if (saveFilter) state.activeFilters = { ...this.activeFilters };
1689
+ if (saveGrouping) state.groupByColumns = [...this.groupByColumns];
1690
+ if (saveGrouping && saveGroupingExpandedStates) {
1691
+ state.collapsedGroups = { ...this.collapsedGroups };
1692
+ }
1693
+ if (savePinning) state.pinnedColumns = { ...this.pinnedColumns };
1694
+ if (saveOrder) state.columnOrder = [...this.columnOrder];
1695
+ if (saveWidths) state.columnWidthOverrides = { ...this.columnWidthOverrides };
1696
+ if (savePagination) {
1697
+ state.currentPage = this.currentPage;
1698
+ state.pageSize = this.pageSize;
1699
+ }
1700
+ if (saveSelection) {
1701
+ state.selectedRowIds = [...this.selectionState.selectedRowIds];
1702
+ }
1703
+ if (saveTreeView) {
1704
+ state.expandedRows = { ...this.expandedRows };
1705
+ state.expandedTreeRows = { ...this.expandedTreeRows };
1706
+ }
1707
+ if (saveFocus) {
1708
+ state.focusedCell = this.cellNavLastRowCol
1709
+ ? {
1710
+ rowId: this.cellNavLastRowCol.rowId,
1711
+ columnName: this.cellNavLastRowCol.columnName,
1712
+ }
1713
+ : null;
1714
+ }
1715
+ if (saveScroll && this.saveStateScrollSnapshot) {
1716
+ const snap = this.saveStateScrollSnapshot();
1717
+ state.scrollTop = snap.scrollTop;
1718
+ state.scrollLeft = snap.scrollLeft;
1719
+ }
1720
+ return state;
753
1721
  }
754
1722
 
1723
+ /**
1724
+ * Apply a previously saved state. All fields are optional — missing
1725
+ * fields are left at whatever the controller currently has. Matches the
1726
+ * old grid's `ui.grid.saveState.restore`.
1727
+ */
755
1728
  setState(state: Partial<GridSaveState>): void {
756
1729
  if (state.sortState !== undefined) this.sortState = { ...state.sortState };
757
1730
  if (state.activeFilters !== undefined) this.activeFilters = { ...state.activeFilters };
758
1731
  if (state.groupByColumns !== undefined) this.groupByColumns = [...state.groupByColumns];
1732
+ if (state.collapsedGroups !== undefined) this.collapsedGroups = { ...state.collapsedGroups };
759
1733
  if (state.pinnedColumns !== undefined) this.pinnedColumns = { ...state.pinnedColumns };
760
1734
  if (state.columnOrder !== undefined) this.columnOrder = [...state.columnOrder];
1735
+ if (state.columnWidthOverrides !== undefined) this.columnWidthOverrides = { ...state.columnWidthOverrides };
761
1736
  if (state.currentPage !== undefined) this.currentPage = state.currentPage;
762
1737
  if (state.pageSize !== undefined) this.pageSize = state.pageSize;
1738
+ if (state.expandedRows !== undefined) this.expandedRows = { ...state.expandedRows };
1739
+ if (state.expandedTreeRows !== undefined) this.expandedTreeRows = { ...state.expandedTreeRows };
1740
+ if (state.selectedRowIds !== undefined) {
1741
+ // Rebuild the selection Set in place — selectedRowIds is readonly on
1742
+ // GridSelectionState so we mutate the existing collection rather
1743
+ // than replacing the reference.
1744
+ this.selectionState.selectedRowIds.clear();
1745
+ for (const id of state.selectedRowIds) this.selectionState.selectedRowIds.add(id);
1746
+ this.selectionState.selectAll = false;
1747
+ }
1748
+ if (state.focusedCell !== undefined) {
1749
+ if (state.focusedCell) {
1750
+ this.cellNavLastRowCol = {
1751
+ rowId: state.focusedCell.rowId,
1752
+ columnName: state.focusedCell.columnName,
1753
+ };
1754
+ this.cellNavFocusedCells = [{ ...this.cellNavLastRowCol }];
1755
+ } else {
1756
+ this.cellNavLastRowCol = null;
1757
+ this.cellNavFocusedCells = [];
1758
+ }
1759
+ }
763
1760
  this.refresh();
1761
+ // Scroll restoration needs to run after render so the scroll container
1762
+ // exists and its scrollHeight is final.
1763
+ if (state.scrollTop !== undefined || state.scrollLeft !== undefined) {
1764
+ this.saveStateScrollRestore?.(state.scrollTop ?? 0, state.scrollLeft ?? 0);
1765
+ }
1766
+ }
1767
+
1768
+ /** Element injects its scroll accessor so save() can capture scrollTop/
1769
+ * scrollLeft. Controller stays DOM-free. */
1770
+ private saveStateScrollSnapshot: (() => { scrollTop: number; scrollLeft: number }) | null = null;
1771
+ private saveStateScrollRestore: ((scrollTop: number, scrollLeft: number) => void) | null = null;
1772
+ setSaveStateScrollHandlers(
1773
+ snapshot: (() => { scrollTop: number; scrollLeft: number }) | null,
1774
+ restore: ((scrollTop: number, scrollLeft: number) => void) | null,
1775
+ ): void {
1776
+ this.saveStateScrollSnapshot = snapshot;
1777
+ this.saveStateScrollRestore = restore;
764
1778
  }
765
1779
 
766
1780
  private refresh(): void {
@@ -790,6 +1804,33 @@ export class VanillaGridController {
790
1804
  this.visibleColumns = applyWidthOverrides([...pinnedLeft, ...middleColumns, ...pinnedRight]);
791
1805
  }
792
1806
 
1807
+ // Selection row-header column. Injected in front of everything else
1808
+ // (including left-pinned columns) so it always sits at the far left,
1809
+ // mirroring the old grid's addRowHeaderColumn(def, 0). The width is
1810
+ // configurable via selectionRowHeaderWidth.
1811
+ const resolvedSelection = resolveGridSelectionOptions(this.options);
1812
+ if (resolvedSelection.enableRowSelection && resolvedSelection.enableRowHeaderSelection) {
1813
+ const selectionCol: GridColumnDef = {
1814
+ name: 'selectionRowHeaderCol',
1815
+ displayName: '',
1816
+ width: `${resolvedSelection.selectionRowHeaderWidth}px`,
1817
+ minWidth: resolvedSelection.selectionRowHeaderWidth,
1818
+ maxWidth: resolvedSelection.selectionRowHeaderWidth,
1819
+ enableSorting: false,
1820
+ enableFiltering: false,
1821
+ enableGrouping: false,
1822
+ enableCellEdit: false,
1823
+ enablePinning: false,
1824
+ enableColumnResizing: false,
1825
+ enableColumnMoving: false,
1826
+ align: 'center',
1827
+ };
1828
+ // Dedupe if already present (setOptions could be re-entering).
1829
+ if (!this.visibleColumns.some((c) => c.name === 'selectionRowHeaderCol')) {
1830
+ this.visibleColumns = [selectionCol, ...this.visibleColumns];
1831
+ }
1832
+ }
1833
+
793
1834
  this.pipeline = defaultGridEngine.buildPipeline({
794
1835
  options: this.options,
795
1836
  columns: this.visibleColumns,
@@ -805,6 +1846,16 @@ export class VanillaGridController {
805
1846
  rowSize: this.getRowSize(),
806
1847
  });
807
1848
 
1849
+ // Re-apply the persisted selection/focus flags to the fresh row instances
1850
+ // produced by the pipeline. Without this, every refresh would wipe the
1851
+ // selected-row indicators because new GridRow objects default to
1852
+ // isSelected=false. Also evaluates isRowSelectable per row.
1853
+ coreReconcileGridSelection(
1854
+ this.selectionState,
1855
+ this.pipeline.visibleRows,
1856
+ this.options.isRowSelectable ?? null,
1857
+ );
1858
+
808
1859
  if (!this.apiRegistered) {
809
1860
  this.options.onRegisterApi?.(this.gridApi);
810
1861
  this.apiRegistered = true;
@@ -821,6 +1872,12 @@ export class VanillaGridController {
821
1872
  }
822
1873
 
823
1874
  private findRowById(rowId: string): GridRow | null {
1875
+ // Prefer the pipeline's current row instances — they carry the live
1876
+ // isSelected / isFocused / enableSelection flags that callers mutate.
1877
+ // Fall back to a fresh build when the pipeline is still empty (before
1878
+ // the first refresh).
1879
+ const fromPipeline = coreFindGridRowById(this.pipeline.visibleRows, rowId);
1880
+ if (fromPipeline) return fromPipeline;
824
1881
  return coreFindGridRowById(this.buildRowsFromData(this.options.data), rowId);
825
1882
  }
826
1883
 
@@ -950,6 +2007,460 @@ export class VanillaGridController {
950
2007
  this.gridApi.core.raise.benchmarkComplete(result);
951
2008
  return result;
952
2009
  }
2010
+
2011
+ /** Returns the rows that match the requested exporter row-type. Mirrors
2012
+ * the old grid's row selection logic: `all` uses the full data set (or
2013
+ * `exporterAllDataFn` when provided), `visible` uses the current pipeline
2014
+ * output, `selected` uses the selection state. */
2015
+ private exporterRowsFor(rowType: GridExporterRowType): readonly GridRow[] {
2016
+ if (rowType === 'visible') return this.pipeline.visibleRows;
2017
+ if (rowType === 'selected') return this.getSelectedGridRows();
2018
+ // 'all' — fall back to the full grid rows, including filtered-out ones.
2019
+ // The `exporterAllDataFn` escape hatch is left to the caller; we only
2020
+ // bundle the in-memory data here.
2021
+ return this.buildRowsFromData(this.options.data);
2022
+ }
2023
+
2024
+ private resolveExporterOptions(): GridExporterOptions {
2025
+ return { ...resolveGridExporterOptions(this.options), ...this.exporterOverrides };
2026
+ }
2027
+
2028
+ private buildCsv(
2029
+ rowType: GridExporterRowType = 'visible',
2030
+ colType: GridExporterColumnType = 'visible',
2031
+ ): string {
2032
+ const exporterOptions = this.resolveExporterOptions();
2033
+ const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
2034
+ return buildGridCsv(columns, this.exporterRowsFor(rowType), exporterOptions, colType);
2035
+ }
2036
+
2037
+ private exportCsv(
2038
+ rowType: GridExporterRowType = 'visible',
2039
+ colType: GridExporterColumnType = 'visible',
2040
+ ): void {
2041
+ const csv = this.buildCsv(rowType, colType);
2042
+ const resolved = this.resolveExporterOptions();
2043
+ const filename = sanitizeDownloadFilename(
2044
+ resolveExporterFilename(resolved.csvFilename, 'download.csv', rowType, colType),
2045
+ );
2046
+ downloadGridCsvFile(csv, filename);
2047
+ }
2048
+
2049
+ private buildPdfDocDefinition(
2050
+ rowType: GridExporterRowType = 'visible',
2051
+ colType: GridExporterColumnType = 'visible',
2052
+ ): GridExporterPdfDocDefinition {
2053
+ const exporterOptions = this.resolveExporterOptions();
2054
+ const pdfOptions = resolveGridExporterPdfOptions(this.options);
2055
+ const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
2056
+ return buildGridPdfDocDefinition(
2057
+ columns,
2058
+ this.exporterRowsFor(rowType),
2059
+ pdfOptions,
2060
+ exporterOptions,
2061
+ colType,
2062
+ );
2063
+ }
2064
+
2065
+ private exportPdf(
2066
+ rowType: GridExporterRowType = 'visible',
2067
+ colType: GridExporterColumnType = 'visible',
2068
+ ): GridExporterPdfDocDefinition {
2069
+ const doc = this.buildPdfDocDefinition(rowType, colType);
2070
+ // pdfMake is an optional global (consumers load it separately). When
2071
+ // present, open the generated PDF the same way the old module did;
2072
+ // when missing, just return the doc definition so the caller can
2073
+ // render it themselves. This mirrors `uiGridExporterService.pdfExport`.
2074
+ const win = typeof window !== 'undefined' ? (window as typeof window & {
2075
+ pdfMake?: { createPdf: (doc: GridExporterPdfDocDefinition) => { open: () => void; download: (filename: string) => void } };
2076
+ }) : undefined;
2077
+ if (win?.pdfMake) {
2078
+ const resolved = this.resolveExporterOptions();
2079
+ const pdfOpts = resolveGridExporterPdfOptions(this.options);
2080
+ const filename = resolveExporterFilename(
2081
+ pdfOpts.filename,
2082
+ 'download.pdf',
2083
+ rowType,
2084
+ colType,
2085
+ );
2086
+ const pdf = win.pdfMake.createPdf(doc);
2087
+ // Prefer download when the consumer supplied a filename; otherwise
2088
+ // fall back to .open() which pops the pdf in a new browser tab.
2089
+ if (resolved.csvFilename || pdfOpts.filename) {
2090
+ pdf.download(filename);
2091
+ } else {
2092
+ pdf.open();
2093
+ }
2094
+ }
2095
+ return doc;
2096
+ }
2097
+
2098
+ private buildExcelSheetData(
2099
+ rowType: GridExporterRowType = 'visible',
2100
+ colType: GridExporterColumnType = 'visible',
2101
+ ): GridExporterExcelSheetData {
2102
+ const exporterOptions = this.resolveExporterOptions();
2103
+ const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
2104
+ return buildGridExcelSheetData(columns, this.exporterRowsFor(rowType), exporterOptions, colType);
2105
+ }
2106
+
2107
+ private exportExcel(
2108
+ rowType: GridExporterRowType = 'visible',
2109
+ colType: GridExporterColumnType = 'visible',
2110
+ ): GridExporterExcelSheetData {
2111
+ const sheetData = this.buildExcelSheetData(rowType, colType);
2112
+ // ExcelBuilder is an optional global (consumers load it separately).
2113
+ // When present, produce + download the xlsx the same way the old module
2114
+ // did; when missing, just return the raw sheet data.
2115
+ const win = typeof window !== 'undefined' ? (window as typeof window & {
2116
+ ExcelBuilder?: {
2117
+ Worksheet: new (config: { name: string }) => { setData: (data: unknown) => void };
2118
+ Workbook: new () => { addWorksheet: (sheet: unknown) => void };
2119
+ Builder: { createFile: (workbook: unknown, options: { type: 'blob' }) => Promise<Blob> };
2120
+ };
2121
+ }) : undefined;
2122
+ const ExcelBuilder = win?.ExcelBuilder;
2123
+ if (!ExcelBuilder) {
2124
+ return sheetData;
2125
+ }
2126
+ const excelOptions = resolveGridExporterExcelOptions(this.options);
2127
+ const sheetName = typeof excelOptions.sheetName === 'function'
2128
+ ? excelOptions.sheetName(rowType, colType)
2129
+ : (excelOptions.sheetName ?? 'Sheet1');
2130
+ const filename = sanitizeDownloadFilename(
2131
+ resolveExporterFilename(excelOptions.filename, 'download.xlsx', rowType, colType),
2132
+ );
2133
+ const sheet = new ExcelBuilder.Worksheet({ name: sheetName });
2134
+ const workbook = new ExcelBuilder.Workbook();
2135
+ workbook.addWorksheet(sheet);
2136
+ sheet.setData(sheetData);
2137
+ void ExcelBuilder.Builder.createFile(workbook, { type: 'blob' }).then((result) => {
2138
+ if (typeof URL === 'undefined' || typeof document === 'undefined') return;
2139
+ const url = URL.createObjectURL(result);
2140
+ const link = document.createElement('a');
2141
+ link.href = url;
2142
+ link.download = filename;
2143
+ link.click();
2144
+ URL.revokeObjectURL(url);
2145
+ });
2146
+ return sheetData;
2147
+ }
2148
+
2149
+ // ---- Validate — ports ui.grid.validate ----------------------------------
2150
+
2151
+ /** Runs the validators declared on `colDef.validators` against the new
2152
+ * value and flips `$$invalid<col>` / `$$errors<col>` on the entity. On
2153
+ * failure raises `validate.validationFailed`. Async validators are
2154
+ * awaited so the caller can `await` for complete results. */
2155
+ private async validateRunValidators(
2156
+ rowEntity: GridRecord,
2157
+ colDef: GridColumnDef,
2158
+ newValue: unknown,
2159
+ oldValue: unknown,
2160
+ ): Promise<string[]> {
2161
+ return runGridCellValidators(
2162
+ rowEntity,
2163
+ colDef,
2164
+ newValue,
2165
+ oldValue,
2166
+ this.validatorRegistry,
2167
+ (entity, def, newVal, oldVal) => {
2168
+ this.gridApi.validate.raise.validationFailed(entity, def, newVal, oldVal);
2169
+ },
2170
+ );
2171
+ }
2172
+
2173
+ private validateGetFormattedErrors(rowEntity: GridRecord, colDef: GridColumnDef): string {
2174
+ const errors = getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry);
2175
+ if (errors.length === 0) return '';
2176
+ return `<p><b>${this.labels.validateError}</b></p>${errors.map((msg) => `${msg}<br/>`).join('')}`;
2177
+ }
2178
+
2179
+ private validateGetTitleFormattedErrors(rowEntity: GridRecord, colDef: GridColumnDef): string {
2180
+ const errors = getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry);
2181
+ if (errors.length === 0) return '';
2182
+ return [this.labels.validateError, ...errors].join('\n');
2183
+ }
2184
+
2185
+ private async validateGetInvalidRows(): Promise<GridRecord[]> {
2186
+ return validateAllGridRows(
2187
+ this.options.data,
2188
+ this.options.columnDefs,
2189
+ this.validatorRegistry,
2190
+ );
2191
+ }
2192
+
2193
+ /** Test hook — exposes the validator registry so consumers can inspect or
2194
+ * extend it outside of the gridApi surface (e.g. from a controller-level
2195
+ * plugin). */
2196
+ getValidatorRegistry(): GridValidatorRegistry {
2197
+ return this.validatorRegistry;
2198
+ }
2199
+
2200
+ // ---- Importer — ports ui.grid.importer ---------------------------------
2201
+
2202
+ /** Set by the element so `importAFile()` can trigger its file-picker flow.
2203
+ * Controller stays DOM-free: it just invokes whatever handler was wired. */
2204
+ private importerFilePickerRequest: (() => void) | null = null;
2205
+ setImporterFilePickerHandler(handler: (() => void) | null): void {
2206
+ this.importerFilePickerRequest = handler;
2207
+ }
2208
+
2209
+ private resolveImporterOptions(): GridImporterOptions {
2210
+ return resolveGridImporterOptions(this.options);
2211
+ }
2212
+
2213
+ private importerRequestFile(): void {
2214
+ this.importerFilePickerRequest?.();
2215
+ }
2216
+
2217
+ private importerDispatchObjects(objects: readonly GridRecord[]): void {
2218
+ if (objects.length === 0) return;
2219
+ const opts = this.resolveImporterOptions();
2220
+ if (opts.dataAddCallback) {
2221
+ opts.dataAddCallback(objects);
2222
+ } else {
2223
+ // No callback configured — append to the in-memory data array so the
2224
+ // grid reflects the imported rows immediately. Matches the
2225
+ // "importerDataAddCallback is required" hint from the old module
2226
+ // but without breaking the common case of a simple local data source.
2227
+ this.options = { ...this.options, data: [...this.options.data, ...objects] };
2228
+ this.refresh();
2229
+ }
2230
+ // If rowEdit is active, flip every freshly-added row dirty so the
2231
+ // consumer's saveRow hook sees them. Mirrors
2232
+ // `service.addObjects → rowEdit.setRowsDirty`.
2233
+ if (this.rowEditState.dirtyRowIds.size >= 0 && this.options.rowEditWaitInterval !== undefined) {
2234
+ // rowEdit is configured — queue the dirty marking on the next
2235
+ // refresh so the new rows are in the pipeline before we flip them.
2236
+ queueMicrotask(() => this.rowEditSetRowsDirty(objects));
2237
+ }
2238
+ }
2239
+
2240
+ private importerImportText(text: string, type?: 'json' | 'csv'): void {
2241
+ const opts = this.resolveImporterOptions();
2242
+ // Type inference: explicit `type` wins. Otherwise try JSON first; if
2243
+ // JSON.parse returns null (meaning invalidJson already reported) AND
2244
+ // the caller didn't force JSON, fall through to CSV. The old module
2245
+ // decided purely based on MIME type; `importText` is a newer entry
2246
+ // point we provide for drag/drop flows.
2247
+ const asJson = type === 'csv' ? null : parseGridImporterJson(text, { errorCallback: type === 'json' ? opts.errorCallback : undefined });
2248
+ if (asJson && asJson.length > 0) {
2249
+ const objects = buildGridImporterObjectsFromJson(asJson, opts);
2250
+ this.importerDispatchObjects(objects);
2251
+ return;
2252
+ }
2253
+ if (type === 'json') return;
2254
+
2255
+ const csv = parseGridImporterCsv(text);
2256
+ if (!csv) {
2257
+ opts.errorCallback?.(
2258
+ 'importer.invalidCsv',
2259
+ 'File could not be processed, is it valid csv? Content was: ',
2260
+ text,
2261
+ );
2262
+ return;
2263
+ }
2264
+ const objects = buildGridImporterObjectsFromCsv(csv, this.options.columnDefs, opts);
2265
+ if (!objects || objects.length === 0) {
2266
+ opts.errorCallback?.(
2267
+ 'importer.noObjects',
2268
+ 'Objects were not able to be derived, content was: ',
2269
+ text,
2270
+ );
2271
+ return;
2272
+ }
2273
+ this.importerDispatchObjects(objects);
2274
+ }
2275
+
2276
+ private importerImportThisFile(file: File): Promise<void> {
2277
+ return new Promise((resolve) => {
2278
+ if (typeof FileReader === 'undefined') {
2279
+ resolve();
2280
+ return;
2281
+ }
2282
+ const reader = new FileReader();
2283
+ reader.onload = () => {
2284
+ const text = typeof reader.result === 'string' ? reader.result : '';
2285
+ // MIME sniff: old module switched on file.type, with `application/json`
2286
+ // as JSON and everything else as CSV. Preserve that default while
2287
+ // still letting the auto-detect path in `importerImportText` recover
2288
+ // when a consumer stores JSON with the wrong MIME.
2289
+ const type: 'json' | 'csv' = file.type === 'application/json' ? 'json' : 'csv';
2290
+ this.importerImportText(text, type);
2291
+ resolve();
2292
+ };
2293
+ reader.onerror = () => resolve();
2294
+ reader.readAsText(file);
2295
+ });
2296
+ }
2297
+
2298
+ private buildExporterMenuItems(): GridExporterMenuItem[] {
2299
+ // Pass the full resolved label bundle — the menu module pulls only
2300
+ // the `exporter*` keys it needs via `Partial<GridLabels>`.
2301
+ return buildGridExporterMenuItems(
2302
+ this.options,
2303
+ this.labels,
2304
+ {
2305
+ csvExport: (rowType, colType) => this.exportCsv(rowType, colType),
2306
+ pdfExport: (rowType, colType) => {
2307
+ this.exportPdf(rowType, colType);
2308
+ },
2309
+ excelExport: (rowType, colType) => {
2310
+ this.exportExcel(rowType, colType);
2311
+ },
2312
+ },
2313
+ () => this.selectionState.selectedRowIds.size > 0,
2314
+ );
2315
+ }
2316
+
2317
+ /** Menu items the importer contributes. Just the single "Import"
2318
+ * entry when the feature is enabled; returns an empty array otherwise. */
2319
+ private buildImporterMenuItems(): GridMenuItem[] {
2320
+ return buildGridImporterMenuItems(
2321
+ this.options,
2322
+ this.labels,
2323
+ { importAFile: () => this.importerRequestFile() },
2324
+ );
2325
+ }
2326
+
2327
+ /** Menu items row-edit contributes: "Save changes" + "Retry errored
2328
+ * rows". Each entry is gated by both a gridOption flag and a runtime
2329
+ * predicate (`hasDirtyRows` / `hasErrorRows`) so the menu only surfaces
2330
+ * actions that have something to do. */
2331
+ private buildRowEditMenuItems(): GridMenuItem[] {
2332
+ return buildGridRowEditMenuItems(
2333
+ this.options,
2334
+ this.labels,
2335
+ {
2336
+ flushDirtyRows: () => this.rowEditFlushDirtyRows(),
2337
+ retryErroredRows: () => this.rowEditRetryErroredRows(),
2338
+ },
2339
+ {
2340
+ hasDirtyRows: () => this.rowEditState.dirtyRowIds.size > 0,
2341
+ hasErrorRows: () => this.rowEditState.errorRowIds.size > 0,
2342
+ },
2343
+ );
2344
+ }
2345
+
2346
+ // ---- Row-edit — ports ui.grid.rowEdit -----------------------------------
2347
+
2348
+ private rowEditSetSavePromise(rowEntity: GridRecord, savePromise: Promise<void>): void {
2349
+ const rowId = this.resolveRowId(rowEntity);
2350
+ this.rowEditSavePromiseOverrides.set(rowId, savePromise);
2351
+ }
2352
+
2353
+ private rowEditGetDirtyRows(): GridRow[] {
2354
+ return this.pipeline.visibleRows.filter((row) =>
2355
+ this.rowEditState.dirtyRowIds.has(row.id),
2356
+ );
2357
+ }
2358
+
2359
+ private rowEditGetErrorRows(): GridRow[] {
2360
+ return this.pipeline.visibleRows.filter((row) =>
2361
+ this.rowEditState.errorRowIds.has(row.id),
2362
+ );
2363
+ }
2364
+
2365
+ private rowEditFlushDirtyRows(): Promise<void> {
2366
+ const dirty = [...this.rowEditState.dirtyRowIds]
2367
+ .map((id) => this.findRowById(id))
2368
+ .filter((row): row is GridRow => row !== undefined);
2369
+ const promises = dirty.map((row) => this.runRowEditSave(row));
2370
+ return Promise.all(promises).then(() => undefined);
2371
+ }
2372
+
2373
+ /** Re-fires `saveRow` for every row currently in the error state. The
2374
+ * "Retry errored rows" menu entry funnels through here. Returns a
2375
+ * promise that resolves once every retry promise has settled. */
2376
+ private rowEditRetryErroredRows(): Promise<void> {
2377
+ const errored = [...this.rowEditState.errorRowIds]
2378
+ .map((id) => this.findRowById(id))
2379
+ .filter((row): row is GridRow => row !== undefined);
2380
+ const promises = errored.map((row) => this.runRowEditSave(row));
2381
+ return Promise.all(promises).then(() => undefined);
2382
+ }
2383
+
2384
+ private rowEditSetRowsDirty(rowEntities: readonly GridRecord[]): void {
2385
+ for (const entity of rowEntities) {
2386
+ const rowId = this.resolveRowId(entity);
2387
+ const row = this.findRowById(rowId);
2388
+ if (!row) continue;
2389
+ markGridRowDirty(this.rowEditState, row);
2390
+ this.considerSetRowEditTimer(row);
2391
+ }
2392
+ this.emit();
2393
+ }
2394
+
2395
+ private rowEditSetRowsClean(rowEntities: readonly GridRecord[]): void {
2396
+ for (const entity of rowEntities) {
2397
+ const rowId = this.resolveRowId(entity);
2398
+ const row = this.findRowById(rowId);
2399
+ if (!row) continue;
2400
+ markGridRowClean(this.rowEditState, row);
2401
+ this.cancelRowEditTimer(row);
2402
+ }
2403
+ this.emit();
2404
+ }
2405
+
2406
+ private considerSetRowEditTimer(row: GridRow): void {
2407
+ this.cancelRowEditTimer(row);
2408
+ if (!row.isDirty || row.isSaving) return;
2409
+ const waitInterval = this.options.rowEditWaitInterval;
2410
+ if (!isGridRowEditTimerEnabled(waitInterval)) return;
2411
+ const effective = resolveGridRowEditWaitInterval(waitInterval);
2412
+ const timer = setTimeout(() => {
2413
+ this.rowEditTimers.delete(row.id);
2414
+ void this.runRowEditSave(row);
2415
+ }, effective);
2416
+ this.rowEditTimers.set(row.id, timer);
2417
+ }
2418
+
2419
+ private cancelRowEditTimer(row: GridRow): void {
2420
+ const timer = this.rowEditTimers.get(row.id);
2421
+ if (timer !== undefined) {
2422
+ clearTimeout(timer);
2423
+ this.rowEditTimers.delete(row.id);
2424
+ }
2425
+ }
2426
+
2427
+ private runRowEditSave(row: GridRow): Promise<void> {
2428
+ if (row.isSaving) {
2429
+ // Re-entrance — return the in-flight promise so `flushDirtyRows`
2430
+ // still awaits the existing save. Matches the old module's
2431
+ // `rowEditSavePromise` short-circuit.
2432
+ return this.rowEditState.savePromises.get(row.id) ?? Promise.resolve();
2433
+ }
2434
+ markGridRowSaving(this.rowEditState, row);
2435
+ this.rowEditSavePromiseOverrides.delete(row.id);
2436
+ this.gridApi.rowEdit.raise.saveRow(row.entity);
2437
+ const savePromise =
2438
+ this.rowEditSavePromiseOverrides.get(row.id) ?? Promise.resolve();
2439
+ this.rowEditState.savePromises.set(row.id, savePromise);
2440
+ this.emit();
2441
+ return savePromise
2442
+ .then(() => {
2443
+ markGridRowClean(this.rowEditState, row);
2444
+ this.emit();
2445
+ })
2446
+ .catch(() => {
2447
+ markGridRowError(this.rowEditState, row);
2448
+ this.emit();
2449
+ });
2450
+ }
2451
+
2452
+ /** Test hook — reports the current row-edit dirty / error / saving state. */
2453
+ getRowEditState(): {
2454
+ dirtyRowIds: string[];
2455
+ errorRowIds: string[];
2456
+ savingRowIds: string[];
2457
+ } {
2458
+ return {
2459
+ dirtyRowIds: [...this.rowEditState.dirtyRowIds],
2460
+ errorRowIds: [...this.rowEditState.errorRowIds],
2461
+ savingRowIds: [...this.rowEditState.savingRowIds],
2462
+ };
2463
+ }
953
2464
  }
954
2465
 
955
2466
  export function createVanillaGridController(options: GridOptions): VanillaGridController {