@alaarab/ogrid-js 2.0.2 → 2.0.4

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
@@ -16,6 +16,8 @@ import { TableLayoutState } from './state/TableLayoutState';
16
16
  import { FillHandleState } from './state/FillHandleState';
17
17
  import { RowSelectionState } from './state/RowSelectionState';
18
18
  import { ColumnPinningState } from './state/ColumnPinningState';
19
+ import { ColumnReorderState } from './state/ColumnReorderState';
20
+ import { VirtualScrollState } from './state/VirtualScrollState';
19
21
  import { MarchingAntsOverlay } from './components/MarchingAntsOverlay';
20
22
  import { InlineCellEditor } from './components/InlineCellEditor';
21
23
  import { ContextMenu } from './components/ContextMenu';
@@ -41,6 +43,8 @@ export class OGrid {
41
43
  this.fillHandleState = null;
42
44
  this.rowSelectionState = null;
43
45
  this.pinningState = null;
46
+ this.reorderState = null;
47
+ this.virtualScrollState = null;
44
48
  this.marchingAnts = null;
45
49
  this.cellEditor = null;
46
50
  this.contextMenu = null;
@@ -159,6 +163,28 @@ export class OGrid {
159
163
  this.unsubscribes.push(this.headerFilterState.onChange(() => {
160
164
  this.renderHeaderFilterPopover();
161
165
  }));
166
+ // Initialize virtual scrolling if configured
167
+ if (options.virtualScroll?.enabled) {
168
+ this.virtualScrollState = new VirtualScrollState(options.virtualScroll);
169
+ this.virtualScrollState.observeContainer(this.tableContainer);
170
+ this.renderer.setVirtualScrollState(this.virtualScrollState);
171
+ // Wire scroll event on the table container
172
+ const handleScroll = () => {
173
+ this.virtualScrollState?.handleScroll(this.tableContainer.scrollTop);
174
+ };
175
+ this.tableContainer.addEventListener('scroll', handleScroll, { passive: true });
176
+ this.unsubscribes.push(() => {
177
+ this.tableContainer.removeEventListener('scroll', handleScroll);
178
+ });
179
+ // Re-render when visible range changes
180
+ this.unsubscribes.push(this.virtualScrollState.onRangeChanged(() => {
181
+ this.updateRendererInteractionState();
182
+ }));
183
+ // Wire scrollToRow API method
184
+ this.api.scrollToRow = (index, opts) => {
185
+ this.virtualScrollState?.scrollToRow(index, this.tableContainer, opts?.align);
186
+ };
187
+ }
162
188
  // Complete initial render (pagination, status bar, column chooser, sidebar, loading)
163
189
  this.renderAll();
164
190
  }
@@ -225,6 +251,14 @@ export class OGrid {
225
251
  this.unsubscribes.push(this.resizeState.onColumnWidthChange(() => {
226
252
  this.updateRendererInteractionState();
227
253
  }));
254
+ // Column reorder
255
+ this.reorderState = new ColumnReorderState();
256
+ this.unsubscribes.push(this.reorderState.onStateChange(({ isDragging, dropIndicatorX }) => {
257
+ this.renderer.updateDropIndicator(dropIndicatorX, isDragging);
258
+ }));
259
+ this.unsubscribes.push(this.reorderState.onReorder(({ columnOrder }) => {
260
+ this.state.setColumnOrder(columnOrder);
261
+ }));
228
262
  // Attach keyboard handler to wrapper
229
263
  const wrapper = this.renderer.getWrapperElement();
230
264
  if (wrapper) {
@@ -307,7 +341,7 @@ export class OGrid {
307
341
  const visibleCols = this.state.visibleColumnDefs;
308
342
  // Compute pinning offsets
309
343
  const columnWidths = this.layoutState.getAllColumnWidths();
310
- const leftOffsets = this.pinningState?.computeLeftOffsets(visibleCols, columnWidths, 120, !!this.rowSelectionState, 40) ?? {};
344
+ const leftOffsets = this.pinningState?.computeLeftOffsets(visibleCols, columnWidths, 120, !!this.rowSelectionState, 40, !!this.options.showRowNumbers) ?? {};
311
345
  const rightOffsets = this.pinningState?.computeRightOffsets(visibleCols, columnWidths, 120) ?? {};
312
346
  this.renderer.setInteractionState({
313
347
  activeCell: this.selectionState.activeCell,
@@ -334,10 +368,19 @@ export class OGrid {
334
368
  },
335
369
  allSelected: this.rowSelectionState?.isAllSelected(items),
336
370
  someSelected: this.rowSelectionState?.isSomeSelected(items),
371
+ // Row numbers
372
+ showRowNumbers: this.options.showRowNumbers,
337
373
  // Column pinning
338
374
  pinnedColumns: this.pinningState?.pinnedColumns,
339
375
  leftOffsets,
340
376
  rightOffsets,
377
+ // Column reorder
378
+ onColumnReorderStart: this.reorderState ? (columnId, event) => {
379
+ const tableEl = this.renderer.getTableElement();
380
+ if (!tableEl)
381
+ return;
382
+ this.reorderState?.startDrag(columnId, event, visibleCols, this.state.columnOrder, this.pinningState?.pinnedColumns, tableEl);
383
+ } : undefined,
341
384
  });
342
385
  this.renderer.update();
343
386
  // Update marching ants overlay
@@ -351,15 +394,43 @@ export class OGrid {
351
394
  if (!range)
352
395
  return;
353
396
  const norm = normalizeSelectionRange(range);
397
+ const anchor = this.selectionState.dragAnchor;
398
+ const minR = norm.startRow;
399
+ const maxR = norm.endRow;
400
+ const minC = norm.startCol;
401
+ const maxC = norm.endCol;
354
402
  const cells = wrapper.querySelectorAll('td[data-row-index][data-col-index]');
355
403
  for (const cell of Array.from(cells)) {
356
- const rowIndex = parseInt(cell.getAttribute('data-row-index') ?? '-1', 10);
357
- const colIndex = parseInt(cell.getAttribute('data-col-index') ?? '-1', 10);
404
+ const el = cell;
405
+ const rowIndex = parseInt(el.getAttribute('data-row-index') ?? '-1', 10);
406
+ const colIndex = parseInt(el.getAttribute('data-col-index') ?? '-1', 10);
358
407
  if (isInSelectionRange(norm, rowIndex, colIndex)) {
359
- cell.setAttribute('data-drag-range', 'true');
408
+ el.setAttribute('data-drag-range', 'true');
409
+ // Anchor cell (white background)
410
+ const isAnchor = anchor && rowIndex === anchor.rowIndex && colIndex === anchor.columnIndex;
411
+ if (isAnchor) {
412
+ el.setAttribute('data-drag-anchor', '');
413
+ }
414
+ else {
415
+ el.removeAttribute('data-drag-anchor');
416
+ }
417
+ // Edge borders via inset box-shadow
418
+ const shadows = [];
419
+ if (rowIndex === minR)
420
+ shadows.push('inset 0 2px 0 0 var(--ogrid-selection, #217346)');
421
+ if (rowIndex === maxR)
422
+ shadows.push('inset 0 -2px 0 0 var(--ogrid-selection, #217346)');
423
+ if (colIndex === minC)
424
+ shadows.push('inset 2px 0 0 0 var(--ogrid-selection, #217346)');
425
+ if (colIndex === maxC)
426
+ shadows.push('inset -2px 0 0 0 var(--ogrid-selection, #217346)');
427
+ el.style.boxShadow = shadows.length > 0 ? shadows.join(', ') : '';
360
428
  }
361
429
  else {
362
- cell.removeAttribute('data-drag-range');
430
+ el.removeAttribute('data-drag-range');
431
+ el.removeAttribute('data-drag-anchor');
432
+ if (el.style.boxShadow)
433
+ el.style.boxShadow = '';
363
434
  }
364
435
  }
365
436
  }
@@ -375,6 +446,8 @@ export class OGrid {
375
446
  return;
376
447
  e.preventDefault();
377
448
  this.selectionState.startDrag(rowIndex, colIndex);
449
+ // Apply drag attributes immediately for instant visual feedback on the initial cell
450
+ setTimeout(() => this.updateDragAttributes(), 0);
378
451
  }
379
452
  handleCellContextMenu(rowIndex, colIndex, e) {
380
453
  e.preventDefault();
@@ -452,7 +525,29 @@ export class OGrid {
452
525
  const onCancel = () => {
453
526
  this.updateRendererInteractionState();
454
527
  };
455
- this.cellEditor.startEdit(rowId, columnId, item, column, cell, onCommit, onCancel);
528
+ const onAfterCommit = () => {
529
+ // After Enter-commit, move the active cell down one row (Excel-style behavior)
530
+ if (this.selectionState) {
531
+ const ac = this.selectionState.activeCell;
532
+ if (ac) {
533
+ const { items: currentItems } = this.state.getProcessedItems();
534
+ const newRow = Math.min(ac.rowIndex + 1, currentItems.length - 1);
535
+ this.selectionState.setActiveCell({ rowIndex: newRow, columnIndex: ac.columnIndex });
536
+ const colOffset = this.renderer.getColOffset();
537
+ const dataCol = ac.columnIndex - colOffset;
538
+ this.selectionState.setSelectionRange({
539
+ startRow: newRow,
540
+ startCol: dataCol,
541
+ endRow: newRow,
542
+ endCol: dataCol,
543
+ });
544
+ }
545
+ }
546
+ // Re-focus the grid wrapper so keyboard nav continues working
547
+ const wrapper = this.renderer.getWrapperElement();
548
+ wrapper?.focus();
549
+ };
550
+ this.cellEditor.startEdit(rowId, columnId, item, column, cell, onCommit, onCancel, onAfterCommit);
456
551
  }
457
552
  buildFilterConfigs() {
458
553
  const columns = flattenColumns(this.options.columns);
@@ -611,6 +706,8 @@ export class OGrid {
611
706
  this.renderer.update();
612
707
  }
613
708
  const { totalCount } = this.state.getProcessedItems();
709
+ // Update virtual scroll with current total row count
710
+ this.virtualScrollState?.setTotalRows(totalCount);
614
711
  this.pagination.render(totalCount);
615
712
  this.statusBar.render({ totalCount });
616
713
  this.columnChooser.render();
@@ -644,6 +741,8 @@ export class OGrid {
644
741
  this.fillHandleState?.destroy();
645
742
  this.rowSelectionState?.destroy();
646
743
  this.pinningState?.destroy();
744
+ this.reorderState?.destroy();
745
+ this.virtualScrollState?.destroy();
647
746
  this.marchingAnts?.destroy();
648
747
  this.layoutState.destroy();
649
748
  this.cellEditor?.closeEditor();
@@ -1,14 +1,16 @@
1
1
  import { GRID_CONTEXT_MENU_ITEMS, formatShortcut } from '@alaarab/ogrid-core';
2
2
  const MENU_STYLE = {
3
3
  position: 'fixed',
4
- backgroundColor: 'white',
5
- border: '1px solid #ccc',
6
- boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
4
+ backgroundColor: 'var(--ogrid-bg, #fff)',
5
+ border: '1px solid var(--ogrid-border, #e0e0e0)',
6
+ boxShadow: 'var(--ogrid-shadow, 0 4px 16px rgba(0, 0, 0, 0.12))',
7
+ borderRadius: '6px',
7
8
  zIndex: '10000',
8
9
  minWidth: '180px',
9
10
  padding: '4px 0',
10
11
  fontFamily: 'system-ui, -apple-system, sans-serif',
11
12
  fontSize: '14px',
13
+ color: 'var(--ogrid-fg, #242424)',
12
14
  };
13
15
  const ITEM_STYLE = {
14
16
  padding: '6px 12px',
@@ -19,7 +21,7 @@ const ITEM_STYLE = {
19
21
  };
20
22
  const DIVIDER_STYLE = {
21
23
  height: '1px',
22
- backgroundColor: '#e0e0e0',
24
+ backgroundColor: 'var(--ogrid-border, #e0e0e0)',
23
25
  margin: '4px 0',
24
26
  };
25
27
  export class ContextMenu {
@@ -49,7 +51,7 @@ export class ContextMenu {
49
51
  const shortcut = document.createElement('span');
50
52
  shortcut.textContent = formatShortcut(item.shortcut);
51
53
  shortcut.style.marginLeft = '20px';
52
- shortcut.style.color = '#666';
54
+ shortcut.style.color = 'var(--ogrid-muted, #666)';
53
55
  shortcut.style.fontSize = '12px';
54
56
  menuItem.appendChild(shortcut);
55
57
  }
@@ -57,15 +59,16 @@ export class ContextMenu {
57
59
  (item.id === 'redo' && !canRedo) ||
58
60
  (item.disabledWhenNoSelection && selectionRange == null);
59
61
  if (isDisabled) {
60
- menuItem.style.color = '#aaa';
62
+ menuItem.style.color = 'var(--ogrid-fg-muted, rgba(0, 0, 0, 0.4))';
63
+ menuItem.style.opacity = '0.5';
61
64
  menuItem.style.cursor = 'not-allowed';
62
65
  }
63
66
  else {
64
67
  menuItem.addEventListener('mouseenter', () => {
65
- menuItem.style.backgroundColor = '#f0f0f0';
68
+ menuItem.style.backgroundColor = 'var(--ogrid-bg-hover, #f5f5f5)';
66
69
  });
67
70
  menuItem.addEventListener('mouseleave', () => {
68
- menuItem.style.backgroundColor = 'white';
71
+ menuItem.style.backgroundColor = '';
69
72
  });
70
73
  menuItem.addEventListener('click', () => {
71
74
  this.handleItemClick(item.id);
@@ -3,7 +3,9 @@ const EDITOR_STYLE = {
3
3
  position: 'absolute',
4
4
  zIndex: '1000',
5
5
  boxSizing: 'border-box',
6
- border: '2px solid #0078d4',
6
+ border: '2px solid var(--ogrid-selection, #217346)',
7
+ background: 'var(--ogrid-bg, #fff)',
8
+ color: 'var(--ogrid-fg, #242424)',
7
9
  outline: 'none',
8
10
  fontFamily: 'inherit',
9
11
  fontSize: 'inherit',
@@ -12,15 +14,19 @@ export class InlineCellEditor {
12
14
  constructor(container) {
13
15
  this.editor = null;
14
16
  this.editingCell = null;
17
+ this.editingCellElement = null;
15
18
  this.onCommit = null;
16
19
  this.onCancel = null;
20
+ this.onAfterCommit = null;
17
21
  this.container = container;
18
22
  }
19
- startEdit(rowId, columnId, item, column, cell, onCommit, onCancel) {
23
+ startEdit(rowId, columnId, item, column, cell, onCommit, onCancel, onAfterCommit) {
20
24
  this.closeEditor();
21
25
  this.editingCell = { rowId, columnId };
26
+ this.editingCellElement = cell;
22
27
  this.onCommit = onCommit;
23
28
  this.onCancel = onCancel;
29
+ this.onAfterCommit = onAfterCommit ?? null;
24
30
  const value = getCellValue(item, column);
25
31
  const rect = cell.getBoundingClientRect();
26
32
  const containerRect = this.container.getBoundingClientRect();
@@ -39,6 +45,26 @@ export class InlineCellEditor {
39
45
  return this.editingCell;
40
46
  }
41
47
  closeEditor() {
48
+ // Reset visibility on the cell that was being edited (Bug 1 & 2 fix:
49
+ // the renderer sets visibility:hidden on the editing cell, and it may
50
+ // not re-render before the next click lands, so we clear it explicitly).
51
+ // Look up the cell by data attributes since the original element reference
52
+ // may have been replaced by a re-render.
53
+ if (this.editingCell) {
54
+ const { rowId, columnId } = this.editingCell;
55
+ const row = this.container.querySelector(`tr[data-row-id="${rowId}"]`);
56
+ if (row) {
57
+ const td = row.querySelector(`td[data-column-id="${columnId}"]`);
58
+ if (td) {
59
+ td.style.visibility = '';
60
+ }
61
+ }
62
+ }
63
+ if (this.editingCellElement) {
64
+ // Also reset the original element in case it's still in the DOM
65
+ this.editingCellElement.style.visibility = '';
66
+ this.editingCellElement = null;
67
+ }
42
68
  if (this.editor) {
43
69
  this.editor.remove();
44
70
  this.editor = null;
@@ -46,6 +72,7 @@ export class InlineCellEditor {
46
72
  this.editingCell = null;
47
73
  this.onCommit = null;
48
74
  this.onCancel = null;
75
+ this.onAfterCommit = null;
49
76
  }
50
77
  createEditor(column, item, value, cell) {
51
78
  const editorType = column.cellEditor;
@@ -93,13 +120,17 @@ export class InlineCellEditor {
93
120
  input.addEventListener('keydown', (e) => {
94
121
  if (e.key === 'Enter') {
95
122
  e.preventDefault();
123
+ e.stopPropagation(); // Prevent grid wrapper from re-opening the editor
96
124
  if (this.editingCell) {
97
125
  this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
98
126
  }
127
+ const afterCommit = this.onAfterCommit;
99
128
  this.closeEditor();
129
+ afterCommit?.(); // Move active cell down after closing
100
130
  }
101
131
  else if (e.key === 'Escape') {
102
132
  e.preventDefault();
133
+ e.stopPropagation();
103
134
  this.onCancel?.();
104
135
  this.closeEditor();
105
136
  }
@@ -129,6 +160,7 @@ export class InlineCellEditor {
129
160
  input.addEventListener('keydown', (e) => {
130
161
  if (e.key === 'Escape') {
131
162
  e.preventDefault();
163
+ e.stopPropagation();
132
164
  this.onCancel?.();
133
165
  this.closeEditor();
134
166
  }
@@ -148,13 +180,17 @@ export class InlineCellEditor {
148
180
  input.addEventListener('keydown', (e) => {
149
181
  if (e.key === 'Enter') {
150
182
  e.preventDefault();
183
+ e.stopPropagation(); // Prevent grid wrapper from re-opening the editor
151
184
  if (this.editingCell) {
152
185
  this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
153
186
  }
187
+ const afterCommit = this.onAfterCommit;
154
188
  this.closeEditor();
189
+ afterCommit?.(); // Move active cell down after closing
155
190
  }
156
191
  else if (e.key === 'Escape') {
157
192
  e.preventDefault();
193
+ e.stopPropagation();
158
194
  this.onCancel?.();
159
195
  this.closeEditor();
160
196
  }
@@ -165,6 +201,7 @@ export class InlineCellEditor {
165
201
  }
166
202
  this.closeEditor();
167
203
  });
204
+ setTimeout(() => input.select(), 0);
168
205
  return input;
169
206
  }
170
207
  createSelectEditor(value, column) {
@@ -187,6 +224,7 @@ export class InlineCellEditor {
187
224
  select.addEventListener('keydown', (e) => {
188
225
  if (e.key === 'Escape') {
189
226
  e.preventDefault();
227
+ e.stopPropagation();
190
228
  this.onCancel?.();
191
229
  this.closeEditor();
192
230
  }
@@ -249,24 +287,30 @@ export class InlineCellEditor {
249
287
  input.addEventListener('keydown', (e) => {
250
288
  if (e.key === 'Enter') {
251
289
  e.preventDefault();
290
+ e.stopPropagation(); // Prevent grid wrapper from re-opening the editor
252
291
  if (this.editingCell) {
253
292
  this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
254
293
  }
294
+ const afterCommit = this.onAfterCommit;
255
295
  this.closeEditor();
296
+ afterCommit?.(); // Move active cell down after closing
256
297
  }
257
298
  else if (e.key === 'Escape') {
258
299
  e.preventDefault();
300
+ e.stopPropagation();
259
301
  this.onCancel?.();
260
302
  this.closeEditor();
261
303
  }
262
304
  });
263
- input.addEventListener('blur', () => {
264
- setTimeout(() => {
265
- if (this.editingCell) {
266
- this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
267
- }
268
- this.closeEditor();
269
- }, 200);
305
+ input.addEventListener('blur', (e) => {
306
+ const related = e.relatedTarget;
307
+ if (related && this.editor?.contains(related)) {
308
+ return; // Focus moved within the editor (e.g., to dropdown), don't close
309
+ }
310
+ if (this.editingCell) {
311
+ this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
312
+ }
313
+ this.closeEditor();
270
314
  });
271
315
  renderOptions('');
272
316
  setTimeout(() => input.select(), 0);
package/dist/esm/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Re-export core types + utils
2
2
  export * from '@alaarab/ogrid-core';
3
+ // Utils
4
+ export { debounce } from './utils';
3
5
  // Classes
4
6
  export { OGrid } from './OGrid';
5
7
  export { GridState } from './state/GridState';
@@ -19,6 +21,8 @@ export { ContextMenu } from './components/ContextMenu';
19
21
  export { FillHandleState } from './state/FillHandleState';
20
22
  export { RowSelectionState } from './state/RowSelectionState';
21
23
  export { ColumnPinningState } from './state/ColumnPinningState';
24
+ export { ColumnReorderState } from './state/ColumnReorderState';
25
+ export { VirtualScrollState } from './state/VirtualScrollState';
22
26
  export { MarchingAntsOverlay } from './components/MarchingAntsOverlay';
23
27
  export { SideBarState } from './state/SideBarState';
24
28
  export { HeaderFilterState } from './state/HeaderFilterState';
@@ -1,4 +1,4 @@
1
- import { getCellValue, buildHeaderRows, isInSelectionRange } from '@alaarab/ogrid-core';
1
+ import { getCellValue, buildHeaderRows, isInSelectionRange, ROW_NUMBER_COLUMN_WIDTH } from '@alaarab/ogrid-core';
2
2
  const CHECKBOX_COL_WIDTH = 40;
3
3
  export class TableRenderer {
4
4
  constructor(container, state) {
@@ -10,9 +10,14 @@ export class TableRenderer {
10
10
  this.headerFilterState = null;
11
11
  this.filterConfigs = new Map();
12
12
  this.onFilterIconClick = null;
13
+ this.dropIndicator = null;
14
+ this.virtualScrollState = null;
13
15
  this.container = container;
14
16
  this.state = state;
15
17
  }
18
+ setVirtualScrollState(vs) {
19
+ this.virtualScrollState = vs;
20
+ }
16
21
  setHeaderFilterState(state, configs) {
17
22
  this.headerFilterState = state;
18
23
  this.filterConfigs = configs;
@@ -53,6 +58,11 @@ export class TableRenderer {
53
58
  this.renderBody();
54
59
  this.table.appendChild(this.tbody);
55
60
  wrapper.appendChild(this.table);
61
+ // Create drop indicator for column reorder (hidden by default)
62
+ this.dropIndicator = document.createElement('div');
63
+ this.dropIndicator.className = 'ogrid-drop-indicator';
64
+ this.dropIndicator.style.display = 'none';
65
+ wrapper.appendChild(this.dropIndicator);
56
66
  this.container.appendChild(wrapper);
57
67
  }
58
68
  /** Re-render body rows and header (after sort/filter/page change). */
@@ -70,9 +80,17 @@ export class TableRenderer {
70
80
  const mode = this.interactionState?.rowSelectionMode;
71
81
  return mode === 'single' || mode === 'multiple';
72
82
  }
73
- /** The column index offset for data columns (1 if checkbox column present, else 0). */
83
+ hasRowNumbersColumn() {
84
+ return !!this.interactionState?.showRowNumbers;
85
+ }
86
+ /** The column index offset for data columns (checkbox + row numbers if present). */
74
87
  getColOffset() {
75
- return this.hasCheckboxColumn() ? 1 : 0;
88
+ let offset = 0;
89
+ if (this.hasCheckboxColumn())
90
+ offset++;
91
+ if (this.hasRowNumbersColumn())
92
+ offset++;
93
+ return offset;
76
94
  }
77
95
  applyPinningStyles(el, columnId, isHeader) {
78
96
  const is = this.interactionState;
@@ -140,7 +158,21 @@ export class TableRenderer {
140
158
  }
141
159
  }
142
160
  if (!cell.isGroup && cell.columnDef) {
161
+ th.setAttribute('data-column-id', cell.columnDef.columnId);
143
162
  this.applyPinningStyles(th, cell.columnDef.columnId, true);
163
+ // Column reorder in grouped headers
164
+ if (this.interactionState?.onColumnReorderStart) {
165
+ th.addEventListener('mousedown', (e) => {
166
+ const target = e.target;
167
+ if (target.classList.contains('ogrid-resize-handle') ||
168
+ target.classList.contains('ogrid-filter-icon')) {
169
+ return;
170
+ }
171
+ if (cell.columnDef) {
172
+ this.interactionState?.onColumnReorderStart?.(cell.columnDef.columnId, e);
173
+ }
174
+ });
175
+ }
144
176
  }
145
177
  tr.appendChild(th);
146
178
  }
@@ -158,6 +190,15 @@ export class TableRenderer {
158
190
  this.appendSelectAllCheckbox(th);
159
191
  tr.appendChild(th);
160
192
  }
193
+ // Row numbers header
194
+ if (this.hasRowNumbersColumn()) {
195
+ const th = document.createElement('th');
196
+ th.className = 'ogrid-header-cell ogrid-row-number-header';
197
+ th.style.width = `${ROW_NUMBER_COLUMN_WIDTH}px`;
198
+ th.style.textAlign = 'center';
199
+ th.textContent = '#';
200
+ tr.appendChild(th);
201
+ }
161
202
  for (let colIdx = 0; colIdx < visibleCols.length; colIdx++) {
162
203
  const col = visibleCols[colIdx];
163
204
  const th = document.createElement('th');
@@ -232,6 +273,18 @@ export class TableRenderer {
232
273
  });
233
274
  th.appendChild(filterBtn);
234
275
  }
276
+ // Column reorder: mousedown on header starts drag
277
+ if (this.interactionState?.onColumnReorderStart) {
278
+ th.addEventListener('mousedown', (e) => {
279
+ // Don't start reorder if clicking resize handle or filter button
280
+ const target = e.target;
281
+ if (target.classList.contains('ogrid-resize-handle') ||
282
+ target.classList.contains('ogrid-filter-icon')) {
283
+ return;
284
+ }
285
+ this.interactionState?.onColumnReorderStart?.(col.columnId, e);
286
+ });
287
+ }
235
288
  tr.appendChild(th);
236
289
  }
237
290
  this.thead.appendChild(tr);
@@ -258,19 +311,47 @@ export class TableRenderer {
258
311
  const visibleCols = this.state.visibleColumnDefs;
259
312
  const { items } = this.state.getProcessedItems();
260
313
  const hasCheckbox = this.hasCheckboxColumn();
314
+ const hasRowNumbers = this.hasRowNumbersColumn();
261
315
  const colOffset = this.getColOffset();
316
+ const totalColSpan = visibleCols.length + colOffset;
317
+ // Calculate row number offset for pagination
318
+ const rowNumberOffset = hasRowNumbers ? (this.state.page - 1) * this.state.pageSize : 0;
262
319
  if (items.length === 0 && !this.state.isLoading) {
263
320
  const tr = document.createElement('tr');
264
321
  const td = document.createElement('td');
265
- td.colSpan = visibleCols.length + colOffset;
322
+ td.colSpan = totalColSpan;
266
323
  td.className = 'ogrid-empty-state';
267
324
  td.textContent = 'No data';
268
325
  tr.appendChild(td);
269
326
  this.tbody.appendChild(tr);
270
327
  return;
271
328
  }
272
- for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
329
+ // Virtual scrolling: determine which rows to render
330
+ const vs = this.virtualScrollState;
331
+ const isVirtual = vs?.enabled === true;
332
+ let startIndex = 0;
333
+ let endIndex = items.length - 1;
334
+ if (isVirtual) {
335
+ const range = vs.visibleRange;
336
+ startIndex = Math.max(0, range.startIndex);
337
+ endIndex = Math.min(items.length - 1, range.endIndex);
338
+ // Top spacer row
339
+ if (range.offsetTop > 0) {
340
+ const topSpacer = document.createElement('tr');
341
+ topSpacer.className = 'ogrid-virtual-spacer';
342
+ const topTd = document.createElement('td');
343
+ topTd.colSpan = totalColSpan;
344
+ topTd.style.height = `${range.offsetTop}px`;
345
+ topTd.style.padding = '0';
346
+ topTd.style.border = 'none';
347
+ topSpacer.appendChild(topTd);
348
+ this.tbody.appendChild(topSpacer);
349
+ }
350
+ }
351
+ for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex++) {
273
352
  const item = items[rowIndex];
353
+ if (!item)
354
+ continue;
274
355
  const rowId = this.state.getRowId(item);
275
356
  const tr = document.createElement('tr');
276
357
  tr.className = 'ogrid-row';
@@ -298,6 +379,17 @@ export class TableRenderer {
298
379
  td.appendChild(checkbox);
299
380
  tr.appendChild(td);
300
381
  }
382
+ // Row numbers column
383
+ if (hasRowNumbers) {
384
+ const td = document.createElement('td');
385
+ td.className = 'ogrid-cell ogrid-row-number-cell';
386
+ td.style.width = `${ROW_NUMBER_COLUMN_WIDTH}px`;
387
+ td.style.textAlign = 'center';
388
+ td.style.color = 'var(--ogrid-fg-muted, #666)';
389
+ td.style.fontSize = '0.9em';
390
+ td.textContent = String(rowNumberOffset + rowIndex + 1);
391
+ tr.appendChild(td);
392
+ }
301
393
  for (let colIndex = 0; colIndex < visibleCols.length; colIndex++) {
302
394
  const col = visibleCols[colIndex];
303
395
  const globalColIndex = colIndex + colOffset;
@@ -404,11 +496,45 @@ export class TableRenderer {
404
496
  }
405
497
  this.tbody.appendChild(tr);
406
498
  }
499
+ // Virtual scrolling: bottom spacer row
500
+ if (isVirtual) {
501
+ const range = vs.visibleRange;
502
+ if (range.offsetBottom > 0) {
503
+ const bottomSpacer = document.createElement('tr');
504
+ bottomSpacer.className = 'ogrid-virtual-spacer';
505
+ const bottomTd = document.createElement('td');
506
+ bottomTd.colSpan = totalColSpan;
507
+ bottomTd.style.height = `${range.offsetBottom}px`;
508
+ bottomTd.style.padding = '0';
509
+ bottomTd.style.border = 'none';
510
+ bottomSpacer.appendChild(bottomTd);
511
+ this.tbody.appendChild(bottomSpacer);
512
+ }
513
+ }
514
+ }
515
+ /** Get the table element (used by ColumnReorderState for header cell queries). */
516
+ getTableElement() {
517
+ return this.table;
518
+ }
519
+ /** Update the drop indicator position during column reorder. */
520
+ updateDropIndicator(x, isDragging) {
521
+ if (!this.dropIndicator || !this.wrapperEl)
522
+ return;
523
+ if (!isDragging || x === null) {
524
+ this.dropIndicator.style.display = 'none';
525
+ return;
526
+ }
527
+ // Convert client X to position relative to the wrapper
528
+ const wrapperRect = this.wrapperEl.getBoundingClientRect();
529
+ const relativeX = x - wrapperRect.left + this.wrapperEl.scrollLeft;
530
+ this.dropIndicator.style.display = 'block';
531
+ this.dropIndicator.style.left = `${relativeX}px`;
407
532
  }
408
533
  destroy() {
409
534
  this.container.innerHTML = '';
410
535
  this.table = null;
411
536
  this.thead = null;
412
537
  this.tbody = null;
538
+ this.dropIndicator = null;
413
539
  }
414
540
  }
@@ -1,3 +1,4 @@
1
+ import { ROW_NUMBER_COLUMN_WIDTH } from '@alaarab/ogrid-core';
1
2
  import { EventEmitter } from './EventEmitter';
2
3
  /**
3
4
  * Manages column pinning state — tracks which columns are pinned left/right.
@@ -40,9 +41,13 @@ export class ColumnPinningState {
40
41
  * Compute sticky left offsets for left-pinned columns.
41
42
  * Returns a map of columnId -> left offset in pixels.
42
43
  */
43
- computeLeftOffsets(visibleCols, columnWidths, defaultWidth, hasCheckboxColumn, checkboxColumnWidth) {
44
+ computeLeftOffsets(visibleCols, columnWidths, defaultWidth, hasCheckboxColumn, checkboxColumnWidth, hasRowNumbersColumn) {
44
45
  const offsets = {};
45
- let left = hasCheckboxColumn ? checkboxColumnWidth : 0;
46
+ let left = 0;
47
+ if (hasCheckboxColumn)
48
+ left += checkboxColumnWidth;
49
+ if (hasRowNumbersColumn)
50
+ left += ROW_NUMBER_COLUMN_WIDTH;
46
51
  for (const col of visibleCols) {
47
52
  if (this._pinnedColumns[col.columnId] === 'left') {
48
53
  offsets[col.columnId] = left;