argent-grid 0.1.0

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 (53) hide show
  1. package/.github/workflows/pages.yml +68 -0
  2. package/AGENTS.md +179 -0
  3. package/README.md +222 -0
  4. package/demo-app/README.md +70 -0
  5. package/demo-app/angular.json +78 -0
  6. package/demo-app/e2e/benchmark.spec.ts +53 -0
  7. package/demo-app/e2e/demo-page.spec.ts +77 -0
  8. package/demo-app/e2e/grid-features.spec.ts +269 -0
  9. package/demo-app/package-lock.json +14023 -0
  10. package/demo-app/package.json +36 -0
  11. package/demo-app/playwright-test-menu.js +19 -0
  12. package/demo-app/playwright.config.ts +23 -0
  13. package/demo-app/src/app/app.component.ts +10 -0
  14. package/demo-app/src/app/app.config.ts +13 -0
  15. package/demo-app/src/app/app.routes.ts +7 -0
  16. package/demo-app/src/app/demo-page/demo-page.component.css +313 -0
  17. package/demo-app/src/app/demo-page/demo-page.component.html +124 -0
  18. package/demo-app/src/app/demo-page/demo-page.component.ts +366 -0
  19. package/demo-app/src/index.html +19 -0
  20. package/demo-app/src/main.ts +6 -0
  21. package/demo-app/tsconfig.json +31 -0
  22. package/ng-package.json +8 -0
  23. package/package.json +60 -0
  24. package/plan.md +131 -0
  25. package/setup-vitest.ts +18 -0
  26. package/src/lib/argent-grid.module.ts +21 -0
  27. package/src/lib/components/argent-grid.component.css +483 -0
  28. package/src/lib/components/argent-grid.component.html +320 -0
  29. package/src/lib/components/argent-grid.component.spec.ts +189 -0
  30. package/src/lib/components/argent-grid.component.ts +1188 -0
  31. package/src/lib/directives/ag-grid-compatibility.directive.ts +92 -0
  32. package/src/lib/rendering/canvas-renderer.ts +962 -0
  33. package/src/lib/rendering/render/blit.spec.ts +453 -0
  34. package/src/lib/rendering/render/blit.ts +393 -0
  35. package/src/lib/rendering/render/cells.ts +369 -0
  36. package/src/lib/rendering/render/index.ts +105 -0
  37. package/src/lib/rendering/render/lines.ts +363 -0
  38. package/src/lib/rendering/render/theme.spec.ts +282 -0
  39. package/src/lib/rendering/render/theme.ts +201 -0
  40. package/src/lib/rendering/render/types.ts +279 -0
  41. package/src/lib/rendering/render/walk.spec.ts +360 -0
  42. package/src/lib/rendering/render/walk.ts +360 -0
  43. package/src/lib/rendering/utils/damage-tracker.spec.ts +444 -0
  44. package/src/lib/rendering/utils/damage-tracker.ts +423 -0
  45. package/src/lib/rendering/utils/index.ts +7 -0
  46. package/src/lib/services/grid.service.spec.ts +1039 -0
  47. package/src/lib/services/grid.service.ts +1284 -0
  48. package/src/lib/types/ag-grid-types.ts +970 -0
  49. package/src/public-api.ts +22 -0
  50. package/tsconfig.json +32 -0
  51. package/tsconfig.lib.json +11 -0
  52. package/tsconfig.spec.json +8 -0
  53. package/vitest.config.ts +55 -0
