@ornery/ui-grid-vanilla 0.1.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +196 -0
  2. package/demo/index.html +3 -3
  3. package/demo/main.ts +1 -1
  4. package/dist/index.d.mts +584 -55
  5. package/dist/index.d.ts +584 -55
  6. package/dist/index.js +4887 -1404
  7. package/dist/index.mjs +5056 -1526
  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 +206 -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 +815 -0
  36. package/src/exporter.test.ts +186 -0
  37. package/src/focus.ts +353 -0
  38. package/src/framework-slots.ts +440 -0
  39. package/src/grid-controller.ts +1431 -20
  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 -1032
  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;
@@ -134,10 +219,31 @@ export class VanillaGridController {
134
219
  private expandedTreeRows: Record<string, boolean> = {};
135
220
  private pinnedColumns: PinnedColumnState = {};
136
221
  private columnOrder: string[] = [];
222
+ private columnWidthOverrides: Record<string, string> = {};
137
223
  private currentPage = 1;
138
224
  private pageSize = 0;
139
225
  private editingCell: GridCellPosition | null = null;
140
226
  private editingValue = '';
227
+ private exporterOverrides: GridExporterOptions = {};
228
+ private validatorRegistry: GridValidatorRegistry;
229
+ private rowEditState: GridRowEditState = createGridRowEditState();
230
+ private rowEditTimers = new Map<string, ReturnType<typeof setTimeout>>();
231
+ private rowEditSavePromiseOverrides = new Map<string, Promise<void>>();
232
+ private selectionState: GridSelectionState = createGridSelectionState();
233
+ /** cellNav state. Persists the last focused cell so the public API's
234
+ * getFocusedCell returns the right value across re-renders, and tracks
235
+ * the focused-cells history for getCurrentSelection. */
236
+ private cellNavLastRowCol: { rowId: string; columnName: string } | null = null;
237
+ private cellNavFocusedCells: { rowId: string; columnName: string }[] = [];
238
+ /** Infinite-scroll state. Direction flags seed from options; dataLoading
239
+ * + previousVisibleRows are runtime bookkeeping for saveScrollPercentage /
240
+ * dataRemovedTop / dataRemovedBottom. */
241
+ private infiniteScrollState: GridInfiniteScrollState = {
242
+ scrollUp: false,
243
+ scrollDown: true,
244
+ dataLoading: false,
245
+ previousVisibleRows: 0,
246
+ };
141
247
  private pipeline: PipelineResult;
142
248
  private labels: GridLabels;
143
249
  private visibleColumns: GridColumnDef[] = [];
@@ -150,7 +256,18 @@ export class VanillaGridController {
150
256
  constructor(options: GridOptions) {
151
257
  this.options = options;
152
258
  this.labels = resolveGridLabels(options.labels);
259
+ // Validator registry is built from the resolved labels so default
260
+ // validator messages ("Field required", etc.) come out localized
261
+ // without the consumer wiring anything up.
262
+ this.validatorRegistry = createGridValidatorRegistry(this.labels);
153
263
  this.columnOrder = options.columnDefs.map((column) => column.name);
264
+ this.groupByColumns = options.grouping?.groupBy ? [...options.grouping.groupBy] : [];
265
+ this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
266
+
267
+ this.infiniteScrollState = resetInfiniteScrollState(
268
+ options.infiniteScrollUp === true,
269
+ options.infiniteScrollDown !== false,
270
+ );
154
271
 
155
272
  this.pipeline = {
156
273
  visibleRows: [],
@@ -180,8 +297,17 @@ export class VanillaGridController {
180
297
  toggleGrouping: (columnName) => this.toggleGrouping(columnName),
181
298
  clearGrouping: () => this.clearGrouping(),
182
299
  benchmark: (iterations) => this.benchmark(iterations),
183
- exportCsv: () => {
184
- // Export remains hosted by framework wrappers for now.
300
+ exportCsv: (rowType, colType) => this.exportCsv(rowType, colType),
301
+ buildCsv: (rowType, colType) => this.buildCsv(rowType, colType),
302
+ pdfExport: (rowType, colType) => this.exportPdf(rowType, colType),
303
+ buildPdfDocDefinition: (rowType, colType) =>
304
+ this.buildPdfDocDefinition(rowType, colType),
305
+ excelExport: (rowType, colType) => this.exportExcel(rowType, colType),
306
+ buildExcelSheetData: (rowType, colType) => this.buildExcelSheetData(rowType, colType),
307
+ getExporterMenuItems: () => this.buildExporterMenuItems(),
308
+ getExporterOptions: () => ({ ...this.exporterOverrides, ...resolveGridExporterOptions(this.options) }),
309
+ setExporterOptions: (overrides) => {
310
+ this.exporterOverrides = { ...this.exporterOverrides, ...overrides };
185
311
  },
186
312
  paginationGetPage: () => this.getCurrentPage(),
187
313
  paginationGetTotalPages: () => this.getTotalPages(),
@@ -224,14 +350,163 @@ export class VanillaGridController {
224
350
  this.cancelCellEdit();
225
351
  },
226
352
  getEditingCell: () => this.editingCell,
353
+ toggleRowSelection: (rowEntity, evt) => this.toggleRowSelectionByEntity(rowEntity, evt),
354
+ selectRow: (rowEntity, evt) => this.selectRow(rowEntity, evt),
355
+ selectRowByVisibleIndex: (rowNum, evt) => this.selectRowByVisibleIndex(rowNum, evt),
356
+ selectRowByKey: (isInEntity, key, comparator, evt, lookInRows) =>
357
+ this.selectRowByKey(isInEntity, key, comparator, evt, lookInRows),
358
+ unSelectRow: (rowEntity, evt) => this.unSelectRow(rowEntity, evt),
359
+ unSelectRowByVisibleIndex: (rowNum, evt) => this.unSelectRowByVisibleIndex(rowNum, evt),
360
+ unSelectRowByKey: (isInEntity, key, comparator, evt, lookInRows) =>
361
+ this.unSelectRowByKey(isInEntity, key, comparator, evt, lookInRows),
362
+ selectAllRows: (evt) => this.selectAllRows(evt),
363
+ selectAllVisibleRows: (evt) => this.selectAllVisibleRows(evt),
364
+ clearSelectedRows: (evt) => this.clearSelectedRows(evt),
365
+ getSelectedRows: () => this.getSelectedRows(),
366
+ getUnSelectedRows: () => this.getUnSelectedRows(),
367
+ getSelectedGridRows: () => this.getSelectedGridRows(),
368
+ getUnSelectedGridRows: () => this.getUnSelectedGridRows(),
369
+ getSelectedCount: () => this.selectionState.selectedRowIds.size,
370
+ setMultiSelect: (multiSelect) => this.setMultiSelect(multiSelect),
371
+ setModifierKeysToMultiSelect: (value) => this.setModifierKeysToMultiSelect(value),
372
+ getSelectAllState: () => this.selectionState.selectAll,
373
+ shiftSelectRow: (rowEntity, evt) => this.shiftSelectRow(rowEntity, evt),
374
+ // cellNav bindings — thin wrappers over the same focus state the
375
+ // element uses. The element raises `cellNav.navigate` via
376
+ // controller.setCellNavFocus(); these methods let consumers inspect
377
+ // and re-drive that state.
378
+ scrollToFocus: (rowEntity, colDef) => this.cellNavScrollToFocus(rowEntity, colDef),
379
+ getFocusedCell: () => this.cellNavGetFocusedCell(),
380
+ getCurrentSelection: () => this.cellNavGetCurrentSelection(),
381
+ rowColSelectIndex: (rowCol) => this.cellNavRowColSelectIndex(rowCol),
382
+ // SaveState bindings — ports ui.grid.saveState public API.
383
+ saveState: () => this.getState() as GridSavedState,
384
+ restoreState: (state) => this.setState(state as Partial<GridSaveState>),
385
+ // Infinite-scroll bindings — ports ui.grid.infiniteScroll public API.
386
+ infiniteScrollDataLoaded: (scrollUp, scrollDown) =>
387
+ this.infiniteScrollDataLoaded(scrollUp, scrollDown),
388
+ infiniteScrollReset: (scrollUp, scrollDown) =>
389
+ this.infiniteScrollResetScroll(scrollUp, scrollDown),
390
+ infiniteScrollSaveScrollPercentage: () => this.infiniteScrollSavePercentage(),
391
+ infiniteScrollDataRemovedTop: (scrollUp, scrollDown) =>
392
+ this.infiniteScrollDataRemovedTop(scrollUp, scrollDown),
393
+ infiniteScrollDataRemovedBottom: (scrollUp, scrollDown) =>
394
+ this.infiniteScrollDataRemovedBottom(scrollUp, scrollDown),
395
+ infiniteScrollSetDirections: (scrollUp, scrollDown) =>
396
+ this.infiniteScrollSetDirections(scrollUp, scrollDown),
397
+ // Row-edit bindings — port of ui.grid.rowEdit public API.
398
+ rowEditSetSavePromise: (rowEntity, promise) =>
399
+ this.rowEditSetSavePromise(rowEntity, promise),
400
+ rowEditGetDirtyRows: () => this.rowEditGetDirtyRows(),
401
+ rowEditGetErrorRows: () => this.rowEditGetErrorRows(),
402
+ rowEditFlushDirtyRows: () => this.rowEditFlushDirtyRows(),
403
+ rowEditRetryErroredRows: () => this.rowEditRetryErroredRows(),
404
+ rowEditSetRowsDirty: (rowEntities) => this.rowEditSetRowsDirty(rowEntities),
405
+ rowEditSetRowsClean: (rowEntities) => this.rowEditSetRowsClean(rowEntities),
406
+ rowEditGetMenuItems: () => this.buildRowEditMenuItems(),
407
+ // Importer bindings — port of ui.grid.importer public API.
408
+ importerImportAFile: () => this.importerRequestFile(),
409
+ importerImportThisFile: (file) => this.importerImportThisFile(file),
410
+ importerImportText: (text, type) => this.importerImportText(text, type),
411
+ importerGetMenuItems: () => this.buildImporterMenuItems(),
412
+ // Validate bindings — port of ui.grid.validate public API.
413
+ validateIsInvalid: (rowEntity, colDef) => isGridCellInvalid(rowEntity, colDef),
414
+ validateGetErrorMessages: (rowEntity, colDef) =>
415
+ getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry),
416
+ validateGetFormattedErrors: (rowEntity, colDef) =>
417
+ this.validateGetFormattedErrors(rowEntity, colDef),
418
+ validateGetTitleFormattedErrors: (rowEntity, colDef) =>
419
+ this.validateGetTitleFormattedErrors(rowEntity, colDef),
420
+ validateRunValidators: (rowEntity, colDef, newValue, oldValue) =>
421
+ this.validateRunValidators(rowEntity, colDef, newValue, oldValue),
422
+ validateSetValidator: (name, factory, messageFn) =>
423
+ this.validatorRegistry.setValidator(name, factory, messageFn),
424
+ validateGetInvalidRows: () => this.validateGetInvalidRows(),
425
+ });
426
+
427
+ // Subscribe row-edit to the edit events so a committed change flips the
428
+ // row into dirty state + schedules a save, and cancelling restarts the
429
+ // timer when the row was already dirty.
430
+ this.gridApi.edit.on.afterCellEdit((rowEntity, col, newValue, previousValue) => {
431
+ const rowId = this.resolveRowId(rowEntity);
432
+ const gridRow = this.findRowById(rowId);
433
+ if (!gridRow) return;
434
+ // Auto-run validators when a column declares them. Ports the old
435
+ // ui.grid.validate → edit integration where afterCellEdit kicked
436
+ // runValidators on the same cell. Async validators settle later;
437
+ // the re-emit makes sure the UI picks up the resulting invalid
438
+ // flag once they do.
439
+ if (col?.validators) {
440
+ void this.validateRunValidators(rowEntity, col, newValue, previousValue).then(() => {
441
+ this.emit();
442
+ });
443
+ }
444
+ if (newValue === previousValue && !gridRow.isDirty) return;
445
+ markGridRowDirty(this.rowEditState, gridRow);
446
+ this.considerSetRowEditTimer(gridRow);
447
+ this.emit();
448
+ });
449
+ this.gridApi.edit.on.beginCellEdit((rowEntity) => {
450
+ const rowId = this.resolveRowId(rowEntity);
451
+ const gridRow = this.findRowById(rowId);
452
+ if (gridRow) this.cancelRowEditTimer(gridRow);
453
+ });
454
+ this.gridApi.edit.on.cancelCellEdit((rowEntity) => {
455
+ const rowId = this.resolveRowId(rowEntity);
456
+ const gridRow = this.findRowById(rowId);
457
+ if (gridRow) this.considerSetRowEditTimer(gridRow);
458
+ });
459
+ // Cellnav navigate — if the user leaves a dirty row, start its timer
460
+ // (matches old grid: a row only autosaves when focus moves off it or
461
+ // the debounce elapses).
462
+ this.gridApi.cellNav.on.navigate((_newRowCol, oldRowCol) => {
463
+ if (!oldRowCol) return;
464
+ this.considerSetRowEditTimer(oldRowCol.row);
465
+ });
466
+
467
+ // When the i18n service swaps the active language, re-resolve labels
468
+ // + re-register the default validators (their messages are locale-
469
+ // driven) + re-render so every cell/tooltip picks up the new strings.
470
+ this.disposeLanguageListener = this.gridApi.i18n.on.languageChanged(() => {
471
+ this.labels = resolveGridLabels(this.options.labels);
472
+ const rebuilt = createGridValidatorRegistry(this.labels);
473
+ for (const name of ['required', 'minLength', 'maxLength']) {
474
+ this.validatorRegistry.setValidator(
475
+ name,
476
+ (arg: unknown) => rebuilt.getValidator(name, arg),
477
+ (arg: unknown) => rebuilt.getMessage(name, arg),
478
+ );
479
+ }
480
+ this.refresh();
227
481
  });
228
482
 
229
483
  this.refresh();
230
484
  }
231
485
 
486
+ /** Disposer for the i18n language-change listener — cleared when the
487
+ * controller is torn down (matches the pattern used for other global
488
+ * subscriptions). */
489
+ private disposeLanguageListener: (() => void) | null = null;
490
+ dispose(): void {
491
+ this.disposeLanguageListener?.();
492
+ this.disposeLanguageListener = null;
493
+ this.subscribers.clear();
494
+ }
495
+
232
496
  setOptions(options: GridOptions): void {
233
497
  this.options = options;
234
498
  this.labels = resolveGridLabels(options.labels);
499
+ // Re-register the built-in validators so their messages track the
500
+ // refreshed locale. Consumer-registered validators survive untouched
501
+ // because we write them onto the same registry instance.
502
+ const rebuiltDefaults = createGridValidatorRegistry(this.labels);
503
+ for (const name of ['required', 'minLength', 'maxLength']) {
504
+ this.validatorRegistry.setValidator(
505
+ name,
506
+ (arg: unknown) => rebuiltDefaults.getValidator(name, arg),
507
+ (arg: unknown) => rebuiltDefaults.getMessage(name, arg),
508
+ );
509
+ }
235
510
  if (this.columnOrder.length === 0) {
236
511
  this.columnOrder = options.columnDefs.map((column) => column.name);
237
512
  } else {
@@ -244,6 +519,37 @@ export class VanillaGridController {
244
519
  }
245
520
  }
246
521
 
522
+ // Apply declarative grouping from options when provided. Matches the
523
+ // Angular element's behaviour — the consumer can set
524
+ // `grouping: { groupBy: [...] }` and it takes effect immediately.
525
+ // When the option is absent, leave the current groupByColumns untouched
526
+ // so interactive toggles persist across non-grouping option updates.
527
+ if (options.grouping?.groupBy !== undefined) {
528
+ this.groupByColumns = [...options.grouping.groupBy];
529
+ }
530
+
531
+ // Re-seed pinned state from columnDefs whenever a columnDef declares
532
+ // `pinnedLeft` / `pinnedRight`. This mirrors the Angular element, which
533
+ // re-derives pinned state on every options update, so switching demo
534
+ // scenarios (different column sets) picks up their declarative pins.
535
+ const declaresPin = options.columnDefs.some(
536
+ (col) => col.pinnedLeft === true || col.pinnedRight === true,
537
+ );
538
+ if (declaresPin) {
539
+ this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
540
+ }
541
+
542
+ // Re-seed infinite-scroll directions when the options declare them,
543
+ // so consumers can flip scrollUp/scrollDown through `grid.options`
544
+ // without needing to go through setScrollDirections.
545
+ if (options.infiniteScrollUp !== undefined || options.infiniteScrollDown !== undefined) {
546
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
547
+ this.infiniteScrollState,
548
+ options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
549
+ options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown,
550
+ );
551
+ }
552
+
247
553
  this.apiRegistered = false;
248
554
  this.refresh();
249
555
  }
@@ -300,6 +606,10 @@ export class VanillaGridController {
300
606
  pageSize: this.getEffectivePageSize(),
301
607
  editingCell: this.editingCell,
302
608
  editingValue: this.editingValue,
609
+ selectedRowIds: this.selectionState.selectedRowIds,
610
+ selectAll: this.selectionState.selectAll,
611
+ focusedRowId: this.selectionState.focusedRowId,
612
+ selectedCount: this.selectionState.selectedRowIds.size,
303
613
  };
304
614
  }
