@alaarab/ogrid-js 2.0.2 → 2.0.3

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) {
@@ -338,6 +372,13 @@ export class OGrid {
338
372
  pinnedColumns: this.pinningState?.pinnedColumns,
339
373
  leftOffsets,
340
374
  rightOffsets,
375
+ // Column reorder
376
+ onColumnReorderStart: this.reorderState ? (columnId, event) => {
377
+ const tableEl = this.renderer.getTableElement();
378
+ if (!tableEl)
379
+ return;
380
+ this.reorderState?.startDrag(columnId, event, visibleCols, this.state.columnOrder, this.pinningState?.pinnedColumns, tableEl);
381
+ } : undefined,
341
382
  });
342
383
  this.renderer.update();
343
384
  // Update marching ants overlay
@@ -452,7 +493,29 @@ export class OGrid {
452
493
  const onCancel = () => {
453
494
  this.updateRendererInteractionState();
454
495
  };
455
- this.cellEditor.startEdit(rowId, columnId, item, column, cell, onCommit, onCancel);
496
+ const onAfterCommit = () => {
497
+ // After Enter-commit, move the active cell down one row (Excel-style behavior)
498
+ if (this.selectionState) {
499
+ const ac = this.selectionState.activeCell;
500
+ if (ac) {
501
+ const { items: currentItems } = this.state.getProcessedItems();
502
+ const newRow = Math.min(ac.rowIndex + 1, currentItems.length - 1);
503
+ this.selectionState.setActiveCell({ rowIndex: newRow, columnIndex: ac.columnIndex });
504
+ const colOffset = this.renderer.getColOffset();
505
+ const dataCol = ac.columnIndex - colOffset;
506
+ this.selectionState.setSelectionRange({
507
+ startRow: newRow,
508
+ startCol: dataCol,
509
+ endRow: newRow,
510
+ endCol: dataCol,
511
+ });
512
+ }
513
+ }
514
+ // Re-focus the grid wrapper so keyboard nav continues working
515
+ const wrapper = this.renderer.getWrapperElement();
516
+ wrapper?.focus();
517
+ };
518
+ this.cellEditor.startEdit(rowId, columnId, item, column, cell, onCommit, onCancel, onAfterCommit);
456
519
  }
457
520
  buildFilterConfigs() {
458
521
  const columns = flattenColumns(this.options.columns);
@@ -611,6 +674,8 @@ export class OGrid {
611
674
  this.renderer.update();
612
675
  }
613
676
  const { totalCount } = this.state.getProcessedItems();
677
+ // Update virtual scroll with current total row count
678
+ this.virtualScrollState?.setTotalRows(totalCount);
614
679
  this.pagination.render(totalCount);
615
680
  this.statusBar.render({ totalCount });
616
681
  this.columnChooser.render();
@@ -644,6 +709,8 @@ export class OGrid {
644
709
  this.fillHandleState?.destroy();
645
710
  this.rowSelectionState?.destroy();
646
711
  this.pinningState?.destroy();
712
+ this.reorderState?.destroy();
713
+ this.virtualScrollState?.destroy();
647
714
  this.marchingAnts?.destroy();
648
715
  this.layoutState.destroy();
649
716
  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
  }
@@ -187,6 +223,7 @@ export class InlineCellEditor {
187
223
  select.addEventListener('keydown', (e) => {
188
224
  if (e.key === 'Escape') {
189
225
  e.preventDefault();
226
+ e.stopPropagation();
190
227
  this.onCancel?.();
191
228
  this.closeEditor();
192
229
  }
@@ -249,24 +286,30 @@ export class InlineCellEditor {
249
286
  input.addEventListener('keydown', (e) => {
250
287
  if (e.key === 'Enter') {
251
288
  e.preventDefault();
289
+ e.stopPropagation(); // Prevent grid wrapper from re-opening the editor
252
290
  if (this.editingCell) {
253
291
  this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
254
292
  }
293
+ const afterCommit = this.onAfterCommit;
255
294
  this.closeEditor();
295
+ afterCommit?.(); // Move active cell down after closing
256
296
  }
257
297
  else if (e.key === 'Escape') {
258
298
  e.preventDefault();
299
+ e.stopPropagation();
259
300
  this.onCancel?.();
260
301
  this.closeEditor();
261
302
  }
262
303
  });
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);
304
+ input.addEventListener('blur', (e) => {
305
+ const related = e.relatedTarget;
306
+ if (related && this.editor?.contains(related)) {
307
+ return; // Focus moved within the editor (e.g., to dropdown), don't close
308
+ }
309
+ if (this.editingCell) {
310
+ this.onCommit?.(this.editingCell.rowId, this.editingCell.columnId, input.value);
311
+ }
312
+ this.closeEditor();
270
313
  });
271
314
  renderOptions('');
272
315
  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';
@@ -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). */
@@ -140,7 +150,21 @@ export class TableRenderer {
140
150
  }
141
151
  }
142
152
  if (!cell.isGroup && cell.columnDef) {
153
+ th.setAttribute('data-column-id', cell.columnDef.columnId);
143
154
  this.applyPinningStyles(th, cell.columnDef.columnId, true);
155
+ // Column reorder in grouped headers
156
+ if (this.interactionState?.onColumnReorderStart) {
157
+ th.addEventListener('mousedown', (e) => {
158
+ const target = e.target;
159
+ if (target.classList.contains('ogrid-resize-handle') ||
160
+ target.classList.contains('ogrid-filter-icon')) {
161
+ return;
162
+ }
163
+ if (cell.columnDef) {
164
+ this.interactionState?.onColumnReorderStart?.(cell.columnDef.columnId, e);
165
+ }
166
+ });
167
+ }
144
168
  }
145
169
  tr.appendChild(th);
146
170
  }
@@ -232,6 +256,18 @@ export class TableRenderer {
232
256
  });
