@alaarab/ogrid-js 2.0.22 → 2.1.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 (35) hide show
  1. package/dist/esm/OGrid.js +189 -503
  2. package/dist/esm/OGridEventWiring.js +178 -0
  3. package/dist/esm/OGridRendering.js +269 -0
  4. package/dist/esm/components/ColumnChooser.js +26 -3
  5. package/dist/esm/components/InlineCellEditor.js +18 -36
  6. package/dist/esm/index.js +2 -0
  7. package/dist/esm/renderer/TableRenderer.js +102 -61
  8. package/dist/esm/state/ClipboardState.js +8 -54
  9. package/dist/esm/state/ColumnPinningState.js +1 -2
  10. package/dist/esm/state/ColumnReorderState.js +8 -1
  11. package/dist/esm/state/EventEmitter.js +3 -2
  12. package/dist/esm/state/FillHandleState.js +27 -41
  13. package/dist/esm/state/GridState.js +36 -10
  14. package/dist/esm/state/HeaderFilterState.js +19 -11
  15. package/dist/esm/state/KeyboardNavState.js +19 -132
  16. package/dist/esm/state/RowSelectionState.js +6 -15
  17. package/dist/esm/state/SideBarState.js +1 -1
  18. package/dist/esm/state/TableLayoutState.js +6 -4
  19. package/dist/esm/utils/getCellCoordinates.js +15 -0
  20. package/dist/esm/utils/index.js +1 -0
  21. package/dist/types/OGrid.d.ts +97 -9
  22. package/dist/types/OGridEventWiring.d.ts +60 -0
  23. package/dist/types/OGridRendering.d.ts +93 -0
  24. package/dist/types/components/ColumnChooser.d.ts +5 -0
  25. package/dist/types/components/InlineCellEditor.d.ts +5 -0
  26. package/dist/types/index.d.ts +6 -1
  27. package/dist/types/renderer/TableRenderer.d.ts +12 -5
  28. package/dist/types/state/EventEmitter.d.ts +1 -1
  29. package/dist/types/state/FillHandleState.d.ts +1 -1
  30. package/dist/types/state/GridState.d.ts +7 -1
  31. package/dist/types/state/HeaderFilterState.d.ts +2 -0
  32. package/dist/types/types/gridTypes.d.ts +15 -0
  33. package/dist/types/utils/getCellCoordinates.d.ts +8 -0
  34. package/dist/types/utils/index.d.ts +1 -0
  35. package/package.json +11 -4
@@ -1,5 +1,5 @@
1
- import { getCellValue, buildHeaderRows, isInSelectionRange, ROW_NUMBER_COLUMN_WIDTH } from '@alaarab/ogrid-core';
2
- const CHECKBOX_COL_WIDTH = 40;
1
+ import { getCellValue, buildHeaderRows, isInSelectionRange, ROW_NUMBER_COLUMN_WIDTH, CHECKBOX_COLUMN_WIDTH } from '@alaarab/ogrid-core';
2
+ import { getCellCoordinates } from '../utils/getCellCoordinates';
3
3
  export class TableRenderer {
4
4
  constructor(container, state) {
5
5
  this.table = null;
@@ -17,6 +17,9 @@ export class TableRenderer {
17
17
  this._tbodyMousedownHandler = null;
18
18
  this._tbodyDblclickHandler = null;
19
19
  this._tbodyContextmenuHandler = null;
20
+ // Delegated event handlers bound to thead (avoids per-<th> inline listeners)
21
+ this._theadClickHandler = null;
22
+ this._theadMousedownHandler = null;
20
23
  // State tracking for incremental DOM patching
21
24
  this.lastActiveCell = null;
22
25
  this.lastSelectionRange = null;
@@ -47,11 +50,10 @@ export class TableRenderer {
47
50
  const cell = target.closest('td[data-row-index]');
48
51
  if (!cell)
49
52
  return null;
50
- const rowIndex = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
51
- const colIndex = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
52
- if (Number.isNaN(rowIndex) || Number.isNaN(colIndex))
53
+ const coords = getCellCoordinates(cell);
54
+ if (!coords)
53
55
  return null;
54
- return { el: cell, rowIndex, colIndex };
56
+ return { el: cell, rowIndex: coords.rowIndex, colIndex: coords.colIndex };
55
57
  }
56
58
  attachBodyDelegation() {
57
59
  if (!this.tbody)
@@ -60,13 +62,19 @@ export class TableRenderer {
60
62
  const cell = this.getCellFromEvent(e);
61
63
  if (!cell)
62
64
  return;
63
- this.interactionState?.onCellClick?.(cell.rowIndex, cell.colIndex, e);
65
+ this.interactionState?.onCellClick?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, event: e });
64
66
  };
65
67
  this._tbodyMousedownHandler = (e) => {
68
+ // Fill handle mousedown — delegated from per-cell inline listener
69
+ const target = e.target;
70
+ if (target.classList.contains('ogrid-fill-handle') || target.getAttribute('data-fill-handle') === 'true') {
71
+ this.interactionState?.onFillHandleMouseDown?.(e);
72
+ return;
73
+ }
66
74
  const cell = this.getCellFromEvent(e);
67
75
  if (!cell)
68
76
  return;
69
- this.interactionState?.onCellMouseDown?.(cell.rowIndex, cell.colIndex, e);
77
+ this.interactionState?.onCellMouseDown?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, event: e });
70
78
  };
