@alaarab/ogrid-angular 2.0.14 → 2.0.16

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.
@@ -20,6 +20,10 @@ export class BaseDataGridTableComponent {
20
20
  this.virtualScrollService = new VirtualScrollService();
21
21
  this.lastMouseShift = false;
22
22
  this.columnSizingVersion = signal(0);
23
+ /** DOM-measured column widths from the last layout pass.
24
+ * Used as a minWidth floor to prevent columns from shrinking
25
+ * when new data loads (e.g. server-side pagination). */
26
+ this.measuredColumnWidths = signal({});
23
27
  // Signal-backed view child elements — set from ngAfterViewInit.
24
28
  // @ViewChild is a plain property (not a signal), so effects/computed that read it
25
29
  // only evaluate once during construction when the ref is still undefined.
@@ -129,17 +133,25 @@ export class BaseDataGridTableComponent {
129
133
  const fc = this.freezeCols();
130
134
  const props = this.getProps();
131
135
  const pinnedCols = props?.pinnedColumns ?? {};
136
+ const measuredWidths = this.measuredColumnWidths();
137
+ const sizingOverrides = this.columnSizingOverrides();
132
138
  return cols.map((col, colIdx) => {
133
139
  const isFreezeCol = fc != null && fc >= 1 && colIdx < fc;
134
140
  const runtimePinned = pinnedCols[col.columnId];
135
141
  const pinnedLeft = runtimePinned === 'left' || col.pinned === 'left' || (isFreezeCol && colIdx === 0);
136
142
  const pinnedRight = runtimePinned === 'right' || col.pinned === 'right';
137
143
  const w = this.getColumnWidth(col);
144
+ // Use previously-measured DOM width as a minWidth floor to prevent columns
145
+ // from shrinking when new data loads (e.g. server-side pagination).
146
+ const hasResizeOverride = !!sizingOverrides[col.columnId];
147
+ const measuredW = measuredWidths[col.columnId];
148
+ const baseMinWidth = col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
149
+ const effectiveMinWidth = hasResizeOverride ? w : Math.max(baseMinWidth, measuredW ?? 0);
138
150
  return {
139
151
  col,
140
152
  pinnedLeft,
141
153
  pinnedRight,
142
- minWidth: col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH,
154
+ minWidth: effectiveMinWidth,
143
155
  width: w,
144
156
  };
145
157
  });
@@ -181,6 +193,41 @@ export class BaseDataGridTableComponent {
181
193
  this.wrapperElSignal.set(wrapper);
182
194
  if (tableContainer)
183
195
  this.tableContainerElSignal.set(tableContainer);
196
+ this.measureColumnWidths();
197
+ }
198
+ /** Lifecycle hook — re-measure column widths after each view update */
199
+ ngAfterViewChecked() {
200
+ this.measureColumnWidths();
201
+ }
202
+ /** Measure actual th widths from the DOM and update the measuredColumnWidths signal.
203
+ * Only updates the signal when values actually change, to avoid render loops. */
204
+ measureColumnWidths() {
205
+ const wrapper = this.getWrapperRef()?.nativeElement;
206
+ if (!wrapper)
207
+ return;
208
+ const headerCells = wrapper.querySelectorAll('th[data-column-id]');
209
+ if (headerCells.length === 0)
210
+ return;
211
+ const measured = {};
212
+ headerCells.forEach((cell) => {
213
+ const colId = cell.getAttribute('data-column-id');
214
+ if (colId)
215
+ measured[colId] = cell.offsetWidth;
216
+ });
217
+ // Only update signal if values changed to avoid triggering computed re-evaluations unnecessarily
218
+ const prev = this.measuredColumnWidths();
219
+ let changed = Object.keys(measured).length !== Object.keys(prev).length;
220
+ if (!changed) {
221
+ for (const key in measured) {
222
+ if (prev[key] !== measured[key]) {
223
+ changed = true;
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ if (changed) {
229
+ this.measuredColumnWidths.set(measured);
230
+ }
184
231
  }
185
232
  /**
186
233
  * Initialize base wiring effects. Must be called from subclass constructor
@@ -236,6 +283,25 @@ export class BaseDataGridTableComponent {
236
283
  });
237
284
  }
238
285
  // --- Helper methods ---
286
+ /** Lookup effective min-width for a column (includes measured width floor) */
287
+ getEffectiveMinWidth(col) {
288
+ const layout = this.columnLayouts().find((l) => l.col.columnId === col.columnId);
289
+ return layout?.minWidth ?? col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
290
+ }
291
+ /**
292
+ * Returns derived cell interaction metadata (non-event attributes) for use in templates.
293
+ * Mirrors React's getCellInteractionProps for the Angular view layer.
294
+ * Event handlers (mousedown, click, dblclick, contextmenu) are still bound inline in templates.
295
+ */
296
+ getCellInteractionProps(descriptor) {
297
+ return {
298
+ tabIndex: descriptor.isActive ? 0 : -1,
299
+ dataRowIndex: descriptor.rowIndex,
300
+ dataColIndex: descriptor.globalColIndex,
301
+ dataInRange: descriptor.isInRange ? 'true' : null,
302
+ role: descriptor.canEditAny ? 'button' : null,
303
+ };
304
+ }
239
305
  asColumnDef(colDef) {
240
306
  return colDef;
241
307
  }
@@ -332,6 +398,10 @@ export class BaseDataGridTableComponent {
332
398
  }
333
399
  onResizeStart(event, col) {
334
400
  event.preventDefault();
401
+ // Clear cell selection before resize (like React) so selection outlines don't persist during drag
402
+ this.state().interaction.setActiveCell(null);
403
+ this.state().interaction.setSelectionRange(null);
404
+ this.getWrapperRef()?.nativeElement.focus({ preventScroll: true });
335
405
  const startX = event.clientX;
336
406
  const startWidth = this.getColumnWidth(col);
337
407
  const minWidth = col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
@@ -0,0 +1,253 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { Input, Output, EventEmitter, signal, computed, ViewChild } from '@angular/core';
8
+ /**
9
+ * Abstract base class for Angular inline cell editors.
10
+ * Contains all shared signals, lifecycle hooks, keyboard handlers,
11
+ * dropdown positioning, and display logic.
12
+ *
13
+ * Subclasses only need a @Component decorator with selector + template.
14
+ */
15
+ export class BaseInlineCellEditorComponent {
16
+ constructor() {
17
+ this.commit = new EventEmitter();
18
+ this.cancel = new EventEmitter();
19
+ this.localValue = signal('');
20
+ this.highlightedIndex = signal(0);
21
+ this.selectOptions = signal([]);
22
+ this.searchText = signal('');
23
+ this.filteredOptions = computed(() => {
24
+ const options = this.selectOptions();
25
+ const search = this.searchText().trim().toLowerCase();
26
+ if (!search)
27
+ return options;
28
+ return options.filter((v) => this.getDisplayText(v).toLowerCase().includes(search));
29
+ });
30
+ this._initialized = false;
31
+ }
32
+ ngOnInit() {
33
+ this._initialized = true;
34
+ this.syncFromInputs();
35
+ }
36
+ ngOnChanges() {
37
+ if (this._initialized) {
38
+ this.syncFromInputs();
39
+ }
40
+ }
41
+ syncFromInputs() {
42
+ const v = this.value;
43
+ this.localValue.set(v != null ? String(v) : '');
44
+ const col = this.column;
45
+ if (col?.cellEditorParams?.values) {
46
+ const vals = col.cellEditorParams.values;
47
+ this.selectOptions.set(vals);
48
+ const initialIdx = vals.findIndex((opt) => String(opt) === String(v));
49
+ this.highlightedIndex.set(Math.max(initialIdx, 0));
50
+ }
51
+ }
52
+ ngAfterViewInit() {
53
+ setTimeout(() => {
54
+ const richSelectInput = this.richSelectInput?.nativeElement;
55
+ if (richSelectInput) {
56
+ richSelectInput.focus();
57
+ richSelectInput.select();
58
+ this.positionFixedDropdown(this.richSelectWrapper, this.richSelectDropdown);
59
+ return;
60
+ }
61
+ const selectWrap = this.selectWrapper?.nativeElement;
62
+ if (selectWrap) {
63
+ selectWrap.focus();
64
+ this.positionFixedDropdown(this.selectWrapper, this.selectDropdown);
65
+ return;
66
+ }
67
+ const el = this.inputEl?.nativeElement;
68
+ if (el) {
69
+ el.focus();
70
+ if (el instanceof HTMLInputElement && el.type === 'text') {
71
+ el.select();
72
+ }
73
+ }
74
+ });
75
+ }
76
+ commitValue(value) {
77
+ this.commit.emit(value);
78
+ }
79
+ onTextKeyDown(e) {
80
+ if (e.key === 'Enter') {
81
+ e.preventDefault();
82
+ this.commitValue(this.localValue());
83
+ }
84
+ else if (e.key === 'Escape') {
85
+ e.preventDefault();
86
+ this.cancel.emit();
87
+ }
88
+ else if (e.key === 'Tab') {
89
+ e.preventDefault();
90
+ this.commitValue(this.localValue());
91
+ }
92
+ }
93
+ getDisplayText(value) {
94
+ const formatValue = this.column?.cellEditorParams?.formatValue;
95
+ if (formatValue)
96
+ return formatValue(value);
97
+ return value != null ? String(value) : '';
98
+ }
99
+ onCustomSelectKeyDown(e) {
100
+ const options = this.selectOptions();
101
+ switch (e.key) {
102
+ case 'ArrowDown':
103
+ e.preventDefault();
104
+ this.highlightedIndex.set(Math.min(this.highlightedIndex() + 1, options.length - 1));
105
+ this.scrollOptionIntoView(this.selectDropdown);
106
+ break;
107
+ case 'ArrowUp':
108
+ e.preventDefault();
109
+ this.highlightedIndex.set(Math.max(this.highlightedIndex() - 1, 0));
110
+ this.scrollOptionIntoView(this.selectDropdown);
111
+ break;
112
+ case 'Enter':
113
+ e.preventDefault();
114
+ e.stopPropagation();
115
+ if (options.length > 0 && this.highlightedIndex() < options.length) {
116
+ this.commitValue(options[this.highlightedIndex()]);
117
+ }
118
+ break;
119
+ case 'Tab':
120
+ e.preventDefault();
121
+ if (options.length > 0 && this.highlightedIndex() < options.length) {
122
+ this.commitValue(options[this.highlightedIndex()]);
123
+ }
124
+ break;
125
+ case 'Escape':
126
+ e.preventDefault();
127
+ e.stopPropagation();
128
+ this.cancel.emit();
129
+ break;
130
+ }
131
+ }
132
+ onRichSelectSearch(text) {
133
+ this.searchText.set(text);
134
+ this.highlightedIndex.set(0);
135
+ }
136
+ onRichSelectKeyDown(e) {
137
+ const options = this.filteredOptions();
138
+ switch (e.key) {
139
+ case 'ArrowDown':
140
+ e.preventDefault();
141
+ this.highlightedIndex.set(Math.min(this.highlightedIndex() + 1, options.length - 1));
142
+ this.scrollOptionIntoView(this.richSelectDropdown);
143
+ break;
144
+ case 'ArrowUp':
145
+ e.preventDefault();
146
+ this.highlightedIndex.set(Math.max(this.highlightedIndex() - 1, 0));
147
+ this.scrollOptionIntoView(this.richSelectDropdown);
148
+ break;
149
+ case 'Enter':
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+ if (options.length > 0 && this.highlightedIndex() < options.length) {
153
+ this.commitValue(options[this.highlightedIndex()]);
154
+ }
155
+ break;
156
+ case 'Escape':
157
+ e.preventDefault();
158
+ e.stopPropagation();
159
+ this.cancel.emit();
160
+ break;
161
+ }
162
+ }
163
+ onCheckboxKeyDown(e) {
164
+ if (e.key === 'Escape') {
165
+ e.preventDefault();
166
+ this.cancel.emit();
167
+ }
168
+ }
169
+ onTextBlur() {
170
+ this.commitValue(this.localValue());
171
+ }
172
+ getInputStyle() {
173
+ const baseStyle = 'width:100%;box-sizing:border-box;padding:6px 10px;border:none;outline:none;font:inherit;background:transparent;color:inherit;';
174
+ const col = this.column;
175
+ if (col.type === 'numeric') {
176
+ return baseStyle + 'text-align:right;';
177
+ }
178
+ return baseStyle;
179
+ }
180
+ /** Position a dropdown using fixed positioning to escape overflow clipping. */
181
+ positionFixedDropdown(wrapperRef, dropdownRef) {
182
+ const wrapper = wrapperRef?.nativeElement;
183
+ const dropdown = dropdownRef?.nativeElement;
184
+ if (!wrapper || !dropdown)
185
+ return;
186
+ const rect = wrapper.getBoundingClientRect();
187
+ const maxH = 200;
188
+ const spaceBelow = window.innerHeight - rect.bottom;
189
+ const flipUp = spaceBelow < maxH && rect.top > spaceBelow;
190
+ dropdown.style.position = 'fixed';
191
+ dropdown.style.left = `${rect.left}px`;
192
+ dropdown.style.width = `${rect.width}px`;
193
+ dropdown.style.maxHeight = `${maxH}px`;
194
+ dropdown.style.zIndex = '9999';
195
+ dropdown.style.right = 'auto';
196
+ if (flipUp) {
197
+ dropdown.style.top = 'auto';
198
+ dropdown.style.bottom = `${window.innerHeight - rect.top}px`;
199
+ }
200
+ else {
201
+ dropdown.style.top = `${rect.bottom}px`;
202
+ }
203
+ }
204
+ /** Scroll the highlighted option into view within a dropdown element. */
205
+ scrollOptionIntoView(dropdownRef) {
206
+ setTimeout(() => {
207
+ const dropdown = dropdownRef?.nativeElement;
208
+ if (!dropdown)
209
+ return;
210
+ const highlighted = dropdown.children[this.highlightedIndex()];
211
+ highlighted?.scrollIntoView({ block: 'nearest' });
212
+ });
213
+ }
214
+ }
215
+ __decorate([
216
+ Input({ required: true })
217
+ ], BaseInlineCellEditorComponent.prototype, "value", void 0);
218
+ __decorate([
219
+ Input({ required: true })
220
+ ], BaseInlineCellEditorComponent.prototype, "item", void 0);
221
+ __decorate([
222
+ Input({ required: true })
223
+ ], BaseInlineCellEditorComponent.prototype, "column", void 0);
224
+ __decorate([
225
+ Input({ required: true })
226
+ ], BaseInlineCellEditorComponent.prototype, "rowIndex", void 0);
227
+ __decorate([
228
+ Input({ required: true })
229
+ ], BaseInlineCellEditorComponent.prototype, "editorType", void 0);
230
+ __decorate([
231
+ Output()
232
+ ], BaseInlineCellEditorComponent.prototype, "commit", void 0);
233
+ __decorate([
234
+ Output()
235
+ ], BaseInlineCellEditorComponent.prototype, "cancel", void 0);
236
+ __decorate([
237
+ ViewChild('inputEl')
238
+ ], BaseInlineCellEditorComponent.prototype, "inputEl", void 0);
239
+ __decorate([
240
+ ViewChild('selectWrapper')
241
+ ], BaseInlineCellEditorComponent.prototype, "selectWrapper", void 0);
242
+ __decorate([
243
+ ViewChild('selectDropdown')
244
+ ], BaseInlineCellEditorComponent.prototype, "selectDropdown", void 0);
245
+ __decorate([
246
+ ViewChild('richSelectWrapper')
247
+ ], BaseInlineCellEditorComponent.prototype, "richSelectWrapper", void 0);
248
+ __decorate([
249
+ ViewChild('richSelectInput')
250
+ ], BaseInlineCellEditorComponent.prototype, "richSelectInput", void 0);
251
+ __decorate([
252
+ ViewChild('richSelectDropdown')
253
+ ], BaseInlineCellEditorComponent.prototype, "richSelectDropdown", void 0);
package/dist/esm/index.js CHANGED
@@ -19,4 +19,5 @@ export { BaseDataGridTableComponent } from './components/base-datagrid-table.com
19
19
  export { BaseColumnHeaderFilterComponent } from './components/base-column-header-filter.component';
20
20
  export { BaseColumnChooserComponent } from './components/base-column-chooser.component';
21
21
  export { BasePaginationControlsComponent } from './components/base-pagination-controls.component';
22
+ export { BaseInlineCellEditorComponent } from './components/base-inline-cell-editor.component';
22
23
  export { getHeaderFilterConfig, getCellRenderDescriptor, resolveCellDisplayContent, resolveCellStyle, createDebouncedSignal, createDebouncedCallback, debounce, createLatestRef, createLatestCallback, } from './utils';
@@ -5,7 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { Injectable, signal, computed, effect, DestroyRef, inject } from '@angular/core';
8
- import { flattenColumns, getDataGridStatusBarConfig, parseValue, computeAggregations, getCellValue, normalizeSelectionRange, CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, } from '@alaarab/ogrid-core';
8
+ import { flattenColumns, getDataGridStatusBarConfig, parseValue, computeAggregations, getCellValue, normalizeSelectionRange, CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH, CELL_PADDING, UndoRedoStack, findCtrlArrowTarget, computeTabNavigation, formatSelectionAsTsv, parseTsvClipboard, rangesEqual, } from '@alaarab/ogrid-core';
9
9
  /**
10
10
  * Single orchestration service for DataGridTable. Takes grid props,
11
11
  * returns all derived state and handlers so Angular UI packages can be thin view layers.
@@ -33,10 +33,8 @@ let DataGridStateService = class DataGridStateService {
33
33
  this.cutRangeSig = signal(null);
34
34
  this.copyRangeSig = signal(null);
35
35
  this.internalClipboard = null;
36
- // Undo/redo state
37
- this.undoHistory = [];
38
- this.redoStack = [];
39
- this.batch = null;
36
+ // Undo/redo state (backed by core UndoRedoStack)
37
+ this.undoRedoStack = new UndoRedoStack(100);
40
38
  this.undoLengthSig = signal(0);
41
39
  this.redoLengthSig = signal(0);
42
40
  // Fill handle state
@@ -70,14 +68,10 @@ let DataGridStateService = class DataGridStateService {
70
68
  if (!original)
71
69
  return undefined;
72
70
  return (event) => {
73
- if (this.batch !== null) {
74
- this.batch.push(event);
75
- }
76
- else {
77
- this.undoHistory = [...this.undoHistory, [event]].slice(-100);
78
- this.redoStack = [];
79
- this.undoLengthSig.set(this.undoHistory.length);
80
- this.redoLengthSig.set(0);
71
+ this.undoRedoStack.record(event);
72
+ if (!this.undoRedoStack.isBatching) {
73
+ this.undoLengthSig.set(this.undoRedoStack.historyLength);
74
+ this.redoLengthSig.set(this.undoRedoStack.redoLength);
81
75
  }
82
76
  original(event);
83
77
  };
@@ -355,11 +349,7 @@ let DataGridStateService = class DataGridStateService {
355
349
  }
356
350
  setSelectionRange(range) {
357
351
  const prev = this.selectionRangeSig();
358
- if (prev === range)
359
- return;
360
- if (prev && range &&
361
- prev.startRow === range.startRow && prev.endRow === range.endRow &&
362
- prev.startCol === range.startCol && prev.endCol === range.endCol)
352
+ if (rangesEqual(prev, range))
363
353
  return;
364
354
  this.selectionRangeSig.set(range);
365
355
  }
@@ -459,22 +449,7 @@ let DataGridStateService = class DataGridStateService {
459
449
  if (range == null)
460
450
  return;
461
451
  const norm = normalizeSelectionRange(range);
462
- const visibleCols = this.visibleCols();
463
- const rows = [];
464
- for (let r = norm.startRow; r <= norm.endRow; r++) {
465
- const cells = [];
466
- for (let c = norm.startCol; c <= norm.endCol; c++) {
467
- if (r >= p.items.length || c >= visibleCols.length)
468
- break;
469
- const item = p.items[r];
470
- const col = visibleCols[c];
471
- const raw = getCellValue(item, col);
472
- const val = col.valueFormatter ? col.valueFormatter(raw, item) : raw;
473
- cells.push(val != null && val !== '' ? String(val).replace(/\t/g, ' ').replace(/\n/g, ' ') : '');
474
- }
475
- rows.push(cells.join('\t'));
476
- }
477
- const tsv = rows.join('\r\n');
452
+ const tsv = formatSelectionAsTsv(p.items, this.visibleCols(), norm);
478
453
  this.internalClipboard = tsv;
479
454
  this.copyRangeSig.set(norm);
480
455
  void navigator.clipboard.writeText(tsv).catch(() => { });
@@ -515,10 +490,10 @@ let DataGridStateService = class DataGridStateService {
515
490
  const anchorRow = norm ? norm.startRow : 0;
516
491
  const anchorCol = norm ? norm.startCol : 0;
517
492
  const visibleCols = this.visibleCols();
518
- const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
493
+ const parsedRows = parseTsvClipboard(text);
519
494
  this.beginBatch();
520
- for (let r = 0; r < lines.length; r++) {
521
- const cells = lines[r].split('\t');
495
+ for (let r = 0; r < parsedRows.length; r++) {
496
+ const cells = parsedRows[r];
522
497
  for (let c = 0; c < cells.length; c++) {
523
498
  const targetRow = anchorRow + r;
524
499
  const targetCol = anchorCol + c;
@@ -566,28 +541,23 @@ let DataGridStateService = class DataGridStateService {
566
541
  }
567
542
  // --- Undo/Redo ---
568
543
  beginBatch() {
569
- this.batch = [];
544
+ this.undoRedoStack.beginBatch();
570
545
  }
571
546
  endBatch() {
572
- const batch = this.batch;
573
- this.batch = null;
574
- if (!batch || batch.length === 0)
575
- return;
576
- this.undoHistory = [...this.undoHistory, batch].slice(-100);
577
- this.redoStack = [];
578
- this.undoLengthSig.set(this.undoHistory.length);
579
- this.redoLengthSig.set(0);
547
+ this.undoRedoStack.endBatch();
548
+ this.undoLengthSig.set(this.undoRedoStack.historyLength);
549
+ this.redoLengthSig.set(this.undoRedoStack.redoLength);
580
550
  }
581
551
  undo() {
582
552
  const p = this.props();
583
553
  const original = p?.onCellValueChanged;
584
- if (!original || this.undoHistory.length === 0)
554
+ if (!original)
555
+ return;
556
+ const lastBatch = this.undoRedoStack.undo();
557
+ if (!lastBatch)
585
558
  return;
586
- const lastBatch = this.undoHistory[this.undoHistory.length - 1];
587
- this.undoHistory = this.undoHistory.slice(0, -1);
588
- this.redoStack = [...this.redoStack, lastBatch];
589
- this.undoLengthSig.set(this.undoHistory.length);
590
- this.redoLengthSig.set(this.redoStack.length);
559
+ this.undoLengthSig.set(this.undoRedoStack.historyLength);
560
+ this.redoLengthSig.set(this.undoRedoStack.redoLength);
591
561
  for (let i = lastBatch.length - 1; i >= 0; i--) {
592
562
  const ev = lastBatch[i];
593
563
  original({ ...ev, oldValue: ev.newValue, newValue: ev.oldValue });
@@ -596,13 +566,13 @@ let DataGridStateService = class DataGridStateService {
596
566
  redo() {
597
567
  const p = this.props();
598
568
  const original = p?.onCellValueChanged;
599
- if (!original || this.redoStack.length === 0)
569
+ if (!original)
600
570
  return;
601
- const nextBatch = this.redoStack[this.redoStack.length - 1];
602
- this.redoStack = this.redoStack.slice(0, -1);
603
- this.undoHistory = [...this.undoHistory, nextBatch];
604
- this.redoLengthSig.set(this.redoStack.length);
605
- this.undoLengthSig.set(this.undoHistory.length);
571
+ const nextBatch = this.undoRedoStack.redo();
572
+ if (!nextBatch)
573
+ return;
574
+ this.undoLengthSig.set(this.undoRedoStack.historyLength);
575
+ this.redoLengthSig.set(this.undoRedoStack.redoLength);
606
576
  for (const ev of nextBatch) {
607
577
  original(ev);
608
578
  }
@@ -645,27 +615,7 @@ let DataGridStateService = class DataGridStateService {
645
615
  const v = getCellValue(items[r], visibleCols[c]);
646
616
  return v == null || v === '';
647
617
  };
648
- const findCtrlTarget = (pos, edge, step, isEmpty) => {
649
- if (pos === edge)
650
- return pos;
651
- const next = pos + step;
652
- if (!isEmpty(pos) && !isEmpty(next)) {
653
- let p = next;
654
- while (p !== edge) {
655
- if (isEmpty(p + step))
656
- return p;
657
- p += step;
658
- }
659
- return edge;
660
- }
661
- let pp = next;
662
- while (pp !== edge) {
663
- if (!isEmpty(pp))
664
- return pp;
665
- pp += step;
666
- }
667
- return edge;
668
- };
618
+ const findCtrlTarget = findCtrlArrowTarget;
669
619
  switch (e.key) {
670
620
  case 'c':
671
621
  if (ctrl) {
@@ -779,29 +729,10 @@ let DataGridStateService = class DataGridStateService {
779
729
  }
780
730
  case 'Tab': {
781
731
  e.preventDefault();
782
- let newRowTab = rowIndex;
783
- let newColTab = columnIndex;
784
- if (e.shiftKey) {
785
- if (columnIndex > colOffset) {
786
- newColTab = columnIndex - 1;
787
- }
788
- else if (rowIndex > 0) {
789
- newRowTab = rowIndex - 1;
790
- newColTab = maxColIndex;
791
- }
792
- }
793
- else {
794
- if (columnIndex < maxColIndex) {
795
- newColTab = columnIndex + 1;
796
- }
797
- else if (rowIndex < maxRowIndex) {
798
- newRowTab = rowIndex + 1;
799
- newColTab = colOffset;
800
- }
801
- }
802
- const newDataColTab = newColTab - colOffset;
803
- this.setSelectionRange({ startRow: newRowTab, startCol: newDataColTab, endRow: newRowTab, endCol: newDataColTab });
804
- this.setActiveCell({ rowIndex: newRowTab, columnIndex: newColTab });
732
+ const tabResult = computeTabNavigation(rowIndex, columnIndex, maxRowIndex, maxColIndex, colOffset, e.shiftKey);
733
+ const newDataColTab = tabResult.columnIndex - colOffset;
734
+ this.setSelectionRange({ startRow: tabResult.rowIndex, startCol: newDataColTab, endRow: tabResult.rowIndex, endCol: newDataColTab });
735
+ this.setActiveCell({ rowIndex: tabResult.rowIndex, columnIndex: tabResult.columnIndex });
805
736
  break;
806
737
  }
807
738
  case 'Home': {
@@ -19,6 +19,10 @@ export declare abstract class BaseDataGridTableComponent<T = unknown> {
19
19
  readonly virtualScrollService: VirtualScrollService;
20
20
  protected lastMouseShift: boolean;
21
21
  readonly columnSizingVersion: import("@angular/core").WritableSignal<number>;
22
+ /** DOM-measured column widths from the last layout pass.
23
+ * Used as a minWidth floor to prevent columns from shrinking
24
+ * when new data loads (e.g. server-side pagination). */
25
+ readonly measuredColumnWidths: import("@angular/core").WritableSignal<Record<string, number>>;
22
26
  protected readonly wrapperElSignal: import("@angular/core").WritableSignal<HTMLElement | null>;
23
27
  protected readonly tableContainerElSignal: import("@angular/core").WritableSignal<HTMLElement | null>;
24
28
  /** Return the IOGridDataGridProps from however the subclass receives them */
@@ -29,6 +33,11 @@ export declare abstract class BaseDataGridTableComponent<T = unknown> {
29
33
  protected abstract getTableContainerRef(): ElementRef<HTMLElement> | undefined;
30
34
  /** Lifecycle hook — populate element signals from @ViewChild refs */
31
35
  ngAfterViewInit(): void;
36
+ /** Lifecycle hook — re-measure column widths after each view update */
37
+ ngAfterViewChecked(): void;
38
+ /** Measure actual th widths from the DOM and update the measuredColumnWidths signal.
39
+ * Only updates the signal when values actually change, to avoid render loops. */
40
+ private measureColumnWidths;
32
41
  readonly state: import("@angular/core").Signal<import("../services/datagrid-state.service").DataGridStateResult<T>>;
33
42
  readonly tableContainerEl: import("@angular/core").Signal<HTMLElement | null>;
34
43
  readonly allItems: import("@angular/core").Signal<T[]>;
@@ -138,6 +147,26 @@ export declare abstract class BaseDataGridTableComponent<T = unknown> {
138
147
  * (effects need to run inside an injection context).
139
148
  */
140
149
  protected initBase(): void;
150
+ /** Lookup effective min-width for a column (includes measured width floor) */
151
+ getEffectiveMinWidth(col: IColumnDef<T>): number;
152
+ /**
153
+ * Returns derived cell interaction metadata (non-event attributes) for use in templates.
154
+ * Mirrors React's getCellInteractionProps for the Angular view layer.
155
+ * Event handlers (mousedown, click, dblclick, contextmenu) are still bound inline in templates.
156
+ */
157
+ getCellInteractionProps(descriptor: {
158
+ isActive: boolean;
159
+ isInRange: boolean;
160
+ canEditAny: boolean;
161
+ globalColIndex: number;
162
+ rowIndex: number;
163
+ }): {
164
+ tabIndex: number;
165
+ dataRowIndex: number;
166
+ dataColIndex: number;
167
+ dataInRange: string | null;
168
+ role: string | null;
169
+ };
141
170
  asColumnDef(colDef: unknown): IColumnDef<T>;
142
171
  visibleColIndex(col: IColumnDef<T>): number;
143
172
  getColumnWidth(col: IColumnDef<T>): number;
@@ -0,0 +1,47 @@
1
+ import { EventEmitter, ElementRef } from '@angular/core';
2
+ import type { IColumnDef } from '@alaarab/ogrid-core';
3
+ /**
4
+ * Abstract base class for Angular inline cell editors.
5
+ * Contains all shared signals, lifecycle hooks, keyboard handlers,
6
+ * dropdown positioning, and display logic.
7
+ *
8
+ * Subclasses only need a @Component decorator with selector + template.
9
+ */
10
+ export declare abstract class BaseInlineCellEditorComponent<T = unknown> {
11
+ value: unknown;
12
+ item: T;
13
+ column: IColumnDef<T>;
14
+ rowIndex: number;
15
+ editorType: 'text' | 'select' | 'checkbox' | 'date' | 'richSelect';
16
+ commit: EventEmitter<unknown>;
17
+ cancel: EventEmitter<void>;
18
+ inputEl?: ElementRef<HTMLInputElement | HTMLSelectElement>;
19
+ selectWrapper?: ElementRef<HTMLDivElement>;
20
+ selectDropdown?: ElementRef<HTMLDivElement>;
21
+ richSelectWrapper?: ElementRef<HTMLDivElement>;
22
+ richSelectInput?: ElementRef<HTMLInputElement>;
23
+ richSelectDropdown?: ElementRef<HTMLDivElement>;
24
+ readonly localValue: import("@angular/core").WritableSignal<unknown>;
25
+ readonly highlightedIndex: import("@angular/core").WritableSignal<number>;
26
+ readonly selectOptions: import("@angular/core").WritableSignal<unknown[]>;
27
+ readonly searchText: import("@angular/core").WritableSignal<string>;
28
+ readonly filteredOptions: import("@angular/core").Signal<unknown[]>;
29
+ private _initialized;
30
+ ngOnInit(): void;
31
+ ngOnChanges(): void;
32
+ private syncFromInputs;
33
+ ngAfterViewInit(): void;
34
+ commitValue(value: unknown): void;
35
+ onTextKeyDown(e: KeyboardEvent): void;
36
+ getDisplayText(value: unknown): string;
37
+ onCustomSelectKeyDown(e: KeyboardEvent): void;
38
+ onRichSelectSearch(text: string): void;
39
+ onRichSelectKeyDown(e: KeyboardEvent): void;
40
+ onCheckboxKeyDown(e: KeyboardEvent): void;
41
+ onTextBlur(): void;
42
+ getInputStyle(): string;
43
+ /** Position a dropdown using fixed positioning to escape overflow clipping. */
44
+ protected positionFixedDropdown(wrapperRef: ElementRef<HTMLDivElement> | undefined, dropdownRef: ElementRef<HTMLDivElement> | undefined): void;
45
+ /** Scroll the highlighted option into view within a dropdown element. */
46
+ protected scrollOptionIntoView(dropdownRef: ElementRef<HTMLDivElement> | undefined): void;
47
+ }
@@ -23,5 +23,6 @@ export type { IColumnHeaderFilterProps } from './components/base-column-header-f
23
23
  export { BaseColumnChooserComponent } from './components/base-column-chooser.component';
24
24
  export type { IColumnChooserProps } from './components/base-column-chooser.component';
25
25
  export { BasePaginationControlsComponent } from './components/base-pagination-controls.component';
26
+ export { BaseInlineCellEditorComponent } from './components/base-inline-cell-editor.component';
26
27
  export type { HeaderFilterConfigInput, HeaderFilterConfig, CellRenderDescriptorInput, CellRenderDescriptor, CellRenderMode, } from './utils';
27
28
  export { getHeaderFilterConfig, getCellRenderDescriptor, resolveCellDisplayContent, resolveCellStyle, createDebouncedSignal, createDebouncedCallback, debounce, createLatestRef, createLatestCallback, } from './utils';
@@ -167,9 +167,7 @@ export declare class DataGridStateService<T> {
167
167
  private readonly cutRangeSig;
168
168
  private readonly copyRangeSig;
169
169
  private internalClipboard;
170
- private undoHistory;
171
- private redoStack;
172
- private batch;
170
+ private readonly undoRedoStack;
173
171
  private readonly undoLengthSig;
174
172
  private readonly redoLengthSig;
175
173
  private fillDragStart;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-angular",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "description": "OGrid Angular – Angular services, signals, and headless components for OGrid data grids.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -35,7 +35,7 @@
35
35
  "node": ">=18"
36
36
  },
37
37
  "dependencies": {
38
- "@alaarab/ogrid-core": "2.0.14"
38
+ "@alaarab/ogrid-core": "2.0.15"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@angular/core": "^21.0.0",