233
257
  th.appendChild(filterBtn);
234
258
  }
259
+ // Column reorder: mousedown on header starts drag
260
+ if (this.interactionState?.onColumnReorderStart) {
261
+ th.addEventListener('mousedown', (e) => {
262
+ // Don't start reorder if clicking resize handle or filter button
263
+ const target = e.target;
264
+ if (target.classList.contains('ogrid-resize-handle') ||
265
+ target.classList.contains('ogrid-filter-icon')) {
266
+ return;
267
+ }
268
+ this.interactionState?.onColumnReorderStart?.(col.columnId, e);
269
+ });
270
+ }
235
271
  tr.appendChild(th);
236
272
  }
237
273
  this.thead.appendChild(tr);
@@ -259,18 +295,43 @@ export class TableRenderer {
259
295
  const { items } = this.state.getProcessedItems();
260
296
  const hasCheckbox = this.hasCheckboxColumn();
261
297
  const colOffset = this.getColOffset();
298
+ const totalColSpan = visibleCols.length + colOffset;
262
299
  if (items.length === 0 && !this.state.isLoading) {
263
300
  const tr = document.createElement('tr');
264
301
  const td = document.createElement('td');
265
- td.colSpan = visibleCols.length + colOffset;
302
+ td.colSpan = totalColSpan;
266
303
  td.className = 'ogrid-empty-state';
267
304
  td.textContent = 'No data';
268
305
  tr.appendChild(td);
269
306
  this.tbody.appendChild(tr);
270
307
  return;
271
308
  }
272
- for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
309
+ // Virtual scrolling: determine which rows to render
310
+ const vs = this.virtualScrollState;
311
+ const isVirtual = vs?.enabled === true;
312
+ let startIndex = 0;
313
+ let endIndex = items.length - 1;
314
+ if (isVirtual) {
315
+ const range = vs.visibleRange;
316
+ startIndex = Math.max(0, range.startIndex);
317
+ endIndex = Math.min(items.length - 1, range.endIndex);
318
+ // Top spacer row
319
+ if (range.offsetTop > 0) {
320
+ const topSpacer = document.createElement('tr');
321
+ topSpacer.className = 'ogrid-virtual-spacer';
322
+ const topTd = document.createElement('td');
323
+ topTd.colSpan = totalColSpan;
324
+ topTd.style.height = `${range.offsetTop}px`;
325
+ topTd.style.padding = '0';
326
+ topTd.style.border = 'none';
327
+ topSpacer.appendChild(topTd);
328
+ this.tbody.appendChild(topSpacer);
329
+ }
330
+ }
331
+ for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex++) {
273
332
  const item = items[rowIndex];
333
+ if (!item)
334
+ continue;
274
335
  const rowId = this.state.getRowId(item);
275
336
  const tr = document.createElement('tr');
276
337
  tr.className = 'ogrid-row';
@@ -404,11 +465,45 @@ export class TableRenderer {
404
465
  }
405
466
  this.tbody.appendChild(tr);
406
467
  }
