@alaarab/ogrid-js 2.0.0-beta
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 +654 -0
- package/dist/esm/components/ColumnChooser.js +68 -0
- package/dist/esm/components/ContextMenu.js +122 -0
- package/dist/esm/components/HeaderFilter.js +281 -0
- package/dist/esm/components/InlineCellEditor.js +278 -0
- package/dist/esm/components/MarchingAntsOverlay.js +170 -0
- package/dist/esm/components/PaginationControls.js +85 -0
- package/dist/esm/components/SideBar.js +353 -0
- package/dist/esm/components/StatusBar.js +34 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/renderer/TableRenderer.js +414 -0
- package/dist/esm/state/ClipboardState.js +171 -0
- package/dist/esm/state/ColumnPinningState.js +78 -0
- package/dist/esm/state/ColumnResizeState.js +55 -0
- package/dist/esm/state/EventEmitter.js +27 -0
- package/dist/esm/state/FillHandleState.js +218 -0
- package/dist/esm/state/GridState.js +261 -0
- package/dist/esm/state/HeaderFilterState.js +205 -0
- package/dist/esm/state/KeyboardNavState.js +374 -0
- package/dist/esm/state/RowSelectionState.js +81 -0
- package/dist/esm/state/SelectionState.js +102 -0
- package/dist/esm/state/SideBarState.js +41 -0
- package/dist/esm/state/TableLayoutState.js +95 -0
- package/dist/esm/state/UndoRedoState.js +82 -0
- package/dist/esm/types/columnTypes.js +1 -0
- package/dist/esm/types/gridTypes.js +1 -0
- package/dist/esm/types/index.js +2 -0
- package/dist/types/OGrid.d.ts +60 -0
- package/dist/types/components/ColumnChooser.d.ts +14 -0
- package/dist/types/components/ContextMenu.d.ts +17 -0
- package/dist/types/components/HeaderFilter.d.ts +24 -0
- package/dist/types/components/InlineCellEditor.d.ts +24 -0
- package/dist/types/components/MarchingAntsOverlay.d.ts +25 -0
- package/dist/types/components/PaginationControls.d.ts +9 -0
- package/dist/types/components/SideBar.d.ts +35 -0
- package/dist/types/components/StatusBar.d.ts +8 -0
- package/dist/types/index.d.ts +26 -0
- package/dist/types/renderer/TableRenderer.d.ts +59 -0
- package/dist/types/state/ClipboardState.d.ts +35 -0
- package/dist/types/state/ColumnPinningState.d.ts +36 -0
- package/dist/types/state/ColumnResizeState.d.ts +23 -0
- package/dist/types/state/EventEmitter.d.ts +9 -0
- package/dist/types/state/FillHandleState.d.ts +51 -0
- package/dist/types/state/GridState.d.ts +68 -0
- package/dist/types/state/HeaderFilterState.d.ts +64 -0
- package/dist/types/state/KeyboardNavState.d.ts +29 -0
- package/dist/types/state/RowSelectionState.d.ts +23 -0
- package/dist/types/state/SelectionState.d.ts +37 -0
- package/dist/types/state/SideBarState.d.ts +19 -0
- package/dist/types/state/TableLayoutState.d.ts +33 -0
- package/dist/types/state/UndoRedoState.d.ts +28 -0
- package/dist/types/types/columnTypes.d.ts +28 -0
- package/dist/types/types/gridTypes.d.ts +69 -0
- package/dist/types/types/index.d.ts +2 -0
- package/package.json +29 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { getCellValue, buildHeaderRows, isInSelectionRange } from '@alaarab/ogrid-core';
|
|
2
|
+
const CHECKBOX_COL_WIDTH = 40;
|
|
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.container = container;
|
|
14
|
+
this.state = state;
|
|
15
|
+
}
|
|
16
|
+
setHeaderFilterState(state, configs) {
|
|
17
|
+
this.headerFilterState = state;
|
|
18
|
+
this.filterConfigs = configs;
|
|
19
|
+
}
|
|
20
|
+
setOnFilterIconClick(handler) {
|
|
21
|
+
this.onFilterIconClick = handler;
|
|
22
|
+
}
|
|
23
|
+
setInteractionState(state) {
|
|
24
|
+
this.interactionState = state;
|
|
25
|
+
}
|
|
26
|
+
getWrapperElement() {
|
|
27
|
+
return this.wrapperEl;
|
|
28
|
+
}
|
|
29
|
+
/** Full render — creates the table structure from scratch. */
|
|
30
|
+
render() {
|
|
31
|
+
// Clear container
|
|
32
|
+
this.container.innerHTML = '';
|
|
33
|
+
// Create wrapper
|
|
34
|
+
const wrapper = document.createElement('div');
|
|
35
|
+
wrapper.className = 'ogrid-wrapper';
|
|
36
|
+
wrapper.setAttribute('role', 'grid');
|
|
37
|
+
wrapper.setAttribute('tabindex', '0'); // Make focusable for keyboard nav
|
|
38
|
+
wrapper.style.position = 'relative'; // For MarchingAnts absolute positioning
|
|
39
|
+
const ariaLabel = this.state._ariaLabel;
|
|
40
|
+
if (ariaLabel) {
|
|
41
|
+
wrapper.setAttribute('aria-label', ariaLabel);
|
|
42
|
+
}
|
|
43
|
+
this.wrapperEl = wrapper;
|
|
44
|
+
// Create table
|
|
45
|
+
this.table = document.createElement('table');
|
|
46
|
+
this.table.className = 'ogrid-table';
|
|
47
|
+
// Render header
|
|
48
|
+
this.thead = document.createElement('thead');
|
|
49
|
+
this.renderHeader();
|
|
50
|
+
this.table.appendChild(this.thead);
|
|
51
|
+
// Render body
|
|
52
|
+
this.tbody = document.createElement('tbody');
|
|
53
|
+
this.renderBody();
|
|
54
|
+
this.table.appendChild(this.tbody);
|
|
55
|
+
wrapper.appendChild(this.table);
|
|
56
|
+
this.container.appendChild(wrapper);
|
|
57
|
+
}
|
|
58
|
+
/** Re-render body rows and header (after sort/filter/page change). */
|
|
59
|
+
update() {
|
|
60
|
+
if (!this.tbody || !this.thead) {
|
|
61
|
+
this.render();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.thead.innerHTML = '';
|
|
65
|
+
this.renderHeader();
|
|
66
|
+
this.tbody.innerHTML = '';
|
|
67
|
+
this.renderBody();
|
|
68
|
+
}
|
|
69
|
+
hasCheckboxColumn() {
|
|
70
|
+
const mode = this.interactionState?.rowSelectionMode;
|
|
71
|
+
return mode === 'single' || mode === 'multiple';
|
|
72
|
+
}
|
|
73
|
+
/** The column index offset for data columns (1 if checkbox column present, else 0). */
|
|
74
|
+
getColOffset() {
|
|
75
|
+
return this.hasCheckboxColumn() ? 1 : 0;
|
|
76
|
+
}
|
|
77
|
+
applyPinningStyles(el, columnId, isHeader) {
|
|
78
|
+
const is = this.interactionState;
|
|
79
|
+
if (!is?.pinnedColumns)
|
|
80
|
+
return;
|
|
81
|
+
const side = is.pinnedColumns[columnId];
|
|
82
|
+
if (!side)
|
|
83
|
+
return;
|
|
84
|
+
el.style.position = 'sticky';
|
|
85
|
+
el.style.zIndex = isHeader ? '3' : '1';
|
|
86
|
+
el.setAttribute('data-pinned', side);
|
|
87
|
+
if (side === 'left' && is.leftOffsets) {
|
|
88
|
+
el.style.left = `${is.leftOffsets[columnId] ?? 0}px`;
|
|
89
|
+
}
|
|
90
|
+
else if (side === 'right' && is.rightOffsets) {
|
|
91
|
+
el.style.right = `${is.rightOffsets[columnId] ?? 0}px`;
|
|
92
|
+
}
|
|
93
|
+
// Background must be set on pinned cells to avoid showing content underneath
|
|
94
|
+
if (!isHeader) {
|
|
95
|
+
el.style.backgroundColor = el.style.backgroundColor || '#fff';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
renderHeader() {
|
|
99
|
+
if (!this.thead)
|
|
100
|
+
return;
|
|
101
|
+
this.thead.innerHTML = '';
|
|
102
|
+
const visibleCols = this.state.visibleColumnDefs;
|
|
103
|
+
const hasCheckbox = this.hasCheckboxColumn();
|
|
104
|
+
// buildHeaderRows expects core column types - cast through unknown
|
|
105
|
+
const headerRows = buildHeaderRows(this.state.allColumns, this.state.visibleColumns);
|
|
106
|
+
// If we have grouped headers (more than 1 row), render all rows
|
|
107
|
+
if (headerRows.length > 1) {
|
|
108
|
+
for (const row of headerRows) {
|
|
109
|
+
const tr = document.createElement('tr');
|
|
110
|
+
if (hasCheckbox) {
|
|
111
|
+
const th = document.createElement('th');
|
|
112
|
+
th.className = 'ogrid-header-cell ogrid-checkbox-header';
|
|
113
|
+
th.style.width = `${CHECKBOX_COL_WIDTH}px`;
|
|
114
|
+
// Select-all checkbox only on last header row
|
|
115
|
+
if (row === headerRows[headerRows.length - 1]) {
|
|
116
|
+
this.appendSelectAllCheckbox(th);
|
|
117
|
+
}
|
|
118
|
+
tr.appendChild(th);
|
|
119
|
+
}
|
|
120
|
+
for (const cell of row) {
|
|
121
|
+
const th = document.createElement('th');
|
|
122
|
+
th.textContent = cell.label;
|
|
123
|
+
th.className = cell.isGroup ? 'ogrid-group-header' : 'ogrid-header-cell';
|
|
124
|
+
if (cell.colSpan > 1)
|
|
125
|
+
th.colSpan = cell.colSpan;
|
|
126
|
+
if (!cell.isGroup && cell.columnDef?.sortable) {
|
|
127
|
+
th.classList.add('ogrid-sortable');
|
|
128
|
+
th.addEventListener('click', () => {
|
|
129
|
+
if (cell.columnDef) {
|
|
130
|
+
this.state.toggleSort(cell.columnDef.columnId);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// Sort indicator
|
|
134
|
+
const sort = this.state.sort;
|
|
135
|
+
if (sort && cell.columnDef && sort.field === cell.columnDef.columnId) {
|
|
136
|
+
const indicator = document.createElement('span');
|
|
137
|
+
indicator.className = 'ogrid-sort-indicator';
|
|
138
|
+
indicator.textContent = sort.direction === 'asc' ? ' \u25B2' : ' \u25BC';
|
|
139
|
+
th.appendChild(indicator);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!cell.isGroup && cell.columnDef) {
|
|
143
|
+
this.applyPinningStyles(th, cell.columnDef.columnId, true);
|
|
144
|
+
}
|
|
145
|
+
tr.appendChild(th);
|
|
146
|
+
}
|
|
147
|
+
this.thead.appendChild(tr);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Single row header
|
|
152
|
+
const tr = document.createElement('tr');
|
|
153
|
+
// Checkbox header
|
|
154
|
+
if (hasCheckbox) {
|
|
155
|
+
const th = document.createElement('th');
|
|
156
|
+
th.className = 'ogrid-header-cell ogrid-checkbox-header';
|
|
157
|
+
th.style.width = `${CHECKBOX_COL_WIDTH}px`;
|
|
158
|
+
this.appendSelectAllCheckbox(th);
|
|
159
|
+
tr.appendChild(th);
|
|
160
|
+
}
|
|
161
|
+
for (let colIdx = 0; colIdx < visibleCols.length; colIdx++) {
|
|
162
|
+
const col = visibleCols[colIdx];
|
|
163
|
+
const th = document.createElement('th');
|
|
164
|
+
th.className = 'ogrid-header-cell';
|
|
165
|
+
th.setAttribute('data-column-id', col.columnId);
|
|
166
|
+
// Text container
|
|
167
|
+
const textSpan = document.createElement('span');
|
|
168
|
+
textSpan.textContent = col.name;
|
|
169
|
+
th.appendChild(textSpan);
|
|
170
|
+
if (col.sortable) {
|
|
171
|
+
th.classList.add('ogrid-sortable');
|
|
172
|
+
th.addEventListener('click', () => this.state.toggleSort(col.columnId));
|
|
173
|
+
const sort = this.state.sort;
|
|
174
|
+
if (sort && sort.field === col.columnId) {
|
|
175
|
+
const indicator = document.createElement('span');
|
|
176
|
+
indicator.className = 'ogrid-sort-indicator';
|
|
177
|
+
indicator.textContent = sort.direction === 'asc' ? ' \u25B2' : ' \u25BC';
|
|
178
|
+
th.appendChild(indicator);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (col.type === 'numeric') {
|
|
182
|
+
th.style.textAlign = 'right';
|
|
183
|
+
}
|
|
184
|
+
// Apply column width from resize state
|
|
185
|
+
if (this.interactionState?.columnWidths[col.columnId]) {
|
|
186
|
+
th.style.width = `${this.interactionState.columnWidths[col.columnId]}px`;
|
|
187
|
+
}
|
|
188
|
+
// Column pinning
|
|
189
|
+
this.applyPinningStyles(th, col.columnId, true);
|
|
190
|
+
// Add resize handle
|
|
191
|
+
const resizeHandle = document.createElement('div');
|
|
192
|
+
resizeHandle.className = 'ogrid-resize-handle';
|
|
193
|
+
resizeHandle.style.position = 'absolute';
|
|
194
|
+
resizeHandle.style.right = '0';
|
|
195
|
+
resizeHandle.style.top = '0';
|
|
196
|
+
resizeHandle.style.bottom = '0';
|
|
197
|
+
resizeHandle.style.width = '4px';
|
|
198
|
+
resizeHandle.style.cursor = 'col-resize';
|
|
199
|
+
resizeHandle.style.userSelect = 'none';
|
|
200
|
+
th.style.position = th.style.position || 'relative';
|
|
201
|
+
th.appendChild(resizeHandle);
|
|
202
|
+
resizeHandle.addEventListener('mousedown', (e) => {
|
|
203
|
+
e.stopPropagation();
|
|
204
|
+
const rect = th.getBoundingClientRect();
|
|
205
|
+
this.interactionState?.onResizeStart?.(col.columnId, e.clientX, rect.width);
|
|
206
|
+
});
|
|
207
|
+
// Filter icon (if column is filterable)
|
|
208
|
+
const filterConfig = this.filterConfigs.get(col.columnId);
|
|
209
|
+
if (filterConfig && this.onFilterIconClick) {
|
|
210
|
+
const filterBtn = document.createElement('button');
|
|
211
|
+
filterBtn.className = 'ogrid-filter-icon';
|
|
212
|
+
filterBtn.setAttribute('aria-label', `Filter ${col.name}`);
|
|
213
|
+
filterBtn.style.border = 'none';
|
|
214
|
+
filterBtn.style.background = 'transparent';
|
|
215
|
+
filterBtn.style.cursor = 'pointer';
|
|
216
|
+
filterBtn.style.fontSize = '10px';
|
|
217
|
+
filterBtn.style.padding = '0 2px';
|
|
218
|
+
filterBtn.style.marginLeft = '4px';
|
|
219
|
+
filterBtn.style.color = 'var(--ogrid-fg, #242424)';
|
|
220
|
+
filterBtn.style.opacity = '0.6';
|
|
221
|
+
// Show active filter indicator
|
|
222
|
+
const hasActive = this.headerFilterState?.hasActiveFilter(filterConfig);
|
|
223
|
+
filterBtn.textContent = hasActive ? '\u25BC' : '\u25BD';
|
|
224
|
+
if (hasActive) {
|
|
225
|
+
filterBtn.style.opacity = '1';
|
|
226
|
+
filterBtn.style.color = 'var(--ogrid-selection, #217346)';
|
|
227
|
+
}
|
|
228
|
+
filterBtn.addEventListener('click', (e) => {
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
this.onFilterIconClick?.(col.columnId, th);
|
|
232
|
+
});
|
|
233
|
+
th.appendChild(filterBtn);
|
|
234
|
+
}
|
|
235
|
+
tr.appendChild(th);
|
|
236
|
+
}
|
|
237
|
+
this.thead.appendChild(tr);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
appendSelectAllCheckbox(th) {
|
|
241
|
+
const is = this.interactionState;
|
|
242
|
+
if (is?.rowSelectionMode !== 'multiple')
|
|
243
|
+
return;
|
|
244
|
+
const checkbox = document.createElement('input');
|
|
245
|
+
checkbox.type = 'checkbox';
|
|
246
|
+
checkbox.className = 'ogrid-select-all-checkbox';
|
|
247
|
+
checkbox.checked = is?.allSelected === true;
|
|
248
|
+
checkbox.indeterminate = is?.someSelected === true;
|
|
249
|
+
checkbox.setAttribute('aria-label', 'Select all rows');
|
|
250
|
+
checkbox.addEventListener('change', () => {
|
|
251
|
+
is?.onSelectAll?.(checkbox.checked);
|
|
252
|
+
});
|
|
253
|
+
th.appendChild(checkbox);
|
|
254
|
+
}
|
|
255
|
+
renderBody() {
|
|
256
|
+
if (!this.tbody)
|
|
257
|
+
return;
|
|
258
|
+
const visibleCols = this.state.visibleColumnDefs;
|
|
259
|
+
const { items } = this.state.getProcessedItems();
|
|
260
|
+
const hasCheckbox = this.hasCheckboxColumn();
|
|
261
|
+
const colOffset = this.getColOffset();
|
|
262
|
+
if (items.length === 0 && !this.state.isLoading) {
|
|
263
|
+
const tr = document.createElement('tr');
|
|
264
|
+
const td = document.createElement('td');
|
|
265
|
+
td.colSpan = visibleCols.length + colOffset;
|
|
266
|
+
td.className = 'ogrid-empty-state';
|
|
267
|
+
td.textContent = 'No data';
|
|
268
|
+
tr.appendChild(td);
|
|
269
|
+
this.tbody.appendChild(tr);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
|
|
273
|
+
const item = items[rowIndex];
|
|
274
|
+
const rowId = this.state.getRowId(item);
|
|
275
|
+
const tr = document.createElement('tr');
|
|
276
|
+
tr.className = 'ogrid-row';
|
|
277
|
+
tr.setAttribute('data-row-id', String(rowId));
|
|
278
|
+
// Row selection state
|
|
279
|
+
const isRowSelected = this.interactionState?.selectedRowIds?.has(rowId) === true;
|
|
280
|
+
if (isRowSelected) {
|
|
281
|
+
tr.setAttribute('data-row-selected', 'true');
|
|
282
|
+
}
|
|
283
|
+
// Checkbox column
|
|
284
|
+
if (hasCheckbox) {
|
|
285
|
+
const td = document.createElement('td');
|
|
286
|
+
td.className = 'ogrid-cell ogrid-checkbox-cell';
|
|
287
|
+
td.style.width = `${CHECKBOX_COL_WIDTH}px`;
|
|
288
|
+
td.style.textAlign = 'center';
|
|
289
|
+
const checkbox = document.createElement('input');
|
|
290
|
+
checkbox.type = 'checkbox';
|
|
291
|
+
checkbox.className = 'ogrid-row-checkbox';
|
|
292
|
+
checkbox.checked = isRowSelected;
|
|
293
|
+
checkbox.setAttribute('aria-label', `Select row ${rowId}`);
|
|
294
|
+
checkbox.addEventListener('click', (e) => {
|
|
295
|
+
e.stopPropagation(); // Don't trigger cell click
|
|
296
|
+
this.interactionState?.onRowCheckboxChange?.(rowId, checkbox.checked, rowIndex, e.shiftKey);
|
|
297
|
+
});
|
|
298
|
+
td.appendChild(checkbox);
|
|
299
|
+
tr.appendChild(td);
|
|
300
|
+
}
|
|
301
|
+
for (let colIndex = 0; colIndex < visibleCols.length; colIndex++) {
|
|
302
|
+
const col = visibleCols[colIndex];
|
|
303
|
+
const globalColIndex = colIndex + colOffset;
|
|
304
|
+
const td = document.createElement('td');
|
|
305
|
+
td.className = 'ogrid-cell';
|
|
306
|
+
td.setAttribute('data-column-id', col.columnId);
|
|
307
|
+
td.setAttribute('data-row-index', String(rowIndex));
|
|
308
|
+
td.setAttribute('data-col-index', String(globalColIndex));
|
|
309
|
+
td.setAttribute('tabindex', '-1'); // Make focusable
|
|
310
|
+
if (col.type === 'numeric') {
|
|
311
|
+
td.style.textAlign = 'right';
|
|
312
|
+
}
|
|
313
|
+
// Column pinning
|
|
314
|
+
this.applyPinningStyles(td, col.columnId, false);
|
|
315
|
+
// Apply interaction state
|
|
316
|
+
if (this.interactionState) {
|
|
317
|
+
const { activeCell, selectionRange, copyRange, cutRange, editingCell } = this.interactionState;
|
|
318
|
+
// Active cell
|
|
319
|
+
if (activeCell && activeCell.rowIndex === rowIndex && activeCell.columnIndex === globalColIndex) {
|
|
320
|
+
td.setAttribute('data-active-cell', 'true');
|
|
321
|
+
td.style.outline = '2px solid #0078d4';
|
|
322
|
+
}
|
|
323
|
+
// Selection range
|
|
324
|
+
if (selectionRange && isInSelectionRange(selectionRange, rowIndex, colIndex)) {
|
|
325
|
+
td.setAttribute('data-in-range', 'true');
|
|
326
|
+
td.style.backgroundColor = '#e3f2fd';
|
|
327
|
+
}
|
|
328
|
+
// Copy range
|
|
329
|
+
if (copyRange && isInSelectionRange(copyRange, rowIndex, colIndex)) {
|
|
330
|
+
td.style.outline = '1px dashed #666';
|
|
331
|
+
}
|
|
332
|
+
// Cut range
|
|
333
|
+
if (cutRange && isInSelectionRange(cutRange, rowIndex, colIndex)) {
|
|
334
|
+
td.style.outline = '1px dashed #d32f2f';
|
|
335
|
+
}
|
|
336
|
+
// Editing cell (hide content, editor overlay will be shown)
|
|
337
|
+
if (editingCell && editingCell.rowId === rowId && editingCell.columnId === col.columnId) {
|
|
338
|
+
td.style.visibility = 'hidden';
|
|
339
|
+
}
|
|
340
|
+
// Cell interaction handlers
|
|
341
|
+
td.addEventListener('click', (e) => {
|
|
342
|
+
this.interactionState?.onCellClick?.(rowIndex, globalColIndex, e);
|
|
343
|
+
});
|
|
344
|
+
td.addEventListener('mousedown', (e) => {
|
|
345
|
+
this.interactionState?.onCellMouseDown?.(rowIndex, globalColIndex, e);
|
|
346
|
+
});
|
|
347
|
+
td.addEventListener('dblclick', () => {
|
|
348
|
+
this.interactionState?.onCellDoubleClick?.(rowIndex, globalColIndex, rowId, col.columnId);
|
|
349
|
+
});
|
|
350
|
+
td.addEventListener('contextmenu', (e) => {
|
|
351
|
+
this.interactionState?.onCellContextMenu?.(rowIndex, globalColIndex, e);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// Custom DOM render
|
|
355
|
+
if (col.renderCell) {
|
|
356
|
+
// Cast col to unknown first to work around structural differences
|
|
357
|
+
const value = getCellValue(item, col);
|
|
358
|
+
col.renderCell(td, item, value);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
// Default: text content via valueFormatter or toString
|
|
362
|
+
const value = getCellValue(item, col);
|
|
363
|
+
if (col.valueFormatter) {
|
|
364
|
+
td.textContent = col.valueFormatter(value, item);
|
|
365
|
+
}
|
|
366
|
+
else if (value != null) {
|
|
367
|
+
td.textContent = String(value);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Apply cell styles
|
|
371
|
+
if (col.cellStyle) {
|
|
372
|
+
const styles = typeof col.cellStyle === 'function' ? col.cellStyle(item) : col.cellStyle;
|
|
373
|
+
if (styles) {
|
|
374
|
+
Object.assign(td.style, styles);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Fill handle: render on the bottom-right cell of the selection range
|
|
378
|
+
// Must be AFTER cell content (td.textContent removes child nodes)
|
|
379
|
+
if (this.interactionState) {
|
|
380
|
+
const { selectionRange } = this.interactionState;
|
|
381
|
+
if (selectionRange &&
|
|
382
|
+
this.interactionState.onFillHandleMouseDown &&
|
|
383
|
+
rowIndex === Math.max(selectionRange.startRow, selectionRange.endRow) &&
|
|
384
|
+
colIndex === Math.max(selectionRange.startCol, selectionRange.endCol)) {
|
|
385
|
+
const fillHandle = document.createElement('div');
|
|
386
|
+
fillHandle.className = 'ogrid-fill-handle';
|
|
387
|
+
fillHandle.setAttribute('data-fill-handle', 'true');
|
|
388
|
+
fillHandle.style.position = 'absolute';
|
|
389
|
+
fillHandle.style.right = '-3px';
|
|
390
|
+
fillHandle.style.bottom = '-3px';
|
|
391
|
+
fillHandle.style.width = '6px';
|
|
392
|
+
fillHandle.style.height = '6px';
|
|
393
|
+
fillHandle.style.backgroundColor = 'var(--ogrid-selection, #217346)';
|
|
394
|
+
fillHandle.style.cursor = 'crosshair';
|
|
395
|
+
fillHandle.style.zIndex = '5';
|
|
396
|
+
td.style.position = td.style.position || 'relative';
|
|
397
|
+
fillHandle.addEventListener('mousedown', (e) => {
|
|
398
|
+
this.interactionState?.onFillHandleMouseDown?.(e);
|
|
399
|
+
});
|
|
400
|
+
td.appendChild(fillHandle);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
tr.appendChild(td);
|
|
404
|
+
}
|
|
405
|
+
this.tbody.appendChild(tr);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
destroy() {
|
|
409
|
+
this.container.innerHTML = '';
|
|
410
|
+
this.table = null;
|
|
411
|
+
this.thead = null;
|
|
412
|
+
this.tbody = null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { normalizeSelectionRange, getCellValue } from '@alaarab/ogrid-core';
|
|
2
|
+
import { parseValue } from '@alaarab/ogrid-core';
|
|
3
|
+
import { EventEmitter } from './EventEmitter';
|
|
4
|
+
export class ClipboardState {
|
|
5
|
+
constructor(params, getActiveCell, getSelectionRange) {
|
|
6
|
+
this.emitter = new EventEmitter();
|
|
7
|
+
this._cutRange = null;
|
|
8
|
+
this._copyRange = null;
|
|
9
|
+
this.internalClipboard = null;
|
|
10
|
+
this.params = params;
|
|
11
|
+
this.getActiveCell = getActiveCell;
|
|
12
|
+
this.getSelectionRange = getSelectionRange;
|
|
13
|
+
}
|
|
14
|
+
updateParams(params) {
|
|
15
|
+
this.params = params;
|
|
16
|
+
}
|
|
17
|
+
get cutRange() {
|
|
18
|
+
return this._cutRange;
|
|
19
|
+
}
|
|
20
|
+
get copyRange() {
|
|
21
|
+
return this._copyRange;
|
|
22
|
+
}
|
|
23
|
+
getEffectiveRange() {
|
|
24
|
+
const sel = this.getSelectionRange();
|
|
25
|
+
const ac = this.getActiveCell();
|
|
26
|
+
return sel ?? (ac != null
|
|
27
|
+
? { startRow: ac.rowIndex, startCol: ac.columnIndex - this.params.colOffset, endRow: ac.rowIndex, endCol: ac.columnIndex - this.params.colOffset }
|
|
28
|
+
: null);
|
|
29
|
+
}
|
|
30
|
+
handleCopy() {
|
|
31
|
+
const range = this.getEffectiveRange();
|
|
32
|
+
if (range == null)
|
|
33
|
+
return;
|
|
34
|
+
const norm = normalizeSelectionRange(range);
|
|
35
|
+
const { items, visibleCols } = this.params;
|
|
36
|
+
const rows = [];
|
|
37
|
+
for (let r = norm.startRow; r <= norm.endRow; r++) {
|
|
38
|
+
const cells = [];
|
|
39
|
+
for (let c = norm.startCol; c <= norm.endCol; c++) {
|
|
40
|
+
if (r >= items.length || c >= visibleCols.length)
|
|
41
|
+
break;
|
|
42
|
+
const item = items[r];
|
|
43
|
+
const col = visibleCols[c];
|
|
44
|
+
const raw = getCellValue(item, col);
|
|
45
|
+
const val = col.valueFormatter ? col.valueFormatter(raw, item) : raw;
|
|
46
|
+
cells.push(val != null && val !== '' ? String(val).replace(/\t/g, ' ').replace(/\n/g, ' ') : '');
|
|
47
|
+
}
|
|
48
|
+
rows.push(cells.join('\t'));
|
|
49
|
+
}
|
|
50
|
+
const tsv = rows.join('\r\n');
|
|
51
|
+
this.internalClipboard = tsv;
|
|
52
|
+
this._copyRange = norm;
|
|
53
|
+
this._cutRange = null;
|
|
54
|
+
this.emitter.emit('rangesChange', { copyRange: this._copyRange, cutRange: null });
|
|
55
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
56
|
+
void navigator.clipboard.writeText(tsv).catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
handleCut() {
|
|
60
|
+
if (this.params.editable === false)
|
|
61
|
+
return;
|
|
62
|
+
const range = this.getEffectiveRange();
|
|
63
|
+
if (range == null)
|
|
64
|
+
return;
|
|
65
|
+
const norm = normalizeSelectionRange(range);
|
|
66
|
+
this._cutRange = norm;
|
|
67
|
+
this._copyRange = null;
|
|
68
|
+
this.handleCopy();
|
|
69
|
+
// handleCopy sets copyRange — override it back since this is a cut
|
|
70
|
+
this._copyRange = null;
|
|
71
|
+
this._cutRange = norm;
|
|
72
|
+
this.emitter.emit('rangesChange', { copyRange: null, cutRange: this._cutRange });
|
|
73
|
+
}
|
|
74
|
+
async handlePaste() {
|
|
75
|
+
if (this.params.editable === false)
|
|
76
|
+
return;
|
|
77
|
+
const { onCellValueChanged } = this.params;
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
81
|
+
text = await navigator.clipboard.readText();
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
text = '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
text = '';
|
|
89
|
+
}
|
|
90
|
+
if (!text.trim() && this.internalClipboard != null) {
|
|
91
|
+
text = this.internalClipboard;
|
|
92
|
+
}
|
|
93
|
+
if (!text.trim())
|
|
94
|
+
return;
|
|
95
|
+
if (onCellValueChanged == null)
|
|
96
|
+
return;
|
|
97
|
+
const norm = this.getEffectiveRange();
|
|
98
|
+
const anchorRow = norm ? norm.startRow : 0;
|
|
99
|
+
const anchorCol = norm ? norm.startCol : 0;
|
|
100
|
+
const { items, visibleCols } = this.params;
|
|
101
|
+
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
|
102
|
+
for (let r = 0; r < lines.length; r++) {
|
|
103
|
+
const cells = lines[r].split('\t');
|
|
104
|
+
for (let c = 0; c < cells.length; c++) {
|
|
105
|
+
const targetRow = anchorRow + r;
|
|
106
|
+
const targetCol = anchorCol + c;
|
|
107
|
+
if (targetRow >= items.length || targetCol >= visibleCols.length)
|
|
108
|
+
continue;
|
|
109
|
+
const item = items[targetRow];
|
|
110
|
+
const col = visibleCols[targetCol];
|
|
111
|
+
const colEditable = col.editable === true ||
|
|
112
|
+
(typeof col.editable === 'function' && col.editable(item));
|
|
113
|
+
if (!colEditable)
|
|
114
|
+
continue;
|
|
115
|
+
const rawValue = cells[c] ?? '';
|
|
116
|
+
const oldValue = getCellValue(item, col);
|
|
117
|
+
const result = parseValue(rawValue, oldValue, item, col);
|
|
118
|
+
if (!result.valid)
|
|
119
|
+
continue;
|
|
120
|
+
onCellValueChanged({
|
|
121
|
+
item,
|
|
122
|
+
columnId: col.columnId,
|
|
123
|
+
oldValue,
|
|
124
|
+
newValue: result.value,
|
|
125
|
+
rowIndex: targetRow,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (this._cutRange) {
|
|
130
|
+
const cut = this._cutRange;
|
|
131
|
+
for (let r = cut.startRow; r <= cut.endRow; r++) {
|
|
132
|
+
for (let c = cut.startCol; c <= cut.endCol; c++) {
|
|
133
|
+
if (r >= items.length || c >= visibleCols.length)
|
|
134
|
+
continue;
|
|
135
|
+
const item = items[r];
|
|
136
|
+
const col = visibleCols[c];
|
|
137
|
+
const colEditable = col.editable === true ||
|
|
138
|
+
(typeof col.editable === 'function' && col.editable(item));
|
|
139
|
+
if (!colEditable)
|
|
140
|
+
continue;
|
|
141
|
+
const oldValue = getCellValue(item, col);
|
|
142
|
+
const result = parseValue('', oldValue, item, col);
|
|
143
|
+
if (!result.valid)
|
|
144
|
+
continue;
|
|
145
|
+
onCellValueChanged({
|
|
146
|
+
item,
|
|
147
|
+
columnId: col.columnId,
|
|
148
|
+
oldValue,
|
|
149
|
+
newValue: result.value,
|
|
150
|
+
rowIndex: r,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this._cutRange = null;
|
|
155
|
+
}
|
|
156
|
+
this._copyRange = null;
|
|
157
|
+
this.emitter.emit('rangesChange', { copyRange: null, cutRange: null });
|
|
158
|
+
}
|
|
159
|
+
clearClipboardRanges() {
|
|
160
|
+
this._copyRange = null;
|
|
161
|
+
this._cutRange = null;
|
|
162
|
+
this.emitter.emit('rangesChange', { copyRange: null, cutRange: null });
|
|
163
|
+
}
|
|
164
|
+
onRangesChange(handler) {
|
|
165
|
+
this.emitter.on('rangesChange', handler);
|
|
166
|
+
return () => this.emitter.off('rangesChange', handler);
|
|
167
|
+
}
|
|
168
|
+
destroy() {
|
|
169
|
+
this.emitter.removeAllListeners();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { EventEmitter } from './EventEmitter';
|
|
2
|
+
/**
|
|
3
|
+
* Manages column pinning state — tracks which columns are pinned left/right.
|
|
4
|
+
* Computes sticky offsets for the renderer.
|
|
5
|
+
*/
|
|
6
|
+
export class ColumnPinningState {
|
|
7
|
+
constructor(pinnedColumns, columns) {
|
|
8
|
+
this.emitter = new EventEmitter();
|
|
9
|
+
this._pinnedColumns = {};
|
|
10
|
+
// Initialize from explicit pinnedColumns prop
|
|
11
|
+
if (pinnedColumns) {
|
|
12
|
+
this._pinnedColumns = { ...pinnedColumns };
|
|
13
|
+
}
|
|
14
|
+
// Also pick up pinned from column definitions
|
|
15
|
+
if (columns) {
|
|
16
|
+
for (const col of columns) {
|
|
17
|
+
if (col.pinned && !(col.columnId in this._pinnedColumns)) {
|
|
18
|
+
this._pinnedColumns[col.columnId] = col.pinned;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
get pinnedColumns() {
|
|
24
|
+
return this._pinnedColumns;
|
|
25
|
+
}
|
|
26
|
+
pinColumn(columnId, side) {
|
|
27
|
+
this._pinnedColumns = { ...this._pinnedColumns, [columnId]: side };
|
|
28
|
+
this.emitter.emit('pinningChange', { pinnedColumns: this._pinnedColumns });
|
|
29
|
+
}
|
|
30
|
+
unpinColumn(columnId) {
|
|
31
|
+
const next = { ...this._pinnedColumns };
|
|
32
|
+
delete next[columnId];
|
|
33
|
+
this._pinnedColumns = next;
|
|
34
|
+
this.emitter.emit('pinningChange', { pinnedColumns: this._pinnedColumns });
|
|
35
|
+
}
|
|
36
|
+
isPinned(columnId) {
|
|
37
|
+
return this._pinnedColumns[columnId];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compute sticky left offsets for left-pinned columns.
|
|
41
|
+
* Returns a map of columnId -> left offset in pixels.
|
|
42
|
+
*/
|
|
43
|
+
computeLeftOffsets(visibleCols, columnWidths, defaultWidth, hasCheckboxColumn, checkboxColumnWidth) {
|
|
44
|
+
const offsets = {};
|
|
45
|
+
let left = hasCheckboxColumn ? checkboxColumnWidth : 0;
|
|
46
|
+
for (const col of visibleCols) {
|
|
47
|
+
if (this._pinnedColumns[col.columnId] === 'left') {
|
|
48
|
+
offsets[col.columnId] = left;
|
|
49
|
+
left += columnWidths[col.columnId] ?? defaultWidth;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return offsets;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Compute sticky right offsets for right-pinned columns.
|
|
56
|
+
* Returns a map of columnId -> right offset in pixels.
|
|
57
|
+
*/
|
|
58
|
+
computeRightOffsets(visibleCols, columnWidths, defaultWidth) {
|
|
59
|
+
const offsets = {};
|
|
60
|
+
let right = 0;
|
|
61
|
+
// Walk right-pinned columns from the end
|
|
62
|
+
for (let i = visibleCols.length - 1; i >= 0; i--) {
|
|
63
|
+
const col = visibleCols[i];
|
|
64
|
+
if (this._pinnedColumns[col.columnId] === 'right') {
|
|
65
|
+
offsets[col.columnId] = right;
|
|
66
|
+
right += columnWidths[col.columnId] ?? defaultWidth;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return offsets;
|
|
70
|
+
}
|
|
71
|
+
onPinningChange(handler) {
|
|
72
|
+
this.emitter.on('pinningChange', handler);
|
|
73
|
+
return () => this.emitter.off('pinningChange', handler);
|
|
74
|
+
}
|
|
75
|
+
destroy() {
|
|
76
|
+
this.emitter.removeAllListeners();
|
|
77
|
+
}
|
|
78
|
+
}
|