305
615
 
@@ -519,6 +829,7 @@ export class VanillaGridController {
519
829
  row: GridRow | GridRecord | string,
520
830
  columnName: string,
521
831
  triggerEvent?: Event | KeyboardEvent | null,
832
+ initialValue?: string,
522
833
  ): void {
523
834
  const rowId = this.resolveRowId(row);
524
835
  const gridRow = this.findRowById(rowId);
@@ -544,6 +855,7 @@ export class VanillaGridController {
544
855
  column,
545
856
  getCellValue(gridRow.entity, column),
546
857
  triggerEvent,
858
+ initialValue,
547
859
  );
548
860
 
549
861
  this.refresh();
@@ -555,6 +867,15 @@ export class VanillaGridController {
555
867
  }
556
868
 
557
869
  commitCellEdit(): void {
870
+ // Guard against re-entry: committing rebuilds the cell markup, which
871
+ // removes the focused <input> and fires blur — whose handler also tries
872
+ // to commit. Without this early-return, the nested render mutates DOM
873
+ // that the outer render hasn't finished unwinding yet, throwing
874
+ // "node to be removed is no longer a child of this node."
875
+ if (this.editingCell === null) {
876
+ return;
877
+ }
878
+
558
879
  commitGridCellEditCommand(this.gridApi, {
559
880
  getEditingCell: () => this.editingCell,
560
881
  getEditingValue: () => this.editingValue,
@@ -575,6 +896,10 @@ export class VanillaGridController {
575
896
  }
576
897
 
577
898
  cancelCellEdit(): void {
899
+ if (this.editingCell === null) {
900
+ return;
901
+ }
902
+
578
903
  cancelGridCellEditCommand(this.gridApi, {
579
904
  getEditingCell: () => this.editingCell,
580
905
  setEditingCell: (next) => {
@@ -590,6 +915,485 @@ export class VanillaGridController {
590
915
  this.refresh();
591
916
  }
592
917
 
918
+ // ---- Row selection -------------------------------------------------
919
+ // Thin method layer on top of grid.core.selection. Each call mutates
920
+ // this.selectionState + row instances in place, raises the appropriate
921
+ // public-API event (single vs batch), then refreshes so the element
922
+ // re-renders with the updated `isSelected` / `isFocused` flags.
923
+
924
+ /** Internal: run a core selection mutation, raise events, trigger a render. */
925
+ private applySelectionChange(
926
+ apply: () => { changed: GridRow[] },
927
+ evt?: Event | null,
928
+ ): void {
929
+ const { changed } = apply();
930
+ if (changed.length === 0) {
931
+ return;
932
+ }
933
+ if (this.options.enableSelectionBatchEvent !== false && changed.length > 1) {
934
+ this.gridApi.selection.raise.rowSelectionChangedBatch(changed, evt);
935
+ } else {
936
+ for (const row of changed) {
937
+ this.gridApi.selection.raise.rowSelectionChanged(row, evt);
938
+ }
939
+ }
940
+ this.refresh();
941
+ }
942
+
943
+ private allGridRows(): GridRow[] {
944
+ // Use the cached pipeline row list when possible — it already reflects
945
+ // `options.data`. Fall back to rebuilding when the pipeline is empty
946
+ // (e.g. before first refresh).
947
+ return this.pipeline.visibleRows.length > 0
948
+ ? this.pipeline.visibleRows
949
+ : this.buildRowsFromData(this.options.data);
950
+ }
951
+
952
+ toggleRowSelectionByEntity(rowEntity: GridRecord, evt?: Event | null): void {
953
+ const row = this.findRowById(this.resolveRowId(rowEntity));
954
+ if (!row) return;
955
+ const resolved = resolveGridSelectionOptions(this.options);
956
+ this.applySelectionChange(
957
+ () =>
958
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
959
+ multiSelect: resolved.multiSelect,
960
+ noUnselect: resolved.noUnselect,
961
+ }),
962
+ evt,
963
+ );
964
+ }
965
+
966
+ selectRow(rowEntity: GridRecord, evt?: Event | null): void {
967
+ const row = this.findRowById(this.resolveRowId(rowEntity));
968
+ if (!row || row.isSelected) return;
969
+ const resolved = resolveGridSelectionOptions(this.options);
970
+ this.applySelectionChange(
971
+ () =>
972
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
973
+ multiSelect: resolved.multiSelect,
974
+ noUnselect: resolved.noUnselect,
975
+ }),
976
+ evt,
977
+ );
978
+ }
979
+
980
+ selectRowByVisibleIndex(rowNum: number, evt?: Event | null): void {
981
+ const rows = this.pipeline.visibleRows;
982
+ const row = rows[rowNum];
983
+ if (!row || row.isSelected) return;
984
+ const resolved = resolveGridSelectionOptions(this.options);
985
+ this.applySelectionChange(
986
+ () =>
987
+ coreToggleGridRowSelection(this.selectionState, rows, row, {
988
+ multiSelect: resolved.multiSelect,
989
+ noUnselect: resolved.noUnselect,
990
+ canBeInvisible: false,
991
+ }),
992
+ evt,
993
+ );
994
+ }
995
+
996
+ selectRowByKey(
997
+ isInEntity: boolean,
998
+ key: string,
999
+ comparator: unknown,
1000
+ evt?: Event | null,
1001
+ lookInRows?: readonly GridRow[],
1002
+ ): void {
1003
+ const rows = lookInRows ?? this.allGridRows();
1004
+ const row = coreFindGridRowByKey(rows, isInEntity, key, comparator);
1005
+ if (!row || row.isSelected) return;
1006
+ const resolved = resolveGridSelectionOptions(this.options);
1007
+ this.applySelectionChange(
1008
+ () =>
1009
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1010
+ multiSelect: resolved.multiSelect,
1011
+ noUnselect: resolved.noUnselect,
1012
+ canBeInvisible: false,
1013
+ }),
1014
+ evt,
1015
+ );
1016
+ }
1017
+
1018
+ unSelectRow(rowEntity: GridRecord, evt?: Event | null): void {
1019
+ const row = this.findRowById(this.resolveRowId(rowEntity));
1020
+ if (!row || !row.isSelected) return;
1021
+ const resolved = resolveGridSelectionOptions(this.options);
1022
+ this.applySelectionChange(
1023
+ () =>
1024
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1025
+ multiSelect: resolved.multiSelect,
1026
+ noUnselect: resolved.noUnselect,
1027
+ }),
1028
+ evt,
1029
+ );
1030
+ }
1031
+
1032
+ unSelectRowByVisibleIndex(rowNum: number, evt?: Event | null): void {
1033
+ const row = this.pipeline.visibleRows[rowNum];
1034
+ if (!row || !row.isSelected) return;
1035
+ const resolved = resolveGridSelectionOptions(this.options);
1036
+ this.applySelectionChange(
1037
+ () =>
1038
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1039
+ multiSelect: resolved.multiSelect,
1040
+ noUnselect: resolved.noUnselect,
1041
+ canBeInvisible: false,
1042
+ }),
1043
+ evt,
1044
+ );
1045
+ }
1046
+
1047
+ unSelectRowByKey(
1048
+ isInEntity: boolean,
1049
+ key: string,
1050
+ comparator: unknown,
1051
+ evt?: Event | null,
1052
+ lookInRows?: readonly GridRow[],
1053
+ ): void {
1054
+ const rows = lookInRows ?? this.allGridRows();
1055
+ const row = coreFindGridRowByKey(rows, isInEntity, key, comparator);
1056
+ if (!row || !row.isSelected) return;
1057
+ const resolved = resolveGridSelectionOptions(this.options);
1058
+ this.applySelectionChange(
1059
+ () =>
1060
+ coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
1061
+ multiSelect: resolved.multiSelect,
1062
+ noUnselect: resolved.noUnselect,
1063
+ canBeInvisible: false,
1064
+ }),
1065
+ evt,
1066
+ );
1067
+ }
1068
+
1069
+ selectAllRows(evt?: Event | null): void {
1070
+ const resolved = resolveGridSelectionOptions(this.options);
1071
+ this.applySelectionChange(
1072
+ () =>
1073
+ coreSelectAllGridRows(this.selectionState, this.allGridRows(), {
1074
+ multiSelect: resolved.multiSelect,
1075
+ isRowSelectable: resolved.isRowSelectable,
1076
+ }),
1077
+ evt,
1078
+ );
1079
+ }
1080
+
1081
+ selectAllVisibleRows(evt?: Event | null): void {
1082
+ const resolved = resolveGridSelectionOptions(this.options);
1083
+ this.applySelectionChange(
1084
+ () =>
1085
+ coreSelectAllVisibleGridRows(this.selectionState, this.allGridRows(), {
1086
+ multiSelect: resolved.multiSelect,
1087
+ isRowSelectable: resolved.isRowSelectable,
1088
+ }),
1089
+ evt,
1090
+ );
1091
+ }
1092
+
1093
+ clearSelectedRows(evt?: Event | null): void {
1094
+ this.applySelectionChange(
1095
+ () => coreClearAllGridSelection(this.selectionState, this.allGridRows()),
1096
+ evt,
1097
+ );
1098
+ }
1099
+
1100
+ shiftSelectRow(rowEntity: GridRecord, evt?: Event | null): void {
1101
+ const row = this.findRowById(this.resolveRowId(rowEntity));
1102
+ if (!row) return;
1103
+ const resolved = resolveGridSelectionOptions(this.options);
1104
+ this.applySelectionChange(
1105
+ () =>
1106
+ coreShiftGridRowSelection(this.selectionState, this.pipeline.visibleRows, row, {
1107
+ multiSelect: resolved.multiSelect,
1108
+ }),
1109
+ evt,
1110
+ );
1111
+ }
1112
+
1113
+ getSelectedRows(): GridRecord[] {
1114
+ return coreMapSelectedRowsToEntities(this.getSelectedGridRows());
1115
+ }
1116
+
1117
+ getUnSelectedRows(): GridRecord[] {
1118
+ return coreMapSelectedRowsToEntities(this.getUnSelectedGridRows());
1119
+ }
1120
+
1121
+ getSelectedGridRows(): GridRow[] {
1122
+ return this.allGridRows().filter((row) => row.isSelected);
1123
+ }
1124
+
1125
+ getUnSelectedGridRows(): GridRow[] {
1126
+ return this.allGridRows().filter((row) => !row.isSelected);
1127
+ }
1128
+
1129
+ setMultiSelect(multiSelect: boolean): void {
1130
+ this.options = { ...this.options, multiSelect };
1131
+ }
1132
+
1133
+ setModifierKeysToMultiSelect(value: boolean): void {
1134
+ this.options = { ...this.options, modifierKeysToMultiSelect: value };
1135
+ }
1136
+
1137
+ /** Focus a row by id — the old module's `row.setFocused(true)` with all
1138
+ * the book-keeping. Raises rowFocusChanged and refreshes. */
1139
+ setRowFocused(rowId: string, focused: boolean, evt?: Event | null): void {
1140
+ const row = this.findRowById(rowId);
1141
+ if (!row) return;
1142
+ const previousFocusedId = this.selectionState.focusedRowId;
1143
+ if (focused) {
1144
+ if (previousFocusedId && previousFocusedId !== rowId) {
1145
+ const previous = this.findRowById(previousFocusedId);
1146
+ previous?.setFocused(false);
1147
+ }
1148
+ row.setFocused(true);
1149
+ this.selectionState.focusedRowId = rowId;
1150
+ } else {
1151
+ row.setFocused(false);
1152
+ if (previousFocusedId === rowId) this.selectionState.focusedRowId = null;
1153
+ }
1154
+ this.gridApi.selection.raise.rowFocusChanged(row, evt);
1155
+ this.refresh();
1156
+ }
1157
+
1158
+ getRowSelectionState(): Readonly<GridSelectionState> {
1159
+ return this.selectionState;
1160
+ }
1161
+
1162
+ /** Resolved/defaulted selection options — exposed for the element layer
1163
+ * so it can decide whether to wire pointer handlers without duplicating
1164
+ * default logic. */
1165
+ getResolvedSelectionOptions(): ReturnType<typeof resolveGridSelectionOptions> {
1166
+ return resolveGridSelectionOptions(this.options);
1167
+ }
1168
+
1169
+ /** Public analogue of findRowById — the element uses this during drag
1170
+ * painting to resolve a row-id back to its entity without calling the
1171
+ * selection API methods that would double-raise events. */
1172
+ findRowByIdPublic(rowId: string): GridRow | null {
1173
+ return this.findRowById(rowId);
1174
+ }
1175
+
1176
+ // ---- End row selection ---------------------------------------------
1177
+
1178
+ // ---- cellNav -------------------------------------------------------
1179
+ // State + accessors for the cellNav public API. Element owns the DOM
1180
+ // focus; controller owns the logical "last focused cell" and the
1181
+ // focused-cells history. Element calls setCellNavFocus() whenever the
1182
+ // active cell changes.
1183
+
1184
+ /** Record a navigation event. Pushes to the focused-cells history,
1185
+ * updates lastRowCol, and raises cellNav.navigate on the public API. */
1186
+ setCellNavFocus(
1187
+ rowId: string | null,
1188
+ columnName: string | null,
1189
+ opts: { appendToSelection?: boolean } = {},
1190
+ ): void {
1191
+ const previous = this.cellNavLastRowCol;
1192
+ const previousRowCol = this.resolveCellNavRowCol(previous);
1193
+ if (rowId && columnName) {
1194
+ this.cellNavLastRowCol = { rowId, columnName };
1195
+ if (opts.appendToSelection) {
1196
+ this.cellNavFocusedCells = [
1197
+ ...this.cellNavFocusedCells.filter(
1198
+ (entry) => !(entry.rowId === rowId && entry.columnName === columnName),
1199
+ ),
1200
+ { rowId, columnName },
1201
+ ];
1202
+ } else {
1203
+ this.cellNavFocusedCells = [{ rowId, columnName }];
1204
+ }
1205
+ } else {
1206
+ this.cellNavLastRowCol = null;
1207
+ this.cellNavFocusedCells = [];
1208
+ }
1209
+ const nextRowCol = this.resolveCellNavRowCol(this.cellNavLastRowCol);
1210
+ this.gridApi.cellNav.raise.navigate(nextRowCol, previousRowCol);
1211
+ }
1212
+
1213
+ /** Raise viewPortKeyDown / viewPortKeyPress on behalf of the element.
1214
+ * Element gates this with `options.keyDownOverrides`. */
1215
+ raiseCellNavKeyEvent(
1216
+ type: 'keydown' | 'keypress',
1217
+ event: KeyboardEvent,
1218
+ ): void {
1219
+ const rowCol = this.resolveCellNavRowCol(this.cellNavLastRowCol);
1220
+ if (type === 'keydown') this.gridApi.cellNav.raise.viewPortKeyDown(event, rowCol);
1221
+ else this.gridApi.cellNav.raise.viewPortKeyPress(event, rowCol);
1222
+ }
1223
+
1224
+ private cellNavGetFocusedCell(): GridRowColumn | null {
1225
+ return this.resolveCellNavRowCol(this.cellNavLastRowCol);
1226
+ }
1227
+
1228
+ private cellNavGetCurrentSelection(): GridRowColumn[] {
1229
+ const out: GridRowColumn[] = [];
1230
+ for (const entry of this.cellNavFocusedCells) {
1231
+ const rowCol = this.resolveCellNavRowCol(entry);
1232
+ if (rowCol) out.push(rowCol);
1233
+ }
1234
+ return out;
1235
+ }
1236
+
1237
+ private cellNavRowColSelectIndex(rowCol: GridRowColumn): number {
1238
+ for (let i = 0; i < this.cellNavFocusedCells.length; i++) {
1239
+ const entry = this.cellNavFocusedCells[i]!;
1240
+ if (entry.rowId === rowCol.row.id && entry.columnName === rowCol.col.name) {
1241
+ return i;
1242
+ }
1243
+ }
1244
+ return -1;
1245
+ }
1246
+
1247
+ private resolveCellNavRowCol(
1248
+ position: { rowId: string; columnName: string } | null,
1249
+ ): GridRowColumn | null {
1250
+ if (!position) return null;
1251
+ const row = this.findRowById(position.rowId);
1252
+ const col = this.findColumnByName(position.columnName);
1253
+ if (!row || !col) return null;
1254
+ return { row, col };
1255
+ }
1256
+
1257
+ /** cellNav.scrollToFocus — resolves rowEntity + colDef into a logical
1258
+ * focus and delegates to the element (if attached) for actual scrolling.
1259
+ * When the element isn't wired (e.g. bare controller in tests), the
1260
+ * state still updates and the promise resolves immediately. */
1261
+ private async cellNavScrollToFocus(
1262
+ rowEntity: GridRecord | null,
1263
+ colDef: GridColumnDef | null,
1264
+ ): Promise<void> {
1265
+ const rowId = rowEntity ? this.resolveRowId(rowEntity) : null;
1266
+ const columnName = colDef?.name ?? null;
1267
+ this.setCellNavFocus(rowId, columnName);
1268
+ this.cellNavScrollRequest?.(rowId, columnName);
1269
+ return Promise.resolve();
1270
+ }
1271
+
1272
+ /** Element registers its scroll-and-focus handler here so the API
1273
+ * binding above can delegate DOM work. Controller stays DOM-free. */
1274
+ private cellNavScrollRequest: ((rowId: string | null, columnName: string | null) => void) | null = null;
1275
+ setCellNavScrollHandler(handler: ((rowId: string | null, columnName: string | null) => void) | null): void {
1276
+ this.cellNavScrollRequest = handler;
1277
+ }
1278
+
1279
+ /** Read-only accessor for the current options. Used by the element to
1280
+ * inspect keyDownOverrides / modifierKeysToMultiSelectCells without
1281
+ * coupling it to a private field. */
1282
+ getOptions(): GridOptions {
1283
+ return this.options;
1284
+ }
1285
+
1286
+ // ---- End cellNav ---------------------------------------------------
1287
+
1288
+ // ---- Infinite scroll ----------------------------------------------
1289
+ // Thin wrappers over grid.core.infinite-scroll's pure helpers. The
1290
+ // element calls `evaluateInfiniteScroll` from its scroll-frame callback;
1291
+ // these helpers raise needLoadMoreData / needLoadMoreDataTop when the
1292
+ // user approaches the end/top of the dataset.
1293
+
1294
+ /** Evaluate whether the current scroll position should request more
1295
+ * data at the top or bottom. Called from the element's scroll frame. */
1296
+ evaluateInfiniteScroll(startIndex: number, visibleRows: number, viewportRows: number): void {
1297
+ if (this.options.enableInfiniteScroll === false) return;
1298
+ const threshold = this.options.infiniteScrollRowsFromEnd ?? 20;
1299
+ const { request, nextState } = maybeRequestInfiniteScrollData({
1300
+ state: this.infiniteScrollState,
1301
+ startIndex,
1302
+ visibleRows,
1303
+ viewportRows,
1304
+ threshold,
1305
+ });
1306
+ if (request === null) return;
1307
+ this.infiniteScrollState = nextState;
1308
+ if (request === 'top') this.gridApi.infiniteScroll.raise.needLoadMoreDataTop();
1309
+ else this.gridApi.infiniteScroll.raise.needLoadMoreData();
1310
+ }
1311
+
1312
+ private infiniteScrollDataLoaded(scrollUp?: boolean, scrollDown?: boolean): Promise<void> {
1313
+ this.infiniteScrollState = completeInfiniteScrollDataLoad(
1314
+ this.infiniteScrollState,
1315
+ scrollUp ?? this.infiniteScrollState.scrollUp,
1316
+ scrollDown ?? this.infiniteScrollState.scrollDown,
1317
+ );
1318
+ this.refresh();
1319
+ return Promise.resolve();
1320
+ }
1321
+
1322
+ private infiniteScrollResetScroll(scrollUp?: boolean, scrollDown?: boolean): void {
1323
+ this.infiniteScrollState = resetInfiniteScrollState(
1324
+ scrollUp ?? false,
1325
+ scrollDown ?? true,
1326
+ );
1327
+ this.infiniteScrollScrollToTopRequest?.();
1328
+ }
1329
+
1330
+ private infiniteScrollSavePercentage(): void {
1331
+ this.infiniteScrollState = saveInfiniteScrollPercentage(
1332
+ this.infiniteScrollState,
1333
+ this.pipeline.visibleRows.length,
1334
+ );
1335
+ }
1336
+
1337
+ private infiniteScrollDataRemovedTop(scrollUp?: boolean, scrollDown?: boolean): void {
1338
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
1339
+ this.infiniteScrollState,
1340
+ scrollUp ?? this.infiniteScrollState.scrollUp,
1341
+ scrollDown ?? this.infiniteScrollState.scrollDown,
1342
+ );
1343
+ this.refresh();
1344
+ }
1345
+
1346
+ private infiniteScrollDataRemovedBottom(scrollUp?: boolean, scrollDown?: boolean): void {
1347
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
1348
+ this.infiniteScrollState,
1349
+ scrollUp ?? this.infiniteScrollState.scrollUp,
1350
+ scrollDown ?? this.infiniteScrollState.scrollDown,
1351
+ );
1352
+ this.refresh();
1353
+ }
1354
+
1355
+ private infiniteScrollSetDirections(scrollUp: boolean, scrollDown: boolean): void {
1356
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
1357
+ this.infiniteScrollState,
1358
+ scrollUp,
1359
+ scrollDown,
1360
+ );
1361
+ }
1362
+
1363
+ /** Element registers a scroll-to-top handler here so resetScroll can
1364
+ * bring the viewport back to 0. Keeps the controller DOM-free. */
1365
+ private infiniteScrollScrollToTopRequest: (() => void) | null = null;
1366
+ setInfiniteScrollResetHandler(handler: (() => void) | null): void {
1367
+ this.infiniteScrollScrollToTopRequest = handler;
1368
+ }
1369
+
1370
+ getInfiniteScrollState(): Readonly<GridInfiniteScrollState> {
1371
+ return this.infiniteScrollState;
1372
+ }
1373
+
1374
+ // ---- End infinite scroll ------------------------------------------
1375
+
1376
+ canResizeColumns(): boolean {
1377
+ return this.options.enableColumnResizing !== false;
1378
+ }
1379
+
1380
+ setColumnWidthOverride(columnName: string, widthPx: number): void {
1381
+ const nextWidth = `${Math.max(88, Math.round(widthPx))}px`;
1382
+ this.columnWidthOverrides = { ...this.columnWidthOverrides, [columnName]: nextWidth };
1383
+ this.refresh();
1384
+ }
1385
+
1386
+ /** Returns the grid-template-columns string as if `columnName` had the given width,
1387
+ * without triggering a full refresh. Used for smooth drag-resize DOM updates. */
1388
+ buildTemplateColumnsWithOverride(columnName: string, widthPx: number): string {
1389
+ const widthStr = `${Math.max(88, Math.round(widthPx))}px`;
1390
+ return buildGridTemplateColumns(
1391
+ this.visibleColumns.map((c) =>
1392
+ c.name === columnName ? { ...c, width: widthStr } : c,
1393
+ ),
1394
+ );
1395
+ }
1396
+
593
1397
  isGroupingEnabled(): boolean {
594
1398
  return isGridGroupingEnabled(this.options);
595
1399
  }