468
+ // Virtual scrolling: bottom spacer row
469
+ if (isVirtual) {
470
+ const range = vs.visibleRange;
471
+ if (range.offsetBottom > 0) {
472
+ const bottomSpacer = document.createElement('tr');
473
+ bottomSpacer.className = 'ogrid-virtual-spacer';
474
+ const bottomTd = document.createElement('td');
475
+ bottomTd.colSpan = totalColSpan;
476
+ bottomTd.style.height = `${range.offsetBottom}px`;
477
+ bottomTd.style.padding = '0';
478
+ bottomTd.style.border = 'none';
479
+ bottomSpacer.appendChild(bottomTd);
480
+ this.tbody.appendChild(bottomSpacer);
481
+ }
482
+ }
483
+ }
484
+ /** Get the table element (used by ColumnReorderState for header cell queries). */
485
+ getTableElement() {
486
+ return this.table;
487
+ }
488
+ /** Update the drop indicator position during column reorder. */
489
+ updateDropIndicator(x, isDragging) {
490
+ if (!this.dropIndicator || !this.wrapperEl)
491
+ return;
492
+ if (!isDragging || x === null) {
493
+ this.dropIndicator.style.display = 'none';
494
+ return;
495
+ }
496
+ // Convert client X to position relative to the wrapper
497
+ const wrapperRect = this.wrapperEl.getBoundingClientRect();
498
+ const relativeX = x - wrapperRect.left + this.wrapperEl.scrollLeft;
499
+ this.dropIndicator.style.display = 'block';
500
+ this.dropIndicator.style.left = `${relativeX}px`;
407
501
  }
408
502
  destroy() {
409
503
  this.container.innerHTML = '';
410
504
  this.table = null;
411
505
  this.thead = null;
412
506
  this.tbody = null;
507
+ this.dropIndicator = null;
413
508
  }
414
509
  }
