@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 +68 -1
- package/dist/esm/components/ContextMenu.js +11 -8
- package/dist/esm/components/InlineCellEditor.js +52 -9
- package/dist/esm/index.js +4 -0
- package/dist/esm/renderer/TableRenderer.js +97 -2
- package/dist/esm/state/ColumnReorderState.js +128 -0
- package/dist/esm/state/GridState.js +38 -5
- package/dist/esm/state/SelectionState.js +12 -0
- package/dist/esm/state/VirtualScrollState.js +128 -0
- package/dist/esm/utils/debounce.js +41 -0
- package/dist/esm/utils/index.js +1 -0
- package/dist/styles/ogrid.css +45 -4
- package/dist/types/OGrid.d.ts +2 -0
- package/dist/types/components/InlineCellEditor.d.ts +3 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/renderer/TableRenderer.d.ts +9 -0
- package/dist/types/state/ColumnReorderState.d.ts +43 -0
- package/dist/types/state/GridState.d.ts +5 -1
- package/dist/types/state/VirtualScrollState.d.ts +51 -0
- package/dist/types/types/gridTypes.d.ts +12 -2
- package/dist/types/utils/debounce.d.ts +25 -0
- package/dist/types/utils/index.d.ts +1 -0
- package/package.json +2 -2
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
|
-
|
|
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: '
|
|
5
|
-
border: '1px solid #
|
|
6
|
-
boxShadow: '0
|
|
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 = '
|
|
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 = '#
|
|
68
|
+
menuItem.style.backgroundColor = 'var(--ogrid-bg-hover, #f5f5f5)';
|
|
66
69
|
});
|
|
67
70
|
menuItem.addEventListener('mouseleave', () => {
|
|
68
|
-
menuItem.style.backgroundColor = '
|
|
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 #
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|