@@ -656,6 +1460,21 @@ export class VanillaGridController {
656
1460
  return isColumnPinnable(this.options, column);
657
1461
  }
658
1462
 
1463
+ /** True when the given column is editable in principle — does NOT evaluate
1464
+ * a per-row `cellEditableCondition`. Use isCellEditable for that. */
1465
+ isColumnEditable(column: GridColumnDef): boolean {
1466
+ return column.enableCellEdit ?? this.options.enableCellEdit ?? false;
1467
+ }
1468
+
1469
+ /** True when the (row, column) cell is editable, respecting both the
1470
+ * column's `enableCellEdit` flag and any `cellEditableCondition`. */
1471
+ isCellEditable(row: GridRow, column: GridColumnDef): boolean {
1472
+ if (!this.isColumnEditable(column)) return false;
1473
+ const condition = column.cellEditableCondition ?? this.options.cellEditableCondition ?? true;
1474
+ if (typeof condition === 'boolean') return condition;
1475
+ return condition({ row: row.entity, column, rowIndex: row.index });
1476
+ }
1477
+
659
1478
  isPinned(column: GridColumnDef): boolean {
660
1479
  return this.pinnedColumns[column.name] !== undefined;
661
1480
  }
@@ -718,35 +1537,134 @@ export class VanillaGridController {
718
1537
  return this.editingCell?.rowId === rowId && this.editingCell?.columnName === columnName;
719
1538
  }