@@ -0,0 +1,128 @@
1
+ import { calculateDropTarget, reorderColumnArray, getPinStateForColumn } from '@alaarab/ogrid-core';
2
+ import { EventEmitter } from './EventEmitter';
3
+ /**
4
+ * Manages column drag-to-reorder for the vanilla JS grid.
5
+ * Follows the EventEmitter + RAF pattern from FillHandleState/SelectionState.
6
+ */
7
+ export class ColumnReorderState {
8
+ constructor() {
9
+ this.emitter = new EventEmitter();
10
+ this._isDragging = false;
11
+ this._draggedColumnId = null;
12
+ this._dropIndicatorX = null;
13
+ this._dropTargetIndex = null;
14
+ this.rafId = 0;
15
+ this.columnOrder = [];
16
+ this.draggedPinState = 'unpinned';
17
+ this.tableElement = null;
18
+ this.onMoveBound = this.handleMouseMove.bind(this);
19
+ this.onUpBound = this.handleMouseUp.bind(this);
20
+ }
21
+ get isDragging() {
22
+ return this._isDragging;
23
+ }
24
+ get dropIndicatorX() {
25
+ return this._dropIndicatorX;
26
+ }
27
+ /**
28
+ * Begin a column drag operation.
29
+ * Called from mousedown on a header cell.
30
+ */
31
+ startDrag(columnId, event, columns, columnOrder, pinnedColumns, tableElement) {
32
+ event.preventDefault();
33
+ this._isDragging = true;
34
+ this._draggedColumnId = columnId;
35
+ this._dropIndicatorX = null;
36
+ this._dropTargetIndex = null;
37
+ this.tableElement = tableElement;
38
+ // Use provided column order, or derive from columns array
39
+ this.columnOrder = columnOrder.length > 0
40
+ ? [...columnOrder]
41
+ : columns.map(c => c.columnId);
42
+ // Convert Record<string, 'left' | 'right'> to { left?: string[]; right?: string[] }
43
+ if (pinnedColumns) {
44
+ const left = [];
45
+ const right = [];
46
+ for (const [id, side] of Object.entries(pinnedColumns)) {
47
+ if (side === 'left')
48
+ left.push(id);
49
+ else if (side === 'right')
50
+ right.push(id);
51
+ }
52
+ this.pinnedColumns = { left, right };
53
+ }
54
+ else {
55
+ this.pinnedColumns = undefined;
56
+ }
57
+ this.draggedPinState = getPinStateForColumn(columnId, this.pinnedColumns);
58
+ window.addEventListener('mousemove', this.onMoveBound, true);
59
+ window.addEventListener('mouseup', this.onUpBound, true);
60
+ this.emitter.emit('stateChange', { isDragging: true, dropIndicatorX: null });
61
+ }
62
+ handleMouseMove(event) {
63
+ if (!this._isDragging || !this._draggedColumnId || !this.tableElement)
64
+ return;
65
+ if (this.rafId)
66
+ cancelAnimationFrame(this.rafId);
67
+ const mouseX = event.clientX;
68
+ this.rafId = requestAnimationFrame(() => {
69
+ this.rafId = 0;
70
+ if (!this._draggedColumnId || !this.tableElement)
71
+ return;
72
+ const result = calculateDropTarget(mouseX, this.columnOrder, this._draggedColumnId, this.draggedPinState, this.tableElement, this.pinnedColumns);
73
+ if (!result)
74
+ return;
75
+ const prevX = this._dropIndicatorX;
76
+ const prevIdx = this._dropTargetIndex;
77
+ this._dropTargetIndex = result.targetIndex;
78
+ this._dropIndicatorX = result.indicatorX;
79
+ // Only emit if something changed
80
+ if (prevX !== result.indicatorX || prevIdx !== result.targetIndex) {
81
+ this.emitter.emit('stateChange', {
82
+ isDragging: true,
83
+ dropIndicatorX: result.indicatorX,
84
+ });
85
+ }
86
+ });
87
+ }
88
+ handleMouseUp() {
89
+ window.removeEventListener('mousemove', this.onMoveBound, true);
90
+ window.removeEventListener('mouseup', this.onUpBound, true);
91
+ if (this.rafId) {
92
+ cancelAnimationFrame(this.rafId);
93
+ this.rafId = 0;
94
+ }
95
+ // Commit reorder if we have a valid drop target that isn't a no-op
96
+ if (this._isDragging &&
97
+ this._draggedColumnId &&
98
+ this._dropTargetIndex !== null &&
99
+ this._dropIndicatorX !== null // null indicatorX means no-op (same position)
100
+ ) {
101
+ const newOrder = reorderColumnArray(this.columnOrder, this._draggedColumnId, this._dropTargetIndex);
102
+ this.emitter.emit('reorder', { columnOrder: newOrder });
103
+ }
104
+ this._isDragging = false;
105
+ this._draggedColumnId = null;
106
+ this._dropIndicatorX = null;
107
+ this._dropTargetIndex = null;
108
+ this.tableElement = null;
109
+ this.emitter.emit('stateChange', { isDragging: false, dropIndicatorX: null });
110
+ }
111
+ onStateChange(handler) {
112
+ this.emitter.on('stateChange', handler);
113
+ return () => this.emitter.off('stateChange', handler);
114
+ }
115
+ onReorder(handler) {
116
+ this.emitter.on('reorder', handler);
117
+ return () => this.emitter.off('reorder', handler);
118
+ }
119
+ destroy() {
120
+ if (this._isDragging) {
121
+ window.removeEventListener('mousemove', this.onMoveBound, true);
122
+ window.removeEventListener('mouseup', this.onUpBound, true);
123
+ }
124
+ if (this.rafId)
125
+ cancelAnimationFrame(this.rafId);
126
+ this.emitter.removeAllListeners();
127
+ }
128
+ }