71
79
  this._tbodyDblclickHandler = (e) => {
72
80
  const cell = this.getCellFromEvent(e);
@@ -79,13 +87,13 @@ export class TableRenderer {
79
87
  if (!item)
80
88
  return;
81
89
  const rowId = this.state.getRowId(item);
82
- this.interactionState?.onCellDoubleClick?.(cell.rowIndex, cell.colIndex, rowId, columnId);
90
+ this.interactionState?.onCellDoubleClick?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, rowId, columnId });
83
91
  };
84
92
  this._tbodyContextmenuHandler = (e) => {
85
93
  const cell = this.getCellFromEvent(e);
86
94
  if (!cell)
87
95
  return;
88
- this.interactionState?.onCellContextMenu?.(cell.rowIndex, cell.colIndex, e);
96
+ this.interactionState?.onCellContextMenu?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, event: e });
89
97
  };
90
98
  this.tbody.addEventListener('click', this._tbodyClickHandler, { passive: true });
91
99
  this.tbody.addEventListener('mousedown', this._tbodyMousedownHandler);
@@ -108,6 +116,57 @@ export class TableRenderer {
108
116
  this._tbodyDblclickHandler = null;
109
117
  this._tbodyContextmenuHandler = null;
110
118
  }
119
+ /** Attach delegated event listeners to <thead> for sort clicks, resize, reorder, and filter icon clicks. */
120
+ attachHeaderDelegation() {
121
+ if (!this.thead)
122
+ return;
123
+ // Sort clicks and filter icon clicks use inline listeners for stale-reference compatibility
124
+ // (tests hold references to <th> elements that become detached after header re-render).
125
+ // Delegation handles resize and column reorder mousedown events only.
126
+ this._theadClickHandler = null;
127
+ this._theadMousedownHandler = (e) => {
128
+ const target = e.target;
129
+ // Resize handle mousedown
130
+ if (target.classList.contains('ogrid-resize-handle')) {
131
+ e.stopPropagation();
132
+ const th = target.closest('th[data-column-id]');
133
+ if (!th)
134
+ return;
135
+ const columnId = th.getAttribute('data-column-id');
136
+ if (columnId) {
137
+ const rect = th.getBoundingClientRect();
138
+ this.interactionState?.onResizeStart?.(columnId, e.clientX, rect.width);
139
+ }
140
+ return;
141
+ }
142
+ // Don't start reorder from filter icon
143
+ if (target.classList.contains('ogrid-filter-icon'))
144
+ return;
145
+ // Column reorder mousedown
146
+ if (this.interactionState?.onColumnReorderStart) {
147
+ const th = target.closest('th[data-column-id]');
148
+ if (!th)
149
+ return;
150
+ const columnId = th.getAttribute('data-column-id');
151
+ if (columnId) {
152
+ this.interactionState.onColumnReorderStart(columnId, e);
153
+ }
154
+ }
155
+ };
156
+ if (this._theadClickHandler)
157
+ this.thead.addEventListener('click', this._theadClickHandler);
158
+ this.thead.addEventListener('mousedown', this._theadMousedownHandler);
159
+ }
160
+ detachHeaderDelegation() {
161
+ if (!this.thead)
162
+ return;
163
+ if (this._theadClickHandler)
164
+ this.thead.removeEventListener('click', this._theadClickHandler);
165
+ if (this._theadMousedownHandler)
166
+ this.thead.removeEventListener('mousedown', this._theadMousedownHandler);
167
+ this._theadClickHandler = null;
168
+ this._theadMousedownHandler = null;
169
+ }
111
170
  getWrapperElement() {
112
171
  return this.wrapperEl;
113
172
  }