720
1539
 
1540
+ /**
1541
+ * Serialise the current grid state. Mirrors `ui.grid.saveState.save`:
1542
+ * every field is gated by an `options.save*` flag with the old grid's
1543
+ * defaults. A missing flag keeps the matching field out of the result.
1544
+ */
721
1545
  getState(): GridSaveState {
722
- return {
723
- sortState: { ...this.sortState },
724
- activeFilters: { ...this.activeFilters },
725
- groupByColumns: [...this.groupByColumns],
726
- pinnedColumns: { ...this.pinnedColumns },
727
- columnOrder: [...this.columnOrder],
728
- currentPage: this.currentPage,
729
- pageSize: this.pageSize,
730
- };
1546
+ const opts = this.options;
1547
+ const saveWidths = opts.saveWidths !== false;
1548
+ const saveOrder = opts.saveOrder !== false;
1549
+ const saveVisible = opts.saveVisible !== false;
1550
+ const saveSort = opts.saveSort !== false;
1551
+ const saveFilter = opts.saveFilter !== false;
1552
+ const saveSelection = opts.saveSelection !== false;
1553
+ const saveGrouping = opts.saveGrouping !== false;
1554
+ const saveGroupingExpandedStates = opts.saveGroupingExpandedStates === true;
1555
+ const savePinning = opts.savePinning !== false;
1556
+ const saveTreeView = opts.saveTreeView !== false;
1557
+ const savePagination = opts.savePagination !== false;
1558
+ const saveScroll = opts.saveScroll === true;
1559
+ const saveFocus = saveScroll ? false : opts.saveFocus !== false;
1560
+ void saveVisible; // Visible column state is derived from columnOrder + defs;
1561
+ // kept as an opt-flag for forward compat.
1562
+
1563
+ const state: GridSaveState = {};
1564
+ if (saveSort) state.sortState = { ...this.sortState };
1565
+ if (saveFilter) state.activeFilters = { ...this.activeFilters };
1566
+ if (saveGrouping) state.groupByColumns = [...this.groupByColumns];
1567
+ if (saveGrouping && saveGroupingExpandedStates) {
1568
+ state.collapsedGroups = { ...this.collapsedGroups };
1569
+ }
1570
+ if (savePinning) state.pinnedColumns = { ...this.pinnedColumns };
1571
+ if (saveOrder) state.columnOrder = [...this.columnOrder];
1572
+ if (saveWidths) state.columnWidthOverrides = { ...this.columnWidthOverrides };
1573
+ if (savePagination) {
1574
+ state.currentPage = this.currentPage;
1575
+ state.pageSize = this.pageSize;
1576
+ }
1577
+ if (saveSelection) {
1578
+ state.selectedRowIds = [...this.selectionState.selectedRowIds];
1579
+ }
1580
+ if (saveTreeView) {
1581
+ state.expandedRows = { ...this.expandedRows };
1582
+ state.expandedTreeRows = { ...this.expandedTreeRows };
1583
+ }
1584
+ if (saveFocus) {
1585
+ state.focusedCell = this.cellNavLastRowCol
1586
+ ? {
1587
+ rowId: this.cellNavLastRowCol.rowId,
1588
+ columnName: this.cellNavLastRowCol.columnName,
1589
+ }
1590
+ : null;
1591
+ }
1592
+ if (saveScroll && this.saveStateScrollSnapshot) {
1593
+ const snap = this.saveStateScrollSnapshot();
1594
+ state.scrollTop = snap.scrollTop;
1595
+ state.scrollLeft = snap.scrollLeft;
1596
+ }
1597
+ return state;
731
1598
  }
