@alaarab/ogrid-js 2.0.19 → 2.0.21

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.
package/dist/esm/OGrid.js CHANGED
@@ -405,8 +405,8 @@ export class OGrid {
405
405
  document.body.style.cursor = 'col-resize';
406
406
  this.resizeState?.startResize(columnId, clientX, currentWidth);
407
407
  };
408
- document.addEventListener('mousemove', handleMouseMove);
409
- document.addEventListener('mouseup', handleMouseUp);
408
+ document.addEventListener('mousemove', handleMouseMove, { passive: true });
409
+ document.addEventListener('mouseup', handleMouseUp, { passive: true });
410
410
  // Store references for cleanup
411
411
  this.unsubscribes.push(() => {
412
412
  document.removeEventListener('mousemove', handleMouseMove);
@@ -489,7 +489,8 @@ export class OGrid {
489
489
  const minC = norm.startCol;
490
490
  const maxC = norm.endCol;
491
491
  const cells = wrapper.querySelectorAll('td[data-row-index][data-col-index]');
492
- for (const cell of Array.from(cells)) {
492
+ for (let _i = 0; _i < cells.length; _i++) {
493
+ const cell = cells[_i];
493
494
  const el = cell;
494
495
  const rowIndex = parseInt(el.getAttribute('data-row-index') ?? '-1', 10);
495
496
  const colIndex = parseInt(el.getAttribute('data-col-index') ?? '-1', 10);
@@ -803,7 +804,7 @@ export class OGrid {
803
804
  const { totalCount } = this.state.getProcessedItems();
804
805
  // Update virtual scroll with current total row count
805
806
  this.virtualScrollState?.setTotalRows(totalCount);
806
- this.pagination.render(totalCount);
807
+ this.pagination.render(totalCount, this.options.pageSizeOptions);
807
808
  this.statusBar.render({ totalCount });
808
809
  this.columnChooser.render();
809
810
  this.renderSideBar();
@@ -66,13 +66,13 @@ export class ContextMenu {
66
66
  else {
67
67
  menuItem.addEventListener('mouseenter', () => {
68
68
  menuItem.style.backgroundColor = 'var(--ogrid-bg-hover, #f5f5f5)';
69
- });
69
+ }, { passive: true });
70
70
  menuItem.addEventListener('mouseleave', () => {
71
71
  menuItem.style.backgroundColor = '';
72
- });
72
+ }, { passive: true });
73
73
  menuItem.addEventListener('click', () => {
74
74
  this.handleItemClick(item.id);
75
- });
75
+ }, { passive: true });
76
76
  }
77
77
  this.menu.appendChild(menuItem);
78
78
  }
@@ -84,7 +84,7 @@ export class ContextMenu {
84
84
  }
85
85
  };
86
86
  setTimeout(() => {
87
- document.addEventListener('mousedown', handleClickOutside);
87
+ document.addEventListener('mousedown', handleClickOutside, { passive: true });
88
88
  }, 0);
89
89
  }
90
90
  close() {
@@ -267,7 +267,8 @@ export class InlineCellEditor {
267
267
  dropdown.style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)';
268
268
  wrapper.appendChild(dropdown);
269
269
  let highlightedIndex = Math.max(values.findIndex((v) => String(v) === String(value)), 0);
270
- const renderOptions = () => {
270
+ // Build all option elements once
271
+ const buildOptions = () => {
271
272
  dropdown.innerHTML = '';
272
273
  for (let i = 0; i < values.length; i++) {
273
274
  const val = values[i];
@@ -291,25 +292,42 @@ export class InlineCellEditor {
291
292
  dropdown.appendChild(option);
292
293
  }
293
294
  };
295
+ // Only update CSS class on old/new highlighted item — avoids rebuilding the DOM
296
+ const updateHighlight = (prevIndex, nextIndex) => {
297
+ const prev = dropdown.children[prevIndex];
298
+ const next = dropdown.children[nextIndex];
299
+ if (prev) {
300
+ prev.style.background = '';
301
+ prev.setAttribute('aria-selected', 'false');
302
+ }
303
+ if (next) {
304
+ next.style.background = 'var(--ogrid-bg-hover, #e8f0fe)';
305
+ next.setAttribute('aria-selected', 'true');
306
+ }
307
+ };
294
308
  const scrollHighlightedIntoView = () => {
295
309
  const highlighted = dropdown.children[highlightedIndex];
296
310
  highlighted?.scrollIntoView({ block: 'nearest' });
297
311
  };
298
- renderOptions();
312
+ buildOptions();
299
313
  wrapper.addEventListener('keydown', (e) => {
300
314
  switch (e.key) {
301
- case 'ArrowDown':
315
+ case 'ArrowDown': {
302
316
  e.preventDefault();
317
+ const prevDown = highlightedIndex;
303
318
  highlightedIndex = Math.min(highlightedIndex + 1, values.length - 1);
304
- renderOptions();
319
+ updateHighlight(prevDown, highlightedIndex);
305
320
  scrollHighlightedIntoView();
306
321
  break;
307
- case 'ArrowUp':
322
+ }
323
+ case 'ArrowUp': {
308
324
  e.preventDefault();
325
+ const prevUp = highlightedIndex;
309
326
  highlightedIndex = Math.max(highlightedIndex - 1, 0);
310
- renderOptions();
327
+ updateHighlight(prevUp, highlightedIndex);
311
328
  scrollHighlightedIntoView();
312
329
  break;
330
+ }
313
331
  case 'Enter':
314
332
  e.preventDefault();
315
333
  e.stopPropagation();
@@ -386,10 +404,10 @@ export class InlineCellEditor {
386
404
  });
387
405
  option.addEventListener('mouseenter', () => {
388
406
  option.style.backgroundColor = 'var(--ogrid-hover-bg, rgba(0, 0, 0, 0.04))';
389
- });
407
+ }, { passive: true });
390
408
  option.addEventListener('mouseleave', () => {
391
409
  option.style.backgroundColor = 'var(--ogrid-bg, #fff)';
392
- });
410
+ }, { passive: true });
393
411
  dropdown.appendChild(option);
394
412
  }
