@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.
Files changed (55) hide show
  1. package/dist/esm/OGrid.js +654 -0
  2. package/dist/esm/components/ColumnChooser.js +68 -0
  3. package/dist/esm/components/ContextMenu.js +122 -0
  4. package/dist/esm/components/HeaderFilter.js +281 -0
  5. package/dist/esm/components/InlineCellEditor.js +278 -0
  6. package/dist/esm/components/MarchingAntsOverlay.js +170 -0
  7. package/dist/esm/components/PaginationControls.js +85 -0
  8. package/dist/esm/components/SideBar.js +353 -0
  9. package/dist/esm/components/StatusBar.js +34 -0
  10. package/dist/esm/index.js +26 -0
  11. package/dist/esm/renderer/TableRenderer.js +414 -0
  12. package/dist/esm/state/ClipboardState.js +171 -0
  13. package/dist/esm/state/ColumnPinningState.js +78 -0
  14. package/dist/esm/state/ColumnResizeState.js +55 -0
  15. package/dist/esm/state/EventEmitter.js +27 -0
  16. package/dist/esm/state/FillHandleState.js +218 -0
  17. package/dist/esm/state/GridState.js +261 -0
  18. package/dist/esm/state/HeaderFilterState.js +205 -0
  19. package/dist/esm/state/KeyboardNavState.js +374 -0
  20. package/dist/esm/state/RowSelectionState.js +81 -0
  21. package/dist/esm/state/SelectionState.js +102 -0
  22. package/dist/esm/state/SideBarState.js +41 -0
  23. package/dist/esm/state/TableLayoutState.js +95 -0
  24. package/dist/esm/state/UndoRedoState.js +82 -0
  25. package/dist/esm/types/columnTypes.js +1 -0
  26. package/dist/esm/types/gridTypes.js +1 -0
  27. package/dist/esm/types/index.js +2 -0
  28. package/dist/types/OGrid.d.ts +60 -0
  29. package/dist/types/components/ColumnChooser.d.ts +14 -0
  30. package/dist/types/components/ContextMenu.d.ts +17 -0
  31. package/dist/types/components/HeaderFilter.d.ts +24 -0
  32. package/dist/types/components/InlineCellEditor.d.ts +24 -0
  33. package/dist/types/components/MarchingAntsOverlay.d.ts +25 -0
  34. package/dist/types/components/PaginationControls.d.ts +9 -0
  35. package/dist/types/components/SideBar.d.ts +35 -0
  36. package/dist/types/components/StatusBar.d.ts +8 -0
  37. package/dist/types/index.d.ts +26 -0
  38. package/dist/types/renderer/TableRenderer.d.ts +59 -0
  39. package/dist/types/state/ClipboardState.d.ts +35 -0
  40. package/dist/types/state/ColumnPinningState.d.ts +36 -0
  41. package/dist/types/state/ColumnResizeState.d.ts +23 -0
  42. package/dist/types/state/EventEmitter.d.ts +9 -0
  43. package/dist/types/state/FillHandleState.d.ts +51 -0
  44. package/dist/types/state/GridState.d.ts +68 -0
  45. package/dist/types/state/HeaderFilterState.d.ts +64 -0
  46. package/dist/types/state/KeyboardNavState.d.ts +29 -0
  47. package/dist/types/state/RowSelectionState.d.ts +23 -0
  48. package/dist/types/state/SelectionState.d.ts +37 -0
  49. package/dist/types/state/SideBarState.d.ts +19 -0
  50. package/dist/types/state/TableLayoutState.d.ts +33 -0
  51. package/dist/types/state/UndoRedoState.d.ts +28 -0
  52. package/dist/types/types/columnTypes.d.ts +28 -0
  53. package/dist/types/types/gridTypes.d.ts +69 -0
  54. package/dist/types/types/index.d.ts +2 -0
  55. 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
+ }