732
1599
 
1600
+ /**
1601
+ * Apply a previously saved state. All fields are optional — missing
1602
+ * fields are left at whatever the controller currently has. Matches the
1603
+ * old grid's `ui.grid.saveState.restore`.
1604
+ */
733
1605
  setState(state: Partial<GridSaveState>): void {
734
1606
  if (state.sortState !== undefined) this.sortState = { ...state.sortState };
735
1607
  if (state.activeFilters !== undefined) this.activeFilters = { ...state.activeFilters };
736
1608
  if (state.groupByColumns !== undefined) this.groupByColumns = [...state.groupByColumns];
1609
+ if (state.collapsedGroups !== undefined) this.collapsedGroups = { ...state.collapsedGroups };
737
1610
  if (state.pinnedColumns !== undefined) this.pinnedColumns = { ...state.pinnedColumns };
738
1611
  if (state.columnOrder !== undefined) this.columnOrder = [...state.columnOrder];
1612
+ if (state.columnWidthOverrides !== undefined) this.columnWidthOverrides = { ...state.columnWidthOverrides };
739
1613
  if (state.currentPage !== undefined) this.currentPage = state.currentPage;
740
1614
  if (state.pageSize !== undefined) this.pageSize = state.pageSize;
1615
+ if (state.expandedRows !== undefined) this.expandedRows = { ...state.expandedRows };
1616
+ if (state.expandedTreeRows !== undefined) this.expandedTreeRows = { ...state.expandedTreeRows };
1617
+ if (state.selectedRowIds !== undefined) {
1618
+ // Rebuild the selection Set in place — selectedRowIds is readonly on
1619
+ // GridSelectionState so we mutate the existing collection rather
1620
+ // than replacing the reference.
1621
+ this.selectionState.selectedRowIds.clear();
1622
+ for (const id of state.selectedRowIds) this.selectionState.selectedRowIds.add(id);
1623
+ this.selectionState.selectAll = false;
1624
+ }
1625
+ if (state.focusedCell !== undefined) {
1626
+ if (state.focusedCell) {
1627
+ this.cellNavLastRowCol = {
1628
+ rowId: state.focusedCell.rowId,
1629
+ columnName: state.focusedCell.columnName,
1630
+ };
1631
+ this.cellNavFocusedCells = [{ ...this.cellNavLastRowCol }];
1632
+ } else {
1633
+ this.cellNavLastRowCol = null;
1634
+ this.cellNavFocusedCells = [];
1635
+ }
1636
+ }
741
1637
  this.refresh();
1638
+ // Scroll restoration needs to run after render so the scroll container
1639
+ // exists and its scrollHeight is final.
1640
+ if (state.scrollTop !== undefined || state.scrollLeft !== undefined) {
1641
+ this.saveStateScrollRestore?.(state.scrollTop ?? 0, state.scrollLeft ?? 0);
1642
+ }
1643
+ }
1644
+
1645
+ /** Element injects its scroll accessor so save() can capture scrollTop/
1646
+ * scrollLeft. Controller stays DOM-free. */
1647
+ private saveStateScrollSnapshot: (() => { scrollTop: number; scrollLeft: number }) | null = null;
1648
+ private saveStateScrollRestore: ((scrollTop: number, scrollLeft: number) => void) | null = null;
1649
+ setSaveStateScrollHandlers(
1650
+ snapshot: (() => { scrollTop: number; scrollLeft: number }) | null,
1651
+ restore: ((scrollTop: number, scrollLeft: number) => void) | null,
1652
+ ): void {
1653
+ this.saveStateScrollSnapshot = snapshot;
1654
+ this.saveStateScrollRestore = restore;
742
1655
  }