@@ -121,13 +180,11 @@ export class TableRenderer {
121
180
  wrapper.setAttribute('role', 'grid');
122
181
  wrapper.setAttribute('tabindex', '0'); // Make focusable for keyboard nav
123
182
  wrapper.style.position = 'relative'; // For MarchingAnts absolute positioning
124
- const rowHeight = this.state._options?.rowHeight;
125
- if (rowHeight) {
126
- wrapper.style.setProperty('--ogrid-row-height', `${rowHeight}px`);
183
+ if (this.state.rowHeight) {
184
+ wrapper.style.setProperty('--ogrid-row-height', `${this.state.rowHeight}px`);
127
185
  }
128
- const ariaLabel = this.state._ariaLabel;
129
- if (ariaLabel) {
130
- wrapper.setAttribute('aria-label', ariaLabel);
186
+ if (this.state.ariaLabel) {
187
+ wrapper.setAttribute('aria-label', this.state.ariaLabel);
131
188
  }
132
189
  this.wrapperEl = wrapper;
133
190
  // Create table
@@ -136,6 +193,7 @@ export class TableRenderer {
136
193
  // Render header
137
194
  this.thead = document.createElement('thead');
138
195
  this.renderHeader();
196
+ this.attachHeaderDelegation();
139
197
  this.table.appendChild(this.thead);
140
198
  // Render body
141
199
  this.tbody = document.createElement('tbody');
@@ -254,8 +312,11 @@ export class TableRenderer {
254
312
  const cells = this.tbody.querySelectorAll('td[data-row-index][data-col-index]');
255
313
  for (let i = 0; i < cells.length; i++) {
256
314
  const el = cells[i];
257
- const rowIndex = parseInt(el.getAttribute('data-row-index'), 10);
258
- const globalColIndex = parseInt(el.getAttribute('data-col-index'), 10);
315
+ const coords = getCellCoordinates(el);
316
+ if (!coords)
317
+ continue;
318
+ const rowIndex = coords.rowIndex;
319
+ const globalColIndex = coords.colIndex;
259
320
  const colOffset = this.getColOffset();
260
321
  const colIndex = globalColIndex - colOffset;
261
322
  // --- Active cell ---
@@ -311,7 +372,7 @@ export class TableRenderer {
311
372
  colIndex === Math.max(selectionRange.startCol, selectionRange.endCol);
312
373
  const hadFill = !!oldFill;
313
374
  if (hadFill && !shouldHaveFill) {
314
- oldFill.remove();
375
+ oldFill?.remove();
315
376
  }
316
377
  else if (!hadFill && shouldHaveFill) {
317
378
  const fillHandle = document.createElement('div');
@@ -416,7 +477,7 @@ export class TableRenderer {
416
477
  if (hasCheckbox) {
417
478
  const th = document.createElement('th');
418
479
  th.className = 'ogrid-header-cell ogrid-checkbox-header';
419
- th.style.width = `${CHECKBOX_COL_WIDTH}px`;
480
+ th.style.width = `${CHECKBOX_COLUMN_WIDTH}px`;
420
481
  // Select-all checkbox only on last header row
421
482
  if (row === headerRows[headerRows.length - 1]) {
422
483
  this.appendSelectAllCheckbox(th);
@@ -431,32 +492,21 @@ export class TableRenderer {
431
492
  th.colSpan = cell.colSpan;
432
493
  if (!cell.isGroup && cell.columnDef?.sortable) {
433
494
  th.classList.add('ogrid-sortable');
495
+ // Sort click also inline for compatibility with tests that hold stale <th> references
434
496
  th.addEventListener('click', () => {
435
- if (cell.columnDef) {
497
+ if (cell.columnDef)
436
498
  this.state.toggleSort(cell.columnDef.columnId);
437
- }
438
499
  });
439
500
  }
440
501
  if (!cell.isGroup && cell.columnDef) {
441
502
  th.setAttribute('data-column-id', cell.columnDef.columnId);
442
503
  this.applyPinningStyles(th, cell.columnDef.columnId, true);
443
- // Column reorder in grouped headers
444
- if (this.interactionState?.onColumnReorderStart) {
445
- th.addEventListener('mousedown', (e) => {
446
- const target = e.target;
447
- if (target.classList.contains('ogrid-resize-handle') ||
448
- target.classList.contains('ogrid-filter-icon')) {
449
- return;
450
- }
451
- if (cell.columnDef) {
452
- this.interactionState?.onColumnReorderStart?.(cell.columnDef.columnId, e);
453
- }
454
- });
455
- }
504
+ // Resize, reorder, and filter icon clicks are handled
505
+ // via delegated listeners on <thead> (attachHeaderDelegation).
456
506
  }
457
507
  tr.appendChild(th);
458
508
  }
459
- this.thead.appendChild(tr);
509
+ this.thead?.appendChild(tr);
460
510
  }
461
511
  }
462
512
  else {
@@ -466,7 +516,7 @@ export class TableRenderer {
466
516
  if (hasCheckbox) {
467
517
  const th = document.createElement('th');
468
518
  th.className = 'ogrid-header-cell ogrid-checkbox-header';
469
- th.style.width = `${CHECKBOX_COL_WIDTH}px`;
519
+ th.style.width = `${CHECKBOX_COLUMN_WIDTH}px`;
470
520
  this.appendSelectAllCheckbox(th);
471
521
  tr.appendChild(th);
472
522
  }
@@ -490,6 +540,7 @@ export class TableRenderer {
490
540
  th.appendChild(textSpan);
491
541
  if (col.sortable) {
492
542
  th.classList.add('ogrid-sortable');
543
+ // Sort click also inline for compatibility with tests that hold stale <th> references
493
544
  th.addEventListener('click', () => this.state.toggleSort(col.columnId));
494
545
  }
495
546
  if (col.type === 'numeric') {
@@ -513,11 +564,7 @@ export class TableRenderer {
513
564
  resizeHandle.style.userSelect = 'none';
514
565
  th.style.position = th.style.position || 'relative';
515
566
  th.appendChild(resizeHandle);
516
- resizeHandle.addEventListener('mousedown', (e) => {
517
- e.stopPropagation();
518
- const rect = th.getBoundingClientRect();
519
- this.interactionState?.onResizeStart?.(col.columnId, e.clientX, rect.width);
520
- });
567
+ // Resize mousedown handled via delegated listener on <thead>
521
568
  // Filter icon (if column is filterable)
522
569
  const filterConfig = this.filterConfigs.get(col.columnId);
523
570
  if (filterConfig && this.onFilterIconClick) {
@@ -546,21 +593,10 @@ export class TableRenderer {
546
593
  });
547
594
  th.appendChild(filterBtn);
548
595
  }
549
- // Column reorder: mousedown on header starts drag
550
- if (this.interactionState?.onColumnReorderStart) {
551
- th.addEventListener('mousedown', (e) => {
552
- // Don't start reorder if clicking resize handle or filter button
553
- const target = e.target;
554
- if (target.classList.contains('ogrid-resize-handle') ||
555
- target.classList.contains('ogrid-filter-icon')) {
556
- return;
557
- }
558
- this.interactionState?.onColumnReorderStart?.(col.columnId, e);
559
- });
560
- }
596
+ // Column reorder mousedown handled via delegated listener on <thead>
561
597
  tr.appendChild(th);
562
598
  }
563
- this.thead.appendChild(tr);
599
+ this.thead?.appendChild(tr);
564
600
  }
565
601
  }
566
602
  appendSelectAllCheckbox(th) {
@@ -605,7 +641,9 @@ export class TableRenderer {
605
641
  let startIndex = 0;
606
642
  let endIndex = items.length - 1;
607
643
  if (isVirtual) {
608
- const range = vs.visibleRange;
644
+ const range = vs?.visibleRange;
645
+ if (!range)
646
+ return;
609
647
  startIndex = Math.max(0, range.startIndex);
610
648
  endIndex = Math.min(items.length - 1, range.endIndex);
611
649
  // Top spacer row
@@ -638,7 +676,7 @@ export class TableRenderer {
638
676
  if (hasCheckbox) {
639
677
  const td = document.createElement('td');
640
678
  td.className = 'ogrid-cell ogrid-checkbox-cell';
641
- td.style.width = `${CHECKBOX_COL_WIDTH}px`;
679
+ td.style.width = `${CHECKBOX_COLUMN_WIDTH}px`;
642
680
  td.style.textAlign = 'center';
643
681
  const checkbox = document.createElement('input');
644
682
  checkbox.type = 'checkbox';
@@ -747,9 +785,7 @@ export class TableRenderer {
747
785
  fillHandle.style.cursor = 'crosshair';
748
786
  fillHandle.style.zIndex = '5';
749
787
  td.style.position = td.style.position || 'relative';
750
- fillHandle.addEventListener('mousedown', (e) => {
751
- this.interactionState?.onFillHandleMouseDown?.(e);
752
- });
788
+ // Fill handle mousedown handled via delegated listener on <tbody>
753
789
  td.appendChild(fillHandle);
754
790
  }
755
791
  }
@@ -758,7 +794,7 @@ export class TableRenderer {
758
794
  this.tbody.appendChild(tr);
759
795
  }
760
796
  // Virtual scrolling: bottom spacer row
761
- if (isVirtual) {
797
+ if (isVirtual && vs) {
762
798
  const range = vs.visibleRange;
763
799
  if (range.offsetBottom > 0) {
764
800
  const bottomSpacer = document.createElement('tr');
@@ -777,6 +813,10 @@ export class TableRenderer {
777
813
  getTableElement() {
778
814
  return this.table;
779
815
  }
816
+ /** Get the current onResizeStart handler from interaction state (avoids bracket notation access). */
817
+ getOnResizeStart() {
818
+ return this.interactionState?.onResizeStart;
819
+ }
780
820
  /** Update the drop indicator position during column reorder. */
781
821
  updateDropIndicator(x, isDragging) {
782
822
  if (!this.dropIndicator || !this.wrapperEl)
@@ -792,6 +832,7 @@ export class TableRenderer {
792
832
  this.dropIndicator.style.left = `${relativeX}px`;
793
833
  }
794
834
  destroy() {
835
+ this.detachHeaderDelegation();
795
836
  this.detachBodyDelegation();
796
837
  this.container.innerHTML = '';
797
838
  this.table = null;
@@ -1,5 +1,4 @@
1
- import { normalizeSelectionRange, getCellValue, formatSelectionAsTsv, parseTsvClipboard } from '@alaarab/ogrid-core';
2
- import { parseValue } from '@alaarab/ogrid-core';
1
+ import { normalizeSelectionRange, formatSelectionAsTsv, parseTsvClipboard, applyPastedValues, applyCutClear } from '@alaarab/ogrid-core';
3
2
  import { EventEmitter } from './EventEmitter';
4
3
  export class ClipboardState {
5
4
  constructor(params, getActiveCell, getSelectionRange) {
@@ -84,59 +83,14 @@ export class ClipboardState {
84
83
  const anchorRow = norm ? norm.startRow : 0;
85
84
  const anchorCol = norm ? norm.startCol : 0;
86
85
  const { items, visibleCols } = this.params;
87
- const lines = parseTsvClipboard(text);
88
- for (let r = 0; r < lines.length; r++) {
89
- const cells = lines[r];
90
- for (let c = 0; c < cells.length; c++) {
91
- const targetRow = anchorRow + r;
92
- const targetCol = anchorCol + c;
93
- if (targetRow >= items.length || targetCol >= visibleCols.length)
94
- continue;
95
- const item = items[targetRow];
96
- const col = visibleCols[targetCol];
97
- const colEditable = col.editable === true ||
98
- (typeof col.editable === 'function' && col.editable(item));
99
- if (!colEditable)
100
- continue;
101
- const rawValue = cells[c] ?? '';
102
- const oldValue = getCellValue(item, col);
103
- const result = parseValue(rawValue, oldValue, item, col);
104
- if (!result.valid)
105
- continue;
106
- onCellValueChanged({
107
- item,
108
- columnId: col.columnId,
109
- oldValue,
110
- newValue: result.value,
111
- rowIndex: targetRow,
112
- });
113
- }
114
- }
86
+ const parsedRows = parseTsvClipboard(text);
87
+ const pasteEvents = applyPastedValues(parsedRows, anchorRow, anchorCol, items, visibleCols);
88
+ for (const evt of pasteEvents)
89
+ onCellValueChanged(evt);
115
90
  if (this._cutRange) {
116
- const cut = this._cutRange;
117
- for (let r = cut.startRow; r <= cut.endRow; r++) {
118
- for (let c = cut.startCol; c <= cut.endCol; c++) {
119
- if (r >= items.length || c >= visibleCols.length)
120
- continue;
121
- const item = items[r];
122
- const col = visibleCols[c];
123
- const colEditable = col.editable === true ||
124
- (typeof col.editable === 'function' && col.editable(item));
125
- if (!colEditable)
126
- continue;
127
- const oldValue = getCellValue(item, col);
128
- const result = parseValue('', oldValue, item, col);
129
- if (!result.valid)
130
- continue;
131
- onCellValueChanged({
132
- item,
133
- columnId: col.columnId,
134
- oldValue,
135
- newValue: result.value,
136
- rowIndex: r,
137
- });
138
- }
139
- }
91
+ const cutEvents = applyCutClear(this._cutRange, items, visibleCols);
92
+ for (const evt of cutEvents)
93
+ onCellValueChanged(evt);
140
94
  this._cutRange = null;
141
95
  }
142
96
  this._copyRange = null;
@@ -29,8 +29,7 @@ export class ColumnPinningState {
29
29
  this.emitter.emit('pinningChange', { pinnedColumns: this._pinnedColumns });
30
30
  }
31
31
  unpinColumn(columnId) {
32
- const next = { ...this._pinnedColumns };
33
- delete next[columnId];
32
+ const { [columnId]: _, ...next } = this._pinnedColumns;
34
33
  this._pinnedColumns = next;
35
34
  this.emitter.emit('pinningChange', { pinnedColumns: this._pinnedColumns });
36
35
  }
@@ -69,7 +69,14 @@ export class ColumnReorderState {
69
69
  this.rafId = 0;
70
70
  if (!this._draggedColumnId || !this.tableElement)
71
71
  return;
72
- const result = calculateDropTarget(mouseX, this.columnOrder, this._draggedColumnId, this.draggedPinState, this.tableElement, this.pinnedColumns);
72
+ const result = calculateDropTarget({
73
+ mouseX,
74
+ columnOrder: this.columnOrder,
75
+ draggedColumnId: this._draggedColumnId,
76
+ draggedPinState: this.draggedPinState,
77
+ tableElement: this.tableElement,
78
+ pinnedColumns: this.pinnedColumns,
79
+ });
73
80
  if (!result)
74
81
  return;
75
82
  const prevX = this._dropIndicatorX;
@@ -6,12 +6,13 @@ export class EventEmitter {
6
6
  if (!this.handlers.has(event)) {
7
7
  this.handlers.set(event, new Set());
8
8
  }
9
- this.handlers.get(event).add(handler);
9
+ this.handlers.get(event)?.add(handler);
10
10
  }
11
11
  off(event, handler) {
12
12
  this.handlers.get(event)?.delete(handler);
13
13
  }
14
- emit(event, data) {
14
+ emit(event, ...args) {
15
+ const data = args[0];
15
16
  this.handlers.get(event)?.forEach(handler => {
16
17
  handler(data);
17
18
  });
@@ -1,5 +1,6 @@
1
- import { normalizeSelectionRange, getCellValue, parseValue } from '@alaarab/ogrid-core';
1
+ import { normalizeSelectionRange, applyFillValues } from '@alaarab/ogrid-core';
2
2
  import { EventEmitter } from './EventEmitter';
3
+ import { getCellCoordinates } from '../utils/getCellCoordinates';
3
4
  /**
4
5
  * Manages Excel-style fill handle drag-to-fill for cell ranges (vanilla JS).
5
6
  * Mirrors the React `useFillHandle` hook as a class-based state.
@@ -109,49 +110,24 @@ export class FillHandleState {
109
110
  this.setSelectionRange(norm);
110
111
  this.setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + this.params.colOffset });
111
112
  // Apply fill values
112
- this.applyFillValues(norm, start);
113
+ this.applyFillValuesFromCore(norm, start);
113
114
  this._isFillDragging = false;
114
115
  this.fillDragStart = null;
115
116
  this.liveFillRange = null;
116
117
  this.lastMousePos = null;
117
118
  this.emitter.emit('fillRangeChange', { fillRange: null });
118
119
  }
119
- applyFillValues(norm, start) {
120
+ applyFillValuesFromCore(norm, start) {
120
121
  const { items, visibleCols, onCellValueChanged, beginBatch, endBatch } = this.params;
121
122
  if (!onCellValueChanged)
122
123
  return;
123
- const startItem = items[norm.startRow];
124
- const startColDef = visibleCols[norm.startCol];
125
- if (!startItem || !startColDef)
126
- return;
127
- const startValue = getCellValue(startItem, startColDef);
128
- beginBatch?.();
129
- for (let row = norm.startRow; row <= norm.endRow; row++) {
130
- for (let col = norm.startCol; col <= norm.endCol; col++) {
131
- if (row === start.startRow && col === start.startCol)
132
- continue;
133
- if (row >= items.length || col >= visibleCols.length)
134
- continue;
135
- const item = items[row];
136
- const colDef = visibleCols[col];
137
- const colEditable = colDef.editable === true ||
138
- (typeof colDef.editable === 'function' && colDef.editable(item));
139
- if (!colEditable)
140
- continue;
141
- const oldValue = getCellValue(item, colDef);
142
- const result = parseValue(startValue, oldValue, item, colDef);
143
- if (!result.valid)
144
- continue;
145
- onCellValueChanged({
146
- item,
147
- columnId: colDef.columnId,
148
- oldValue,
149
- newValue: result.value,
150
- rowIndex: row,
151
- });
152
- }
124
+ const fillEvents = applyFillValues(norm, start.startRow, start.startCol, items, visibleCols);
125
+ if (fillEvents.length > 0) {
126
+ beginBatch?.();
127
+ for (const evt of fillEvents)
128
+ onCellValueChanged(evt);
129
+ endBatch?.();
153
130
  }
154
- endBatch?.();
155
131
  }
156
132
  resolveRange(cx, cy) {
157
133
  if (!this.fillDragStart || !this.wrapperRef)
@@ -160,11 +136,11 @@ export class FillHandleState {
160
136
  const cell = target?.closest?.('[data-row-index][data-col-index]');
161
137
  if (!cell || !this.wrapperRef.contains(cell))
162
138
  return null;
163
- const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
164
- const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
165
- if (Number.isNaN(r) || Number.isNaN(c) || c < this.params.colOffset)
139
+ const coords = getCellCoordinates(cell);
140
+ if (!coords || coords.colIndex < this.params.colOffset)
166
141
  return null;
167
- const dataCol = c - this.params.colOffset;
142
+ const r = coords.rowIndex;
143
+ const dataCol = coords.colIndex - this.params.colOffset;
168
144
  return normalizeSelectionRange({
169
145
  startRow: this.fillDragStart.startRow,
170
146
  startCol: this.fillDragStart.startCol,
@@ -183,8 +159,11 @@ export class FillHandleState {
183
159
  const maxC = Math.max(range.startCol, range.endCol);
184
160
  for (let i = 0; i < cells.length; i++) {
185
161
  const el = cells[i];
186
- const r = parseInt(el.getAttribute('data-row-index'), 10);
187
- const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
162
+ const coords = getCellCoordinates(el);
163
+ if (!coords)
164
+ continue;
165
+ const r = coords.rowIndex;
166
+ const c = coords.colIndex - colOff;
188
167
  const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
189
168
  if (inRange) {
190
169
  if (!el.hasAttribute('data-drag-range'))
@@ -212,9 +191,16 @@ export class FillHandleState {
212
191
  if (this._isFillDragging) {
213
192
  window.removeEventListener('mousemove', this.onMoveBound, true);
214
193
  window.removeEventListener('mouseup', this.onUpBound, true);
194
+ this.clearDragAttrs();
195
+ this._isFillDragging = false;
196
+ this.fillDragStart = null;
197
+ this.liveFillRange = null;
198
+ this.lastMousePos = null;
215
199
  }
216
- if (this.rafHandle)
200
+ if (this.rafHandle) {
217
201
  cancelAnimationFrame(this.rafHandle);
202
+ this.rafHandle = 0;
203
+ }
218
204
  this.emitter.removeAllListeners();
219
205
  }
220
206
  }
@@ -1,4 +1,4 @@
1
- import { flattenColumns, processClientSideData, exportToCsv as coreExportToCsv, getCellValue, deriveFilterOptionsFromData, mergeFilter, } from '@alaarab/ogrid-core';
1
+ import { flattenColumns, processClientSideData, exportToCsv as coreExportToCsv, getCellValue, deriveFilterOptionsFromData, mergeFilter, validateColumns, validateRowIds, } from '@alaarab/ogrid-core';
2
2
  import { EventEmitter } from './EventEmitter';
3
3
  export class GridState {
4
4
  constructor(options) {
@@ -15,6 +15,9 @@ export class GridState {
15
15
  this._filterOptions = {};
16
16
  // Column display order (array of columnIds)
17
17
  this._columnOrder = [];
18
+ // Dirty-flag memoization for visibleColumnDefs getter
19
+ this._visibleColsCache = null;
20
+ this._visibleColsDirty = true;
18
21
  this._allColumns = options.columns;
19
22
  this._columns = flattenColumns(options.columns);
20
23
  this._getRowId = options.getRowId;
@@ -28,10 +31,18 @@ export class GridState {
28
31
  this._columnOrder = this._columns.map(c => c.columnId);
29
32
  this._onError = options.onError;
30
33
  this._onFirstDataRendered = options.onFirstDataRendered;
34
+ this._rowHeight = options.rowHeight;
35
+ this._ariaLabel = options.ariaLabel;
31
36
  // Derive initial filter options for client-side data
32
37
  if (!this._dataSource) {
33
38
  this._filterOptions = deriveFilterOptionsFromData(this._data, this._columns);
34
39
  }
40
+ // Runtime validation — runs once at construction
41
+ validateColumns(this._columns);
42
+ if (!this._dataSource && this._data.length > 0) {
43
+ validateRowIds(this._data, this._getRowId);
44
+ this._firstDataRendered = true;
45
+ }
35
46
  // If server-side, trigger initial fetch
36
47
  if (this._dataSource) {
37
48
  this._isLoading = true;
@@ -52,17 +63,26 @@ export class GridState {
52
63
  get isServerSide() { return this._dataSource != null; }
53
64
  get filterOptions() { return this._filterOptions; }
54
65
  get columnOrder() { return this._columnOrder; }
55
- /** Get the visible columns in display order (respects column reorder). */
66
+ get rowHeight() { return this._rowHeight; }
67
+ get ariaLabel() { return this._ariaLabel; }
68
+ /** Get the visible columns in display order (respects column reorder). Memoized via dirty flag. */
56
69
  get visibleColumnDefs() {
70
+ if (!this._visibleColsDirty && this._visibleColsCache)
71
+ return this._visibleColsCache;
57
72
  const visible = this._columns.filter(c => this._visibleColumns.has(c.columnId));
58
- if (this._columnOrder.length === 0)
59
- return visible;
60
- const orderMap = new Map(this._columnOrder.map((id, idx) => [id, idx]));
61
- return [...visible].sort((a, b) => {
62
- const ai = orderMap.get(a.columnId) ?? Infinity;
63
- const bi = orderMap.get(b.columnId) ?? Infinity;
64
- return ai - bi;
65
- });
73
+ if (this._columnOrder.length === 0) {
74
+ this._visibleColsCache = visible;
75
+ }
76
+ else {
77
+ const orderMap = new Map(this._columnOrder.map((id, idx) => [id, idx]));
78
+ this._visibleColsCache = [...visible].sort((a, b) => {
79
+ const ai = orderMap.get(a.columnId) ?? Infinity;
80
+ const bi = orderMap.get(b.columnId) ?? Infinity;
81
+ return ai - bi;
82
+ });
83
+ }
84
+ this._visibleColsDirty = false;
85
+ return this._visibleColsCache;
66
86
  }
67
87
  /** Get processed (sorted, filtered, paginated) items for current page. */
68
88
  getProcessedItems() {
@@ -105,6 +125,7 @@ export class GridState {
105
125
  this._isLoading = false;
106
126
  if (!this._firstDataRendered && res.items.length > 0) {
107
127
  this._firstDataRendered = true;
128
+ validateRowIds(res.items, this._getRowId);
108
129
  this._onFirstDataRendered?.();
109
130
  }
110
131
  this.emitter.emit('stateChange', { type: 'data' });
@@ -196,10 +217,12 @@ export class GridState {
196
217
  }
197
218
  setVisibleColumns(columns) {
198
219
  this._visibleColumns = columns;
220
+ this._visibleColsDirty = true;
199
221
  this.emitter.emit('stateChange', { type: 'columns' });
200
222
  }
201
223
  setColumnOrder(order) {
202
224
  this._columnOrder = order;
225
+ this._visibleColsDirty = true;
203
226
  this.emitter.emit('stateChange', { type: 'columns' });
204
227
  }
205
228
  setLoading(loading) {
@@ -265,6 +288,9 @@ export class GridState {
265
288
  },
266
289
  getDisplayedRows: () => this.getProcessedItems().items,
267
290
  refreshData: () => this.refreshData(),
291
+ // scrollToRow is wired by OGrid after construction when virtualScrollState is present.
292
+ // This stub is replaced by OGrid.ts (see "Wire scrollToRow API method") for virtual scroll.
293
+ // For non-virtual grids it remains a no-op (native browser scroll handles row visibility).
268
294
  scrollToRow: () => { },
269
295
  getColumnOrder: () => [...this._columnOrder],
270
296
  setColumnOrder: (order) => this.setColumnOrder(order),