395
413
  };
@@ -5,10 +5,10 @@ export class PaginationControls {
5
5
  this.container = container;
6
6
  this.state = state;
7
7
  }
8
- render(totalCount) {
8
+ render(totalCount, pageSizeOptions) {
9
9
  if (this.el)
10
10
  this.el.remove();
11
- const vm = getPaginationViewModel(this.state.page, this.state.pageSize, totalCount);
11
+ const vm = getPaginationViewModel(this.state.page, this.state.pageSize, totalCount, pageSizeOptions ? { pageSizeOptions } : undefined);
12
12
  if (!vm)
13
13
  return; // No pagination if totalCount is 0
14
14
  this.el = document.createElement('div');
@@ -12,6 +12,20 @@ export class TableRenderer {
12
12
  this.onFilterIconClick = null;
13
13
  this.dropIndicator = null;
14
14
  this.virtualScrollState = null;
15
+ // Delegated event handlers bound to tbody
16
+ this._tbodyClickHandler = null;
17
+ this._tbodyMousedownHandler = null;
18
+ this._tbodyDblclickHandler = null;
19
+ this._tbodyContextmenuHandler = null;
20
+ // State tracking for incremental DOM patching
21
+ this.lastActiveCell = null;
22
+ this.lastSelectionRange = null;
23
+ this.lastCopyRange = null;
24
+ this.lastCutRange = null;
25
+ this.lastEditingCell = null;
26
+ this.lastColumnWidths = {};
27
+ this.lastHeaderSignature = '';
28
+ this.lastRenderedItems = null;
15
29
  this.container = container;
16
30
  this.state = state;
17
31
  }
@@ -28,6 +42,72 @@ export class TableRenderer {
28
42
  setInteractionState(state) {
29
43
  this.interactionState = state;
30
44
  }
45
+ getCellFromEvent(e) {
46
+ const target = e.target;
47
+ const cell = target.closest('td[data-row-index]');
48
+ if (!cell)
49
+ 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
+ return null;
54
+ return { el: cell, rowIndex, colIndex };
55
+ }
56
+ attachBodyDelegation() {
57
+ if (!this.tbody)
58
+ return;
59
+ this._tbodyClickHandler = (e) => {
60
+ const cell = this.getCellFromEvent(e);
61
+ if (!cell)
62
+ return;
63
+ this.interactionState?.onCellClick?.(cell.rowIndex, cell.colIndex, e);
64
+ };
65
+ this._tbodyMousedownHandler = (e) => {
66
+ const cell = this.getCellFromEvent(e);
67
+ if (!cell)
68
+ return;
69
+ this.interactionState?.onCellMouseDown?.(cell.rowIndex, cell.colIndex, e);
70
+ };
71
+ this._tbodyDblclickHandler = (e) => {
72
+ const cell = this.getCellFromEvent(e);
73
+ if (!cell)
74
+ return;
75
+ const columnId = cell.el.getAttribute('data-column-id') ?? '';
76
+ // Retrieve the typed rowId by looking up the item at the row index (avoids string/number mismatch from data-row-id)
77
+ const { items } = this.state.getProcessedItems();
78
+ const item = items[cell.rowIndex];
79
+ if (!item)
80
+ return;
81
+ const rowId = this.state.getRowId(item);
82
+ this.interactionState?.onCellDoubleClick?.(cell.rowIndex, cell.colIndex, rowId, columnId);
83
+ };
84
+ this._tbodyContextmenuHandler = (e) => {
85
+ const cell = this.getCellFromEvent(e);
86
+ if (!cell)
87
+ return;
88
+ this.interactionState?.onCellContextMenu?.(cell.rowIndex, cell.colIndex, e);
89
+ };
90
+ this.tbody.addEventListener('click', this._tbodyClickHandler, { passive: true });
91
+ this.tbody.addEventListener('mousedown', this._tbodyMousedownHandler);
92
+ this.tbody.addEventListener('dblclick', this._tbodyDblclickHandler, { passive: true });
93
+ this.tbody.addEventListener('contextmenu', this._tbodyContextmenuHandler);
94
+ }
95
+ detachBodyDelegation() {
96
+ if (!this.tbody)
97
+ return;
98
+ if (this._tbodyClickHandler)
99
+ this.tbody.removeEventListener('click', this._tbodyClickHandler);
100
+ if (this._tbodyMousedownHandler)
101
+ this.tbody.removeEventListener('mousedown', this._tbodyMousedownHandler);
102
+ if (this._tbodyDblclickHandler)
103
+ this.tbody.removeEventListener('dblclick', this._tbodyDblclickHandler);
104
+ if (this._tbodyContextmenuHandler)
105
+ this.tbody.removeEventListener('contextmenu', this._tbodyContextmenuHandler);
106
+ this._tbodyClickHandler = null;
107
+ this._tbodyMousedownHandler = null;
108
+ this._tbodyDblclickHandler = null;
109
+ this._tbodyContextmenuHandler = null;
110
+ }
31
111
  getWrapperElement() {
32
112
  return this.wrapperEl;
33
113
  }
@@ -41,6 +121,10 @@ export class TableRenderer {
41
121
  wrapper.setAttribute('role', 'grid');
42
122
  wrapper.setAttribute('tabindex', '0'); // Make focusable for keyboard nav
43
123
  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`);
127
+ }
44
128
  const ariaLabel = this.state._ariaLabel;
45
129
  if (ariaLabel) {
46
130
  wrapper.setAttribute('aria-label', ariaLabel);
@@ -56,6 +140,7 @@ export class TableRenderer {
56
140
  // Render body
57
141
  this.tbody = document.createElement('tbody');
58
142
  this.renderBody();
143
+ this.attachBodyDelegation();
59
144
  this.table.appendChild(this.tbody);
60
145
  wrapper.appendChild(this.table);
61
146
  // Create drop indicator for column reorder (hidden by default)
@@ -64,17 +149,220 @@ export class TableRenderer {
64
149
  this.dropIndicator.style.display = 'none';
65
150
  wrapper.appendChild(this.dropIndicator);
66
151
  this.container.appendChild(wrapper);
152
+ this.snapshotState();
153
+ }
154
+ /** Compute a signature string that captures header-affecting state. */
155
+ computeHeaderSignature() {
156
+ const cols = this.state.visibleColumnDefs;
157
+ const is = this.interactionState;
158
+ const parts = [];
159
+ for (const col of cols) {
160
+ parts.push(col.columnId);
161
+ parts.push(col.name);
162
+ parts.push(is?.columnWidths[col.columnId]?.toString() ?? '');
163
+ }
164
+ // Include sort state
165
+ const sort = this.state.sort;
166
+ if (sort)
167
+ parts.push(`sort:${sort.field}:${sort.direction}`);
168
+ // Include row selection mode and checkbox header state
169
+ parts.push(`sel:${is?.rowSelectionMode ?? ''}`);
170
+ parts.push(`allSel:${is?.allSelected ?? ''}`);
171
+ parts.push(`someSel:${is?.someSelected ?? ''}`);
172
+ // Include showRowNumbers
173
+ parts.push(`rn:${is?.showRowNumbers ?? ''}`);
174
+ // Include filter active states
175
+ for (const [colId, config] of this.filterConfigs) {
176
+ const hasActive = this.headerFilterState?.hasActiveFilter(config);
177
+ if (hasActive)
178
+ parts.push(`flt:${colId}`);
179
+ }
180
+ return parts.join('|');
181
+ }
182
+ /** Save current interaction state for next diff comparison. */
183
+ snapshotState() {
184
+ const is = this.interactionState;
185
+ this.lastActiveCell = is?.activeCell ? { ...is.activeCell } : null;
186
+ this.lastSelectionRange = is?.selectionRange ? { ...is.selectionRange } : null;
187
+ this.lastCopyRange = is?.copyRange ? { ...is.copyRange } : null;
188
+ this.lastCutRange = is?.cutRange ? { ...is.cutRange } : null;
189
+ this.lastEditingCell = is?.editingCell ? { ...is.editingCell } : null;
190
+ this.lastColumnWidths = is?.columnWidths ? { ...is.columnWidths } : {};
191
+ this.lastRowSelectionMode = is?.rowSelectionMode;
192
+ this.lastSelectedRowIds = is?.selectedRowIds ? new Set(is.selectedRowIds) : undefined;
193
+ this.lastShowRowNumbers = is?.showRowNumbers;
194
+ this.lastPinnedColumns = is?.pinnedColumns;
195
+ this.lastAllSelected = is?.allSelected;
196
+ this.lastSomeSelected = is?.someSelected;
197
+ this.lastHeaderSignature = this.computeHeaderSignature();
198
+ const { items } = this.state.getProcessedItems();
199
+ this.lastRenderedItems = items;
200
+ }
201
+ /** Check if only selection/active-cell/copy/cut ranges changed (no data or header changes). */
202
+ isSelectionOnlyChange() {
203
+ if (!this.lastRenderedItems)
204
+ return false;
205
+ const is = this.interactionState;
206
+ const { items } = this.state.getProcessedItems();
207
+ // If data items changed, need full body rebuild
208
+ if (items !== this.lastRenderedItems)
209
+ return false;
210
+ // If header signature changed, need header rebuild
211
+ const currentHeaderSig = this.computeHeaderSignature();
212
+ if (currentHeaderSig !== this.lastHeaderSignature)
213
+ return false;
214
+ // If editing cell changed, need body rebuild (visibility toggle on the td)
215
+ const curEdit = is?.editingCell;
216
+ const lastEdit = this.lastEditingCell;
217
+ if (curEdit?.rowId !== lastEdit?.rowId || curEdit?.columnId !== lastEdit?.columnId)
218
+ return false;
219
+ // If row selection changed, need body rebuild (checkbox states, row attrs)
220
+ if (is?.rowSelectionMode !== this.lastRowSelectionMode)
221
+ return false;
222
+ if (is?.selectedRowIds !== this.lastSelectedRowIds) {
223
+ // Compare sets
224
+ const curIds = is?.selectedRowIds;
225
+ const lastIds = this.lastSelectedRowIds;
226
+ if (!curIds && !lastIds) { /* both null, ok */ }
227
+ else if (!curIds || !lastIds || curIds.size !== lastIds.size)
228
+ return false;
229
+ else {
230
+ for (const id of curIds) {
231
+ if (!lastIds.has(id))
232
+ return false;
233
+ }
234
+ }
235
+ }
236
+ // If pinning or row numbers changed
237
+ if (is?.showRowNumbers !== this.lastShowRowNumbers)
238
+ return false;
239
+ if (is?.pinnedColumns !== this.lastPinnedColumns)
240
+ return false;
241
+ // Otherwise it's just selection/active-cell/copy/cut changes
242
+ return true;
243
+ }
244
+ /** Patch only CSS classes/styles for selection, active cell, copy/cut ranges without rebuilding DOM. */
245
+ patchSelectionClasses() {
246
+ if (!this.tbody || !this.interactionState)
247
+ return;
248
+ const is = this.interactionState;
249
+ const { activeCell, selectionRange, copyRange, cutRange } = is;
250
+ const lastActive = this.lastActiveCell;
251
+ const lastSelection = this.lastSelectionRange;
252
+ const lastCopy = this.lastCopyRange;
253
+ const lastCut = this.lastCutRange;
254
+ const cells = this.tbody.querySelectorAll('td[data-row-index][data-col-index]');
255
+ for (let i = 0; i < cells.length; i++) {
256
+ const el = cells[i];
257
+ const rowIndex = parseInt(el.getAttribute('data-row-index'), 10);
258
+ const globalColIndex = parseInt(el.getAttribute('data-col-index'), 10);
259
+ const colOffset = this.getColOffset();
260
+ const colIndex = globalColIndex - colOffset;
261
+ // --- Active cell ---
262
+ const wasActive = lastActive && lastActive.rowIndex === rowIndex && lastActive.columnIndex === globalColIndex;
263
+ const isActive = activeCell && activeCell.rowIndex === rowIndex && activeCell.columnIndex === globalColIndex;
264
+ if (wasActive && !isActive) {
265
+ el.removeAttribute('data-active-cell');
266
+ el.style.outline = '';
267
+ }
268
+ else if (isActive && !wasActive) {
269
+ el.setAttribute('data-active-cell', 'true');
270
+ el.style.outline = '2px solid var(--ogrid-accent, #0078d4)';
271
+ }
272
+ // --- Selection range ---
273
+ const wasInRange = lastSelection && isInSelectionRange(lastSelection, rowIndex, colIndex);
274
+ const isInRange = selectionRange && isInSelectionRange(selectionRange, rowIndex, colIndex);
275
+ if (wasInRange && !isInRange) {
276
+ el.removeAttribute('data-in-range');
277
+ el.style.backgroundColor = '';
278
+ }
279
+ else if (isInRange && !wasInRange) {
280
+ el.setAttribute('data-in-range', 'true');
281
+ el.style.backgroundColor = 'var(--ogrid-range-bg, rgba(33, 115, 70, 0.12))';
282
+ }
283
+ // --- Copy range ---
284
+ const wasInCopy = lastCopy && isInSelectionRange(lastCopy, rowIndex, colIndex);
285
+ const isInCopy = copyRange && isInSelectionRange(copyRange, rowIndex, colIndex);
286
+ if (wasInCopy && !isInCopy) {
287
+ // Only clear outline if not being set by another range (active/cut)
288
+ if (!isActive && !(cutRange && isInSelectionRange(cutRange, rowIndex, colIndex))) {
289
+ el.style.outline = '';
290
+ }
291
+ }
292
+ else if (isInCopy && !wasInCopy) {
293
+ el.style.outline = '1px dashed var(--ogrid-fg-muted, rgba(0, 0, 0, 0.5))';
294
+ }
295
+ // --- Cut range ---
296
+ const wasInCut = lastCut && isInSelectionRange(lastCut, rowIndex, colIndex);
297
+ const isInCut = cutRange && isInSelectionRange(cutRange, rowIndex, colIndex);
298
+ if (wasInCut && !isInCut) {
299
+ if (!isActive && !(copyRange && isInSelectionRange(copyRange, rowIndex, colIndex))) {
300
+ el.style.outline = '';
301
+ }
302
+ }
303
+ else if (isInCut && !wasInCut) {
304
+ el.style.outline = '1px dashed var(--ogrid-accent, #0078d4)';
305
+ }
306
+ // --- Fill handle ---
307
+ // Remove old fill handle if it was on a cell no longer at the bottom-right of selection
308
+ const oldFill = el.querySelector('.ogrid-fill-handle');
309
+ const shouldHaveFill = selectionRange && is.onFillHandleMouseDown &&
310
+ rowIndex === Math.max(selectionRange.startRow, selectionRange.endRow) &&
311
+ colIndex === Math.max(selectionRange.startCol, selectionRange.endCol);
312
+ const hadFill = !!oldFill;
313
+ if (hadFill && !shouldHaveFill) {
314
+ oldFill.remove();
315
+ }
316
+ else if (!hadFill && shouldHaveFill) {
317
+ const fillHandle = document.createElement('div');
318
+ fillHandle.className = 'ogrid-fill-handle';
319
+ fillHandle.setAttribute('data-fill-handle', 'true');
320
+ fillHandle.style.position = 'absolute';
321
+ fillHandle.style.right = '-3px';
322
+ fillHandle.style.bottom = '-3px';
323
+ fillHandle.style.width = '6px';
324
+ fillHandle.style.height = '6px';
325
+ fillHandle.style.backgroundColor = 'var(--ogrid-selection, #217346)';
326
+ fillHandle.style.cursor = 'crosshair';
327
+ fillHandle.style.zIndex = '5';
328
+ el.style.position = el.style.position || 'relative';
329
+ fillHandle.addEventListener('mousedown', (e) => {
330
+ this.interactionState?.onFillHandleMouseDown?.(e);
331
+ });
332
+ el.appendChild(fillHandle);
333
+ }
334
+ // Restore pinned cell background if needed (selection removal may have cleared it)
335
+ if (!isInRange && is.pinnedColumns) {
336
+ const columnId = el.getAttribute('data-column-id');
337
+ if (columnId && is.pinnedColumns[columnId]) {
338
+ el.style.backgroundColor = el.style.backgroundColor || 'var(--ogrid-bg, #fff)';
339
+ }
340
+ }
341
+ }
342
+ this.snapshotState();
67
343
  }
68
344
  /** Re-render body rows and header (after sort/filter/page change). */
69
345
  update() {
70
346
  if (!this.tbody || !this.thead) {
71
347
  this.render();
348
+ this.snapshotState();
72
349
  return;
73
350
  }
74
- this.thead.innerHTML = '';
75
- this.renderHeader();
351
+ // Check if only selection-related state changed — if so, patch CSS only
352
+ if (this.isSelectionOnlyChange()) {
353
+ this.patchSelectionClasses();
354
+ return;
355
+ }
356
+ // Check if header needs rebuild
357
+ const currentHeaderSig = this.computeHeaderSignature();
358
+ if (currentHeaderSig !== this.lastHeaderSignature) {
359
+ this.thead.innerHTML = '';
360
+ this.renderHeader();
361
+ }
362
+ // Delegation listeners are on tbody itself — just clear inner HTML, keep listeners
76
363
  this.tbody.innerHTML = '';
77
364
  this.renderBody();
365
+ this.snapshotState();
78
366
  }
79
367
  hasCheckboxColumn() {
80
368
  const mode = this.interactionState?.rowSelectionMode;
@@ -148,14 +436,6 @@ export class TableRenderer {
148
436
  this.state.toggleSort(cell.columnDef.columnId);
149
437
  }
150
438
  });
151
- // Sort indicator
152
- const sort = this.state.sort;
153
- if (sort && cell.columnDef && sort.field === cell.columnDef.columnId) {
154
- const indicator = document.createElement('span');
155
- indicator.className = 'ogrid-sort-indicator';
156
- indicator.textContent = sort.direction === 'asc' ? ' \u25B2' : ' \u25BC';
157
- th.appendChild(indicator);
158
- }
159
439
  }
160
440
  if (!cell.isGroup && cell.columnDef) {
161
441
  th.setAttribute('data-column-id', cell.columnDef.columnId);
@@ -211,13 +491,6 @@ export class TableRenderer {
211
491
  if (col.sortable) {
212
492
  th.classList.add('ogrid-sortable');
213
493
  th.addEventListener('click', () => this.state.toggleSort(col.columnId));
214
- const sort = this.state.sort;
215
- if (sort && sort.field === col.columnId) {
216
- const indicator = document.createElement('span');
217
- indicator.className = 'ogrid-sort-indicator';
218
- indicator.textContent = sort.direction === 'asc' ? ' \u25B2' : ' \u25BC';
219
- th.appendChild(indicator);
220
- }
221
494
  }
222
495
  if (col.type === 'numeric') {
223
496
  th.style.textAlign = 'right';
@@ -429,19 +702,7 @@ export class TableRenderer {
429
702
  if (editingCell && editingCell.rowId === rowId && editingCell.columnId === col.columnId) {
430
703
  td.style.visibility = 'hidden';
431
704
  }
432
- // Cell interaction handlers
433
- td.addEventListener('click', (e) => {
434
- this.interactionState?.onCellClick?.(rowIndex, globalColIndex, e);
435
- });
436
- td.addEventListener('mousedown', (e) => {
437
- this.interactionState?.onCellMouseDown?.(rowIndex, globalColIndex, e);
438
- });
439
- td.addEventListener('dblclick', () => {
440
- this.interactionState?.onCellDoubleClick?.(rowIndex, globalColIndex, rowId, col.columnId);
441
- });
442
- td.addEventListener('contextmenu', (e) => {
443
- this.interactionState?.onCellContextMenu?.(rowIndex, globalColIndex, e);
444
- });
705
+ // Cell interaction is handled by delegated listeners on tbody
445
706
  }
446
707
  // Custom DOM render
447
708
  if (col.renderCell) {
@@ -531,6 +792,7 @@ export class TableRenderer {
531
792
  this.dropIndicator.style.left = `${relativeX}px`;
532
793
  }
533
794
  destroy() {
795
+ this.detachBodyDelegation();
534
796
  this.container.innerHTML = '';
535
797
  this.table = null;
536
798
  this.thead = null;
@@ -55,8 +55,8 @@ export class ColumnReorderState {
55
55
  this.pinnedColumns = undefined;
56
56
  }
57
57
  this.draggedPinState = getPinStateForColumn(columnId, this.pinnedColumns);
58
- window.addEventListener('mousemove', this.onMoveBound, true);
59
- window.addEventListener('mouseup', this.onUpBound, true);
58
+ window.addEventListener('mousemove', this.onMoveBound, { capture: true, passive: true });
59
+ window.addEventListener('mouseup', this.onUpBound, { capture: true, passive: true });
60
60
  this.emitter.emit('stateChange', { isDragging: true, dropIndicatorX: null });
61
61
  }
62
62
  handleMouseMove(event) {
@@ -14,6 +14,7 @@ export class FillHandleState {
14
14
  this.rafHandle = 0;
15
15
  this.liveFillRange = null;
16
16
  this.lastMousePos = null;
17
+ this.cachedCells = null;
17
18
  this.params = params;
18
19
  this.getSelectionRange = getSelectionRange;
19
20
  this.setSelectionRange = setSelectionRange;
@@ -46,8 +47,10 @@ export class FillHandleState {
46
47
  this.fillDragStart = { startRow: range.startRow, startCol: range.startCol };
47
48
  this.fillDragEnd = { endRow: range.startRow, endCol: range.startCol };
48
49
  this.liveFillRange = null;
49
- window.addEventListener('mousemove', this.onMoveBound, true);
50
- window.addEventListener('mouseup', this.onUpBound, true);
50
+ // Cache querySelectorAll result once on drag start
51
+ this.cachedCells = this.wrapperRef ? this.wrapperRef.querySelectorAll('[data-row-index][data-col-index]') : null;
52
+ window.addEventListener('mousemove', this.onMoveBound, { capture: true, passive: true });
53
+ window.addEventListener('mouseup', this.onUpBound, { capture: true, passive: true });
51
54
  }
52
55
  onMouseMove(e) {
53
56
  if (!this._isFillDragging || !this.fillDragStart)
@@ -170,15 +173,14 @@ export class FillHandleState {
170
173
  });
171
174
  }
172
175
  applyDragAttrs(range) {
173
- const wrapper = this.wrapperRef;
174
- if (!wrapper)
176
+ const cells = this.cachedCells;
177
+ if (!cells)
175
178
  return;
176
179
  const colOff = this.params.colOffset;
177
180
  const minR = Math.min(range.startRow, range.endRow);
178
181
  const maxR = Math.max(range.startRow, range.endRow);
179
182
  const minC = Math.min(range.startCol, range.endCol);
180
183
  const maxC = Math.max(range.startCol, range.endCol);
181
- const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
182
184
  for (let i = 0; i < cells.length; i++) {
183
185
  const el = cells[i];
184
186
  const r = parseInt(el.getAttribute('data-row-index'), 10);
@@ -195,12 +197,12 @@ export class FillHandleState {
195
197
  }
196
198
  }
197
199
  clearDragAttrs() {
198
- const wrapper = this.wrapperRef;
199
- if (!wrapper)
200
- return;
201
- const marked = wrapper.querySelectorAll('[data-drag-range]');
202
- for (let i = 0; i < marked.length; i++)
203
- marked[i].removeAttribute('data-drag-range');
200
+ const cells = this.cachedCells;
201
+ if (cells) {
202
+ for (let i = 0; i < cells.length; i++)
203
+ cells[i].removeAttribute('data-drag-range');
204
+ }
205
+ this.cachedCells = null;
204
206
  }
205
207
  onFillRangeChange(handler) {
206
208
  this.emitter.on('fillRangeChange', handler);
@@ -107,7 +107,7 @@ export class HeaderFilterState {
107
107
  }
108
108
  };
109
109
  setTimeout(() => {
110
- document.addEventListener('mousedown', this._clickOutsideHandler);
110
+ document.addEventListener('mousedown', this._clickOutsideHandler, { passive: true });
111
111
  }, 0);
112
112
  document.addEventListener('keydown', this._escapeHandler, true);
113
113
  this.emitter.emit('change', undefined);
@@ -38,7 +38,7 @@ export class TableLayoutState {
38
38
  }
39
39
  /** Set a column width override (from resize drag). */
40
40
  setColumnOverride(columnId, widthPx) {
41
- this._columnSizingOverrides = { ...this._columnSizingOverrides, [columnId]: widthPx };
41
+ this._columnSizingOverrides[columnId] = widthPx;
42
42
  this.emitter.emit('layoutChange', { type: 'columnOverride' });
43
43
  }
44
44
  /** Compute minimum table width from visible columns. */
@@ -1,20 +1,14 @@
1
+ import { UndoRedoStack } from '@alaarab/ogrid-core';
1
2
  import { EventEmitter } from './EventEmitter';
2
3
  export class UndoRedoState {
3
4
  constructor(onCellValueChanged, maxUndoDepth = 100) {
4
5
  this.onCellValueChanged = onCellValueChanged;
5
6
  this.emitter = new EventEmitter();
6
- this.historyStack = [];
7
- this.redoStack = [];
8
- this.batch = null;
9
- this.maxUndoDepth = maxUndoDepth;
7
+ this.stack = new UndoRedoStack(maxUndoDepth);
10
8
  if (onCellValueChanged) {
11
9
  this.wrappedCallback = (event) => {
12
- if (this.batch !== null) {
13
- this.batch.push(event);
14
- }
15
- else {
16
- this.historyStack = [...this.historyStack, [event]].slice(-this.maxUndoDepth);
17
- this.redoStack = [];
10
+ this.stack.record(event);
11
+ if (!this.stack.isBatching) {
18
12
  this.emitStackChange();
19
13
  }
20
14
  onCellValueChanged(event);
@@ -22,32 +16,27 @@ export class UndoRedoState {
22
16
  }
23
17
  }
24
18
  get canUndo() {
25
- return this.historyStack.length > 0;
19
+ return this.stack.canUndo;
26
20
  }
27
21
  get canRedo() {
28
- return this.redoStack.length > 0;
22
+ return this.stack.canRedo;
29
23
  }
30
24
  getWrappedCallback() {
31
25
  return this.wrappedCallback;
32
26
  }
33
27
  beginBatch() {
34
- this.batch = [];
28
+ this.stack.beginBatch();
35
29
  }
36
30
  endBatch() {
37
- const currentBatch = this.batch;
38
- this.batch = null;
39
- if (!currentBatch || currentBatch.length === 0)
40
- return;
41
- this.historyStack = [...this.historyStack, currentBatch].slice(-this.maxUndoDepth);
42
- this.redoStack = [];
31
+ this.stack.endBatch();
43
32
  this.emitStackChange();
44
33
  }
45
34
  undo() {
46
- if (!this.onCellValueChanged || this.historyStack.length === 0)
35
+ if (!this.onCellValueChanged)
36
+ return;
37
+ const lastBatch = this.stack.undo();
38
+ if (!lastBatch)
47
39
  return;
48
- const lastBatch = this.historyStack[this.historyStack.length - 1];
49
- this.historyStack = this.historyStack.slice(0, -1);
50
- this.redoStack = [...this.redoStack, lastBatch];
51
40
  this.emitStackChange();
52
41
  for (let i = lastBatch.length - 1; i >= 0; i--) {
53
42
  const ev = lastBatch[i];
@@ -59,11 +48,11 @@ export class UndoRedoState {
59
48
  }
60
49
  }
61
50
  redo() {
62
- if (!this.onCellValueChanged || this.redoStack.length === 0)
51
+ if (!this.onCellValueChanged)
52
+ return;
53
+ const nextBatch = this.stack.redo();
54
+ if (!nextBatch)
63
55
  return;
64
- const nextBatch = this.redoStack[this.redoStack.length - 1];
65
- this.redoStack = this.redoStack.slice(0, -1);
66
- this.historyStack = [...this.historyStack, nextBatch];
67
56
  this.emitStackChange();
68
57
  for (const ev of nextBatch) {
69
58
  this.onCellValueChanged(ev);
@@ -177,6 +177,10 @@
177
177
  table-layout: auto;
178
178
  }
179
179
 
180
+ .ogrid-table tbody tr {
181
+ height: var(--ogrid-row-height, auto);
182
+ }
183
+
180
184
  .ogrid-table th,
181
185
  .ogrid-table td {
182
186
  min-width: 80px;
@@ -220,12 +224,6 @@
220
224
  user-select: none;
221
225
  }
222
226
 
223
- .ogrid-sort-indicator {
224
- margin-left: 4px;
225
- font-size: 12px;
226
- color: var(--ogrid-muted, #616161);
227
- }
228
-
229
227
  .ogrid-group-header {
230
228
  text-align: center;
231
229
  font-weight: 600;
@@ -4,6 +4,6 @@ export declare class PaginationControls<T> {
4
4
  private state;
5
5
  private el;
6
6
  constructor(container: HTMLElement, state: GridState<T>);
7
- render(totalCount: number): void;
7
+ render(totalCount: number, pageSizeOptions?: number[]): void;
8
8
  destroy(): void;
9
9
  }
@@ -44,14 +44,43 @@ export declare class TableRenderer<T> {
44
44
  private onFilterIconClick;
45
45
  private dropIndicator;
46
46
  private virtualScrollState;
47
+ private _tbodyClickHandler;
48
+ private _tbodyMousedownHandler;
49
+ private _tbodyDblclickHandler;
50
+ private _tbodyContextmenuHandler;
51
+ private lastActiveCell;
52
+ private lastSelectionRange;
53
+ private lastCopyRange;
54
+ private lastCutRange;
55
+ private lastEditingCell;
56
+ private lastColumnWidths;
57
+ private lastHeaderSignature;
58
+ private lastRenderedItems;
59
+ private lastRowSelectionMode;
60
+ private lastSelectedRowIds;
61
+ private lastShowRowNumbers;
62
+ private lastPinnedColumns;
63
+ private lastAllSelected;
64
+ private lastSomeSelected;
47
65
  constructor(container: HTMLElement, state: GridState<T>);
48
66
  setVirtualScrollState(vs: VirtualScrollState): void;
49
67
  setHeaderFilterState(state: HeaderFilterState, configs: Map<string, HeaderFilterConfig>): void;
50
68
  setOnFilterIconClick(handler: (columnId: string, headerEl: HTMLElement) => void): void;
51
69
  setInteractionState(state: TableRendererInteractionState | null): void;
70
+ private getCellFromEvent;
71
+ private attachBodyDelegation;
72
+ private detachBodyDelegation;
52
73
  getWrapperElement(): HTMLDivElement | null;
53
74
  /** Full render — creates the table structure from scratch. */
54
75
  render(): void;
76
+ /** Compute a signature string that captures header-affecting state. */
77
+ private computeHeaderSignature;
78
+ /** Save current interaction state for next diff comparison. */
79
+ private snapshotState;
80
+ /** Check if only selection/active-cell/copy/cut ranges changed (no data or header changes). */
81
+ private isSelectionOnlyChange;
82
+ /** Patch only CSS classes/styles for selection, active cell, copy/cut ranges without rebuilding DOM. */
83
+ private patchSelectionClasses;
55
84
  /** Re-render body rows and header (after sort/filter/page change). */
56
85
  update(): void;
57
86
  private hasCheckboxColumn;
@@ -30,6 +30,7 @@ export declare class FillHandleState<T> {
30
30
  private rafHandle;
31
31
  private liveFillRange;
32
32
  private lastMousePos;
33
+ private cachedCells;
33
34
  private onMoveBound;
34
35
  private onUpBound;
35
36
  constructor(params: FillHandleParams<T>, getSelectionRange: () => ISelectionRange | null, setSelectionRange: (range: ISelectionRange | null) => void, setActiveCell: (cell: IActiveCell | null) => void);
@@ -8,10 +8,7 @@ interface UndoRedoStateEvents extends Record<string, unknown> {
8
8
  export declare class UndoRedoState<T> {
9
9
  private onCellValueChanged;
10
10
  private emitter;
11
- private historyStack;
12
- private redoStack;
13
- private batch;
14
- private maxUndoDepth;
11
+ private stack;
15
12
  private wrappedCallback;
16
13
  constructor(onCellValueChanged: ((event: ICellValueChangedEvent<T>) => void) | undefined, maxUndoDepth?: number);
17
14
  get canUndo(): boolean;
@@ -1,5 +1,5 @@
1
1
  import type { IColumnDef, IColumnGroupDef, ICellValueChangedEvent } from './columnTypes';
2
- import type { RowId, IFilters, IDataSource, RowSelectionMode, IRowSelectionChangeEvent, IOGridApi, ISideBarDef, IVirtualScrollConfig } from '@alaarab/ogrid-core';
2
+ import type { RowId, IFilters, IDataSource, RowSelectionMode, IRowSelectionChangeEvent, IOGridApi, ISideBarDef, IStatusBarProps, IVirtualScrollConfig } from '@alaarab/ogrid-core';
3
3
  export type { RowId, UserLike, UserLikeInput, FilterValue, IFilters, IFetchParams, IPageResult, IDataSource, IGridColumnState, RowSelectionMode, IRowSelectionChangeEvent, StatusBarPanel, IStatusBarProps, IActiveCell, ISelectionRange, SideBarPanelId, ISideBarDef, IVirtualScrollConfig, IOGridApi, } from '@alaarab/ogrid-core';
4
4
  /** Extended API for the vanilla JS package (adds methods not in the core IOGridApi). */
5
5
  export interface IJsOGridApi<T> extends IOGridApi<T> {
@@ -41,6 +41,10 @@ export interface OGridOptions<T> {
41
41
  onCellValueChanged?: (event: ICellValueChangedEvent<T>) => void;
42
42
  /** Show row numbers column. Default: false. */
43
43
  showRowNumbers?: boolean;
44
+ /** Status bar configuration or boolean to enable/disable with defaults. */
45
+ statusBar?: boolean | IStatusBarProps;
46
+ /** Plural label for the entity type (e.g. 'items'). Used in status bar and empty state. */
47
+ entityLabelPlural?: string;
44
48
  rowSelection?: RowSelectionMode;
45
49
  /** Callback fired when row selection changes. */
46
50
  onSelectionChange?: (event: IRowSelectionChangeEvent<T>) => void;
@@ -55,14 +59,50 @@ export interface OGridOptions<T> {
55
59
  emptyMessage?: string;
56
60
  /** Accessible label for the grid. */
57
61
  'aria-label'?: string;
62
+ /** Accessible label reference for the grid (ID of a labelling element). */
63
+ 'aria-labelledby'?: string;
58
64
  /** Side bar configuration (columns panel + filters panel). */
59
65
  sideBar?: boolean | ISideBarDef;
60
66
  /** Error callback for server-side data source failures. */
61
67
  onError?: (error: unknown) => void;
68
+ /** Called when a cell editor throws an error. JS alternative: listen to the 'cellError' event. */
69
+ onCellError?: (error: Error, info: unknown) => void;
70
+ /** Called when undo is triggered. JS alternative: listen to the 'undo' event. */
71
+ onUndo?: () => void;
72
+ /** Called when redo is triggered. JS alternative: listen to the 'redo' event. */
73
+ onRedo?: () => void;
74
+ /** Whether there are undo operations available. */
75
+ canUndo?: boolean;
76
+ /** Whether there are redo operations available. */
77
+ canRedo?: boolean;
78
+ /** Called when the current page changes. JS alternative: listen to the 'pageChange' event. */
79
+ onPageChange?: (page: number) => void;
80
+ /** Called when the page size changes. JS alternative: listen to the 'pageSizeChange' event. */
81
+ onPageSizeChange?: (size: number) => void;
62
82
  /** Callback fired when first data is rendered. */
63
83
  onFirstDataRendered?: () => void;
64
84
  /** Virtual scrolling configuration. */
65
85
  virtualScroll?: IVirtualScrollConfig;
86
+ /** Fixed row height in pixels. Overrides default row height (36px). */
87
+ rowHeight?: number;
88
+ /** Cell spacing/density preset. Controls cell padding throughout the grid. Default: 'normal'. */
89
+ density?: 'compact' | 'normal' | 'comfortable';
90
+ /** Enable column reordering via drag-and-drop on header cells. Default: false. */
91
+ columnReorder?: boolean;
92
+ /** Page size options shown in the pagination dropdown. Default: [10, 20, 50, 100]. */
93
+ pageSizeOptions?: number[];
94
+ /** Initial column display order (array of column ids). */
95
+ columnOrder?: string[];
96
+ /** Callback fired when column order changes. */
97
+ onColumnOrderChange?: (order: string[]) => void;
98
+ /** Callback fired when a column is resized. */
99
+ onColumnResized?: (columnId: string, width: number) => void;
100
+ /** Callback fired when a column is pinned or unpinned. */
101
+ onColumnPinned?: (columnId: string, pin: 'left' | 'right' | null) => void;
102
+ /** Where the column chooser renders. `true` or `'toolbar'` (default): toolbar. `'sidebar'`: sidebar only. `false`: hidden. */
103
+ columnChooser?: boolean | 'toolbar' | 'sidebar';
104
+ /** Secondary toolbar row rendered below the primary toolbar. */
105
+ toolbarBelow?: HTMLElement | null;
66
106
  }
67
107
  /** Events emitted by the OGrid instance. */
68
108
  export interface OGridEvents<T> extends Record<string, unknown> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-js",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "OGrid vanilla JS – framework-free data grid with sorting, filtering, pagination, and spreadsheet-style editing.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -36,7 +36,7 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
- "@alaarab/ogrid-core": "2.0.19"
39
+ "@alaarab/ogrid-core": "2.0.21"
40
40
  },
41
41
  "sideEffects": false,
42
42
  "publishConfig": {