@@ -0,0 +1,1188 @@
1
+ import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ElementRef, ViewChild, ChangeDetectionStrategy, AfterViewInit, OnChanges, SimpleChanges, ChangeDetectorRef, Inject } from '@angular/core';
2
+ import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
3
+ import { GridApi, GridOptions, ColDef, ColGroupDef, IRowNode, Column, CellRange, DefaultMenuItem, MenuItemDef, GetContextMenuItemsParams } from '../types/ag-grid-types';
4
+ import { Subject } from 'rxjs';
5
+ import { takeUntil } from 'rxjs/operators';
6
+ import { GridService } from '../services/grid.service';
7
+ import { CanvasRenderer } from '../rendering/canvas-renderer';
8
+
9
+ @Component({
10
+ selector: 'argent-grid',
11
+ templateUrl: './argent-grid.component.html',
12
+ styleUrls: ['./argent-grid.component.css'],
13
+ changeDetection: ChangeDetectionStrategy.OnPush
14
+ })
15
+ export class ArgentGridComponent<TData = any> implements OnInit, OnDestroy, AfterViewInit, OnChanges {
16
+ @Input() columnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null = null;
17
+ @Input() rowData: TData[] | null = null;
18
+ @Input() gridOptions: GridOptions<TData> | null = null;
19
+ @Input() height = '500px';
20
+ @Input() width = '100%';
21
+ @Input() rowHeight = 32;
22
+
23
+ @Output() gridReady = new EventEmitter<GridApi<TData>>();
24
+ @Output() rowClicked = new EventEmitter<{ data: TData; node: IRowNode<TData> }>();
25
+ @Output() selectionChanged = new EventEmitter<IRowNode<TData>[]>();
26
+
27
+ @ViewChild('gridCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
28
+ @ViewChild('viewport') viewportRef!: ElementRef<HTMLDivElement>;
29
+ @ViewChild('headerScrollable') headerScrollableRef!: ElementRef<HTMLDivElement>;
30
+ @ViewChild('headerScrollableFilter') headerScrollableFilterRef!: ElementRef<HTMLDivElement>;
31
+ @ViewChild('editorInput') editorInputRef!: ElementRef<HTMLInputElement>;
32
+
33
+ canvasHeight = 0;
34
+ showOverlay = false;
35
+ private viewportHeight = 500;
36
+
37
+ get totalHeight(): number {
38
+ if (this.gridApi) return this.gridApi.getTotalHeight();
39
+ return (this.rowData?.length || 0) * this.rowHeight;
40
+ }
41
+
42
+ get totalWidth(): number {
43
+ if (!this.gridApi) return 0;
44
+ return this.gridApi.getAllColumns()
45
+ .filter(col => col.visible)
46
+ .reduce((sum, col) => sum + col.width, 0);
47
+ }
48
+
49
+ // Selection state
50
+ showSelectionColumn = false;
51
+ selectionColumnWidth = 50;
52
+ isAllSelected = false;
53
+ isIndeterminateSelection = false;
54
+
55
+ trackByColumn(index: number, col: Column | ColDef<TData> | ColGroupDef<TData>): string {
56
+ return (col as any).colId || (col as any).field?.toString() || index.toString();
57
+ }
58
+
59
+ // Cell editing state
60
+ isEditing = false;
61
+ editingValue = '';
62
+ editorPosition = { x: 0, y: 0, width: 100, height: 32 };
63
+ private editingRowNode: IRowNode<TData> | null = null;
64
+ private editingColDef: ColDef<TData> | null = null;
65
+
66
+ // Header Menu state
67
+ activeHeaderMenu: Column | ColDef<TData> | ColGroupDef<TData> | null = null;
68
+ headerMenuPosition = { x: 0, y: 0 };
69
+
70
+ // Resizing state
71
+ isResizing = false;
72
+ resizeColumn: Column | null = null;
73
+ private resizeStartX = 0;
74
+ private resizeStartWidth = 0;
75
+
76
+ // Range Selection state
77
+ isRangeSelecting = false;
78
+ private rangeStartCell: { rowIndex: number, colId: string } | null = null;
79
+
80
+ // Side Bar state
81
+ sideBarVisible = false;
82
+ activeToolPanel: 'columns' | 'filters' | null = null;
83
+
84
+ // Context Menu state
85
+ activeContextMenu = false;
86
+ contextMenuPosition = { x: 0, y: 0 };
87
+ contextMenuItems: MenuItemDef[] = [];
88
+ private contextMenuCell: { rowNode: IRowNode<TData>, column: Column } | null = null;
89
+ private initialColumnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null = null;
90
+
91
+ private gridApi!: GridApi<TData>;
92
+ private canvasRenderer!: CanvasRenderer;
93
+ private destroy$ = new Subject<void>();
94
+ private gridService = new GridService<TData>();
95
+ private horizontalScrollListener?: (e: Event) => void;
96
+ private resizeObserver?: ResizeObserver;
97
+
98
+ constructor(@Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef) {}
99
+
100
+ ngOnInit(): void {
101
+ this.initialColumnDefs = this.columnDefs ? JSON.parse(JSON.stringify(this.columnDefs)) : null;
102
+ this.initializeGrid();
103
+ }
104
+
105
+ ngOnChanges(changes: SimpleChanges): void {
106
+ // Handle rowData changes after initialization
107
+ if (changes['rowData'] && !changes['rowData'].firstChange) {
108
+ this.onRowDataChanged(changes['rowData'].currentValue);
109
+ }
110
+
111
+ // Handle columnDefs changes
112
+ if (changes['columnDefs'] && !changes['columnDefs'].firstChange) {
113
+ this.onColumnDefsChanged(changes['columnDefs'].currentValue);
114
+ }
115
+
116
+ // Handle gridOptions changes
117
+ if (changes['gridOptions'] && !changes['gridOptions'].firstChange) {
118
+ this.onGridOptionsChanged(changes['gridOptions'].currentValue);
119
+ }
120
+ }
121
+
122
+ ngAfterViewInit(): void {
123
+ // Setup canvas renderer after view is initialized
124
+ if (this.canvasRef && !this.canvasRenderer) {
125
+ this.canvasRenderer = new CanvasRenderer(
126
+ this.canvasRef.nativeElement,
127
+ this.gridApi,
128
+ this.rowHeight
129
+ );
130
+
131
+ // Wire up cell editing callback
132
+ this.canvasRenderer.onCellDoubleClick = (rowIndex, colId) => {
133
+ this.startEditing(rowIndex, colId);
134
+ };
135
+
136
+ // Wire up row click for selection
137
+ this.canvasRenderer.onRowClick = (rowIndex, event) => {
138
+ this.onRowClick(rowIndex, event);
139
+ };
140
+
141
+ // Range Selection Logic
142
+ this.canvasRenderer.onMouseDown = (event, rowIndex, colId) => {
143
+ if (event.button !== 0 || !colId || rowIndex === -1) return;
144
+
145
+ const rangeSelectionEnabled = this.gridApi?.getGridOption('enableRangeSelection');
146
+ if (!rangeSelectionEnabled) return;
147
+
148
+ this.isRangeSelecting = true;
149
+ this.rangeStartCell = { rowIndex, colId };
150
+
151
+ // Clear previous selection if not holding Shift/Ctrl
152
+ if (!event.shiftKey && !event.ctrlKey && !event.metaKey) {
153
+ this.gridApi?.clearRangeSelection();
154
+ }
155
+ };
156
+
157
+ this.canvasRenderer.onMouseMove = (event, rowIndex, colId) => {
158
+ if (!this.isRangeSelecting || !this.rangeStartCell || !colId || rowIndex === -1) return;
159
+
160
+ const start = this.rangeStartCell;
161
+ const end = { rowIndex, colId };
162
+
163
+ const columns = this.canvasRenderer.getAllColumns();
164
+ const startColIdx = columns.findIndex(c => c.colId === start.colId);
165
+ const endColIdx = columns.findIndex(c => c.colId === end.colId);
166
+
167
+ if (startColIdx === -1 || endColIdx === -1) return;
168
+
169
+ const range: CellRange = {
170
+ startRow: Math.min(start.rowIndex, end.rowIndex),
171
+ endRow: Math.max(start.rowIndex, end.rowIndex),
172
+ startColumn: columns[Math.min(startColIdx, endColIdx)].colId,
173
+ endColumn: columns[Math.max(startColIdx, endColIdx)].colId,
174
+ columns: columns.slice(Math.min(startColIdx, endColIdx), Math.max(startColIdx, endColIdx) + 1)
175
+ };
176
+
177
+ this.gridApi?.addCellRange(range);
178
+ };
179
+
180
+ this.canvasRenderer.onMouseUp = () => {
181
+ this.isRangeSelecting = false;
182
+ };
183
+ }
184
+
185
+ // Setup viewport dimensions and resize observer
186
+ if (this.viewportRef) {
187
+ const rect = this.viewportRef.nativeElement.getBoundingClientRect();
188
+ this.viewportHeight = rect.height || 500;
189
+ this.canvasRenderer?.setViewportDimensions(rect.width, this.viewportHeight);
190
+ this.canvasRenderer?.setTotalRowCount(this.rowData?.length || 0);
191
+
192
+ // Synchronize horizontal scroll with DOM header
193
+ this.horizontalScrollListener = () => {
194
+ if (this.headerScrollableRef) {
195
+ this.headerScrollableRef.nativeElement.scrollLeft = this.viewportRef.nativeElement.scrollLeft;
196
+ }
197
+ if (this.headerScrollableFilterRef) {
198
+ this.headerScrollableFilterRef.nativeElement.scrollLeft = this.viewportRef.nativeElement.scrollLeft;
199
+ }
200
+ };
201
+
202
+ this.viewportRef.nativeElement.addEventListener('scroll', this.horizontalScrollListener, { passive: true });
203
+
204
+ // Add ResizeObserver to handle sidebar toggling and other size changes
205
+ if (typeof ResizeObserver !== 'undefined') {
206
+ this.resizeObserver = new ResizeObserver(entries => {
207
+ for (const entry of entries) {
208
+ const { width, height } = entry.contentRect;
209
+ this.viewportHeight = height;
210
+ this.canvasRenderer?.setViewportDimensions(width, height);
211
+ this.canvasRenderer?.render();
212
+ this.cdr.detectChanges();
213
+ }
214
+ });
215
+ this.resizeObserver.observe(this.viewportRef.nativeElement);
216
+ }
217
+ }
218
+ }
219
+
220
+ ngOnDestroy(): void {
221
+ this.destroy$.next();
222
+ this.destroy$.complete();
223
+
224
+ // Remove horizontal scroll listener
225
+ if (this.viewportRef && this.horizontalScrollListener) {
226
+ this.viewportRef.nativeElement.removeEventListener('scroll', this.horizontalScrollListener);
227
+ }
228
+
229
+ if (this.resizeObserver) {
230
+ this.resizeObserver.disconnect();
231
+ }
232
+
233
+ this.gridApi?.destroy();
234
+ this.canvasRenderer?.destroy();
235
+ }
236
+
237
+ private initializeGrid(): void {
238
+ // Initialize grid API
239
+ this.gridApi = this.gridService.createApi(this.columnDefs, this.rowData, this.gridOptions);
240
+
241
+ // Listen for grid state changes from API (filters, sorts, options)
242
+ this.gridService.gridStateChanged$
243
+ .pipe(takeUntil(this.destroy$))
244
+ .subscribe((event) => {
245
+ if (event.type === 'optionChanged' && event.key === 'sideBar') {
246
+ this.sideBarVisible = !!event.value;
247
+ }
248
+ this.canvasRenderer?.render();
249
+ this.cdr.detectChanges();
250
+ });
251
+
252
+ // Check if any column has checkbox selection
253
+ this.showSelectionColumn = this.columnDefs?.some(col =>
254
+ !('children' in col) && col.checkboxSelection
255
+ ) || false;
256
+
257
+ // Canvas renderer will be initialized in ngAfterViewInit
258
+
259
+ // Emit grid ready event
260
+ this.gridReady.emit(this.gridApi);
261
+
262
+ // Sidebar state
263
+ this.sideBarVisible = !!this.gridOptions?.sideBar;
264
+ if (this.sideBarVisible && !this.activeToolPanel) {
265
+ this.activeToolPanel = 'columns';
266
+ }
267
+
268
+ // Update overlay state
269
+ this.showOverlay = !this.rowData || this.rowData.length === 0;
270
+
271
+ // Update selection state
272
+ this.updateSelectionState();
273
+ }
274
+
275
+ private onRowDataChanged(newData: TData[] | null): void {
276
+ this.rowData = newData;
277
+
278
+ if (this.gridApi) {
279
+ this.gridApi.setRowData(newData || []);
280
+ this.canvasRenderer?.setTotalRowCount(newData?.length || 0);
281
+ this.canvasRenderer?.render();
282
+ }
283
+
284
+ this.showOverlay = !newData || newData.length === 0;
285
+ this.updateSelectionState();
286
+
287
+ // Trigger change detection with OnPush
288
+ this.cdr.detectChanges();
289
+ }
290
+
291
+ private onColumnDefsChanged(newColumnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null): void {
292
+ this.columnDefs = newColumnDefs;
293
+
294
+ if (this.gridApi) {
295
+ this.gridApi.setColumnDefs(newColumnDefs);
296
+ this.canvasRenderer?.render();
297
+ }
298
+
299
+ this.cdr.detectChanges();
300
+ }
301
+
302
+ private onGridOptionsChanged(newOptions: GridOptions<TData> | null): void {
303
+ this.gridOptions = newOptions;
304
+ if (this.gridApi && newOptions) {
305
+ // Update all options in the API
306
+ Object.keys(newOptions).forEach(key => {
307
+ this.gridApi.setGridOption(key as any, (newOptions as any)[key]);
308
+ });
309
+ this.canvasRenderer?.render();
310
+ }
311
+ this.cdr.detectChanges();
312
+ }
313
+
314
+ getColumnWidth(col: Column | ColDef<TData> | ColGroupDef<TData>): number {
315
+ if ('children' in col) {
316
+ // Column group - sum children widths
317
+ return col.children.reduce((sum, child) => sum + this.getColumnWidth(child), 0);
318
+ }
319
+ return col.width || 150;
320
+ }
321
+
322
+ getLeftPinnedColumns(): Column[] {
323
+ if (!this.gridApi) return [];
324
+ return this.gridApi.getAllColumns().filter(col => {
325
+ return col.visible && col.pinned === 'left';
326
+ });
327
+ }
328
+
329
+ getRightPinnedColumns(): Column[] {
330
+ if (!this.gridApi) return [];
331
+ return this.gridApi.getAllColumns().filter(col => {
332
+ return col.visible && col.pinned === 'right';
333
+ });
334
+ }
335
+
336
+ getNonPinnedColumns(): Column[] {
337
+ if (!this.gridApi) return [];
338
+ return this.gridApi.getAllColumns().filter(col => {
339
+ return col.visible && !col.pinned;
340
+ });
341
+ }
342
+
343
+ isSortable(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
344
+ // If it has children, it's a group and cannot be sorted directly
345
+ if ('children' in col) return false;
346
+
347
+ // Check if the object itself has sortable property (ColDef)
348
+ if ('sortable' in col && col.sortable !== undefined) {
349
+ return !!col.sortable;
350
+ }
351
+
352
+ // It's likely a Column object, look up its ColDef
353
+ const colDef = this.getColumnDefForColumn(col as any);
354
+ return colDef ? (colDef.sortable !== false) : true;
355
+ }
356
+
357
+ getHeaderName(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
358
+ if ('children' in col) {
359
+ return col.headerName || '';
360
+ }
361
+ return col.headerName || (col as any).field?.toString() || '';
362
+ }
363
+
364
+ getSortIndicator(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
365
+ if ('children' in col || !col.sort) {
366
+ return '';
367
+ }
368
+ return col.sort === 'asc' ? '▲' : '▼';
369
+ }
370
+
371
+ onHeaderClick(col: Column | ColDef<TData> | ColGroupDef<TData>): void {
372
+ if (!this.isSortable(col) || 'children' in col) {
373
+ return;
374
+ }
375
+
376
+ // Toggle sort
377
+ const currentSort = col.sort;
378
+ const newSort = currentSort === 'asc' ? 'desc' : currentSort === 'desc' ? null : 'asc';
379
+
380
+ const colId = (col as any).colId || (col as any).field?.toString() || '';
381
+
382
+ // Update the column directly if it's a Column object
383
+ if ('colId' in col && !(col as any).children) {
384
+ (col as any).sort = newSort;
385
+ }
386
+
387
+ this.gridApi.setSortModel(newSort ? [{ colId, sort: newSort }] : []);
388
+ this.canvasRenderer?.render();
389
+ }
390
+
391
+ // --- Header Menu Logic ---
392
+
393
+ hasHeaderMenu(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
394
+ if ('children' in col) return false;
395
+ const colDef = this.getColumnDefForColumn(col as any);
396
+ return colDef ? colDef.suppressHeaderMenuButton !== true : true;
397
+ }
398
+
399
+ onHeaderMenuClick(event: MouseEvent, col: Column | ColDef<TData> | ColGroupDef<TData>): void {
400
+ event.stopPropagation();
401
+
402
+ if (this.activeHeaderMenu === col) {
403
+ this.closeHeaderMenu();
404
+ return;
405
+ }
406
+
407
+ this.activeHeaderMenu = col;
408
+
409
+ // Position menu below the icon using fixed (viewport) coordinates
410
+ const target = event.target as HTMLElement;
411
+ const rect = target.getBoundingClientRect();
412
+
413
+ let x = rect.right - 200; // Align right, assuming menu width ~200px
414
+ let y = rect.bottom + 4;
415
+
416
+ // Prevent menu from going off-screen
417
+ if (x < 0) x = 0;
418
+ if (x + 200 > window.innerWidth) x = window.innerWidth - 200;
419
+ if (y + 250 > window.innerHeight) { // Assuming max menu height ~250px
420
+ y = rect.top - 250; // Show above if overflows bottom
421
+ }
422
+
423
+ this.headerMenuPosition = { x, y };
424
+
425
+ this.cdr.detectChanges();
426
+ }
427
+
428
+ closeHeaderMenu(): void {
429
+ this.activeHeaderMenu = null;
430
+ this.cdr.detectChanges();
431
+ }
432
+
433
+ onContainerClick(event: MouseEvent): void {
434
+ if (this.activeHeaderMenu) {
435
+ this.closeHeaderMenu();
436
+ }
437
+ if (this.activeContextMenu) {
438
+ this.closeContextMenu();
439
+ }
440
+ // Handle closing editor on click outside
441
+ if (this.isEditing) {
442
+ const target = event.target as HTMLElement;
443
+ if (!target.closest('.argent-grid-cell-editor')) {
444
+ this.stopEditing(true);
445
+ }
446
+ }
447
+ }
448
+
449
+ onCanvasContextMenu(event: MouseEvent): void {
450
+ event.preventDefault();
451
+
452
+ // Get hit test from canvas renderer to know which cell was clicked
453
+ const hitTest = this.canvasRenderer.getHitTestResult(event);
454
+ if (!hitTest || hitTest.rowIndex === -1) return;
455
+
456
+ const rowNode = this.gridApi.getDisplayedRowAtIndex(hitTest.rowIndex);
457
+ const columns = this.gridApi.getAllColumns().filter(col => col.visible);
458
+ const column = columns[hitTest.columnIndex];
459
+
460
+ if (!rowNode || !column) return;
461
+
462
+ this.contextMenuCell = { rowNode, column };
463
+
464
+ // Resolve menu items via API if provided
465
+ const getContextMenuItems = this.gridApi.getGridOption('getContextMenuItems');
466
+ if (getContextMenuItems) {
467
+ const params: GetContextMenuItemsParams<TData> = {
468
+ node: rowNode,
469
+ column: column,
470
+ api: this.gridApi,
471
+ type: 'cell',
472
+ event: event
473
+ };
474
+ this.contextMenuItems = this.resolveContextMenuItems(getContextMenuItems(params));
475
+ } else {
476
+ // Fallback to defaults if no callback provided
477
+ this.contextMenuItems = this.resolveContextMenuItems([
478
+ 'copy', 'copyWithHeaders', 'separator', 'export', 'separator', 'resetColumns'
479
+ ]);
480
+ }
481
+
482
+ if (this.contextMenuItems.length === 0) return;
483
+
484
+ this.activeContextMenu = true;
485
+
486
+ // Position menu at mouse coordinates (fixed/viewport)
487
+ let x = event.clientX;
488
+ let y = event.clientY;
489
+
490
+ // Prevent menu from going off-screen
491
+ if (x + 200 > window.innerWidth) x = window.innerWidth - 200;
492
+ if (y + 200 > window.innerHeight) y = window.innerHeight - 200;
493
+
494
+ this.contextMenuPosition = { x, y };
495
+
496
+ // Select the row
497
+ this.gridApi.deselectAll();
498
+ rowNode.selected = true;
499
+ this.updateSelectionState();
500
+ this.canvasRenderer?.render();
501
+ this.selectionChanged.emit(this.gridApi.getSelectedRows());
502
+
503
+ this.cdr.detectChanges();
504
+ }
505
+
506
+ private resolveContextMenuItems(items: (DefaultMenuItem | MenuItemDef)[]): MenuItemDef[] {
507
+ const resolved: MenuItemDef[] = [];
508
+
509
+ items.forEach(item => {
510
+ if (typeof item === 'string') {
511
+ const defaultItem = this.getDefaultMenuItem(item);
512
+ if (defaultItem) resolved.push(defaultItem);
513
+ } else {
514
+ resolved.push(item);
515
+ }
516
+ });
517
+
518
+ return resolved;
519
+ }
520
+
521
+ private getDefaultMenuItem(key: DefaultMenuItem): MenuItemDef | null {
522
+ switch (key) {
523
+ case 'copy':
524
+ return { name: 'Copy Cell', action: () => this.copyContextMenuCell(), icon: '📋' };
525
+ case 'copyWithHeaders':
526
+ return this.hasRangeSelection() ?
527
+ { name: 'Copy with Headers', action: () => this.copyRangeWithHeaders(), icon: '📋' } : null;
528
+ case 'export':
529
+ return {
530
+ name: 'Export',
531
+ action: () => {},
532
+ icon: '⤓',
533
+ subMenu: [
534
+ { name: 'Export to CSV', action: () => this.exportCSV() },
535
+ { name: 'Export to Excel (.xlsx)', action: () => this.exportExcel() }
536
+ ]
537
+ };
538
+ case 'resetColumns':
539
+ return { name: 'Reset Columns', action: () => this.resetColumns(), icon: '⟲' };
540
+ case 'separator':
541
+ return { name: '', action: () => {}, separator: true };
542
+ default:
543
+ return null;
544
+ }
545
+ }
546
+
547
+ closeContextMenu(): void {
548
+ this.activeContextMenu = false;
549
+ this.contextMenuCell = null;
550
+ this.cdr.detectChanges();
551
+ }
552
+
553
+ // Side Bar Methods
554
+ toggleToolPanel(panel: 'columns' | 'filters'): void {
555
+ if (this.activeToolPanel === panel) {
556
+ this.activeToolPanel = null;
557
+ } else {
558
+ this.activeToolPanel = panel;
559
+ }
560
+ this.cdr.detectChanges();
561
+ }
562
+
563
+ toggleColumnVisibility(col: Column): void {
564
+ const colDef = this.getColumnDefForColumn(col);
565
+ if (colDef) {
566
+ colDef.hide = col.visible; // Toggle
567
+ this.initializeGrid(); // Re-initialize to handle visibility changes correctly
568
+ this.canvasRenderer?.render();
569
+ this.cdr.detectChanges();
570
+ }
571
+ }
572
+
573
+ getAllColumns(): Column[] {
574
+ return this.gridApi?.getAllColumns() || [];
575
+ }
576
+
577
+ onSidebarColumnDropped(event: CdkDragDrop<Column[]>): void {
578
+ if (!this.columnDefs) return;
579
+
580
+ const columns = this.getAllColumns();
581
+ moveItemInArray(columns, event.previousIndex, event.currentIndex);
582
+
583
+ // Map back to ColDefs
584
+ const newDefs: (ColDef<TData> | ColGroupDef<TData>)[] = [];
585
+ columns.forEach(col => {
586
+ const def = this.getColumnDefForColumn(col);
587
+ if (def) newDefs.push(def);
588
+ });
589
+
590
+ this.onColumnDefsChanged(newDefs);
591
+ }
592
+
593
+ copyContextMenuCell(): void {
594
+ if (!this.contextMenuCell || !this.contextMenuCell.column.field) return;
595
+
596
+ const val = (this.contextMenuCell.rowNode.data as any)[this.contextMenuCell.column.field];
597
+ if (val !== undefined && val !== null) {
598
+ navigator.clipboard.writeText(String(val)).catch(err => {
599
+ console.error('Failed to copy text: ', err);
600
+ });
601
+ }
602
+ this.closeContextMenu();
603
+ }
604
+
605
+ hasRangeSelection(): boolean {
606
+ return (this.gridApi?.getCellRanges()?.length || 0) > 0;
607
+ }
608
+
609
+ copyRangeWithHeaders(): void {
610
+ const ranges = this.gridApi?.getCellRanges();
611
+ if (!ranges || ranges.length === 0) return;
612
+
613
+ const range = ranges[0];
614
+ const columns = range.columns;
615
+
616
+ let text = columns.map(c => this.getHeaderName(c)).join('\t') + '\n';
617
+
618
+ for (let i = range.startRow; i <= range.endRow; i++) {
619
+ const node = this.gridApi.getDisplayedRowAtIndex(i);
620
+ if (node) {
621
+ text += columns.map(c => {
622
+ const val = (node.data as any)[c.field || ''];
623
+ return val !== null && val !== undefined ? String(val) : '';
624
+ }).join('\t') + '\n';
625
+ }
626
+ }
627
+
628
+ navigator.clipboard.writeText(text).catch(err => {
629
+ console.error('Failed to copy range: ', err);
630
+ });
631
+ this.closeContextMenu();
632
+ }
633
+
634
+ exportCSV(): void {
635
+ this.gridApi.exportDataAsCsv();
636
+ this.closeContextMenu();
637
+ }
638
+
639
+ exportExcel(): void {
640
+ this.gridApi.exportDataAsExcel();
641
+ this.closeContextMenu();
642
+ }
643
+
644
+ resetColumns(): void {
645
+ if (this.initialColumnDefs) {
646
+ // Deep copy back the original defs
647
+ const restored = JSON.parse(JSON.stringify(this.initialColumnDefs));
648
+ this.onColumnDefsChanged(restored);
649
+
650
+ // Also clear sort model
651
+ this.gridApi.setSortModel([]);
652
+ }
653
+ this.closeContextMenu();
654
+ }
655
+
656
+ sortColumnMenu(direction: 'asc' | 'desc' | null): void {
657
+ if (!this.activeHeaderMenu) return;
658
+
659
+ const col = this.activeHeaderMenu as any;
660
+ const colId = col.colId || col.field?.toString() || '';
661
+
662
+ // Update original ColDef to ensure persistence
663
+ const colDef = this.getColumnDefForColumn(col);
664
+ if (colDef) {
665
+ colDef.sort = direction;
666
+ }
667
+
668
+ this.gridApi.setSortModel(direction ? [{ colId, sort: direction }] : []);
669
+ this.canvasRenderer?.render();
670
+
671
+ this.closeHeaderMenu();
672
+ }
673
+
674
+ hideColumnMenu(): void {
675
+ if (!this.activeHeaderMenu) return;
676
+
677
+ const col = this.activeHeaderMenu as any;
678
+
679
+ // Update the original column definition
680
+ const colDef = this.getColumnDefForColumn(col);
681
+ if (colDef) {
682
+ colDef.hide = true;
683
+ }
684
+
685
+ // Create new array to trigger change detection and API update
686
+ if (this.columnDefs) {
687
+ this.onColumnDefsChanged([...this.columnDefs]);
688
+ }
689
+
690
+ this.closeHeaderMenu();
691
+ }
692
+
693
+ pinColumnMenu(pin: 'left' | 'right' | null): void {
694
+ if (!this.activeHeaderMenu) return;
695
+
696
+ const col = this.activeHeaderMenu as any;
697
+
698
+ // Update the original column definition
699
+ const colDef = this.getColumnDefForColumn(col);
700
+ if (colDef) {
701
+ colDef.pinned = pin as any;
702
+ }
703
+
704
+ if (this.columnDefs) {
705
+ this.onColumnDefsChanged([...this.columnDefs]);
706
+ }
707
+
708
+ this.closeHeaderMenu();
709
+ }
710
+
711
+ onColumnDropped(event: CdkDragDrop<any[]>, pinType: 'left' | 'right' | 'none'): void {
712
+ if (!this.columnDefs) return;
713
+
714
+ // Get current groups (using internal Columns)
715
+ const left = [...this.getLeftPinnedColumns()];
716
+ const center = [...this.getNonPinnedColumns()];
717
+ const right = [...this.getRightPinnedColumns()];
718
+
719
+ const containerMap: { [key: string]: any[] } = {
720
+ 'left-pinned': left,
721
+ 'scrollable': center,
722
+ 'right-pinned': right
723
+ };
724
+
725
+ const previousContainerData = containerMap[event.previousContainer.id];
726
+ const currentContainerData = containerMap[event.container.id];
727
+
728
+ if (event.previousContainer === event.container) {
729
+ moveItemInArray(currentContainerData, event.previousIndex, event.currentIndex);
730
+ } else {
731
+ transferArrayItem(
732
+ previousContainerData,
733
+ currentContainerData,
734
+ event.previousIndex,
735
+ event.currentIndex
736
+ );
737
+
738
+ // Update pinned state of the moved column in its original definition
739
+ const movedCol = currentContainerData[event.currentIndex] as Column;
740
+ const colDef = this.getColumnDefForColumn(movedCol);
741
+ if (colDef) {
742
+ colDef.pinned = pinType === 'none' ? null : pinType as any;
743
+ }
744
+ }
745
+
746
+ // Map internal Columns back to their original ColDefs in the new order
747
+ const orderedVisibleColDefs: (ColDef<TData> | ColGroupDef<TData>)[] = [];
748
+ [...left, ...center, ...right].forEach(col => {
749
+ const def = this.getColumnDefForColumn(col);
750
+ if (def) orderedVisibleColDefs.push(def);
751
+ });
752
+
753
+ // Reconstruct full columnDefs array, maintaining hidden columns
754
+ const hidden = this.columnDefs.filter(c => {
755
+ if ('children' in c) return false;
756
+ return (c as ColDef).hide;
757
+ });
758
+
759
+ const newDefs = [...orderedVisibleColDefs, ...hidden];
760
+
761
+ this.onColumnDefsChanged(newDefs);
762
+ }
763
+
764
+ // --- Column Resizing Logic ---
765
+
766
+ isResizable(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
767
+ if ('children' in col) return false;
768
+ const colDef = this.getColumnDefForColumn(col as any);
769
+ return colDef ? colDef.resizable !== false : true;
770
+ }
771
+
772
+ onResizeMouseDown(event: MouseEvent, col: Column): void {
773
+ event.stopPropagation();
774
+ event.preventDefault();
775
+
776
+ this.isResizing = true;
777
+ this.resizeColumn = col;
778
+ this.resizeStartX = event.clientX;
779
+ this.resizeStartWidth = col.width;
780
+
781
+ const mouseMoveHandler = (e: MouseEvent) => this.onResizeMouseMove(e);
782
+ const mouseUpHandler = () => {
783
+ this.onResizeMouseUp();
784
+ window.removeEventListener('mousemove', mouseMoveHandler);
785
+ window.removeEventListener('mouseup', mouseUpHandler);
786
+ };
787
+
788
+ window.addEventListener('mousemove', mouseMoveHandler);
789
+ window.addEventListener('mouseup', mouseUpHandler);
790
+ }
791
+
792
+ private onResizeMouseMove(event: MouseEvent): void {
793
+ if (!this.isResizing || !this.resizeColumn) return;
794
+
795
+ const deltaX = event.clientX - this.resizeStartX;
796
+ const newWidth = Math.max(20, this.resizeStartWidth + deltaX);
797
+
798
+ // Update internal column width
799
+ this.resizeColumn.width = newWidth;
800
+
801
+ // Update original ColDef
802
+ const colDef = this.getColumnDefForColumn(this.resizeColumn);
803
+ if (colDef) {
804
+ colDef.width = newWidth;
805
+ }
806
+
807
+ // Force re-render
808
+ this.canvasRenderer?.render();
809
+ this.cdr.detectChanges();
810
+ }
811
+
812
+ private onResizeMouseUp(): void {
813
+ this.isResizing = false;
814
+ this.resizeColumn = null;
815
+ }
816
+
817
+ // --- Floating Filter Logic ---
818
+
819
+ hasFloatingFilters(): boolean {
820
+ if (this.gridApi?.getGridOption('floatingFilter')) return true;
821
+
822
+ if (!this.columnDefs) return false;
823
+ return this.columnDefs.some(col => {
824
+ if ('children' in col) {
825
+ return col.children.some(child => 'floatingFilter' in child && child.floatingFilter);
826
+ }
827
+ return col.floatingFilter;
828
+ });
829
+ }
830
+
831
+ isFloatingFilterEnabled(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
832
+ const colDef = this.getColumnDefForColumn(col as any);
833
+ if (!colDef || 'children' in colDef) return false;
834
+ if (!colDef.filter) return false;
835
+
836
+ if (colDef.floatingFilter === true) return true;
837
+ if (colDef.floatingFilter === false) return false;
838
+
839
+ return !!this.gridApi?.getGridOption('floatingFilter');
840
+ }
841
+
842
+ isFilterable(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
843
+ const colDef = this.getColumnDefForColumn(col as any);
844
+ if (!colDef || 'children' in colDef) return false;
845
+ return !!colDef.filter;
846
+ }
847
+
848
+ getFilterInputType(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
849
+ const colDef = this.getColumnDefForColumn(col as any);
850
+ if (!colDef || 'children' in colDef) return 'text';
851
+ const filter = colDef.filter;
852
+ if (filter === 'number') return 'number';
853
+ if (filter === 'date') return 'date';
854
+ return 'text';
855
+ }
856
+
857
+ private filterTimeout: any;
858
+ onFloatingFilterInput(event: Event, col: Column | ColDef<TData> | ColGroupDef<TData>): void {
859
+ const colDef = this.getColumnDefForColumn(col as any);
860
+ if (!colDef || 'children' in colDef) return;
861
+
862
+ const input = event.target as HTMLInputElement;
863
+ const value = input.value;
864
+ const colId = (col as any).colId || (col as any).field?.toString() || '';
865
+
866
+ this.cdr.detectChanges(); // Update clear button visibility immediately
867
+
868
+ clearTimeout(this.filterTimeout);
869
+ this.filterTimeout = setTimeout(() => {
870
+ const currentModel = this.gridApi.getFilterModel();
871
+
872
+ if (!value) {
873
+ delete currentModel[colId];
874
+ } else {
875
+ const filterType = this.getFilterTypeFromCol(colDef);
876
+ currentModel[colId] = {
877
+ filterType: filterType as any,
878
+ type: filterType === 'text' ? 'contains' : 'equals',
879
+ filter: value
880
+ };
881
+ }
882
+
883
+ this.gridApi.setFilterModel(currentModel);
884
+ this.canvasRenderer?.render();
885
+ }, 300);
886
+ }
887
+
888
+ private getFilterTypeFromCol(col: ColDef<TData>): string {
889
+ const filter = col.filter;
890
+ if (filter === 'number') return 'number';
891
+ if (filter === 'date') return 'date';
892
+ if (filter === 'boolean') return 'boolean';
893
+ return 'text';
894
+ }
895
+
896
+ getFloatingFilterValue(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
897
+ if (!this.gridApi) return '';
898
+ const colId = (col as any).colId || (col as any).field?.toString() || '';
899
+ const model = this.gridApi.getFilterModel();
900
+ return model[colId]?.filter || '';
901
+ }
902
+
903
+ hasFilterValue(col: Column | ColDef<TData> | ColGroupDef<TData>, input: HTMLInputElement): boolean {
904
+ return !!this.getFloatingFilterValue(col);
905
+ }
906
+
907
+ clearFloatingFilter(col: Column | ColDef<TData> | ColGroupDef<TData>, input: HTMLInputElement): void {
908
+ const colDef = this.getColumnDefForColumn(col as any);
909
+ if (!colDef || 'children' in colDef) return;
910
+
911
+ input.value = '';
912
+ const colId = (col as any).colId || (col as any).field?.toString() || '';
913
+
914
+ const currentModel = this.gridApi.getFilterModel();
915
+ delete currentModel[colId];
916
+
917
+ this.gridApi.setFilterModel(currentModel);
918
+ this.canvasRenderer?.render();
919
+ this.cdr.detectChanges();
920
+ }
921
+
922
+ // Public API methods
923
+ getApi(): GridApi<TData> {
924
+ return this.gridApi;
925
+ }
926
+
927
+ refresh(): void {
928
+ this.canvasRenderer?.render();
929
+ }
930
+
931
+ getLastFrameTime(): number {
932
+ return this.canvasRenderer?.lastFrameTime || 0;
933
+ }
934
+
935
+ // Cell Editing Methods
936
+ startEditing(rowIndex: number, colId: string): void {
937
+ const rowNode = this.gridApi.getDisplayedRowAtIndex(rowIndex);
938
+ const column = this.gridApi.getColumn(colId);
939
+
940
+ // Prevent editing on group rows or missing data/column
941
+ if (!rowNode || rowNode.group || !column || !column.field) return;
942
+
943
+ // Check if cell is editable
944
+ const colDef = this.getColumnDefForColumn(column);
945
+ if (colDef && colDef.editable === false) return;
946
+
947
+ // If already editing another cell, stop it first
948
+ if (this.isEditing) {
949
+ this.stopEditing(true);
950
+ }
951
+
952
+ const value = (rowNode.data as any)[column.field];
953
+
954
+ this.editingRowNode = rowNode;
955
+ this.editingColDef = colDef;
956
+ this.editingValue = value !== null && value !== undefined ? String(value) : '';
957
+
958
+ // Calculate editor position based on row and column
959
+ const columns = this.gridApi.getAllColumns().filter(c => c.visible);
960
+ let x = 0;
961
+ for (const col of columns) {
962
+ if (col.colId === colId) break;
963
+ x += col.width;
964
+ }
965
+
966
+ this.editorPosition = {
967
+ x: x - this.canvasRenderer.currentScrollLeft,
968
+ y: (rowIndex * this.rowHeight) - this.canvasRenderer.currentScrollTop,
969
+ width: column.width,
970
+ height: this.rowHeight
971
+ };
972
+
973
+ this.isEditing = true;
974
+
975
+ // Focus input after view update
976
+ setTimeout(() => {
977
+ if (this.editorInputRef) {
978
+ const input = this.editorInputRef.nativeElement;
979
+ input.focus();
980
+ input.select();
981
+ }
982
+ }, 0);
983
+ }
984
+
985
+ stopEditing(save: boolean = true): void {
986
+ if (!this.isEditing) return;
987
+
988
+ const rowNode = this.editingRowNode;
989
+ const colDef = this.editingColDef;
990
+
991
+ // Capture current value from input directly if it exists, to be sure
992
+ if (this.editorInputRef) {
993
+ this.editingValue = this.editorInputRef.nativeElement.value;
994
+ }
995
+
996
+ if (save && colDef && rowNode) {
997
+ const newValue = this.editingValue;
998
+ const field = colDef.field as string;
999
+ const oldValue = (rowNode.data as any)[field];
1000
+
1001
+ // Apply valueParser if provided
1002
+ let parsedValue: any = newValue;
1003
+ if (typeof colDef.valueParser === 'function') {
1004
+ parsedValue = colDef.valueParser({
1005
+ value: rowNode.data,
1006
+ newValue,
1007
+ data: rowNode.data,
1008
+ node: rowNode,
1009
+ colDef,
1010
+ api: this.gridApi
1011
+ });
1012
+ }
1013
+
1014
+ // Apply valueSetter if provided
1015
+ if (typeof colDef.valueSetter === 'function') {
1016
+ colDef.valueSetter({
1017
+ value: parsedValue,
1018
+ newValue: parsedValue,
1019
+ data: rowNode.data,
1020
+ node: rowNode,
1021
+ colDef,
1022
+ api: this.gridApi
1023
+ });
1024
+ } else if (field) {
1025
+ // Default: update data directly
1026
+ (rowNode.data as any)[field] = parsedValue;
1027
+ }
1028
+
1029
+ // Update via transaction
1030
+ this.gridApi.applyTransaction({
1031
+ update: [rowNode.data]
1032
+ });
1033
+
1034
+ // Trigger callback
1035
+ if (colDef.onCellValueChanged) {
1036
+ const column = this.gridApi.getColumn(colDef.colId || field || '');
1037
+ if (column) {
1038
+ colDef.onCellValueChanged({
1039
+ newValue: parsedValue,
1040
+ oldValue,
1041
+ data: rowNode.data,
1042
+ node: rowNode,
1043
+ column
1044
+ });
1045
+ }
1046
+ }
1047
+
1048
+ this.canvasRenderer?.render();
1049
+ }
1050
+
1051
+ this.isEditing = false;
1052
+ this.editingRowNode = null;
1053
+ this.editingColDef = null;
1054
+ this.cdr.detectChanges();
1055
+ }
1056
+
1057
+ onEditorInput(event: Event): void {
1058
+ const input = event.target as HTMLInputElement;
1059
+ this.editingValue = input.value;
1060
+ }
1061
+
1062
+ onEditorKeydown(event: KeyboardEvent): void {
1063
+ if (event.key === 'Enter') {
1064
+ event.preventDefault();
1065
+ this.stopEditing(true);
1066
+ } else if (event.key === 'Escape') {
1067
+ event.preventDefault();
1068
+ this.stopEditing(false);
1069
+ } else if (event.key === 'Tab') {
1070
+ event.preventDefault();
1071
+ const currentRowIndex = this.editingRowNode?.displayedRowIndex ?? -1;
1072
+ const currentColId = this.editingColDef?.colId || this.editingColDef?.field?.toString() || '';
1073
+
1074
+ this.stopEditing(true);
1075
+
1076
+ // Standard AG Grid Tab behavior: move to next cell
1077
+ if (currentRowIndex !== -1) {
1078
+ this.moveToNextCell(currentRowIndex, currentColId, event.shiftKey);
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ private moveToNextCell(rowIndex: number, colId: string, backwards: boolean): void {
1084
+ const columns = this.gridApi.getAllColumns().filter(c => c.visible);
1085
+ const colIndex = columns.findIndex(c => c.colId === colId);
1086
+
1087
+ if (colIndex === -1) return;
1088
+
1089
+ let nextColIndex = backwards ? colIndex - 1 : colIndex + 1;
1090
+ let nextRowIndex = rowIndex;
1091
+
1092
+ if (nextColIndex >= columns.length) {
1093
+ nextColIndex = 0;
1094
+ nextRowIndex++;
1095
+ } else if (nextColIndex < 0) {
1096
+ nextColIndex = columns.length - 1;
1097
+ nextRowIndex--;
1098
+ }
1099
+
1100
+ if (nextRowIndex >= 0 && nextRowIndex < this.gridApi.getDisplayedRowCount()) {
1101
+ const nextCol = columns[nextColIndex];
1102
+ this.startEditing(nextRowIndex, nextCol.colId);
1103
+ }
1104
+ }
1105
+
1106
+ onEditorBlur(): void {
1107
+ // Save on blur, matching AG Grid default behavior
1108
+ if (this.isEditing) {
1109
+ this.stopEditing(true);
1110
+ }
1111
+ }
1112
+ private getColumnDefForColumn(column: Column | ColDef<TData> | ColGroupDef<TData>): ColDef<TData> | null {
1113
+ if (!this.columnDefs) return null;
1114
+
1115
+ const colId = (column as any).colId || (column as any).field?.toString();
1116
+ if (!colId) return null;
1117
+
1118
+ for (const def of this.columnDefs) {
1119
+ if ('children' in def) {
1120
+ const found = def.children.find(c => {
1121
+ const cDef = c as ColDef;
1122
+ return cDef.colId === colId || cDef.field?.toString() === colId;
1123
+ });
1124
+ if (found) return found as ColDef<TData>;
1125
+ } else {
1126
+ const cDef = def as ColDef;
1127
+ if (cDef.colId === colId || cDef.field?.toString() === colId) {
1128
+ return def as ColDef<TData>;
1129
+ }
1130
+ }
1131
+ }
1132
+ return null;
1133
+ }
1134
+
1135
+ // Selection Methods
1136
+ onRowClick(rowIndex: number, event: MouseEvent): void {
1137
+ const rowNode = this.gridApi.getDisplayedRowAtIndex(rowIndex);
1138
+ if (!rowNode) return;
1139
+
1140
+ // Handle multi-select with Ctrl/Cmd
1141
+ if (event.ctrlKey || event.metaKey) {
1142
+ rowNode.selected = !rowNode.selected;
1143
+ } else if (event.shiftKey) {
1144
+ // Range selection (TODO: implement)
1145
+ rowNode.selected = true;
1146
+ } else {
1147
+ // Single select - deselect all others
1148
+ this.gridApi.deselectAll();
1149
+ rowNode.selected = true;
1150
+ }
1151
+
1152
+ this.updateSelectionState();
1153
+ this.canvasRenderer?.render();
1154
+ this.selectionChanged.emit(this.gridApi.getSelectedRows());
1155
+ }
1156
+
1157
+ onSelectionHeaderClick(): void {
1158
+ // Toggle all
1159
+ if (this.isAllSelected) {
1160
+ this.gridApi.deselectAll();
1161
+ } else {
1162
+ this.gridApi.selectAll();
1163
+ }
1164
+ this.updateSelectionState();
1165
+ this.canvasRenderer?.render();
1166
+ this.selectionChanged.emit(this.gridApi.getSelectedRows());
1167
+ }
1168
+
1169
+ onSelectionHeaderChange(event: Event): void {
1170
+ const checkbox = event.target as HTMLInputElement;
1171
+ if (checkbox.checked) {
1172
+ this.gridApi.selectAll();
1173
+ } else {
1174
+ this.gridApi.deselectAll();
1175
+ }
1176
+ this.updateSelectionState();
1177
+ this.canvasRenderer?.render();
1178
+ this.selectionChanged.emit(this.gridApi.getSelectedRows());
1179
+ }
1180
+
1181
+ updateSelectionState(): void {
1182
+ const selectedCount = this.gridApi.getSelectedRows().length;
1183
+ const totalCount = this.gridApi.getDisplayedRowCount();
1184
+
1185
+ this.isAllSelected = selectedCount === totalCount && totalCount > 0;
1186
+ this.isIndeterminateSelection = selectedCount > 0 && selectedCount < totalCount;
1187
+ }
1188
+ }