@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.
Files changed (35) hide show
  1. package/dist/esm/index.js +6343 -32
  2. package/package.json +7 -5
  3. package/dist/esm/OGrid.js +0 -578
  4. package/dist/esm/OGridEventWiring.js +0 -178
  5. package/dist/esm/OGridRendering.js +0 -269
  6. package/dist/esm/components/ColumnChooser.js +0 -91
  7. package/dist/esm/components/ContextMenu.js +0 -125
  8. package/dist/esm/components/HeaderFilter.js +0 -281
  9. package/dist/esm/components/InlineCellEditor.js +0 -434
  10. package/dist/esm/components/MarchingAntsOverlay.js +0 -156
  11. package/dist/esm/components/PaginationControls.js +0 -85
  12. package/dist/esm/components/SideBar.js +0 -353
  13. package/dist/esm/components/StatusBar.js +0 -34
  14. package/dist/esm/renderer/TableRenderer.js +0 -846
  15. package/dist/esm/state/ClipboardState.js +0 -111
  16. package/dist/esm/state/ColumnPinningState.js +0 -82
  17. package/dist/esm/state/ColumnReorderState.js +0 -135
  18. package/dist/esm/state/ColumnResizeState.js +0 -55
  19. package/dist/esm/state/EventEmitter.js +0 -28
  20. package/dist/esm/state/FillHandleState.js +0 -206
  21. package/dist/esm/state/GridState.js +0 -324
  22. package/dist/esm/state/HeaderFilterState.js +0 -213
  23. package/dist/esm/state/KeyboardNavState.js +0 -216
  24. package/dist/esm/state/RowSelectionState.js +0 -72
  25. package/dist/esm/state/SelectionState.js +0 -109
  26. package/dist/esm/state/SideBarState.js +0 -41
  27. package/dist/esm/state/TableLayoutState.js +0 -97
  28. package/dist/esm/state/UndoRedoState.js +0 -71
  29. package/dist/esm/state/VirtualScrollState.js +0 -128
  30. package/dist/esm/types/columnTypes.js +0 -1
  31. package/dist/esm/types/gridTypes.js +0 -1
  32. package/dist/esm/types/index.js +0 -2
  33. package/dist/esm/utils/debounce.js +0 -2
  34. package/dist/esm/utils/getCellCoordinates.js +0 -15
  35. 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
- }