@alaarab/ogrid-js 2.1.2 → 2.1.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/index.js +6343 -32
- package/package.json +7 -5
- package/dist/esm/OGrid.js +0 -578
- package/dist/esm/OGridEventWiring.js +0 -178
- package/dist/esm/OGridRendering.js +0 -269
- package/dist/esm/components/ColumnChooser.js +0 -91
- package/dist/esm/components/ContextMenu.js +0 -125
- package/dist/esm/components/HeaderFilter.js +0 -281
- package/dist/esm/components/InlineCellEditor.js +0 -434
- package/dist/esm/components/MarchingAntsOverlay.js +0 -156
- package/dist/esm/components/PaginationControls.js +0 -85
- package/dist/esm/components/SideBar.js +0 -353
- package/dist/esm/components/StatusBar.js +0 -34
- package/dist/esm/renderer/TableRenderer.js +0 -846
- package/dist/esm/state/ClipboardState.js +0 -111
- package/dist/esm/state/ColumnPinningState.js +0 -82
- package/dist/esm/state/ColumnReorderState.js +0 -135
- package/dist/esm/state/ColumnResizeState.js +0 -55
- package/dist/esm/state/EventEmitter.js +0 -28
- package/dist/esm/state/FillHandleState.js +0 -206
- package/dist/esm/state/GridState.js +0 -324
- package/dist/esm/state/HeaderFilterState.js +0 -213
- package/dist/esm/state/KeyboardNavState.js +0 -216
- package/dist/esm/state/RowSelectionState.js +0 -72
- package/dist/esm/state/SelectionState.js +0 -109
- package/dist/esm/state/SideBarState.js +0 -41
- package/dist/esm/state/TableLayoutState.js +0 -97
- package/dist/esm/state/UndoRedoState.js +0 -71
- package/dist/esm/state/VirtualScrollState.js +0 -128
- package/dist/esm/types/columnTypes.js +0 -1
- package/dist/esm/types/gridTypes.js +0 -1
- package/dist/esm/types/index.js +0 -2
- package/dist/esm/utils/debounce.js +0 -2
- package/dist/esm/utils/getCellCoordinates.js +0 -15
- package/dist/esm/utils/index.js +0 -2
|
@@ -1,846 +0,0 @@
|
|
|
1
|
-
import { getCellValue, buildHeaderRows, isInSelectionRange, ROW_NUMBER_COLUMN_WIDTH, CHECKBOX_COLUMN_WIDTH } from '@alaarab/ogrid-core';
|
|
2
|
-
import { getCellCoordinates } from '../utils/getCellCoordinates';
|
|
3
|
-
export class TableRenderer {
|
|
4
|
-
constructor(container, state) {
|
|
5
|
-
this.table = null;
|
|
6
|
-
this.thead = null;
|
|
7
|
-
this.tbody = null;
|
|
8
|
-
this.interactionState = null;
|
|
9
|
-
this.wrapperEl = null;
|
|
10
|
-
this.headerFilterState = null;
|
|
11
|
-
this.filterConfigs = new Map();
|
|
12
|
-
this.onFilterIconClick = null;
|
|
13
|
-
this.dropIndicator = null;
|
|
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
|
-
// Delegated event handlers bound to thead (avoids per-<th> inline listeners)
|
|
21
|
-
this._theadClickHandler = null;
|
|
22
|
-
this._theadMousedownHandler = null;
|
|
23
|
-
// State tracking for incremental DOM patching
|
|
24
|
-
this.lastActiveCell = null;
|
|
25
|
-
this.lastSelectionRange = null;
|
|
26
|
-
this.lastCopyRange = null;
|
|
27
|
-
this.lastCutRange = null;
|
|
28
|
-
this.lastEditingCell = null;
|
|
29
|
-
this.lastColumnWidths = {};
|
|
30
|
-
this.lastHeaderSignature = '';
|
|
31
|
-
this.lastRenderedItems = null;
|
|
32
|
-
this.container = container;
|
|
33
|
-
this.state = state;
|
|
34
|
-
}
|
|
35
|
-
setVirtualScrollState(vs) {
|
|
36
|
-
this.virtualScrollState = vs;
|
|
37
|
-
}
|
|
38
|
-
setHeaderFilterState(state, configs) {
|
|
39
|
-
this.headerFilterState = state;
|
|
40
|
-
this.filterConfigs = configs;
|
|
41
|
-
}
|
|
42
|
-
setOnFilterIconClick(handler) {
|
|
43
|
-
this.onFilterIconClick = handler;
|
|
44
|
-
}
|
|
45
|
-
setInteractionState(state) {
|
|
46
|
-
this.interactionState = state;
|
|
47
|
-
}
|
|
48
|
-
getCellFromEvent(e) {
|
|
49
|
-
const target = e.target;
|
|
50
|
-
const cell = target.closest('td[data-row-index]');
|
|
51
|
-
if (!cell)
|
|
52
|
-
return null;
|
|
53
|
-
const coords = getCellCoordinates(cell);
|
|
54
|
-
if (!coords)
|
|
55
|
-
return null;
|
|
56
|
-
return { el: cell, rowIndex: coords.rowIndex, colIndex: coords.colIndex };
|
|
57
|
-
}
|
|
58
|
-
attachBodyDelegation() {
|
|
59
|
-
if (!this.tbody)
|
|
60
|
-
return;
|
|
61
|
-
this._tbodyClickHandler = (e) => {
|
|
62
|
-
const cell = this.getCellFromEvent(e);
|
|
63
|
-
if (!cell)
|
|
64
|
-
return;
|
|
65
|
-
this.interactionState?.onCellClick?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, event: e });
|
|
66
|
-
};
|
|
67
|
-
this._tbodyMousedownHandler = (e) => {
|
|
68
|
-
// Fill handle mousedown — delegated from per-cell inline listener
|
|
69
|
-
const target = e.target;
|
|
70
|
-
if (target.classList.contains('ogrid-fill-handle') || target.getAttribute('data-fill-handle') === 'true') {
|
|
71
|
-
this.interactionState?.onFillHandleMouseDown?.(e);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const cell = this.getCellFromEvent(e);
|
|
75
|
-
if (!cell)
|
|
76
|
-
return;
|
|
77
|
-
this.interactionState?.onCellMouseDown?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, event: e });
|
|
78
|
-
};
|
|
79
|
-
this._tbodyDblclickHandler = (e) => {
|
|
80
|
-
const cell = this.getCellFromEvent(e);
|
|
81
|
-
if (!cell)
|
|
82
|
-
return;
|
|
83
|
-
const columnId = cell.el.getAttribute('data-column-id') ?? '';
|
|
84
|
-
// Retrieve the typed rowId by looking up the item at the row index (avoids string/number mismatch from data-row-id)
|
|
85
|
-
const { items } = this.state.getProcessedItems();
|
|
86
|
-
const item = items[cell.rowIndex];
|
|
87
|
-
if (!item)
|
|
88
|
-
return;
|
|
89
|
-
const rowId = this.state.getRowId(item);
|
|
90
|
-
this.interactionState?.onCellDoubleClick?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, rowId, columnId });
|
|
91
|
-
};
|
|
92
|
-
this._tbodyContextmenuHandler = (e) => {
|
|
93
|
-
const cell = this.getCellFromEvent(e);
|
|
94
|
-
if (!cell)
|
|
95
|
-
return;
|
|
96
|
-
this.interactionState?.onCellContextMenu?.({ rowIndex: cell.rowIndex, colIndex: cell.colIndex, event: e });
|
|
97
|
-
};
|
|
98
|
-
this.tbody.addEventListener('click', this._tbodyClickHandler, { passive: true });
|
|
99
|
-
this.tbody.addEventListener('mousedown', this._tbodyMousedownHandler);
|
|
100
|
-
this.tbody.addEventListener('dblclick', this._tbodyDblclickHandler, { passive: true });
|
|
101
|
-
this.tbody.addEventListener('contextmenu', this._tbodyContextmenuHandler);
|
|
102
|
-
}
|
|
103
|
-
detachBodyDelegation() {
|
|
104
|
-
if (!this.tbody)
|
|
105
|
-
return;
|
|
106
|
-
if (this._tbodyClickHandler)
|
|
107
|
-
this.tbody.removeEventListener('click', this._tbodyClickHandler);
|
|
108
|
-
if (this._tbodyMousedownHandler)
|
|
109
|
-
this.tbody.removeEventListener('mousedown', this._tbodyMousedownHandler);
|
|
110
|
-
if (this._tbodyDblclickHandler)
|
|
111
|
-
this.tbody.removeEventListener('dblclick', this._tbodyDblclickHandler);
|
|
112
|
-
if (this._tbodyContextmenuHandler)
|
|
113
|
-
this.tbody.removeEventListener('contextmenu', this._tbodyContextmenuHandler);
|
|
114
|
-
this._tbodyClickHandler = null;
|
|
115
|
-
this._tbodyMousedownHandler = null;
|
|
116
|
-
this._tbodyDblclickHandler = null;
|
|
117
|
-
this._tbodyContextmenuHandler = null;
|
|
118
|
-
}
|
|
119
|
-
/** Attach delegated event listeners to <thead> for sort clicks, resize, reorder, and filter icon clicks. */
|
|
120
|
-
attachHeaderDelegation() {
|
|
121
|
-
if (!this.thead)
|
|
122
|
-
return;
|
|
123
|
-
// Sort clicks and filter icon clicks use inline listeners for stale-reference compatibility
|
|
124
|
-
// (tests hold references to <th> elements that become detached after header re-render).
|
|
125
|
-
// Delegation handles resize and column reorder mousedown events only.
|
|
126
|
-
this._theadClickHandler = null;
|
|
127
|
-
this._theadMousedownHandler = (e) => {
|
|
128
|
-
const target = e.target;
|
|
129
|
-
// Resize handle mousedown
|
|
130
|
-
if (target.classList.contains('ogrid-resize-handle')) {
|
|
131
|
-
e.stopPropagation();
|
|
132
|
-
const th = target.closest('th[data-column-id]');
|
|
133
|
-
if (!th)
|
|
134
|
-
return;
|
|
135
|
-
const columnId = th.getAttribute('data-column-id');
|
|
136
|
-
if (columnId) {
|
|
137
|
-
const rect = th.getBoundingClientRect();
|
|
138
|
-
this.interactionState?.onResizeStart?.(columnId, e.clientX, rect.width);
|
|
139
|
-
}
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
// Don't start reorder from filter icon
|
|
143
|
-
if (target.classList.contains('ogrid-filter-icon'))
|
|
144
|
-
return;
|
|
145
|
-
// Column reorder mousedown
|
|
146
|
-
if (this.interactionState?.onColumnReorderStart) {
|
|
147
|
-
const th = target.closest('th[data-column-id]');
|
|
148
|
-
if (!th)
|
|
149
|
-
return;
|
|
150
|
-
const columnId = th.getAttribute('data-column-id');
|
|
151
|
-
if (columnId) {
|
|
152
|
-
this.interactionState.onColumnReorderStart(columnId, e);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
if (this._theadClickHandler)
|
|
157
|
-
this.thead.addEventListener('click', this._theadClickHandler);
|
|
158
|
-
this.thead.addEventListener('mousedown', this._theadMousedownHandler);
|
|
159
|
-
}
|
|
160
|
-
detachHeaderDelegation() {
|
|
161
|
-
if (!this.thead)
|
|
162
|
-
return;
|
|
163
|
-
if (this._theadClickHandler)
|
|
164
|
-
this.thead.removeEventListener('click', this._theadClickHandler);
|
|
165
|
-
if (this._theadMousedownHandler)
|
|
166
|
-
this.thead.removeEventListener('mousedown', this._theadMousedownHandler);
|
|
167
|
-
this._theadClickHandler = null;
|
|
168
|
-
this._theadMousedownHandler = null;
|
|
169
|
-
}
|
|
170
|
-
getWrapperElement() {
|
|
171
|
-
return this.wrapperEl;
|
|
172
|
-
}
|
|
173
|
-
/** Full render — creates the table structure from scratch. */
|
|
174
|
-
render() {
|
|
175
|
-
// Clear container
|
|
176
|
-
this.container.innerHTML = '';
|
|
177
|
-
// Create wrapper
|
|
178
|
-
const wrapper = document.createElement('div');
|
|
179
|
-
wrapper.className = 'ogrid-wrapper';
|
|
180
|
-
wrapper.setAttribute('role', 'grid');
|
|
181
|
-
wrapper.setAttribute('tabindex', '0'); // Make focusable for keyboard nav
|
|
182
|
-
wrapper.style.position = 'relative'; // For MarchingAnts absolute positioning
|
|
183
|
-
if (this.state.rowHeight) {
|
|
184
|
-
wrapper.style.setProperty('--ogrid-row-height', `${this.state.rowHeight}px`);
|
|
185
|
-
}
|
|
186
|
-
if (this.state.ariaLabel) {
|
|
187
|
-
wrapper.setAttribute('aria-label', this.state.ariaLabel);
|
|
188
|
-
}
|
|
189
|
-
this.wrapperEl = wrapper;
|
|
190
|
-
// Create table
|
|
191
|
-
this.table = document.createElement('table');
|
|
192
|
-
this.table.className = 'ogrid-table';
|
|
193
|
-
// Render header
|
|
194
|
-
this.thead = document.createElement('thead');
|
|
195
|
-
if (this.state.stickyHeader) {
|
|
196
|
-
this.thead.classList.add('ogrid-sticky-header');
|
|
197
|
-
}
|
|
198
|
-
this.renderHeader();
|
|
199
|
-
this.attachHeaderDelegation();
|
|
200
|
-
this.table.appendChild(this.thead);
|
|
201
|
-
// Render body
|
|
202
|
-
this.tbody = document.createElement('tbody');
|
|
203
|
-
this.renderBody();
|
|
204
|
-
this.attachBodyDelegation();
|
|
205
|
-
this.table.appendChild(this.tbody);
|
|
206
|
-
wrapper.appendChild(this.table);
|
|
207
|
-
// Create drop indicator for column reorder (hidden by default)
|
|
208
|
-
this.dropIndicator = document.createElement('div');
|
|
209
|
-
this.dropIndicator.className = 'ogrid-drop-indicator';
|
|
210
|
-
this.dropIndicator.style.display = 'none';
|
|
211
|
-
wrapper.appendChild(this.dropIndicator);
|
|
212
|
-
this.container.appendChild(wrapper);
|
|
213
|
-
this.snapshotState();
|
|
214
|
-
}
|
|
215
|
-
/** Compute a signature string that captures header-affecting state. */
|
|
216
|
-
computeHeaderSignature() {
|
|
217
|
-
const cols = this.state.visibleColumnDefs;
|
|
218
|
-
const is = this.interactionState;
|
|
219
|
-
const parts = [];
|
|
220
|
-
for (const col of cols) {
|
|
221
|
-
parts.push(col.columnId);
|
|
222
|
-
parts.push(col.name);
|
|
223
|
-
parts.push(is?.columnWidths[col.columnId]?.toString() ?? '');
|
|
224
|
-
}
|
|
225
|
-
// Include sort state
|
|
226
|
-
const sort = this.state.sort;
|
|
227
|
-
if (sort)
|
|
228
|
-
parts.push(`sort:${sort.field}:${sort.direction}`);
|
|
229
|
-
// Include row selection mode and checkbox header state
|
|
230
|
-
parts.push(`sel:${is?.rowSelectionMode ?? ''}`);
|
|
231
|
-
parts.push(`allSel:${is?.allSelected ?? ''}`);
|
|
232
|
-
parts.push(`someSel:${is?.someSelected ?? ''}`);
|
|
233
|
-
// Include showRowNumbers
|
|
234
|
-
parts.push(`rn:${is?.showRowNumbers ?? ''}`);
|
|
235
|
-
// Include filter active states
|
|
236
|
-
for (const [colId, config] of this.filterConfigs) {
|
|
237
|
-
const hasActive = this.headerFilterState?.hasActiveFilter(config);
|
|
238
|
-
if (hasActive)
|
|
239
|
-
parts.push(`flt:${colId}`);
|
|
240
|
-
}
|
|
241
|
-
return parts.join('|');
|
|
242
|
-
}
|
|
243
|
-
/** Save current interaction state for next diff comparison. */
|
|
244
|
-
snapshotState() {
|
|
245
|
-
const is = this.interactionState;
|
|
246
|
-
this.lastActiveCell = is?.activeCell ? { ...is.activeCell } : null;
|
|
247
|
-
this.lastSelectionRange = is?.selectionRange ? { ...is.selectionRange } : null;
|
|
248
|
-
this.lastCopyRange = is?.copyRange ? { ...is.copyRange } : null;
|
|
249
|
-
this.lastCutRange = is?.cutRange ? { ...is.cutRange } : null;
|
|
250
|
-
this.lastEditingCell = is?.editingCell ? { ...is.editingCell } : null;
|
|
251
|
-
this.lastColumnWidths = is?.columnWidths ? { ...is.columnWidths } : {};
|
|
252
|
-
this.lastRowSelectionMode = is?.rowSelectionMode;
|
|
253
|
-
this.lastSelectedRowIds = is?.selectedRowIds ? new Set(is.selectedRowIds) : undefined;
|
|
254
|
-
this.lastShowRowNumbers = is?.showRowNumbers;
|
|
255
|
-
this.lastPinnedColumns = is?.pinnedColumns;
|
|
256
|
-
this.lastAllSelected = is?.allSelected;
|
|
257
|
-
this.lastSomeSelected = is?.someSelected;
|
|
258
|
-
this.lastHeaderSignature = this.computeHeaderSignature();
|
|
259
|
-
const { items } = this.state.getProcessedItems();
|
|
260
|
-
this.lastRenderedItems = items;
|
|
261
|
-
}
|
|
262
|
-
/** Check if only selection/active-cell/copy/cut ranges changed (no data or header changes). */
|
|
263
|
-
isSelectionOnlyChange() {
|
|
264
|
-
if (!this.lastRenderedItems)
|
|
265
|
-
return false;
|
|
266
|
-
const is = this.interactionState;
|
|
267
|
-
const { items } = this.state.getProcessedItems();
|
|
268
|
-
// If data items changed, need full body rebuild
|
|
269
|
-
if (items !== this.lastRenderedItems)
|
|
270
|
-
return false;
|
|
271
|
-
// If header signature changed, need header rebuild
|
|
272
|
-
const currentHeaderSig = this.computeHeaderSignature();
|
|
273
|
-
if (currentHeaderSig !== this.lastHeaderSignature)
|
|
274
|
-
return false;
|
|
275
|
-
// If editing cell changed, need body rebuild (visibility toggle on the td)
|
|
276
|
-
const curEdit = is?.editingCell;
|
|
277
|
-
const lastEdit = this.lastEditingCell;
|
|
278
|
-
if (curEdit?.rowId !== lastEdit?.rowId || curEdit?.columnId !== lastEdit?.columnId)
|
|
279
|
-
return false;
|
|
280
|
-
// If row selection changed, need body rebuild (checkbox states, row attrs)
|
|
281
|
-
if (is?.rowSelectionMode !== this.lastRowSelectionMode)
|
|
282
|
-
return false;
|
|
283
|
-
if (is?.selectedRowIds !== this.lastSelectedRowIds) {
|
|
284
|
-
// Compare sets
|
|
285
|
-
const curIds = is?.selectedRowIds;
|
|
286
|
-
const lastIds = this.lastSelectedRowIds;
|
|
287
|
-
if (!curIds && !lastIds) { /* both null, ok */ }
|
|
288
|
-
else if (!curIds || !lastIds || curIds.size !== lastIds.size)
|
|
289
|
-
return false;
|
|
290
|
-
else {
|
|
291
|
-
for (const id of curIds) {
|
|
292
|
-
if (!lastIds.has(id))
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
// If pinning or row numbers changed
|
|
298
|
-
if (is?.showRowNumbers !== this.lastShowRowNumbers)
|
|
299
|
-
return false;
|
|
300
|
-
if (is?.pinnedColumns !== this.lastPinnedColumns)
|
|
301
|
-
return false;
|
|
302
|
-
// Otherwise it's just selection/active-cell/copy/cut changes
|
|
303
|
-
return true;
|
|
304
|
-
}
|
|
305
|
-
/** Patch only CSS classes/styles for selection, active cell, copy/cut ranges without rebuilding DOM. */
|
|
306
|
-
patchSelectionClasses() {
|
|
307
|
-
if (!this.tbody || !this.interactionState)
|
|
308
|
-
return;
|
|
309
|
-
const is = this.interactionState;
|
|
310
|
-
const { activeCell, selectionRange, copyRange, cutRange } = is;
|
|
311
|
-
const lastActive = this.lastActiveCell;
|
|
312
|
-
const lastSelection = this.lastSelectionRange;
|
|
313
|
-
const lastCopy = this.lastCopyRange;
|
|
314
|
-
const lastCut = this.lastCutRange;
|
|
315
|
-
const cells = this.tbody.querySelectorAll('td[data-row-index][data-col-index]');
|
|
316
|
-
for (let i = 0; i < cells.length; i++) {
|
|
317
|
-
const el = cells[i];
|
|
318
|
-
const coords = getCellCoordinates(el);
|
|
319
|
-
if (!coords)
|
|
320
|
-
continue;
|
|
321
|
-
const rowIndex = coords.rowIndex;
|
|
322
|
-
const globalColIndex = coords.colIndex;
|
|
323
|
-
const colOffset = this.getColOffset();
|
|
324
|
-
const colIndex = globalColIndex - colOffset;
|
|
325
|
-
// --- Active cell ---
|
|
326
|
-
const wasActive = lastActive && lastActive.rowIndex === rowIndex && lastActive.columnIndex === globalColIndex;
|
|
327
|
-
const isActive = activeCell && activeCell.rowIndex === rowIndex && activeCell.columnIndex === globalColIndex;
|
|
328
|
-
if (wasActive && !isActive) {
|
|
329
|
-
el.removeAttribute('data-active-cell');
|
|
330
|
-
el.style.outline = '';
|
|
331
|
-
}
|
|
332
|
-
else if (isActive && !wasActive) {
|
|
333
|
-
el.setAttribute('data-active-cell', 'true');
|
|
334
|
-
el.style.outline = '2px solid var(--ogrid-accent, #0078d4)';
|
|
335
|
-
}
|
|
336
|
-
// --- Selection range ---
|
|
337
|
-
const wasInRange = lastSelection && isInSelectionRange(lastSelection, rowIndex, colIndex);
|
|
338
|
-
const isInRange = selectionRange && isInSelectionRange(selectionRange, rowIndex, colIndex);
|
|
339
|
-
if (wasInRange && !isInRange) {
|
|
340
|
-
el.removeAttribute('data-in-range');
|
|
341
|
-
el.style.backgroundColor = '';
|
|
342
|
-
}
|
|
343
|
-
else if (isInRange && !wasInRange) {
|
|
344
|
-
el.setAttribute('data-in-range', 'true');
|
|
345
|
-
el.style.backgroundColor = 'var(--ogrid-range-bg, rgba(33, 115, 70, 0.12))';
|
|
346
|
-
}
|
|
347
|
-
// --- Copy range ---
|
|
348
|
-
const wasInCopy = lastCopy && isInSelectionRange(lastCopy, rowIndex, colIndex);
|
|
349
|
-
const isInCopy = copyRange && isInSelectionRange(copyRange, rowIndex, colIndex);
|
|
350
|
-
if (wasInCopy && !isInCopy) {
|
|
351
|
-
// Only clear outline if not being set by another range (active/cut)
|
|
352
|
-
if (!isActive && !(cutRange && isInSelectionRange(cutRange, rowIndex, colIndex))) {
|
|
353
|
-
el.style.outline = '';
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
else if (isInCopy && !wasInCopy) {
|
|
357
|
-
el.style.outline = '1px dashed var(--ogrid-fg-muted, rgba(0, 0, 0, 0.5))';
|
|
358
|
-
}
|
|
359
|
-
// --- Cut range ---
|
|
360
|
-
const wasInCut = lastCut && isInSelectionRange(lastCut, rowIndex, colIndex);
|
|
361
|
-
const isInCut = cutRange && isInSelectionRange(cutRange, rowIndex, colIndex);
|
|
362
|
-
if (wasInCut && !isInCut) {
|
|
363
|
-
if (!isActive && !(copyRange && isInSelectionRange(copyRange, rowIndex, colIndex))) {
|
|
364
|
-
el.style.outline = '';
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
else if (isInCut && !wasInCut) {
|
|
368
|
-
el.style.outline = '1px dashed var(--ogrid-accent, #0078d4)';
|
|
369
|
-
}
|
|
370
|
-
// --- Fill handle ---
|
|
371
|
-
// Remove old fill handle if it was on a cell no longer at the bottom-right of selection
|
|
372
|
-
const oldFill = el.querySelector('.ogrid-fill-handle');
|
|
373
|
-
const shouldHaveFill = selectionRange && is.onFillHandleMouseDown &&
|
|
374
|
-
rowIndex === Math.max(selectionRange.startRow, selectionRange.endRow) &&
|
|
375
|
-
colIndex === Math.max(selectionRange.startCol, selectionRange.endCol);
|
|
376
|
-
const hadFill = !!oldFill;
|
|
377
|
-
if (hadFill && !shouldHaveFill) {
|
|
378
|
-
oldFill?.remove();
|
|
379
|
-
}
|
|
380
|
-
else if (!hadFill && shouldHaveFill) {
|
|
381
|
-
const fillHandle = document.createElement('div');
|
|
382
|
-
fillHandle.className = 'ogrid-fill-handle';
|
|
383
|
-
fillHandle.setAttribute('data-fill-handle', 'true');
|
|
384
|
-
fillHandle.style.position = 'absolute';
|
|
385
|
-
fillHandle.style.right = '-3px';
|
|
386
|
-
fillHandle.style.bottom = '-3px';
|
|
387
|
-
fillHandle.style.width = '6px';
|
|
388
|
-
fillHandle.style.height = '6px';
|
|
389
|
-
fillHandle.style.backgroundColor = 'var(--ogrid-selection, #217346)';
|
|
390
|
-
fillHandle.style.cursor = 'crosshair';
|
|
391
|
-
fillHandle.style.zIndex = '5';
|
|
392
|
-
el.style.position = el.style.position || 'relative';
|
|
393
|
-
fillHandle.addEventListener('mousedown', (e) => {
|
|
394
|
-
this.interactionState?.onFillHandleMouseDown?.(e);
|
|
395
|
-
});
|
|
396
|
-
el.appendChild(fillHandle);
|
|
397
|
-
}
|
|
398
|
-
// Restore pinned cell background if needed (selection removal may have cleared it)
|
|
399
|
-
if (!isInRange && is.pinnedColumns) {
|
|
400
|
-
const columnId = el.getAttribute('data-column-id');
|
|
401
|
-
if (columnId && is.pinnedColumns[columnId]) {
|
|
402
|
-
el.style.backgroundColor = el.style.backgroundColor || 'var(--ogrid-bg, #fff)';
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
this.snapshotState();
|
|
407
|
-
}
|
|
408
|
-
/** Re-render body rows and header (after sort/filter/page change). */
|
|
409
|
-
update() {
|
|
410
|
-
if (!this.tbody || !this.thead) {
|
|
411
|
-
this.render();
|
|
412
|
-
this.snapshotState();
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
// Check if only selection-related state changed — if so, patch CSS only
|
|
416
|
-
if (this.isSelectionOnlyChange()) {
|
|
417
|
-
this.patchSelectionClasses();
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
// Check if header needs rebuild
|
|
421
|
-
const currentHeaderSig = this.computeHeaderSignature();
|
|
422
|
-
if (currentHeaderSig !== this.lastHeaderSignature) {
|
|
423
|
-
this.thead.innerHTML = '';
|
|
424
|
-
this.renderHeader();
|
|
425
|
-
}
|
|
426
|
-
// Delegation listeners are on tbody itself — just clear inner HTML, keep listeners
|
|
427
|
-
this.tbody.innerHTML = '';
|
|
428
|
-
this.renderBody();
|
|
429
|
-
this.snapshotState();
|
|
430
|
-
}
|
|
431
|
-
hasCheckboxColumn() {
|
|
432
|
-
const mode = this.interactionState?.rowSelectionMode;
|
|
433
|
-
return mode === 'single' || mode === 'multiple';
|
|
434
|
-
}
|
|
435
|
-
hasRowNumbersColumn() {
|
|
436
|
-
return !!this.interactionState?.showRowNumbers;
|
|
437
|
-
}
|
|
438
|
-
/** The column index offset for data columns (checkbox + row numbers if present). */
|
|
439
|
-
getColOffset() {
|
|
440
|
-
let offset = 0;
|
|
441
|
-
if (this.hasCheckboxColumn())
|
|
442
|
-
offset++;
|
|
443
|
-
if (this.hasRowNumbersColumn())
|
|
444
|
-
offset++;
|
|
445
|
-
return offset;
|
|
446
|
-
}
|
|
447
|
-
applyPinningStyles(el, columnId, isHeader) {
|
|
448
|
-
const is = this.interactionState;
|
|
449
|
-
if (!is?.pinnedColumns)
|
|
450
|
-
return;
|
|
451
|
-
const side = is.pinnedColumns[columnId];
|
|
452
|
-
if (!side)
|
|
453
|
-
return;
|
|
454
|
-
el.style.position = 'sticky';
|
|
455
|
-
el.style.zIndex = isHeader ? '3' : '1';
|
|
456
|
-
el.setAttribute('data-pinned', side);
|
|
457
|
-
if (side === 'left' && is.leftOffsets) {
|
|
458
|
-
el.style.left = `${is.leftOffsets[columnId] ?? 0}px`;
|
|
459
|
-
}
|
|
460
|
-
else if (side === 'right' && is.rightOffsets) {
|
|
461
|
-
el.style.right = `${is.rightOffsets[columnId] ?? 0}px`;
|
|
462
|
-
}
|
|
463
|
-
// Background must be set on pinned cells to avoid showing content underneath
|
|
464
|
-
if (!isHeader) {
|
|
465
|
-
el.style.backgroundColor = el.style.backgroundColor || 'var(--ogrid-bg, #fff)';
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
renderHeader() {
|
|
469
|
-
if (!this.thead)
|
|
470
|
-
return;
|
|
471
|
-
this.thead.innerHTML = '';
|
|
472
|
-
const visibleCols = this.state.visibleColumnDefs;
|
|
473
|
-
const hasCheckbox = this.hasCheckboxColumn();
|
|
474
|
-
// buildHeaderRows expects core column types - cast through unknown
|
|
475
|
-
const headerRows = buildHeaderRows(this.state.allColumns, this.state.visibleColumns);
|
|
476
|
-
// If we have grouped headers (more than 1 row), render all rows
|
|
477
|
-
if (headerRows.length > 1) {
|
|
478
|
-
for (const row of headerRows) {
|
|
479
|
-
const tr = document.createElement('tr');
|
|
480
|
-
if (hasCheckbox) {
|
|
481
|
-
const th = document.createElement('th');
|
|
482
|
-
th.className = 'ogrid-header-cell ogrid-checkbox-header';
|
|
483
|
-
th.style.width = `${CHECKBOX_COLUMN_WIDTH}px`;
|
|
484
|
-
// Select-all checkbox only on last header row
|
|
485
|
-
if (row === headerRows[headerRows.length - 1]) {
|
|
486
|
-
this.appendSelectAllCheckbox(th);
|
|
487
|
-
}
|
|
488
|
-
tr.appendChild(th);
|
|
489
|
-
}
|
|
490
|
-
for (const cell of row) {
|
|
491
|
-
const th = document.createElement('th');
|
|
492
|
-
th.textContent = cell.label;
|
|
493
|
-
th.className = cell.isGroup ? 'ogrid-group-header' : 'ogrid-header-cell';
|
|
494
|
-
if (cell.colSpan > 1)
|
|
495
|
-
th.colSpan = cell.colSpan;
|
|
496
|
-
if (!cell.isGroup && cell.columnDef?.sortable) {
|
|
497
|
-
th.classList.add('ogrid-sortable');
|
|
498
|
-
// Sort click also inline for compatibility with tests that hold stale <th> references
|
|
499
|
-
th.addEventListener('click', () => {
|
|
500
|
-
if (cell.columnDef)
|
|
501
|
-
this.state.toggleSort(cell.columnDef.columnId);
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
if (!cell.isGroup && cell.columnDef) {
|
|
505
|
-
th.setAttribute('data-column-id', cell.columnDef.columnId);
|
|
506
|
-
this.applyPinningStyles(th, cell.columnDef.columnId, true);
|
|
507
|
-
// Resize, reorder, and filter icon clicks are handled
|
|
508
|
-
// via delegated listeners on <thead> (attachHeaderDelegation).
|
|
509
|
-
}
|
|
510
|
-
tr.appendChild(th);
|
|
511
|
-
}
|
|
512
|
-
this.thead?.appendChild(tr);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
else {
|
|
516
|
-
// Single row header
|
|
517
|
-
const tr = document.createElement('tr');
|
|
518
|
-
// Checkbox header
|
|
519
|
-
if (hasCheckbox) {
|
|
520
|
-
const th = document.createElement('th');
|
|
521
|
-
th.className = 'ogrid-header-cell ogrid-checkbox-header';
|
|
522
|
-
th.style.width = `${CHECKBOX_COLUMN_WIDTH}px`;
|
|
523
|
-
this.appendSelectAllCheckbox(th);
|
|
524
|
-
tr.appendChild(th);
|
|
525
|
-
}
|
|
526
|
-
// Row numbers header
|
|
527
|
-
if (this.hasRowNumbersColumn()) {
|
|
528
|
-
const th = document.createElement('th');
|
|
529
|
-
th.className = 'ogrid-header-cell ogrid-row-number-header';
|
|
530
|
-
th.style.width = `${ROW_NUMBER_COLUMN_WIDTH}px`;
|
|
531
|
-
th.style.textAlign = 'center';
|
|
532
|
-
th.textContent = '#';
|
|
533
|
-
tr.appendChild(th);
|
|
534
|
-
}
|
|
535
|
-
for (let colIdx = 0; colIdx < visibleCols.length; colIdx++) {
|
|
536
|
-
const col = visibleCols[colIdx];
|
|
537
|
-
const th = document.createElement('th');
|
|
538
|
-
th.className = 'ogrid-header-cell';
|
|
539
|
-
th.setAttribute('data-column-id', col.columnId);
|
|
540
|
-
// Text container
|
|
541
|
-
const textSpan = document.createElement('span');
|
|
542
|
-
textSpan.textContent = col.name;
|
|
543
|
-
th.appendChild(textSpan);
|
|
544
|
-
if (col.sortable) {
|
|
545
|
-
th.classList.add('ogrid-sortable');
|
|
546
|
-
// Sort click also inline for compatibility with tests that hold stale <th> references
|
|
547
|
-
th.addEventListener('click', () => this.state.toggleSort(col.columnId));
|
|
548
|
-
}
|
|
549
|
-
if (col.type === 'numeric') {
|
|
550
|
-
th.style.textAlign = 'right';
|
|
551
|
-
}
|
|
552
|
-
// Apply column width from resize state
|
|
553
|
-
if (this.interactionState?.columnWidths[col.columnId]) {
|
|
554
|
-
th.style.width = `${this.interactionState.columnWidths[col.columnId]}px`;
|
|
555
|
-
}
|
|
556
|
-
// Column pinning
|
|
557
|
-
this.applyPinningStyles(th, col.columnId, true);
|
|
558
|
-
// Add resize handle
|
|
559
|
-
const resizeHandle = document.createElement('div');
|
|
560
|
-
resizeHandle.className = 'ogrid-resize-handle';
|
|
561
|
-
resizeHandle.style.position = 'absolute';
|
|
562
|
-
resizeHandle.style.right = '0';
|
|
563
|
-
resizeHandle.style.top = '0';
|
|
564
|
-
resizeHandle.style.bottom = '0';
|
|
565
|
-
resizeHandle.style.width = '4px';
|
|
566
|
-
resizeHandle.style.cursor = 'col-resize';
|
|
567
|
-
resizeHandle.style.userSelect = 'none';
|
|
568
|
-
th.style.position = th.style.position || 'relative';
|
|
569
|
-
th.appendChild(resizeHandle);
|
|
570
|
-
// Resize mousedown handled via delegated listener on <thead>
|
|
571
|
-
// Filter icon (if column is filterable)
|
|
572
|
-
const filterConfig = this.filterConfigs.get(col.columnId);
|
|
573
|
-
if (filterConfig && this.onFilterIconClick) {
|
|
574
|
-
const filterBtn = document.createElement('button');
|
|
575
|
-
filterBtn.className = 'ogrid-filter-icon';
|
|
576
|
-
filterBtn.setAttribute('aria-label', `Filter ${col.name}`);
|
|
577
|
-
filterBtn.style.border = 'none';
|
|
578
|
-
filterBtn.style.background = 'transparent';
|
|
579
|
-
filterBtn.style.cursor = 'pointer';
|
|
580
|
-
filterBtn.style.fontSize = '10px';
|
|
581
|
-
filterBtn.style.padding = '0 2px';
|
|
582
|
-
filterBtn.style.marginLeft = '4px';
|
|
583
|
-
filterBtn.style.color = 'var(--ogrid-fg, #242424)';
|
|
584
|
-
filterBtn.style.opacity = '0.6';
|
|
585
|
-
// Show active filter indicator
|
|
586
|
-
const hasActive = this.headerFilterState?.hasActiveFilter(filterConfig);
|
|
587
|
-
filterBtn.textContent = hasActive ? '\u25BC' : '\u25BD';
|
|
588
|
-
if (hasActive) {
|
|
589
|
-
filterBtn.style.opacity = '1';
|
|
590
|
-
filterBtn.style.color = 'var(--ogrid-selection, #217346)';
|
|
591
|
-
}
|
|
592
|
-
filterBtn.addEventListener('click', (e) => {
|
|
593
|
-
e.stopPropagation();
|
|
594
|
-
e.preventDefault();
|
|
595
|
-
this.onFilterIconClick?.(col.columnId, th);
|
|
596
|
-
});
|
|
597
|
-
th.appendChild(filterBtn);
|
|
598
|
-
}
|
|
599
|
-
// Column reorder mousedown handled via delegated listener on <thead>
|
|
600
|
-
tr.appendChild(th);
|
|
601
|
-
}
|
|
602
|
-
this.thead?.appendChild(tr);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
appendSelectAllCheckbox(th) {
|
|
606
|
-
const is = this.interactionState;
|
|
607
|
-
if (is?.rowSelectionMode !== 'multiple')
|
|
608
|
-
return;
|
|
609
|
-
const checkbox = document.createElement('input');
|
|
610
|
-
checkbox.type = 'checkbox';
|
|
611
|
-
checkbox.className = 'ogrid-select-all-checkbox';
|
|
612
|
-
checkbox.checked = is?.allSelected === true;
|
|
613
|
-
checkbox.indeterminate = is?.someSelected === true;
|
|
614
|
-
checkbox.setAttribute('aria-label', 'Select all rows');
|
|
615
|
-
checkbox.addEventListener('change', () => {
|
|
616
|
-
is?.onSelectAll?.(checkbox.checked);
|
|
617
|
-
});
|
|
618
|
-
th.appendChild(checkbox);
|
|
619
|
-
}
|
|
620
|
-
renderBody() {
|
|
621
|
-
if (!this.tbody)
|
|
622
|
-
return;
|
|
623
|
-
const visibleCols = this.state.visibleColumnDefs;
|
|
624
|
-
const { items } = this.state.getProcessedItems();
|
|
625
|
-
const hasCheckbox = this.hasCheckboxColumn();
|
|
626
|
-
const hasRowNumbers = this.hasRowNumbersColumn();
|
|
627
|
-
const colOffset = this.getColOffset();
|
|
628
|
-
const totalColSpan = visibleCols.length + colOffset;
|
|
629
|
-
// Calculate row number offset for pagination
|
|
630
|
-
const rowNumberOffset = hasRowNumbers ? (this.state.page - 1) * this.state.pageSize : 0;
|
|
631
|
-
if (items.length === 0 && !this.state.isLoading) {
|
|
632
|
-
const tr = document.createElement('tr');
|
|
633
|
-
const td = document.createElement('td');
|
|
634
|
-
td.colSpan = totalColSpan;
|
|
635
|
-
td.className = 'ogrid-empty-state';
|
|
636
|
-
td.textContent = 'No data';
|
|
637
|
-
tr.appendChild(td);
|
|
638
|
-
this.tbody.appendChild(tr);
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
// Virtual scrolling: determine which rows to render
|
|
642
|
-
const vs = this.virtualScrollState;
|
|
643
|
-
const isVirtual = vs?.enabled === true;
|
|
644
|
-
let startIndex = 0;
|
|
645
|
-
let endIndex = items.length - 1;
|
|
646
|
-
if (isVirtual) {
|
|
647
|
-
const range = vs?.visibleRange;
|
|
648
|
-
if (!range)
|
|
649
|
-
return;
|
|
650
|
-
startIndex = Math.max(0, range.startIndex);
|
|
651
|
-
endIndex = Math.min(items.length - 1, range.endIndex);
|
|
652
|
-
// Top spacer row
|
|
653
|
-
if (range.offsetTop > 0) {
|
|
654
|
-
const topSpacer = document.createElement('tr');
|
|
655
|
-
topSpacer.className = 'ogrid-virtual-spacer';
|
|
656
|
-
const topTd = document.createElement('td');
|
|
657
|
-
topTd.colSpan = totalColSpan;
|
|
658
|
-
topTd.style.height = `${range.offsetTop}px`;
|
|
659
|
-
topTd.style.padding = '0';
|
|
660
|
-
topTd.style.border = 'none';
|
|
661
|
-
topSpacer.appendChild(topTd);
|
|
662
|
-
this.tbody.appendChild(topSpacer);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex++) {
|
|
666
|
-
const item = items[rowIndex];
|
|
667
|
-
if (!item)
|
|
668
|
-
continue;
|
|
669
|
-
const rowId = this.state.getRowId(item);
|
|
670
|
-
const tr = document.createElement('tr');
|
|
671
|
-
tr.className = 'ogrid-row';
|
|
672
|
-
tr.setAttribute('data-row-id', String(rowId));
|
|
673
|
-
// Row selection state
|
|
674
|
-
const isRowSelected = this.interactionState?.selectedRowIds?.has(rowId) === true;
|
|
675
|
-
if (isRowSelected) {
|
|
676
|
-
tr.setAttribute('data-row-selected', 'true');
|
|
677
|
-
}
|
|
678
|
-
// Checkbox column
|
|
679
|
-
if (hasCheckbox) {
|
|
680
|
-
const td = document.createElement('td');
|
|
681
|
-
td.className = 'ogrid-cell ogrid-checkbox-cell';
|
|
682
|
-
td.style.width = `${CHECKBOX_COLUMN_WIDTH}px`;
|
|
683
|
-
td.style.textAlign = 'center';
|
|
684
|
-
const checkbox = document.createElement('input');
|
|
685
|
-
checkbox.type = 'checkbox';
|
|
686
|
-
checkbox.className = 'ogrid-row-checkbox';
|
|
687
|
-
checkbox.checked = isRowSelected;
|
|
688
|
-
checkbox.setAttribute('aria-label', `Select row ${rowId}`);
|
|
689
|
-
checkbox.addEventListener('click', (e) => {
|
|
690
|
-
e.stopPropagation(); // Don't trigger cell click
|
|
691
|
-
this.interactionState?.onRowCheckboxChange?.(rowId, checkbox.checked, rowIndex, e.shiftKey);
|
|
692
|
-
});
|
|
693
|
-
td.appendChild(checkbox);
|
|
694
|
-
tr.appendChild(td);
|
|
695
|
-
}
|
|
696
|
-
// Row numbers column
|
|
697
|
-
if (hasRowNumbers) {
|
|
698
|
-
const td = document.createElement('td');
|
|
699
|
-
td.className = 'ogrid-cell ogrid-row-number-cell';
|
|
700
|
-
td.style.width = `${ROW_NUMBER_COLUMN_WIDTH}px`;
|
|
701
|
-
td.style.textAlign = 'center';
|
|
702
|
-
td.style.color = 'var(--ogrid-fg-muted, #666)';
|
|
703
|
-
td.style.fontSize = '0.9em';
|
|
704
|
-
td.textContent = String(rowNumberOffset + rowIndex + 1);
|
|
705
|
-
tr.appendChild(td);
|
|
706
|
-
}
|
|
707
|
-
for (let colIndex = 0; colIndex < visibleCols.length; colIndex++) {
|
|
708
|
-
const col = visibleCols[colIndex];
|
|
709
|
-
const globalColIndex = colIndex + colOffset;
|
|
710
|
-
const td = document.createElement('td');
|
|
711
|
-
td.className = 'ogrid-cell';
|
|
712
|
-
td.setAttribute('data-column-id', col.columnId);
|
|
713
|
-
td.setAttribute('data-row-index', String(rowIndex));
|
|
714
|
-
td.setAttribute('data-col-index', String(globalColIndex));
|
|
715
|
-
td.setAttribute('tabindex', '-1'); // Make focusable
|
|
716
|
-
if (col.type === 'numeric') {
|
|
717
|
-
td.style.textAlign = 'right';
|
|
718
|
-
}
|
|
719
|
-
// Column pinning
|
|
720
|
-
this.applyPinningStyles(td, col.columnId, false);
|
|
721
|
-
// Apply interaction state
|
|
722
|
-
if (this.interactionState) {
|
|
723
|
-
const { activeCell, selectionRange, copyRange, cutRange, editingCell } = this.interactionState;
|
|
724
|
-
// Active cell
|
|
725
|
-
if (activeCell && activeCell.rowIndex === rowIndex && activeCell.columnIndex === globalColIndex) {
|
|
726
|
-
td.setAttribute('data-active-cell', 'true');
|
|
727
|
-
td.style.outline = '2px solid var(--ogrid-accent, #0078d4)';
|
|
728
|
-
}
|
|
729
|
-
// Selection range
|
|
730
|
-
if (selectionRange && isInSelectionRange(selectionRange, rowIndex, colIndex)) {
|
|
731
|
-
td.setAttribute('data-in-range', 'true');
|
|
732
|
-
td.style.backgroundColor = 'var(--ogrid-range-bg, rgba(33, 115, 70, 0.12))';
|
|
733
|
-
}
|
|
734
|
-
// Copy range
|
|
735
|
-
if (copyRange && isInSelectionRange(copyRange, rowIndex, colIndex)) {
|
|
736
|
-
td.style.outline = '1px dashed var(--ogrid-fg-muted, rgba(0, 0, 0, 0.5))';
|
|
737
|
-
}
|
|
738
|
-
// Cut range
|
|
739
|
-
if (cutRange && isInSelectionRange(cutRange, rowIndex, colIndex)) {
|
|
740
|
-
td.style.outline = '1px dashed var(--ogrid-accent, #0078d4)';
|
|
741
|
-
}
|
|
742
|
-
// Editing cell (hide content, editor overlay will be shown)
|
|
743
|
-
if (editingCell && editingCell.rowId === rowId && editingCell.columnId === col.columnId) {
|
|
744
|
-
td.style.visibility = 'hidden';
|
|
745
|
-
}
|
|
746
|
-
// Cell interaction is handled by delegated listeners on tbody
|
|
747
|
-
}
|
|
748
|
-
// Custom DOM render
|
|
749
|
-
if (col.renderCell) {
|
|
750
|
-
// Cast col to unknown first to work around structural differences
|
|
751
|
-
const value = getCellValue(item, col);
|
|
752
|
-
col.renderCell(td, item, value);
|
|
753
|
-
}
|
|
754
|
-
else {
|
|
755
|
-
// Default: text content via valueFormatter or toString
|
|
756
|
-
const value = getCellValue(item, col);
|
|
757
|
-
if (col.valueFormatter) {
|
|
758
|
-
td.textContent = col.valueFormatter(value, item);
|
|
759
|
-
}
|
|
760
|
-
else if (value != null) {
|
|
761
|
-
td.textContent = String(value);
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
// Apply cell styles
|
|
765
|
-
if (col.cellStyle) {
|
|
766
|
-
const styles = typeof col.cellStyle === 'function' ? col.cellStyle(item) : col.cellStyle;
|
|
767
|
-
if (styles) {
|
|
768
|
-
Object.assign(td.style, styles);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
// Fill handle: render on the bottom-right cell of the selection range
|
|
772
|
-
// Must be AFTER cell content (td.textContent removes child nodes)
|
|
773
|
-
if (this.interactionState) {
|
|
774
|
-
const { selectionRange } = this.interactionState;
|
|
775
|
-
if (selectionRange &&
|
|
776
|
-
this.interactionState.onFillHandleMouseDown &&
|
|
777
|
-
rowIndex === Math.max(selectionRange.startRow, selectionRange.endRow) &&
|
|
778
|
-
colIndex === Math.max(selectionRange.startCol, selectionRange.endCol)) {
|
|
779
|
-
const fillHandle = document.createElement('div');
|
|
780
|
-
fillHandle.className = 'ogrid-fill-handle';
|
|
781
|
-
fillHandle.setAttribute('data-fill-handle', 'true');
|
|
782
|
-
fillHandle.style.position = 'absolute';
|
|
783
|
-
fillHandle.style.right = '-3px';
|
|
784
|
-
fillHandle.style.bottom = '-3px';
|
|
785
|
-
fillHandle.style.width = '6px';
|
|
786
|
-
fillHandle.style.height = '6px';
|
|
787
|
-
fillHandle.style.backgroundColor = 'var(--ogrid-selection, #217346)';
|
|
788
|
-
fillHandle.style.cursor = 'crosshair';
|
|
789
|
-
fillHandle.style.zIndex = '5';
|
|
790
|
-
td.style.position = td.style.position || 'relative';
|
|
791
|
-
// Fill handle mousedown handled via delegated listener on <tbody>
|
|
792
|
-
td.appendChild(fillHandle);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
tr.appendChild(td);
|
|
796
|
-
}
|
|
797
|
-
this.tbody.appendChild(tr);
|
|
798
|
-
}
|
|
799
|
-
// Virtual scrolling: bottom spacer row
|
|
800
|
-
if (isVirtual && vs) {
|
|
801
|
-
const range = vs.visibleRange;
|
|
802
|
-
if (range.offsetBottom > 0) {
|
|
803
|
-
const bottomSpacer = document.createElement('tr');
|
|
804
|
-
bottomSpacer.className = 'ogrid-virtual-spacer';
|
|
805
|
-
const bottomTd = document.createElement('td');
|
|
806
|
-
bottomTd.colSpan = totalColSpan;
|
|
807
|
-
bottomTd.style.height = `${range.offsetBottom}px`;
|
|
808
|
-
bottomTd.style.padding = '0';
|
|
809
|
-
bottomTd.style.border = 'none';
|
|
810
|
-
bottomSpacer.appendChild(bottomTd);
|
|
811
|
-
this.tbody.appendChild(bottomSpacer);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
/** Get the table element (used by ColumnReorderState for header cell queries). */
|
|
816
|
-
getTableElement() {
|
|
817
|
-
return this.table;
|
|
818
|
-
}
|
|
819
|
-
/** Get the current onResizeStart handler from interaction state (avoids bracket notation access). */
|
|
820
|
-
getOnResizeStart() {
|
|
821
|
-
return this.interactionState?.onResizeStart;
|
|
822
|
-
}
|
|
823
|
-
/** Update the drop indicator position during column reorder. */
|
|
824
|
-
updateDropIndicator(x, isDragging) {
|
|
825
|
-
if (!this.dropIndicator || !this.wrapperEl)
|
|
826
|
-
return;
|
|
827
|
-
if (!isDragging || x === null) {
|
|
828
|
-
this.dropIndicator.style.display = 'none';
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
// Convert client X to position relative to the wrapper
|
|
832
|
-
const wrapperRect = this.wrapperEl.getBoundingClientRect();
|
|
833
|
-
const relativeX = x - wrapperRect.left + this.wrapperEl.scrollLeft;
|
|
834
|
-
this.dropIndicator.style.display = 'block';
|
|
835
|
-
this.dropIndicator.style.left = `${relativeX}px`;
|
|
836
|
-
}
|
|
837
|
-
destroy() {
|
|
838
|
-
this.detachHeaderDelegation();
|
|
839
|
-
this.detachBodyDelegation();
|
|
840
|
-
this.container.innerHTML = '';
|
|
841
|
-
this.table = null;
|
|
842
|
-
this.thead = null;
|
|
843
|
-
this.tbody = null;
|
|
844
|
-
this.dropIndicator = null;
|
|
845
|
-
}
|
|
846
|
-
}
|