743
1656
 
744
1657
  private refresh(): void {
745
1658
  const orderedColumns = orderVisibleColumns(this.options.columnDefs, this.columnOrder);
1659
+ const applyWidthOverrides = (columns: GridColumnDef[]): GridColumnDef[] =>
1660
+ columns.map((col) => {
1661
+ const override = this.columnWidthOverrides[col.name];
1662
+ return override == null ? col : { ...col, width: override };
1663
+ });
746
1664
  const pinnedEntries = Object.entries(this.pinnedColumns);
747
1665
 
748
1666
  if (pinnedEntries.length === 0) {
749
- this.visibleColumns = orderedColumns;
1667
+ this.visibleColumns = applyWidthOverrides(orderedColumns);
750
1668
  } else {
751
1669
  const byName = new Map(orderedColumns.map((column) => [column.name, column]));
752
1670
  const pinnedLeft = pinnedEntries
@@ -760,7 +1678,30 @@ export class VanillaGridController {
760
1678
  const middleColumns = orderedColumns.filter(
761
1679
  (column) => this.pinnedColumns[column.name] === undefined,
762
1680
  );
763
- this.visibleColumns = [...pinnedLeft, ...middleColumns, ...pinnedRight];
1681
+ this.visibleColumns = applyWidthOverrides([...pinnedLeft, ...middleColumns, ...pinnedRight]);
1682
+ }
1683
+
1684
+ // Selection row-header column. Injected in front of everything else
1685
+ // (including left-pinned columns) so it always sits at the far left,
1686
+ // mirroring the old grid's addRowHeaderColumn(def, 0). The width is
1687
+ // configurable via selectionRowHeaderWidth.
1688
+ const resolvedSelection = resolveGridSelectionOptions(this.options);
1689
+ if (resolvedSelection.enableRowSelection && resolvedSelection.enableRowHeaderSelection) {
1690
+ const selectionCol: GridColumnDef = {
1691
+ name: 'selectionRowHeaderCol',
1692
+ displayName: '',
1693
+ width: `${resolvedSelection.selectionRowHeaderWidth}px`,
1694
+ enableSorting: false,
1695
+ enableFiltering: false,
1696
+ enableGrouping: false,
1697
+ enableCellEdit: false,
1698
+ enablePinning: false,
1699
+ align: 'center',
1700
+ };
1701
+ // Dedupe if already present (setOptions could be re-entering).
1702
+ if (!this.visibleColumns.some((c) => c.name === 'selectionRowHeaderCol')) {
1703
+ this.visibleColumns = [selectionCol, ...this.visibleColumns];
1704
+ }
764
1705
  }
765
1706
 
766
1707
  this.pipeline = defaultGridEngine.buildPipeline({
@@ -778,6 +1719,16 @@ export class VanillaGridController {
778
1719
  rowSize: this.getRowSize(),
779
1720
  });
780
1721
 
1722
+ // Re-apply the persisted selection/focus flags to the fresh row instances
1723
+ // produced by the pipeline. Without this, every refresh would wipe the
1724
+ // selected-row indicators because new GridRow objects default to
1725
+ // isSelected=false. Also evaluates isRowSelectable per row.
1726
+ coreReconcileGridSelection(
1727
+ this.selectionState,
1728
+ this.pipeline.visibleRows,
1729
+ this.options.isRowSelectable ?? null,
1730
+ );
1731
+
781
1732
  if (!this.apiRegistered) {
782
1733
  this.options.onRegisterApi?.(this.gridApi);
783
1734
  this.apiRegistered = true;
@@ -794,6 +1745,12 @@ export class VanillaGridController {
794
1745
  }
795
1746
 
796
1747
  private findRowById(rowId: string): GridRow | null {
1748
+ // Prefer the pipeline's current row instances — they carry the live
1749
+ // isSelected / isFocused / enableSelection flags that callers mutate.
1750
+ // Fall back to a fresh build when the pipeline is still empty (before
1751
+ // the first refresh).
1752
+ const fromPipeline = coreFindGridRowById(this.pipeline.visibleRows, rowId);
1753
+ if (fromPipeline) return fromPipeline;
797
1754
  return coreFindGridRowById(this.buildRowsFromData(this.options.data), rowId);
798
1755
  }
799
1756
 
@@ -923,6 +1880,460 @@ export class VanillaGridController {
923
1880
  this.gridApi.core.raise.benchmarkComplete(result);
924
1881
  return result;
925
1882
  }
1883
+
1884
+ /** Returns the rows that match the requested exporter row-type. Mirrors
1885
+ * the old grid's row selection logic: `all` uses the full data set (or
1886
+ * `exporterAllDataFn` when provided), `visible` uses the current pipeline
1887
+ * output, `selected` uses the selection state. */
1888
+ private exporterRowsFor(rowType: GridExporterRowType): readonly GridRow[] {
1889
+ if (rowType === 'visible') return this.pipeline.visibleRows;
1890
+ if (rowType === 'selected') return this.getSelectedGridRows();
1891
+ // 'all' — fall back to the full grid rows, including filtered-out ones.
1892
+ // The `exporterAllDataFn` escape hatch is left to the caller; we only
1893
+ // bundle the in-memory data here.
1894
+ return this.buildRowsFromData(this.options.data);
1895
+ }
1896
+
1897
+ private resolveExporterOptions(): GridExporterOptions {
1898
+ return { ...resolveGridExporterOptions(this.options), ...this.exporterOverrides };
1899
+ }
1900
+
1901
+ private buildCsv(
1902
+ rowType: GridExporterRowType = 'visible',
1903
+ colType: GridExporterColumnType = 'visible',
1904
+ ): string {
1905
+ const exporterOptions = this.resolveExporterOptions();
1906
+ const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
1907
+ return buildGridCsv(columns, this.exporterRowsFor(rowType), exporterOptions, colType);
1908
+ }
1909
+
1910
+ private exportCsv(
1911
+ rowType: GridExporterRowType = 'visible',
1912
+ colType: GridExporterColumnType = 'visible',
1913
+ ): void {
1914
+ const csv = this.buildCsv(rowType, colType);
1915
+ const resolved = this.resolveExporterOptions();
1916
+ const filename = sanitizeDownloadFilename(
1917
+ resolveExporterFilename(resolved.csvFilename, 'download.csv', rowType, colType),
1918
+ );
1919
+ downloadGridCsvFile(csv, filename);
1920
+ }
1921
+
1922
+ private buildPdfDocDefinition(
1923
+ rowType: GridExporterRowType = 'visible',
1924
+ colType: GridExporterColumnType = 'visible',
1925
+ ): GridExporterPdfDocDefinition {
1926
+ const exporterOptions = this.resolveExporterOptions();
1927
+ const pdfOptions = resolveGridExporterPdfOptions(this.options);
1928
+ const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
1929
+ return buildGridPdfDocDefinition(
1930
+ columns,
1931
+ this.exporterRowsFor(rowType),
1932
+ pdfOptions,
1933
+ exporterOptions,
1934
+ colType,
1935
+ );
1936
+ }
1937
+
1938
+ private exportPdf(
1939
+ rowType: GridExporterRowType = 'visible',
1940
+ colType: GridExporterColumnType = 'visible',
1941
+ ): GridExporterPdfDocDefinition {
1942
+ const doc = this.buildPdfDocDefinition(rowType, colType);
1943
+ // pdfMake is an optional global (consumers load it separately). When
1944
+ // present, open the generated PDF the same way the old module did;
1945
+ // when missing, just return the doc definition so the caller can
1946
+ // render it themselves. This mirrors `uiGridExporterService.pdfExport`.
1947
+ const win = typeof window !== 'undefined' ? (window as typeof window & {
1948
+ pdfMake?: { createPdf: (doc: GridExporterPdfDocDefinition) => { open: () => void; download: (filename: string) => void } };
1949
+ }) : undefined;
1950
+ if (win?.pdfMake) {
1951
+ const resolved = this.resolveExporterOptions();
1952
+ const pdfOpts = resolveGridExporterPdfOptions(this.options);
1953
+ const filename = resolveExporterFilename(
1954
+ pdfOpts.filename,
1955
+ 'download.pdf',
1956
+ rowType,
1957
+ colType,
1958
+ );
1959
+ const pdf = win.pdfMake.createPdf(doc);
1960
+ // Prefer download when the consumer supplied a filename; otherwise
1961
+ // fall back to .open() which pops the pdf in a new browser tab.
1962
+ if (resolved.csvFilename || pdfOpts.filename) {
1963
+ pdf.download(filename);
1964
+ } else {
1965
+ pdf.open();
1966
+ }
1967
+ }
1968
+ return doc;
1969
+ }
1970
+
1971
+ private buildExcelSheetData(
1972
+ rowType: GridExporterRowType = 'visible',
1973
+ colType: GridExporterColumnType = 'visible',
1974
+ ): GridExporterExcelSheetData {
1975
+ const exporterOptions = this.resolveExporterOptions();
1976
+ const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
1977
+ return buildGridExcelSheetData(columns, this.exporterRowsFor(rowType), exporterOptions, colType);
1978
+ }
1979
+
1980
+ private exportExcel(
1981
+ rowType: GridExporterRowType = 'visible',
1982
+ colType: GridExporterColumnType = 'visible',
1983
+ ): GridExporterExcelSheetData {
1984
+ const sheetData = this.buildExcelSheetData(rowType, colType);
1985
+ // ExcelBuilder is an optional global (consumers load it separately).
1986
+ // When present, produce + download the xlsx the same way the old module
1987
+ // did; when missing, just return the raw sheet data.
1988
+ const win = typeof window !== 'undefined' ? (window as typeof window & {
1989
+ ExcelBuilder?: {
1990
+ Worksheet: new (config: { name: string }) => { setData: (data: unknown) => void };
1991
+ Workbook: new () => { addWorksheet: (sheet: unknown) => void };
1992
+ Builder: { createFile: (workbook: unknown, options: { type: 'blob' }) => Promise<Blob> };
1993
+ };
1994
+ }) : undefined;
1995
+ const ExcelBuilder = win?.ExcelBuilder;
1996
+ if (!ExcelBuilder) {
1997
+ return sheetData;
1998
+ }
1999
+ const excelOptions = resolveGridExporterExcelOptions(this.options);
2000
+ const sheetName = typeof excelOptions.sheetName === 'function'
2001
+ ? excelOptions.sheetName(rowType, colType)
2002
+ : (excelOptions.sheetName ?? 'Sheet1');
2003
+ const filename = sanitizeDownloadFilename(
2004
+ resolveExporterFilename(excelOptions.filename, 'download.xlsx', rowType, colType),
2005
+ );
2006
+ const sheet = new ExcelBuilder.Worksheet({ name: sheetName });
2007
+ const workbook = new ExcelBuilder.Workbook();
2008
+ workbook.addWorksheet(sheet);
2009
+ sheet.setData(sheetData);
2010
+ void ExcelBuilder.Builder.createFile(workbook, { type: 'blob' }).then((result) => {
2011
+ if (typeof URL === 'undefined' || typeof document === 'undefined') return;
2012
+ const url = URL.createObjectURL(result);
2013
+ const link = document.createElement('a');
2014
+ link.href = url;
2015
+ link.download = filename;
2016
+ link.click();
2017
+ URL.revokeObjectURL(url);
2018
+ });
2019
+ return sheetData;
2020
+ }
2021
+
2022
+ // ---- Validate — ports ui.grid.validate ----------------------------------
2023
+
2024
+ /** Runs the validators declared on `colDef.validators` against the new
2025
+ * value and flips `$$invalid<col>` / `$$errors<col>` on the entity. On
2026
+ * failure raises `validate.validationFailed`. Async validators are
2027
+ * awaited so the caller can `await` for complete results. */
2028
+ private async validateRunValidators(
2029
+ rowEntity: GridRecord,
2030
+ colDef: GridColumnDef,
2031
+ newValue: unknown,
2032
+ oldValue: unknown,
2033
+ ): Promise<string[]> {
2034
+ return runGridCellValidators(
2035
+ rowEntity,
2036
+ colDef,
2037
+ newValue,
2038
+ oldValue,
2039
+ this.validatorRegistry,
2040
+ (entity, def, newVal, oldVal) => {
2041
+ this.gridApi.validate.raise.validationFailed(entity, def, newVal, oldVal);
2042
+ },
2043
+ );
2044
+ }
2045
+
2046
+ private validateGetFormattedErrors(rowEntity: GridRecord, colDef: GridColumnDef): string {
2047
+ const errors = getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry);
2048
+ if (errors.length === 0) return '';
2049
+ return `<p><b>${this.labels.validateError}</b></p>${errors.map((msg) => `${msg}<br/>`).join('')}`;
2050
+ }
2051
+
2052
+ private validateGetTitleFormattedErrors(rowEntity: GridRecord, colDef: GridColumnDef): string {
2053
+ const errors = getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry);
2054
+ if (errors.length === 0) return '';
2055
+ return [this.labels.validateError, ...errors].join('\n');
2056
+ }
2057
+
2058
+ private async validateGetInvalidRows(): Promise<GridRecord[]> {
2059
+ return validateAllGridRows(
2060
+ this.options.data,
2061
+ this.options.columnDefs,
2062
+ this.validatorRegistry,
2063
+ );
2064
+ }
2065
+
2066
+ /** Test hook — exposes the validator registry so consumers can inspect or
2067
+ * extend it outside of the gridApi surface (e.g. from a controller-level
2068
+ * plugin). */
2069
+ getValidatorRegistry(): GridValidatorRegistry {
2070
+ return this.validatorRegistry;
2071
+ }
2072
+
2073
+ // ---- Importer — ports ui.grid.importer ---------------------------------
2074
+
2075
+ /** Set by the element so `importAFile()` can trigger its file-picker flow.
2076
+ * Controller stays DOM-free: it just invokes whatever handler was wired. */
2077
+ private importerFilePickerRequest: (() => void) | null = null;
2078
+ setImporterFilePickerHandler(handler: (() => void) | null): void {
2079
+ this.importerFilePickerRequest = handler;
2080
+ }
2081
+
2082
+ private resolveImporterOptions(): GridImporterOptions {
2083
+ return resolveGridImporterOptions(this.options);
2084
+ }
2085
+
2086
+ private importerRequestFile(): void {
2087
+ this.importerFilePickerRequest?.();
2088
+ }
2089
+
2090
+ private importerDispatchObjects(objects: readonly GridRecord[]): void {
2091
+ if (objects.length === 0) return;
2092
+ const opts = this.resolveImporterOptions();
2093
+ if (opts.dataAddCallback) {
2094
+ opts.dataAddCallback(objects);
2095
+ } else {
2096
+ // No callback configured — append to the in-memory data array so the
2097
+ // grid reflects the imported rows immediately. Matches the
2098
+ // "importerDataAddCallback is required" hint from the old module
2099
+ // but without breaking the common case of a simple local data source.
2100
+ this.options = { ...this.options, data: [...this.options.data, ...objects] };
2101
+ this.refresh();
2102
+ }
2103
+ // If rowEdit is active, flip every freshly-added row dirty so the
2104
+ // consumer's saveRow hook sees them. Mirrors
2105
+ // `service.addObjects → rowEdit.setRowsDirty`.
2106
+ if (this.rowEditState.dirtyRowIds.size >= 0 && this.options.rowEditWaitInterval !== undefined) {
2107
+ // rowEdit is configured — queue the dirty marking on the next
2108
+ // refresh so the new rows are in the pipeline before we flip them.
2109
+ queueMicrotask(() => this.rowEditSetRowsDirty(objects));
2110
+ }
2111
+ }
2112
+
2113
+ private importerImportText(text: string, type?: 'json' | 'csv'): void {
2114
+ const opts = this.resolveImporterOptions();
2115
+ // Type inference: explicit `type` wins. Otherwise try JSON first; if
2116
+ // JSON.parse returns null (meaning invalidJson already reported) AND
2117
+ // the caller didn't force JSON, fall through to CSV. The old module
2118
+ // decided purely based on MIME type; `importText` is a newer entry
2119
+ // point we provide for drag/drop flows.
2120
+ const asJson = type === 'csv' ? null : parseGridImporterJson(text, { errorCallback: type === 'json' ? opts.errorCallback : undefined });
2121
+ if (asJson && asJson.length > 0) {
2122
+ const objects = buildGridImporterObjectsFromJson(asJson, opts);
2123
+ this.importerDispatchObjects(objects);
2124
+ return;
2125
+ }
2126
+ if (type === 'json') return;
2127
+
2128
+ const csv = parseGridImporterCsv(text);
2129
+ if (!csv) {
2130
+ opts.errorCallback?.(
2131
+ 'importer.invalidCsv',
2132
+ 'File could not be processed, is it valid csv? Content was: ',
2133
+ text,
2134
+ );
2135
+ return;
2136
+ }
2137
+ const objects = buildGridImporterObjectsFromCsv(csv, this.options.columnDefs, opts);
2138
+ if (!objects || objects.length === 0) {
2139
+ opts.errorCallback?.(
2140
+ 'importer.noObjects',
2141
+ 'Objects were not able to be derived, content was: ',
2142
+ text,
2143
+ );
2144
+ return;
2145
+ }
2146
+ this.importerDispatchObjects(objects);
2147
+ }
2148
+
2149
+ private importerImportThisFile(file: File): Promise<void> {
2150
+ return new Promise((resolve) => {
2151
+ if (typeof FileReader === 'undefined') {
2152
+ resolve();
2153
+ return;
2154
+ }
2155
+ const reader = new FileReader();
2156
+ reader.onload = () => {
2157
+ const text = typeof reader.result === 'string' ? reader.result : '';
2158
+ // MIME sniff: old module switched on file.type, with `application/json`
2159
+ // as JSON and everything else as CSV. Preserve that default while
2160
+ // still letting the auto-detect path in `importerImportText` recover
2161
+ // when a consumer stores JSON with the wrong MIME.
2162
+ const type: 'json' | 'csv' = file.type === 'application/json' ? 'json' : 'csv';
2163
+ this.importerImportText(text, type);
2164
+ resolve();
2165
+ };
2166
+ reader.onerror = () => resolve();
2167
+ reader.readAsText(file);
2168
+ });
2169
+ }
2170
+
2171
+ private buildExporterMenuItems(): GridExporterMenuItem[] {
2172
+ // Pass the full resolved label bundle — the menu module pulls only
2173
+ // the `exporter*` keys it needs via `Partial<GridLabels>`.
2174
+ return buildGridExporterMenuItems(
2175
+ this.options,
2176
+ this.labels,
2177
+ {
2178
+ csvExport: (rowType, colType) => this.exportCsv(rowType, colType),
2179
+ pdfExport: (rowType, colType) => {
2180
+ this.exportPdf(rowType, colType);
2181
+ },
2182
+ excelExport: (rowType, colType) => {
2183
+ this.exportExcel(rowType, colType);
2184
+ },
2185
+ },
2186
+ () => this.selectionState.selectedRowIds.size > 0,
2187
+ );
2188
+ }
2189
+
2190
+ /** Menu items the importer contributes. Just the single "Import"
2191
+ * entry when the feature is enabled; returns an empty array otherwise. */
2192
+ private buildImporterMenuItems(): GridMenuItem[] {
2193
+ return buildGridImporterMenuItems(
2194
+ this.options,
2195
+ this.labels,
2196
+ { importAFile: () => this.importerRequestFile() },
2197
+ );
2198
+ }
2199
+
2200
+ /** Menu items row-edit contributes: "Save changes" + "Retry errored
2201
+ * rows". Each entry is gated by both a gridOption flag and a runtime
2202
+ * predicate (`hasDirtyRows` / `hasErrorRows`) so the menu only surfaces
2203
+ * actions that have something to do. */
2204
+ private buildRowEditMenuItems(): GridMenuItem[] {
2205
+ return buildGridRowEditMenuItems(
2206
+ this.options,
2207
+ this.labels,
2208
+ {
2209
+ flushDirtyRows: () => this.rowEditFlushDirtyRows(),
2210
+ retryErroredRows: () => this.rowEditRetryErroredRows(),
2211
+ },
2212
+ {
2213
+ hasDirtyRows: () => this.rowEditState.dirtyRowIds.size > 0,
2214
+ hasErrorRows: () => this.rowEditState.errorRowIds.size > 0,
2215
+ },
2216
+ );
2217
+ }
2218
+
2219
+ // ---- Row-edit — ports ui.grid.rowEdit -----------------------------------
2220
+
2221
+ private rowEditSetSavePromise(rowEntity: GridRecord, savePromise: Promise<void>): void {
2222
+ const rowId = this.resolveRowId(rowEntity);
2223
+ this.rowEditSavePromiseOverrides.set(rowId, savePromise);
2224
+ }
2225
+
2226
+ private rowEditGetDirtyRows(): GridRow[] {
2227
+ return this.pipeline.visibleRows.filter((row) =>
2228
+ this.rowEditState.dirtyRowIds.has(row.id),
2229
+ );
2230
+ }
2231
+
2232
+ private rowEditGetErrorRows(): GridRow[] {
2233
+ return this.pipeline.visibleRows.filter((row) =>
2234
+ this.rowEditState.errorRowIds.has(row.id),
2235
+ );
2236
+ }
2237
+
2238
+ private rowEditFlushDirtyRows(): Promise<void> {
2239
+ const dirty = [...this.rowEditState.dirtyRowIds]
2240
+ .map((id) => this.findRowById(id))
2241
+ .filter((row): row is GridRow => row !== undefined);
2242
+ const promises = dirty.map((row) => this.runRowEditSave(row));
2243
+ return Promise.all(promises).then(() => undefined);
2244
+ }
2245
+
2246
+ /** Re-fires `saveRow` for every row currently in the error state. The
2247
+ * "Retry errored rows" menu entry funnels through here. Returns a
2248
+ * promise that resolves once every retry promise has settled. */
2249
+ private rowEditRetryErroredRows(): Promise<void> {
2250
+ const errored = [...this.rowEditState.errorRowIds]
2251
+ .map((id) => this.findRowById(id))
2252
+ .filter((row): row is GridRow => row !== undefined);
2253
+ const promises = errored.map((row) => this.runRowEditSave(row));
2254
+ return Promise.all(promises).then(() => undefined);
2255
+ }
2256
+
2257
+ private rowEditSetRowsDirty(rowEntities: readonly GridRecord[]): void {
2258
+ for (const entity of rowEntities) {
2259
+ const rowId = this.resolveRowId(entity);
2260
+ const row = this.findRowById(rowId);
2261
+ if (!row) continue;
2262
+ markGridRowDirty(this.rowEditState, row);
2263
+ this.considerSetRowEditTimer(row);
2264
+ }
2265
+ this.emit();
2266
+ }
2267
+
2268
+ private rowEditSetRowsClean(rowEntities: readonly GridRecord[]): void {
2269
+ for (const entity of rowEntities) {
2270
+ const rowId = this.resolveRowId(entity);
2271
+ const row = this.findRowById(rowId);
2272
+ if (!row) continue;
2273
+ markGridRowClean(this.rowEditState, row);
2274
+ this.cancelRowEditTimer(row);
2275
+ }
2276
+ this.emit();
2277
+ }
2278
+
2279
+ private considerSetRowEditTimer(row: GridRow): void {
2280
+ this.cancelRowEditTimer(row);
2281
+ if (!row.isDirty || row.isSaving) return;
2282
+ const waitInterval = this.options.rowEditWaitInterval;
2283
+ if (!isGridRowEditTimerEnabled(waitInterval)) return;
2284
+ const effective = resolveGridRowEditWaitInterval(waitInterval);
2285
+ const timer = setTimeout(() => {
2286
+ this.rowEditTimers.delete(row.id);
2287
+ void this.runRowEditSave(row);
2288
+ }, effective);
2289
+ this.rowEditTimers.set(row.id, timer);
2290
+ }
2291
+
2292
+ private cancelRowEditTimer(row: GridRow): void {
2293
+ const timer = this.rowEditTimers.get(row.id);
2294
+ if (timer !== undefined) {
2295
+ clearTimeout(timer);
2296
+ this.rowEditTimers.delete(row.id);
2297
+ }
2298
+ }
2299
+
2300
+ private runRowEditSave(row: GridRow): Promise<void> {
2301
+ if (row.isSaving) {
2302
+ // Re-entrance — return the in-flight promise so `flushDirtyRows`
2303
+ // still awaits the existing save. Matches the old module's
2304
+ // `rowEditSavePromise` short-circuit.
2305
+ return this.rowEditState.savePromises.get(row.id) ?? Promise.resolve();
2306
+ }
2307
+ markGridRowSaving(this.rowEditState, row);
2308
+ this.rowEditSavePromiseOverrides.delete(row.id);
2309
+ this.gridApi.rowEdit.raise.saveRow(row.entity);
2310
+ const savePromise =
2311
+ this.rowEditSavePromiseOverrides.get(row.id) ?? Promise.resolve();
2312
+ this.rowEditState.savePromises.set(row.id, savePromise);
2313
+ this.emit();
2314
+ return savePromise
2315
+ .then(() => {
2316
+ markGridRowClean(this.rowEditState, row);
2317
+ this.emit();
2318
+ })
2319
+ .catch(() => {
2320
+ markGridRowError(this.rowEditState, row);
2321
+ this.emit();
2322
+ });
2323
+ }
2324
+
2325
+ /** Test hook — reports the current row-edit dirty / error / saving state. */
2326
+ getRowEditState(): {
2327
+ dirtyRowIds: string[];
2328
+ errorRowIds: string[];
2329
+ savingRowIds: string[];
2330
+ } {
2331
+ return {
2332
+ dirtyRowIds: [...this.rowEditState.dirtyRowIds],
2333
+ errorRowIds: [...this.rowEditState.errorRowIds],
2334
+ savingRowIds: [...this.rowEditState.savingRowIds],
2335
+ };
2336
+ }
926
2337
  }
927
2338
 
928
2339
  export function createVanillaGridController(options: GridOptions): VanillaGridController {