@vii7/div-table-widget 1.0.1 → 1.1.1

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/src/div-table.js CHANGED
@@ -82,6 +82,17 @@ class DivTable {
82
82
  // Fixed columns option (frozen left columns)
83
83
  this.fixedColumns = options.fixedColumns || 0;
84
84
 
85
+ // Lazy cell rendering option (for performance optimization)
86
+ this.lazyCellRendering = options.lazyCellRendering !== false; // Enabled by default
87
+ this.lazyRenderMargin = options.lazyRenderMargin || '200px'; // Pre-render rows slightly before visible
88
+ this.rowObserver = null; // IntersectionObserver for lazy rendering
89
+
90
+ // Aggregate summary row options
91
+ // Header summary shows totals for entire dataset (grand total)
92
+ // Group summary shows totals per group when data is grouped
93
+ this.showHeaderSummary = options.showHeaderSummary || false;
94
+ this.showGroupSummary = options.showGroupSummary || false;
95
+
85
96
  // Find primary key field first
86
97
  this.primaryKeyField = this.columns.find(col => col.primaryKey)?.field || 'id';
87
98
 
@@ -341,6 +352,32 @@ class DivTable {
341
352
  this.scrollBodyContainer.scrollLeft = this.scrollHeaderContainer.scrollLeft;
342
353
  requestAnimationFrame(() => { isSyncingScroll = false; });
343
354
  });
355
+
356
+ // Adjust fixed body padding for horizontal scrollbar height
357
+ this.adjustFixedBodyForHorizontalScrollbar();
358
+
359
+ // Re-adjust on window resize
360
+ window.addEventListener('resize', () => {
361
+ this.adjustFixedBodyForHorizontalScrollbar();
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Adjust fixed body padding to account for horizontal scrollbar in scroll body
367
+ * This prevents row misalignment at the bottom when scrolled to the end
368
+ */
369
+ adjustFixedBodyForHorizontalScrollbar() {
370
+ if (!this.fixedBodyContainer || !this.scrollBodyContainer) return;
371
+
372
+ // Calculate horizontal scrollbar height
373
+ const scrollbarHeight = this.scrollBodyContainer.offsetHeight - this.scrollBodyContainer.clientHeight;
374
+
375
+ // Add padding to fixed body to compensate for scrollbar
376
+ if (scrollbarHeight > 0) {
377
+ this.fixedBodyContainer.style.paddingBottom = `${scrollbarHeight}px`;
378
+ } else {
379
+ this.fixedBodyContainer.style.paddingBottom = '';
380
+ }
344
381
  }
345
382
 
346
383
  getEffectiveFixedColumnCount() {
@@ -710,8 +747,25 @@ class DivTable {
710
747
  this.handleKeyDown(e);
711
748
  });
712
749
 
713
- bodyContainer.addEventListener('focus', () => {
714
- if (!this.focusedRowId) {
750
+ // Track whether focus came from keyboard (Tab) vs mouse click
751
+ // Only auto-focus to first record on Tab navigation, not mouse clicks
752
+ let lastInputWasKeyboard = false;
753
+
754
+ bodyContainer.addEventListener('keydown', () => {
755
+ lastInputWasKeyboard = true;
756
+ }, { capture: true });
757
+
758
+ bodyContainer.addEventListener('mousedown', () => {
759
+ lastInputWasKeyboard = false;
760
+ }, { capture: true });
761
+
762
+ // Use focusin to detect when focus enters the container
763
+ bodyContainer.addEventListener('focusin', (e) => {
764
+ // Only auto-focus to first record if:
765
+ // 1. There's no currently focused row
766
+ // 2. Focus came from keyboard navigation (Tab), not mouse click
767
+ // This prevents auto-scrolling when clicking the scrollbar
768
+ if (!this.focusedRowId && lastInputWasKeyboard && e.target === bodyContainer) {
715
769
  this.focusFirstRecord();
716
770
  }
717
771
  });
@@ -1277,15 +1331,17 @@ class DivTable {
1277
1331
  }
1278
1332
  });
1279
1333
 
1280
- // Update group header checkbox states
1281
- if (this.groupByField) {
1334
+ // Update group header checkbox states (always check for group headers in DOM)
1335
+ const groupHeaders = this.bodyContainer.querySelectorAll('.div-table-row.group-header');
1336
+ if (groupHeaders.length > 0) {
1337
+ // Group headers exist, so groupByField must be set - get the groups
1282
1338
  const groups = this.groupData(this.sortData(this.filteredData));
1283
1339
 
1284
- this.bodyContainer.querySelectorAll('.div-table-row.group-header').forEach((groupRow) => {
1340
+ groupHeaders.forEach((groupRow) => {
1285
1341
  const checkbox = groupRow.querySelector('input[type="checkbox"]');
1286
1342
  if (!checkbox) return;
1287
1343
 
1288
- // Find the group by matching the groupKey instead of relying on index
1344
+ // Find the group by matching the groupKey
1289
1345
  const groupKey = groupRow.dataset.groupKey;
1290
1346
  const group = groups.find(g => g.key === groupKey);
1291
1347
  if (!group) return;
@@ -1294,21 +1350,25 @@ class DivTable {
1294
1350
  const groupItemIds = group.items.map(item => String(item[this.primaryKeyField]));
1295
1351
  const selectedInGroup = groupItemIds.filter(id => this.selectedRows.has(id));
1296
1352
 
1353
+ // Set indeterminate BEFORE checked to ensure proper visual update
1297
1354
  if (selectedInGroup.length === 0) {
1298
- checkbox.checked = false;
1299
1355
  checkbox.indeterminate = false;
1356
+ checkbox.checked = false;
1300
1357
  } else if (selectedInGroup.length === groupItemIds.length) {
1301
- checkbox.checked = true;
1302
1358
  checkbox.indeterminate = false;
1359
+ checkbox.checked = true;
1303
1360
  } else {
1304
- checkbox.checked = false;
1305
1361
  checkbox.indeterminate = true;
1362
+ checkbox.checked = false;
1306
1363
  }
1307
1364
  });
1308
1365
  }
1309
1366
 
1310
1367
  // Update main header checkbox state
1311
1368
  this.updateHeaderCheckbox();
1369
+
1370
+ // Update summary row aggregates based on current selection
1371
+ this.updateSummaryRows();
1312
1372
  }
1313
1373
 
1314
1374
  updateSelectionStatesWithFixedColumns() {
@@ -1337,11 +1397,13 @@ class DivTable {
1337
1397
  }
1338
1398
  });
1339
1399
 
1340
- // Update group header checkbox states
1341
- if (this.groupByField) {
1400
+ // Update group header checkbox states (always check for group headers in DOM)
1401
+ const groupHeaders = this.fixedBodyContainer.querySelectorAll('.div-table-row.group-header');
1402
+ if (groupHeaders.length > 0) {
1403
+ // Group headers exist, so groupByField must be set - get the groups
1342
1404
  const groups = this.groupData(this.sortData(this.filteredData));
1343
1405
 
1344
- this.fixedBodyContainer.querySelectorAll('.div-table-row.group-header').forEach((groupRow) => {
1406
+ groupHeaders.forEach((groupRow) => {
1345
1407
  const checkbox = groupRow.querySelector('input[type="checkbox"]');
1346
1408
  if (!checkbox) return;
1347
1409
 
@@ -1352,21 +1414,25 @@ class DivTable {
1352
1414
  const groupItemIds = group.items.map(item => String(item[this.primaryKeyField]));
1353
1415
  const selectedInGroup = groupItemIds.filter(id => this.selectedRows.has(id));
1354
1416
 
1417
+ // Set indeterminate BEFORE checked to ensure proper visual update
1355
1418
  if (selectedInGroup.length === 0) {
1356
- checkbox.checked = false;
1357
1419
  checkbox.indeterminate = false;
1420
+ checkbox.checked = false;
1358
1421
  } else if (selectedInGroup.length === groupItemIds.length) {
1359
- checkbox.checked = true;
1360
1422
  checkbox.indeterminate = false;
1423
+ checkbox.checked = true;
1361
1424
  } else {
1362
- checkbox.checked = false;
1363
1425
  checkbox.indeterminate = true;
1426
+ checkbox.checked = false;
1364
1427
  }
1365
1428
  });
1366
1429
  }
1367
1430
 
1368
1431
  // Update main header checkbox state
1369
1432
  this.updateHeaderCheckbox();
1433
+
1434
+ // Update summary row aggregates based on current selection
1435
+ this.updateSummaryRows();
1370
1436
  }
1371
1437
 
1372
1438
  updateHeaderCheckbox() {
@@ -1495,8 +1561,8 @@ class DivTable {
1495
1561
  const checkbox = document.createElement('input');
1496
1562
  checkbox.type = 'checkbox';
1497
1563
  checkbox.addEventListener('change', (e) => {
1498
- if (e.target.checked || e.target.indeterminate) {
1499
- // If checked or indeterminate, select all
1564
+ if (e.target.checked) {
1565
+ // If checked, select all
1500
1566
  this.selectAll();
1501
1567
  } else {
1502
1568
  // If unchecked, clear selection
@@ -1577,7 +1643,7 @@ class DivTable {
1577
1643
  const checkbox = document.createElement('input');
1578
1644
  checkbox.type = 'checkbox';
1579
1645
  checkbox.addEventListener('change', (e) => {
1580
- if (e.target.checked || e.target.indeterminate) {
1646
+ if (e.target.checked) {
1581
1647
  this.selectAll();
1582
1648
  } else {
1583
1649
  this.clearSelection();
@@ -1715,6 +1781,10 @@ class DivTable {
1715
1781
  // Group headers span all columns - set explicit width to match total
1716
1782
  row.style.gridTemplateColumns = '1fr';
1717
1783
  row.style.minWidth = `${totalWidth}px`;
1784
+ } else if (row.classList.contains('summary-row')) {
1785
+ // Summary rows need the same grid template as regular rows
1786
+ row.style.gridTemplateColumns = gridTemplate;
1787
+ row.style.minWidth = `${totalWidth}px`;
1718
1788
  } else {
1719
1789
  row.style.gridTemplateColumns = gridTemplate;
1720
1790
  }
@@ -2239,6 +2309,11 @@ class DivTable {
2239
2309
  }
2240
2310
 
2241
2311
  renderBody() {
2312
+ // Disconnect lazy rendering observer before clearing content
2313
+ if (this.rowObserver) {
2314
+ this.rowObserver.disconnect();
2315
+ }
2316
+
2242
2317
  // Handle fixed columns layout
2243
2318
  if (this.fixedColumns > 0) {
2244
2319
  this.renderBodyWithFixedColumns();
@@ -2284,9 +2359,24 @@ class DivTable {
2284
2359
  } else {
2285
2360
  this.renderRegularRows(dataToRender);
2286
2361
  }
2362
+
2363
+ // Add header summary row if enabled and has aggregate columns
2364
+ if (this.showHeaderSummary && this.hasAggregateColumns()) {
2365
+ const summaryRow = this.createHeaderSummaryRow(dataToRender);
2366
+ // Insert at the beginning of body container (after header)
2367
+ this.bodyContainer.insertBefore(summaryRow, this.bodyContainer.firstChild);
2368
+ }
2369
+
2370
+ // Setup lazy cell rendering observer if enabled
2371
+ if (this.lazyCellRendering) {
2372
+ this.setupLazyRenderingObserver();
2373
+ }
2287
2374
  }
2288
2375
 
2289
2376
  renderBodyWithFixedColumns() {
2377
+ // Preserve horizontal scroll position before clearing
2378
+ const scrollLeft = this.scrollBodyContainer?.scrollLeft || 0;
2379
+
2290
2380
  this.fixedBodyContainer.innerHTML = '';
2291
2381
  this.scrollBodyContainer.innerHTML = '';
2292
2382
 
@@ -2327,11 +2417,36 @@ class DivTable {
2327
2417
  this.renderRegularRowsWithFixedColumns(dataToRender);
2328
2418
  }
2329
2419
 
2420
+ // Add header summary row if enabled and has aggregate columns
2421
+ if (this.showHeaderSummary && this.hasAggregateColumns()) {
2422
+ const { fixedSummary, scrollSummary } = this.createHeaderSummaryRowWithFixedColumns(dataToRender);
2423
+ // Insert at the beginning of body containers (after header)
2424
+ this.fixedBodyContainer.insertBefore(fixedSummary, this.fixedBodyContainer.firstChild);
2425
+ this.scrollBodyContainer.insertBefore(scrollSummary, this.scrollBodyContainer.firstChild);
2426
+ }
2427
+
2330
2428
  // Synchronize column widths in scroll section (must be done before row heights)
2331
2429
  this.syncFixedColumnsColumnWidths();
2332
2430
 
2333
2431
  // Synchronize row heights between fixed and scroll sections
2334
2432
  this.syncFixedColumnsRowHeights();
2433
+
2434
+ // Setup lazy cell rendering observer if enabled
2435
+ if (this.lazyCellRendering) {
2436
+ this.setupLazyRenderingObserver();
2437
+ }
2438
+
2439
+ // Restore horizontal scroll position after rendering
2440
+ if (scrollLeft > 0) {
2441
+ requestAnimationFrame(() => {
2442
+ this.scrollBodyContainer.scrollLeft = scrollLeft;
2443
+ });
2444
+ }
2445
+
2446
+ // Adjust fixed body padding for horizontal scrollbar (after content is rendered)
2447
+ requestAnimationFrame(() => {
2448
+ this.adjustFixedBodyForHorizontalScrollbar();
2449
+ });
2335
2450
  }
2336
2451
 
2337
2452
  renderRegularRowsWithFixedColumns(dataToRender = this.filteredData) {
@@ -2388,6 +2503,115 @@ class DivTable {
2388
2503
  this.scrollBodyContainer.appendChild(scrollRow);
2389
2504
  });
2390
2505
  }
2506
+
2507
+ // Add group summary row after group (visible even when collapsed)
2508
+ if (this.showGroupSummary && this.hasAggregateColumns()) {
2509
+ const { fixedSummary, scrollSummary } = this.createGroupSummaryRowWithFixedColumns(group);
2510
+ this.fixedBodyContainer.appendChild(fixedSummary);
2511
+ this.scrollBodyContainer.appendChild(scrollSummary);
2512
+ }
2513
+ });
2514
+ }
2515
+
2516
+ /**
2517
+ * Setup IntersectionObserver for lazy cell rendering
2518
+ * Observes unpopulated rows and populates them when they enter the viewport
2519
+ */
2520
+ setupLazyRenderingObserver() {
2521
+ // Disconnect existing observer if any
2522
+ if (this.rowObserver) {
2523
+ this.rowObserver.disconnect();
2524
+ }
2525
+
2526
+ // Check for IntersectionObserver support
2527
+ if (typeof IntersectionObserver === 'undefined') {
2528
+ // Fallback: populate all rows immediately
2529
+ console.warn('DivTable: IntersectionObserver not supported, falling back to eager rendering');
2530
+ this.populateAllUnpopulatedRows();
2531
+ return;
2532
+ }
2533
+
2534
+ // Determine the root element for observation
2535
+ const rootElement = this.fixedColumns > 0
2536
+ ? this.scrollBodyContainer
2537
+ : this.bodyContainer;
2538
+
2539
+ // Create observer with margin to pre-render rows slightly before visible
2540
+ this.rowObserver = new IntersectionObserver((entries) => {
2541
+ entries.forEach(entry => {
2542
+ if (entry.isIntersecting) {
2543
+ const row = entry.target;
2544
+
2545
+ // Only populate if not already done
2546
+ if (row.dataset.populated !== 'true') {
2547
+ const rowId = row.dataset.id;
2548
+ const item = this.findRowData(rowId);
2549
+
2550
+ if (item) {
2551
+ // Handle fixed columns layout
2552
+ if (this.fixedColumns > 0) {
2553
+ // Find the corresponding row in the other container
2554
+ const isFixedRow = row.classList.contains('div-table-fixed-row');
2555
+ let fixedRow, scrollRow;
2556
+
2557
+ if (isFixedRow) {
2558
+ fixedRow = row;
2559
+ scrollRow = this.scrollBodyContainer.querySelector(`.div-table-row[data-id="${rowId}"]`);
2560
+ } else {
2561
+ scrollRow = row;
2562
+ fixedRow = this.fixedBodyContainer.querySelector(`.div-table-row[data-id="${rowId}"]`);
2563
+ }
2564
+
2565
+ if (fixedRow && scrollRow) {
2566
+ this.populateRowCellsWithFixedColumns(fixedRow, scrollRow, item);
2567
+ }
2568
+ } else {
2569
+ this.populateRowCells(row, item);
2570
+ }
2571
+ }
2572
+ }
2573
+
2574
+ // Unobserve after population
2575
+ this.rowObserver.unobserve(row);
2576
+ }
2577
+ });
2578
+ }, {
2579
+ root: rootElement,
2580
+ rootMargin: this.lazyRenderMargin, // Pre-render before visible
2581
+ threshold: 0
2582
+ });
2583
+
2584
+ // Observe all unpopulated rows
2585
+ const rows = this.fixedColumns > 0
2586
+ ? this.scrollBodyContainer.querySelectorAll('.div-table-row[data-populated="false"]')
2587
+ : this.bodyContainer.querySelectorAll('.div-table-row[data-populated="false"]');
2588
+
2589
+ rows.forEach(row => {
2590
+ this.rowObserver.observe(row);
2591
+ });
2592
+
2593
+ // Also observe in fixed section if using fixed columns
2594
+ if (this.fixedColumns > 0 && this.fixedBodyContainer) {
2595
+ const fixedRows = this.fixedBodyContainer.querySelectorAll('.div-table-row[data-populated="false"]');
2596
+ fixedRows.forEach(row => {
2597
+ this.rowObserver.observe(row);
2598
+ });
2599
+ }
2600
+ }
2601
+
2602
+ /**
2603
+ * Fallback method to populate all unpopulated rows (when IntersectionObserver not available)
2604
+ */
2605
+ populateAllUnpopulatedRows() {
2606
+ const bodyContainer = this.fixedColumns > 0 ? this.scrollBodyContainer : this.bodyContainer;
2607
+ const rows = bodyContainer.querySelectorAll('.div-table-row[data-populated="false"]');
2608
+
2609
+ rows.forEach(row => {
2610
+ const rowId = row.dataset.id;
2611
+ const item = this.findRowData(rowId);
2612
+ if (item) {
2613
+ this.populateRowCells(row, item);
2614
+ }
2391
2615
  });
2392
2616
  }
2393
2617
 
@@ -2443,58 +2667,26 @@ class DivTable {
2443
2667
  this.bodyContainer.appendChild(row);
2444
2668
  });
2445
2669
  }
2670
+
2671
+ // Add group summary row after group (visible even when collapsed)
2672
+ if (this.showGroupSummary && this.hasAggregateColumns()) {
2673
+ const groupSummary = this.createGroupSummaryRow(group);
2674
+ this.bodyContainer.appendChild(groupSummary);
2675
+ }
2446
2676
  });
2447
2677
  }
2448
2678
 
2449
- createRow(item) {
2450
- const row = document.createElement('div');
2451
- row.className = 'div-table-row';
2452
- row.dataset.id = item[this.primaryKeyField];
2453
- // Don't set tabindex here - will be managed by updateTabIndexes() based on checkbox presence
2679
+ /**
2680
+ * Populate cells for a row that was created as an empty shell (lazy rendering)
2681
+ * @param {HTMLElement} row - The row element to populate
2682
+ * @param {Object} item - The data item for this row
2683
+ */
2684
+ populateRowCells(row, item) {
2685
+ // Skip if already populated
2686
+ if (row.dataset.populated === 'true') return;
2454
2687
 
2455
2688
  const compositeColumns = this.getCompositeColumns();
2456
-
2457
- // Build grid template matching the header
2458
- let gridTemplate = '';
2459
- if (this.showCheckboxes) {
2460
- gridTemplate = '40px '; // Checkbox column
2461
- }
2462
-
2463
- // Add column templates for each composite group
2464
- compositeColumns.forEach(composite => {
2465
- const firstCol = composite.columns[0];
2466
- const responsive = firstCol.responsive || {};
2467
- switch (responsive.size) {
2468
- case 'fixed-narrow':
2469
- gridTemplate += '80px ';
2470
- break;
2471
- case 'fixed-medium':
2472
- gridTemplate += '120px ';
2473
- break;
2474
- case 'flexible-small':
2475
- gridTemplate += '1fr ';
2476
- break;
2477
- case 'flexible-medium':
2478
- gridTemplate += '2fr ';
2479
- break;
2480
- case 'flexible-large':
2481
- gridTemplate += '3fr ';
2482
- break;
2483
- default:
2484
- gridTemplate += '1fr ';
2485
- }
2486
- });
2487
-
2488
- row.style.gridTemplateColumns = gridTemplate.trim();
2489
-
2490
- // Selection state
2491
2689
  const rowId = String(item[this.primaryKeyField]);
2492
- if (this.selectedRows.has(rowId)) {
2493
- row.classList.add('selected');
2494
- }
2495
- if (this.focusedRowId === rowId) {
2496
- row.classList.add('focused');
2497
- }
2498
2690
 
2499
2691
  // Checkbox column
2500
2692
  if (this.showCheckboxes) {
@@ -2508,7 +2700,6 @@ class DivTable {
2508
2700
  checkbox.addEventListener('change', (e) => {
2509
2701
  e.stopPropagation();
2510
2702
 
2511
- // Verify that the row data exists before proceeding
2512
2703
  const rowData = this.findRowData(rowId);
2513
2704
  if (!rowData) {
2514
2705
  console.warn('DivTable: Could not find data for row ID:', rowId);
@@ -2525,14 +2716,10 @@ class DivTable {
2525
2716
  rowData.selected = false;
2526
2717
  row.classList.remove('selected');
2527
2718
 
2528
- // If filter is active and we just deselected a row, re-render to remove it from view
2529
2719
  if (this.showOnlySelected) {
2530
- // Re-render the body to update the filtered view
2531
2720
  this.renderBody();
2532
- // Update info section after render
2533
2721
  this.updateInfoSection();
2534
2722
 
2535
- // Trigger selection change callback
2536
2723
  const selectedData = Array.from(this.selectedRows)
2537
2724
  .map(id => this.findRowData(id))
2538
2725
  .filter(Boolean);
@@ -2540,17 +2727,13 @@ class DivTable {
2540
2727
  if (typeof this.onSelectionChange === 'function') {
2541
2728
  this.onSelectionChange(selectedData);
2542
2729
  }
2543
-
2544
- // Early return since we already updated everything
2545
2730
  return;
2546
2731
  }
2547
2732
  }
2548
2733
 
2549
- // Update all checkbox states (group and header)
2550
2734
  this.updateSelectionStates();
2551
2735
  this.updateInfoSection();
2552
2736
 
2553
- // Ensure we only return valid data objects
2554
2737
  const selectedData = Array.from(this.selectedRows)
2555
2738
  .map(id => this.findRowData(id))
2556
2739
  .filter(Boolean);
@@ -2560,24 +2743,14 @@ class DivTable {
2560
2743
  }
2561
2744
  });
2562
2745
 
2563
- // Sync checkbox focus with row focus
2564
2746
  checkbox.addEventListener('focus', (e) => {
2565
2747
  this.updateFocusState(row);
2566
2748
  });
2567
2749
 
2568
- checkbox.addEventListener('blur', (e) => {
2569
- // Optionally handle blur if needed - for now, keep row focused
2570
- // This allows arrow key navigation to continue working
2571
- });
2572
-
2573
2750
  checkboxCell.appendChild(checkbox);
2574
2751
 
2575
- // Make the entire checkbox cell clickable
2576
2752
  checkboxCell.addEventListener('click', (e) => {
2577
- // If clicked on the checkbox itself, let it handle naturally
2578
2753
  if (e.target === checkbox) return;
2579
-
2580
- // If clicked elsewhere in the cell, toggle the checkbox
2581
2754
  e.stopPropagation();
2582
2755
  checkbox.click();
2583
2756
  });
@@ -2591,7 +2764,6 @@ class DivTable {
2591
2764
  cell.className = 'div-table-cell';
2592
2765
 
2593
2766
  if (composite.compositeName) {
2594
- // Composite cell with multiple columns stacked vertically
2595
2767
  cell.classList.add('composite-cell');
2596
2768
  cell.style.display = 'flex';
2597
2769
  cell.style.flexDirection = 'column';
@@ -2601,12 +2773,10 @@ class DivTable {
2601
2773
  const subCell = document.createElement('div');
2602
2774
  subCell.className = 'composite-sub-cell';
2603
2775
 
2604
- // For grouped column, show empty
2605
2776
  if (this.groupByField && col.field === this.groupByField) {
2606
2777
  subCell.classList.add('grouped-column');
2607
2778
  subCell.textContent = '';
2608
2779
  } else {
2609
- // Check if this column has subField (vertical stacking within the sub-cell)
2610
2780
  if (col.subField) {
2611
2781
  subCell.classList.add('composite-column');
2612
2782
  subCell.style.display = 'flex';
@@ -2632,7 +2802,6 @@ class DivTable {
2632
2802
  subCell.appendChild(mainDiv);
2633
2803
  subCell.appendChild(subDiv);
2634
2804
  } else {
2635
- // Regular rendering
2636
2805
  if (typeof col.render === 'function') {
2637
2806
  subCell.innerHTML = col.render(item[col.field], item);
2638
2807
  } else {
@@ -2644,15 +2813,12 @@ class DivTable {
2644
2813
  cell.appendChild(subCell);
2645
2814
  });
2646
2815
  } else {
2647
- // Single column
2648
2816
  const col = composite.columns[0];
2649
2817
 
2650
- // For grouped column, show empty
2651
2818
  if (this.groupByField && col.field === this.groupByField) {
2652
2819
  cell.classList.add('grouped-column');
2653
2820
  cell.textContent = '';
2654
2821
  } else {
2655
- // Check if this is a composite column with subField (vertical stacking)
2656
2822
  if (col.subField) {
2657
2823
  cell.classList.add('composite-column');
2658
2824
 
@@ -2675,7 +2841,6 @@ class DivTable {
2675
2841
  cell.appendChild(mainDiv);
2676
2842
  cell.appendChild(subDiv);
2677
2843
  } else {
2678
- // Regular column rendering
2679
2844
  if (typeof col.render === 'function') {
2680
2845
  cell.innerHTML = col.render(item[col.field], item);
2681
2846
  } else {
@@ -2688,86 +2853,42 @@ class DivTable {
2688
2853
  row.appendChild(cell);
2689
2854
  });
2690
2855
 
2691
- // Row click handler - set focus index for clicked row
2692
- row.addEventListener('click', (e) => {
2693
- // Only handle focus if not clicking on checkbox column
2694
- if (e.target.closest('.checkbox-column')) return;
2695
-
2696
- // Check if user is making a text selection
2697
- const selection = window.getSelection();
2698
- if (selection.toString().length > 0) {
2699
- return; // Don't trigger focus if user is selecting text
2700
- }
2701
-
2702
- // Find the focusable element for this row
2703
- const focusableElement = this.getFocusableElementForRow(row);
2704
- if (focusableElement) {
2705
- // Check if this row already has focus
2706
- const currentFocused = this.getCurrentFocusedElement();
2707
- if (currentFocused === focusableElement) {
2708
- return; // Don't trigger focus event if already focused
2709
- }
2710
-
2711
- // Get all focusable elements and find the index of this element
2712
- const focusableElements = this.getAllFocusableElements();
2713
- const focusIndex = focusableElements.indexOf(focusableElement);
2856
+ // Mark as populated
2857
+ row.dataset.populated = 'true';
2858
+
2859
+ // Measure actual height and set it explicitly to prevent bouncing on re-renders
2860
+ // Use requestAnimationFrame to ensure DOM has updated
2861
+ requestAnimationFrame(() => {
2862
+ const actualHeight = row.offsetHeight;
2863
+ if (actualHeight > 0) {
2864
+ // Set explicit height to prevent layout recalculation bouncing
2865
+ row.style.minHeight = `${actualHeight}px`;
2866
+ row.style.height = `${actualHeight}px`;
2714
2867
 
2715
- if (focusIndex !== -1) {
2716
- // Use the existing focus system to set focus to this index
2717
- this.focusElementAtIndex(focusIndex);
2868
+ // Update estimate for future unpopulated rows
2869
+ if (actualHeight > this.estimatedRowHeight) {
2870
+ this.estimatedRowHeight = actualHeight;
2718
2871
  }
2719
2872
  }
2720
2873
  });
2721
-
2722
- // Row focus event to sync with tabIndex navigation
2723
- row.addEventListener('focus', (e) => {
2724
- this.updateFocusState(row);
2725
- });
2726
-
2727
- return row;
2874
+
2875
+ // Update tab indexes after population
2876
+ this.updateTabIndexes();
2728
2877
  }
2729
2878
 
2730
- createRowWithFixedColumns(item) {
2879
+ /**
2880
+ * Populate cells for fixed columns layout (both fixed and scroll row parts)
2881
+ * @param {HTMLElement} fixedRow - The fixed row element
2882
+ * @param {HTMLElement} scrollRow - The scroll row element
2883
+ * @param {Object} item - The data item for this row
2884
+ */
2885
+ populateRowCellsWithFixedColumns(fixedRow, scrollRow, item) {
2886
+ // Skip if already populated
2887
+ if (fixedRow.dataset.populated === 'true') return;
2888
+
2731
2889
  const { fixedColumns, scrollColumns } = this.splitColumnsForFixedLayout();
2732
2890
  const rowId = String(item[this.primaryKeyField]);
2733
2891
 
2734
- // Create fixed row part
2735
- const fixedRow = document.createElement('div');
2736
- fixedRow.className = 'div-table-row div-table-fixed-row';
2737
- fixedRow.dataset.id = rowId;
2738
-
2739
- // Build grid template for fixed row
2740
- let fixedGridTemplate = '';
2741
- if (this.showCheckboxes) {
2742
- fixedGridTemplate = '40px ';
2743
- }
2744
- fixedColumns.forEach(composite => {
2745
- fixedGridTemplate += this.getColumnGridSize(composite) + ' ';
2746
- });
2747
- fixedRow.style.gridTemplateColumns = fixedGridTemplate.trim();
2748
-
2749
- // Create scrollable row part
2750
- const scrollRow = document.createElement('div');
2751
- scrollRow.className = 'div-table-row div-table-scroll-row';
2752
- scrollRow.dataset.id = rowId;
2753
-
2754
- // Build grid template for scrollable row
2755
- let scrollGridTemplate = '';
2756
- scrollColumns.forEach(composite => {
2757
- scrollGridTemplate += this.getColumnGridSize(composite) + ' ';
2758
- });
2759
- scrollRow.style.gridTemplateColumns = scrollGridTemplate.trim();
2760
-
2761
- // Apply selection state to both row parts
2762
- if (this.selectedRows.has(rowId)) {
2763
- fixedRow.classList.add('selected');
2764
- scrollRow.classList.add('selected');
2765
- }
2766
- if (this.focusedRowId === rowId) {
2767
- fixedRow.classList.add('focused');
2768
- scrollRow.classList.add('focused');
2769
- }
2770
-
2771
2892
  // Checkbox column (in fixed row only)
2772
2893
  if (this.showCheckboxes) {
2773
2894
  const checkboxCell = document.createElement('div');
@@ -2852,21 +2973,562 @@ class DivTable {
2852
2973
  scrollRow.appendChild(cell);
2853
2974
  });
2854
2975
 
2855
- // Row click handlers for both parts
2856
- const handleRowClick = (e, targetRow) => {
2857
- if (e.target.closest('.checkbox-column')) return;
2858
-
2859
- const selection = window.getSelection();
2860
- if (selection.toString().length > 0) return;
2861
-
2862
- const focusableElement = this.getFocusableElementForRow(fixedRow);
2863
- if (focusableElement) {
2864
- const focusableElements = this.getAllFocusableElements();
2865
- const focusIndex = focusableElements.indexOf(focusableElement);
2866
- if (focusIndex !== -1) {
2867
- this.focusElementAtIndex(focusIndex);
2976
+ // Mark as populated
2977
+ fixedRow.dataset.populated = 'true';
2978
+ scrollRow.dataset.populated = 'true';
2979
+
2980
+ // Synchronize heights between fixed and scroll row parts after cell population
2981
+ // Use double requestAnimationFrame to ensure layout is fully complete
2982
+ requestAnimationFrame(() => {
2983
+ requestAnimationFrame(() => {
2984
+ // Get natural heights after cell content is fully rendered
2985
+ // Temporarily remove any height constraints to get true natural height
2986
+ const prevFixedHeight = fixedRow.style.height;
2987
+ const prevScrollHeight = scrollRow.style.height;
2988
+ const prevFixedMinHeight = fixedRow.style.minHeight;
2989
+ const prevScrollMinHeight = scrollRow.style.minHeight;
2990
+
2991
+ fixedRow.style.height = '';
2992
+ scrollRow.style.height = '';
2993
+ fixedRow.style.minHeight = '40px';
2994
+ scrollRow.style.minHeight = '40px';
2995
+
2996
+ // Force layout recalculation
2997
+ const fixedHeight = fixedRow.offsetHeight;
2998
+ const scrollHeight = scrollRow.offsetHeight;
2999
+
3000
+ // Set both to the maximum height to keep rows in sync
3001
+ const maxHeight = Math.max(fixedHeight, scrollHeight, 40); // Ensure minimum 44px
3002
+ fixedRow.style.minHeight = `${maxHeight}px`;
3003
+ fixedRow.style.height = `${maxHeight}px`;
3004
+ scrollRow.style.minHeight = `${maxHeight}px`;
3005
+ scrollRow.style.height = `${maxHeight}px`;
3006
+
3007
+ // Update estimate for future unpopulated rows
3008
+ if (maxHeight > this.estimatedRowHeight) {
3009
+ this.estimatedRowHeight = maxHeight;
2868
3010
  }
2869
- }
3011
+ });
3012
+ });
3013
+
3014
+ // Update tab indexes after population
3015
+ this.updateTabIndexes();
3016
+ }
3017
+
3018
+ createRow(item) {
3019
+ const row = document.createElement('div');
3020
+ row.className = 'div-table-row';
3021
+ row.dataset.id = item[this.primaryKeyField];
3022
+ // Don't set tabindex here - will be managed by updateTabIndexes() based on checkbox presence
3023
+
3024
+ const compositeColumns = this.getCompositeColumns();
3025
+
3026
+ // Build grid template matching the header
3027
+ let gridTemplate = '';
3028
+ if (this.showCheckboxes) {
3029
+ gridTemplate = '40px '; // Checkbox column
3030
+ }
3031
+
3032
+ // Add column templates for each composite group
3033
+ compositeColumns.forEach(composite => {
3034
+ const firstCol = composite.columns[0];
3035
+ const responsive = firstCol.responsive || {};
3036
+ switch (responsive.size) {
3037
+ case 'fixed-narrow':
3038
+ gridTemplate += '80px ';
3039
+ break;
3040
+ case 'fixed-medium':
3041
+ gridTemplate += '120px ';
3042
+ break;
3043
+ case 'flexible-small':
3044
+ gridTemplate += '1fr ';
3045
+ break;
3046
+ case 'flexible-medium':
3047
+ gridTemplate += '2fr ';
3048
+ break;
3049
+ case 'flexible-large':
3050
+ gridTemplate += '3fr ';
3051
+ break;
3052
+ default:
3053
+ gridTemplate += '1fr ';
3054
+ }
3055
+ });
3056
+
3057
+ row.style.gridTemplateColumns = gridTemplate.trim();
3058
+
3059
+ // Selection state
3060
+ const rowId = String(item[this.primaryKeyField]);
3061
+ if (this.selectedRows.has(rowId)) {
3062
+ row.classList.add('selected');
3063
+ }
3064
+ if (this.focusedRowId === rowId) {
3065
+ row.classList.add('focused');
3066
+ }
3067
+
3068
+ // If lazy cell rendering is enabled, create empty shell and defer cell creation
3069
+ if (this.lazyCellRendering) {
3070
+ // Set minimum height to prevent layout shift
3071
+ row.style.minHeight = this.estimatedRowHeight + 'px';
3072
+ row.dataset.populated = 'false';
3073
+
3074
+ // Row click handler - populate cells first if needed
3075
+ row.addEventListener('click', (e) => {
3076
+ // Populate cells if not yet done
3077
+ if (row.dataset.populated !== 'true') {
3078
+ this.populateRowCells(row, item);
3079
+ }
3080
+
3081
+ // Only handle focus if not clicking on checkbox column
3082
+ if (e.target.closest('.checkbox-column')) return;
3083
+
3084
+ const selection = window.getSelection();
3085
+ if (selection.toString().length > 0) return;
3086
+
3087
+ const focusableElement = this.getFocusableElementForRow(row);
3088
+ if (focusableElement) {
3089
+ const currentFocused = this.getCurrentFocusedElement();
3090
+ if (currentFocused === focusableElement) return;
3091
+
3092
+ const focusableElements = this.getAllFocusableElements();
3093
+ const focusIndex = focusableElements.indexOf(focusableElement);
3094
+
3095
+ if (focusIndex !== -1) {
3096
+ this.focusElementAtIndex(focusIndex);
3097
+ }
3098
+ }
3099
+ });
3100
+
3101
+ row.addEventListener('focus', (e) => {
3102
+ // Populate cells if not yet done
3103
+ if (row.dataset.populated !== 'true') {
3104
+ this.populateRowCells(row, item);
3105
+ }
3106
+ this.updateFocusState(row);
3107
+ });
3108
+
3109
+ return row;
3110
+ }
3111
+
3112
+ // Non-lazy rendering: create cells immediately (original behavior)
3113
+ if (this.showCheckboxes) {
3114
+ const checkboxCell = document.createElement('div');
3115
+ checkboxCell.className = 'div-table-cell checkbox-column';
3116
+
3117
+ const checkbox = document.createElement('input');
3118
+ checkbox.type = 'checkbox';
3119
+ checkbox.checked = this.selectedRows.has(rowId);
3120
+
3121
+ checkbox.addEventListener('change', (e) => {
3122
+ e.stopPropagation();
3123
+
3124
+ // Verify that the row data exists before proceeding
3125
+ const rowData = this.findRowData(rowId);
3126
+ if (!rowData) {
3127
+ console.warn('DivTable: Could not find data for row ID:', rowId);
3128
+ return;
3129
+ }
3130
+
3131
+ if (checkbox.checked) {
3132
+ if (!this.multiSelect) this.clearSelection();
3133
+ this.selectedRows.add(rowId);
3134
+ rowData.selected = true;
3135
+ row.classList.add('selected');
3136
+ } else {
3137
+ this.selectedRows.delete(rowId);
3138
+ rowData.selected = false;
3139
+ row.classList.remove('selected');
3140
+
3141
+ // If filter is active and we just deselected a row, re-render to remove it from view
3142
+ if (this.showOnlySelected) {
3143
+ // Re-render the body to update the filtered view
3144
+ this.renderBody();
3145
+ // Update info section after render
3146
+ this.updateInfoSection();
3147
+
3148
+ // Trigger selection change callback
3149
+ const selectedData = Array.from(this.selectedRows)
3150
+ .map(id => this.findRowData(id))
3151
+ .filter(Boolean);
3152
+
3153
+ if (typeof this.onSelectionChange === 'function') {
3154
+ this.onSelectionChange(selectedData);
3155
+ }
3156
+
3157
+ // Early return since we already updated everything
3158
+ return;
3159
+ }
3160
+ }
3161
+
3162
+ // Update all checkbox states (group and header)
3163
+ this.updateSelectionStates();
3164
+ this.updateInfoSection();
3165
+
3166
+ // Ensure we only return valid data objects
3167
+ const selectedData = Array.from(this.selectedRows)
3168
+ .map(id => this.findRowData(id))
3169
+ .filter(Boolean);
3170
+
3171
+ if (typeof this.onSelectionChange === 'function') {
3172
+ this.onSelectionChange(selectedData);
3173
+ }
3174
+ });
3175
+
3176
+ // Sync checkbox focus with row focus
3177
+ checkbox.addEventListener('focus', (e) => {
3178
+ this.updateFocusState(row);
3179
+ });
3180
+
3181
+ checkbox.addEventListener('blur', (e) => {
3182
+ // Optionally handle blur if needed - for now, keep row focused
3183
+ // This allows arrow key navigation to continue working
3184
+ });
3185
+
3186
+ checkboxCell.appendChild(checkbox);
3187
+
3188
+ // Make the entire checkbox cell clickable
3189
+ checkboxCell.addEventListener('click', (e) => {
3190
+ // If clicked on the checkbox itself, let it handle naturally
3191
+ if (e.target === checkbox) return;
3192
+
3193
+ // If clicked elsewhere in the cell, toggle the checkbox
3194
+ e.stopPropagation();
3195
+ checkbox.click();
3196
+ });
3197
+
3198
+ row.appendChild(checkboxCell);
3199
+ }
3200
+
3201
+ // Data columns - render using composite structure
3202
+ compositeColumns.forEach(composite => {
3203
+ const cell = document.createElement('div');
3204
+ cell.className = 'div-table-cell';
3205
+
3206
+ if (composite.compositeName) {
3207
+ // Composite cell with multiple columns stacked vertically
3208
+ cell.classList.add('composite-cell');
3209
+ cell.style.display = 'flex';
3210
+ cell.style.flexDirection = 'column';
3211
+ cell.style.gap = '4px';
3212
+
3213
+ composite.columns.forEach((col, index) => {
3214
+ const subCell = document.createElement('div');
3215
+ subCell.className = 'composite-sub-cell';
3216
+
3217
+ // For grouped column, show empty
3218
+ if (this.groupByField && col.field === this.groupByField) {
3219
+ subCell.classList.add('grouped-column');
3220
+ subCell.textContent = '';
3221
+ } else {
3222
+ // Check if this column has subField (vertical stacking within the sub-cell)
3223
+ if (col.subField) {
3224
+ subCell.classList.add('composite-column');
3225
+ subCell.style.display = 'flex';
3226
+ subCell.style.flexDirection = 'column';
3227
+ subCell.style.gap = '2px';
3228
+
3229
+ const mainDiv = document.createElement('div');
3230
+ mainDiv.className = 'composite-main';
3231
+ if (typeof col.render === 'function') {
3232
+ mainDiv.innerHTML = col.render(item[col.field], item);
3233
+ } else {
3234
+ mainDiv.innerHTML = item[col.field] ?? '';
3235
+ }
3236
+
3237
+ const subDiv = document.createElement('div');
3238
+ subDiv.className = 'composite-sub';
3239
+ if (typeof col.subRender === 'function') {
3240
+ subDiv.innerHTML = col.subRender(item[col.subField], item);
3241
+ } else {
3242
+ subDiv.innerHTML = item[col.subField] ?? '';
3243
+ }
3244
+
3245
+ subCell.appendChild(mainDiv);
3246
+ subCell.appendChild(subDiv);
3247
+ } else {
3248
+ // Regular rendering
3249
+ if (typeof col.render === 'function') {
3250
+ subCell.innerHTML = col.render(item[col.field], item);
3251
+ } else {
3252
+ subCell.innerHTML = item[col.field] ?? '';
3253
+ }
3254
+ }
3255
+ }
3256
+
3257
+ cell.appendChild(subCell);
3258
+ });
3259
+ } else {
3260
+ // Single column
3261
+ const col = composite.columns[0];
3262
+
3263
+ // For grouped column, show empty
3264
+ if (this.groupByField && col.field === this.groupByField) {
3265
+ cell.classList.add('grouped-column');
3266
+ cell.textContent = '';
3267
+ } else {
3268
+ // Check if this is a composite column with subField (vertical stacking)
3269
+ if (col.subField) {
3270
+ cell.classList.add('composite-column');
3271
+
3272
+ const mainDiv = document.createElement('div');
3273
+ mainDiv.className = 'composite-main';
3274
+ if (typeof col.render === 'function') {
3275
+ mainDiv.innerHTML = col.render(item[col.field], item);
3276
+ } else {
3277
+ mainDiv.innerHTML = item[col.field] ?? '';
3278
+ }
3279
+
3280
+ const subDiv = document.createElement('div');
3281
+ subDiv.className = 'composite-sub';
3282
+ if (typeof col.subRender === 'function') {
3283
+ subDiv.innerHTML = col.subRender(item[col.subField], item);
3284
+ } else {
3285
+ subDiv.innerHTML = item[col.subField] ?? '';
3286
+ }
3287
+
3288
+ cell.appendChild(mainDiv);
3289
+ cell.appendChild(subDiv);
3290
+ } else {
3291
+ // Regular column rendering
3292
+ if (typeof col.render === 'function') {
3293
+ cell.innerHTML = col.render(item[col.field], item);
3294
+ } else {
3295
+ cell.innerHTML = item[col.field] ?? '';
3296
+ }
3297
+ }
3298
+ }
3299
+ }
3300
+
3301
+ row.appendChild(cell);
3302
+ });
3303
+
3304
+ // Row click handler - set focus index for clicked row
3305
+ row.addEventListener('click', (e) => {
3306
+ // Only handle focus if not clicking on checkbox column
3307
+ if (e.target.closest('.checkbox-column')) return;
3308
+
3309
+ // Check if user is making a text selection
3310
+ const selection = window.getSelection();
3311
+ if (selection.toString().length > 0) {
3312
+ return; // Don't trigger focus if user is selecting text
3313
+ }
3314
+
3315
+ // Find the focusable element for this row
3316
+ const focusableElement = this.getFocusableElementForRow(row);
3317
+ if (focusableElement) {
3318
+ // Check if this row already has focus
3319
+ const currentFocused = this.getCurrentFocusedElement();
3320
+ if (currentFocused === focusableElement) {
3321
+ return; // Don't trigger focus event if already focused
3322
+ }
3323
+
3324
+ // Get all focusable elements and find the index of this element
3325
+ const focusableElements = this.getAllFocusableElements();
3326
+ const focusIndex = focusableElements.indexOf(focusableElement);
3327
+
3328
+ if (focusIndex !== -1) {
3329
+ // Use the existing focus system to set focus to this index
3330
+ this.focusElementAtIndex(focusIndex);
3331
+ }
3332
+ }
3333
+ });
3334
+
3335
+ // Row focus event to sync with tabIndex navigation
3336
+ row.addEventListener('focus', (e) => {
3337
+ this.updateFocusState(row);
3338
+ });
3339
+
3340
+ return row;
3341
+ }
3342
+
3343
+ createRowWithFixedColumns(item) {
3344
+ const { fixedColumns, scrollColumns } = this.splitColumnsForFixedLayout();
3345
+ const rowId = String(item[this.primaryKeyField]);
3346
+
3347
+ // Create fixed row part
3348
+ const fixedRow = document.createElement('div');
3349
+ fixedRow.className = 'div-table-row div-table-fixed-row';
3350
+ fixedRow.dataset.id = rowId;
3351
+
3352
+ // Build grid template for fixed row
3353
+ let fixedGridTemplate = '';
3354
+ if (this.showCheckboxes) {
3355
+ fixedGridTemplate = '40px ';
3356
+ }
3357
+ fixedColumns.forEach(composite => {
3358
+ fixedGridTemplate += this.getColumnGridSize(composite) + ' ';
3359
+ });
3360
+ fixedRow.style.gridTemplateColumns = fixedGridTemplate.trim();
3361
+
3362
+ // Create scrollable row part
3363
+ const scrollRow = document.createElement('div');
3364
+ scrollRow.className = 'div-table-row div-table-scroll-row';
3365
+ scrollRow.dataset.id = rowId;
3366
+
3367
+ // Build grid template for scrollable row
3368
+ let scrollGridTemplate = '';
3369
+ scrollColumns.forEach(composite => {
3370
+ scrollGridTemplate += this.getColumnGridSize(composite) + ' ';
3371
+ });
3372
+ scrollRow.style.gridTemplateColumns = scrollGridTemplate.trim();
3373
+
3374
+ // Apply selection state to both row parts
3375
+ if (this.selectedRows.has(rowId)) {
3376
+ fixedRow.classList.add('selected');
3377
+ scrollRow.classList.add('selected');
3378
+ }
3379
+ if (this.focusedRowId === rowId) {
3380
+ fixedRow.classList.add('focused');
3381
+ scrollRow.classList.add('focused');
3382
+ }
3383
+
3384
+ // If lazy cell rendering is enabled, create empty shells and defer cell creation
3385
+ if (this.lazyCellRendering) {
3386
+ fixedRow.style.minHeight = this.estimatedRowHeight + 'px';
3387
+ scrollRow.style.minHeight = this.estimatedRowHeight + 'px';
3388
+ fixedRow.dataset.populated = 'false';
3389
+ scrollRow.dataset.populated = 'false';
3390
+
3391
+ // Row click handler - populate cells first if needed
3392
+ const handleRowClick = (e, targetRow) => {
3393
+ // Populate cells if not yet done
3394
+ if (fixedRow.dataset.populated !== 'true') {
3395
+ this.populateRowCellsWithFixedColumns(fixedRow, scrollRow, item);
3396
+ }
3397
+
3398
+ if (e.target.closest('.checkbox-column')) return;
3399
+
3400
+ const selection = window.getSelection();
3401
+ if (selection.toString().length > 0) return;
3402
+
3403
+ const focusableElement = this.getFocusableElementForRow(fixedRow);
3404
+ if (focusableElement) {
3405
+ const focusableElements = this.getAllFocusableElements();
3406
+ const focusIndex = focusableElements.indexOf(focusableElement);
3407
+ if (focusIndex !== -1) {
3408
+ this.focusElementAtIndex(focusIndex);
3409
+ }
3410
+ }
3411
+ };
3412
+
3413
+ fixedRow.addEventListener('click', (e) => handleRowClick(e, fixedRow));
3414
+ scrollRow.addEventListener('click', (e) => handleRowClick(e, scrollRow));
3415
+
3416
+ fixedRow.addEventListener('focus', (e) => {
3417
+ if (fixedRow.dataset.populated !== 'true') {
3418
+ this.populateRowCellsWithFixedColumns(fixedRow, scrollRow, item);
3419
+ }
3420
+ this.updateFocusStateForFixedRows(fixedRow, scrollRow);
3421
+ });
3422
+
3423
+ // Sync hover state between row parts
3424
+ fixedRow.addEventListener('mouseenter', () => scrollRow.classList.add('hover'));
3425
+ fixedRow.addEventListener('mouseleave', () => scrollRow.classList.remove('hover'));
3426
+ scrollRow.addEventListener('mouseenter', () => fixedRow.classList.add('hover'));
3427
+ scrollRow.addEventListener('mouseleave', () => fixedRow.classList.remove('hover'));
3428
+
3429
+ return { fixedRow, scrollRow };
3430
+ }
3431
+
3432
+ // Non-lazy rendering: create cells immediately (original behavior)
3433
+ // Checkbox column (in fixed row only)
3434
+ if (this.showCheckboxes) {
3435
+ const checkboxCell = document.createElement('div');
3436
+ checkboxCell.className = 'div-table-cell checkbox-column';
3437
+
3438
+ const checkbox = document.createElement('input');
3439
+ checkbox.type = 'checkbox';
3440
+ checkbox.checked = this.selectedRows.has(rowId);
3441
+
3442
+ checkbox.addEventListener('change', (e) => {
3443
+ e.stopPropagation();
3444
+
3445
+ const rowData = this.findRowData(rowId);
3446
+ if (!rowData) {
3447
+ console.warn('DivTable: Could not find data for row ID:', rowId);
3448
+ return;
3449
+ }
3450
+
3451
+ if (checkbox.checked) {
3452
+ if (!this.multiSelect) this.clearSelection();
3453
+ this.selectedRows.add(rowId);
3454
+ rowData.selected = true;
3455
+ fixedRow.classList.add('selected');
3456
+ scrollRow.classList.add('selected');
3457
+ } else {
3458
+ this.selectedRows.delete(rowId);
3459
+ rowData.selected = false;
3460
+ fixedRow.classList.remove('selected');
3461
+ scrollRow.classList.remove('selected');
3462
+
3463
+ if (this.showOnlySelected) {
3464
+ this.renderBody();
3465
+ this.updateInfoSection();
3466
+
3467
+ const selectedData = Array.from(this.selectedRows)
3468
+ .map(id => this.findRowData(id))
3469
+ .filter(Boolean);
3470
+
3471
+ if (typeof this.onSelectionChange === 'function') {
3472
+ this.onSelectionChange(selectedData);
3473
+ }
3474
+ return;
3475
+ }
3476
+ }
3477
+
3478
+ this.updateSelectionStates();
3479
+ this.updateInfoSection();
3480
+
3481
+ const selectedData = Array.from(this.selectedRows)
3482
+ .map(id => this.findRowData(id))
3483
+ .filter(Boolean);
3484
+
3485
+ if (typeof this.onSelectionChange === 'function') {
3486
+ this.onSelectionChange(selectedData);
3487
+ }
3488
+ });
3489
+
3490
+ checkbox.addEventListener('focus', (e) => {
3491
+ this.updateFocusStateForFixedRows(fixedRow, scrollRow);
3492
+ });
3493
+
3494
+ checkboxCell.appendChild(checkbox);
3495
+
3496
+ checkboxCell.addEventListener('click', (e) => {
3497
+ if (e.target === checkbox) return;
3498
+ e.stopPropagation();
3499
+ checkbox.click();
3500
+ });
3501
+
3502
+ fixedRow.appendChild(checkboxCell);
3503
+ }
3504
+
3505
+ // Render fixed columns
3506
+ fixedColumns.forEach(composite => {
3507
+ const cell = this.createCellForComposite(composite, item);
3508
+ fixedRow.appendChild(cell);
3509
+ });
3510
+
3511
+ // Render scrollable columns
3512
+ scrollColumns.forEach(composite => {
3513
+ const cell = this.createCellForComposite(composite, item);
3514
+ scrollRow.appendChild(cell);
3515
+ });
3516
+
3517
+ // Row click handlers for both parts
3518
+ const handleRowClick = (e, targetRow) => {
3519
+ if (e.target.closest('.checkbox-column')) return;
3520
+
3521
+ const selection = window.getSelection();
3522
+ if (selection.toString().length > 0) return;
3523
+
3524
+ const focusableElement = this.getFocusableElementForRow(fixedRow);
3525
+ if (focusableElement) {
3526
+ const focusableElements = this.getAllFocusableElements();
3527
+ const focusIndex = focusableElements.indexOf(focusableElement);
3528
+ if (focusIndex !== -1) {
3529
+ this.focusElementAtIndex(focusIndex);
3530
+ }
3531
+ }
2870
3532
  };
2871
3533
 
2872
3534
  fixedRow.addEventListener('click', (e) => handleRowClick(e, fixedRow));
@@ -2914,6 +3576,13 @@ class DivTable {
2914
3576
  const cell = document.createElement('div');
2915
3577
  cell.className = 'div-table-cell';
2916
3578
 
3579
+ // Apply text alignment from column config (single column only)
3580
+ if (!composite.compositeName && composite.columns[0]?.align) {
3581
+ cell.style.textAlign = composite.columns[0].align;
3582
+ cell.style.justifyContent = composite.columns[0].align === 'right' ? 'flex-end' :
3583
+ composite.columns[0].align === 'center' ? 'center' : 'flex-start';
3584
+ }
3585
+
2917
3586
  if (composite.compositeName) {
2918
3587
  // Composite cell with multiple columns stacked vertically
2919
3588
  cell.classList.add('composite-cell');
@@ -3512,10 +4181,15 @@ class DivTable {
3512
4181
  updateInfoSection() {
3513
4182
  if (!this.infoSection) return;
3514
4183
 
3515
- const total = this.virtualScrolling ? this.totalRecords : this.data.length;
4184
+ // For virtual scrolling, use the max of totalRecords and actual loaded data
4185
+ // This handles cases where loaded data exceeds the reported total
4186
+ const total = this.virtualScrolling
4187
+ ? Math.max(this.totalRecords, this.data.length)
4188
+ : this.data.length;
3516
4189
  const loaded = this.data.length;
3517
4190
  const filtered = this.filteredData.length;
3518
- const selected = this.selectedRows.size;
4191
+ // Count only valid selected rows (detail records, not stale IDs)
4192
+ const selected = this.getValidSelectedCount();
3519
4193
 
3520
4194
  // Clear existing content
3521
4195
  this.infoSection.innerHTML = '';
@@ -3951,10 +4625,12 @@ class DivTable {
3951
4625
  updateInfoSectionWithAnticipatedProgress() {
3952
4626
  if (!this.infoSection || !this.virtualScrolling) return;
3953
4627
 
3954
- const total = this.totalRecords;
4628
+ // Use max of totalRecords and actual loaded data to handle inconsistencies
4629
+ const total = Math.max(this.totalRecords, this.data.length);
3955
4630
  const currentLoaded = this.data.length;
3956
4631
  const filtered = this.filteredData.length;
3957
- const selected = this.selectedRows.size;
4632
+ // Count only valid selected rows (detail records, not stale IDs)
4633
+ const selected = this.getValidSelectedCount();
3958
4634
 
3959
4635
  // Calculate anticipated progress (assume we'll get a full page of data)
3960
4636
  const anticipatedLoaded = Math.min(currentLoaded + this.pageSize, total);
@@ -4232,6 +4908,15 @@ class DivTable {
4232
4908
  return Array.from(this.selectedRows).map(id => this.findRowData(id)).filter(Boolean);
4233
4909
  }
4234
4910
 
4911
+ /**
4912
+ * Get the count of selected rows that have valid data records
4913
+ * This excludes any stale selections (IDs that no longer exist in data)
4914
+ * @returns {number} The count of valid selected rows
4915
+ */
4916
+ getValidSelectedCount() {
4917
+ return this.getSelectedRows().length;
4918
+ }
4919
+
4235
4920
  /**
4236
4921
  * Toggle the filter to show only selected rows or all rows
4237
4922
  * @param {boolean} [showOnlySelected] - Optional: explicitly set the filter state (true = show only selected, false = show all). If omitted, toggles current state.
@@ -4853,145 +5538,705 @@ class DivTable {
4853
5538
  }
4854
5539
  }
4855
5540
 
4856
- if (addedCount > 0 || updatedCount > 0) {
4857
- this.isLoadingState = false;
4858
-
4859
- // Update the query engine with new/updated data
4860
- this.queryEngine.setObjects(this.data);
4861
-
4862
- // Update query editor if field values changed (for completion suggestions)
4863
- this.updateQueryEditorIfNeeded();
5541
+ if (addedCount > 0 || updatedCount > 0) {
5542
+ this.isLoadingState = false;
5543
+
5544
+ // Update the query engine with new/updated data
5545
+ this.queryEngine.setObjects(this.data);
5546
+
5547
+ // Update query editor if field values changed (for completion suggestions)
5548
+ this.updateQueryEditorIfNeeded();
5549
+
5550
+ // Update filtered data if no active query
5551
+ if (!this.currentQuery.trim()) {
5552
+ this.filteredData = [...this.data];
5553
+ } else {
5554
+ // Re-apply query to include new/updated data
5555
+ this.applyQuery(this.currentQuery);
5556
+ }
5557
+
5558
+ // Only update info section and re-render if not skipped
5559
+ // (loadNextPage will handle this after setting loading state correctly)
5560
+ if (!skipInfoUpdate) {
5561
+ this.updateInfoSection();
5562
+ this.render();
5563
+ } else {
5564
+ // Still need to render the data, just skip the info section update
5565
+ this.render();
5566
+ }
5567
+ }
5568
+
5569
+ return {
5570
+ added: addedCount,
5571
+ updated: updatedCount,
5572
+ skipped: invalid.length,
5573
+ invalid
5574
+ };
5575
+ }
5576
+
5577
+ replaceData(newData) {
5578
+ if (!newData || !Array.isArray(newData)) {
5579
+ console.warn('replaceData requires a valid array');
5580
+ return { success: false, message: 'Invalid data provided' };
5581
+ }
5582
+
5583
+ // Validate data integrity and check for duplicates within the new data
5584
+ const duplicates = [];
5585
+ const seenIds = new Set();
5586
+ const validRecords = [];
5587
+
5588
+ for (const record of newData) {
5589
+ if (!record || typeof record !== 'object') {
5590
+ console.warn('replaceData: Skipping invalid record', record);
5591
+ continue;
5592
+ }
5593
+
5594
+ // Ensure the record has a primary key
5595
+ if (!record[this.primaryKeyField]) {
5596
+ console.warn(`replaceData: Skipping record without ${this.primaryKeyField}`, record);
5597
+ continue;
5598
+ }
5599
+
5600
+ // Check for duplicate primary key within the new data
5601
+ const recordId = String(record[this.primaryKeyField]);
5602
+ if (seenIds.has(recordId)) {
5603
+ duplicates.push(recordId);
5604
+ console.warn(`replaceData: Skipping duplicate ${this.primaryKeyField} '${recordId}' within new data`);
5605
+ continue;
5606
+ }
5607
+
5608
+ seenIds.add(recordId);
5609
+ validRecords.push(record);
5610
+ }
5611
+
5612
+ this.data = validRecords;
5613
+ this.isLoadingState = false;
5614
+ this.clearRefreshButtonLoadingState();
5615
+
5616
+ this.queryEngine.setObjects(this.data);
5617
+
5618
+ this.updateQueryEditorIfNeeded();
5619
+
5620
+ if (this.currentQuery && this.currentQuery.trim()) {
5621
+ this.applyQuery(this.currentQuery);
5622
+ } else {
5623
+ this.filteredData = [...this.data];
5624
+ }
5625
+
5626
+ this.selectedRows.clear();
5627
+
5628
+ this.virtualScrollingState = {
5629
+ scrollTop: 0,
5630
+ displayStartIndex: 0,
5631
+ displayEndIndex: Math.min(this.pageSize, this.data.length),
5632
+ isLoading: false,
5633
+ };
5634
+
5635
+ // Reset pagination to first page (zero-indexed)
5636
+ this.currentPage = 0;
5637
+ this.startId = 1;
5638
+
5639
+ // Update hasMoreData flag based on whether we have less data than totalRecords
5640
+ if (this.virtualScrolling && this.totalRecords) {
5641
+ this.hasMoreData = validRecords.length < this.totalRecords;
5642
+ }
5643
+
5644
+ // Update info display and re-render
5645
+ this.updateInfoSection();
5646
+ this.render();
5647
+
5648
+ return {
5649
+ success: true,
5650
+ totalProvided: newData.length,
5651
+ validRecords: validRecords.length,
5652
+ skipped: newData.length - validRecords.length,
5653
+ duplicates
5654
+ };
5655
+ }
5656
+
5657
+ // Loading placeholder management
5658
+ resetToLoading() {
5659
+ this.isLoadingState = true;
5660
+ this.data = [];
5661
+ this.filteredData = [];
5662
+ this.selectedRows.clear();
5663
+ this.currentQuery = '';
5664
+
5665
+ // Update Monaco editor if it exists
5666
+ if (this.queryEditor?.editor) {
5667
+ this.queryEditor.editor.setValue('');
5668
+ }
5669
+
5670
+ // Update the query engine with empty data
5671
+ this.queryEngine.setObjects([]);
5672
+
5673
+ // Re-render to show loading placeholder
5674
+ this.render();
5675
+ }
5676
+
5677
+ setLoadingState(isLoading) {
5678
+ this.isLoadingState = Boolean(isLoading);
5679
+ this.render(); // Re-render to show/hide loading placeholder
5680
+ }
5681
+
5682
+ // =====================================
5683
+ // Aggregate Summary Row Methods
5684
+ // =====================================
5685
+
5686
+ /**
5687
+ * Check if any column has an aggregate defined
5688
+ * @returns {boolean} True if at least one column has aggregate property
5689
+ */
5690
+ hasAggregateColumns() {
5691
+ return this.columns.some(col => col.aggregate);
5692
+ }
5693
+
5694
+ /**
5695
+ * Get columns that have aggregate functions defined
5696
+ * @returns {Array} Array of columns with aggregate property
5697
+ */
5698
+ getAggregateColumns() {
5699
+ return this.columns.filter(col => col.aggregate);
5700
+ }
5701
+
5702
+ /**
5703
+ * Calculate aggregate value for a specific column and data set
5704
+ * @param {Object} column - Column definition with aggregate property
5705
+ * @param {Array} data - Array of data items to aggregate
5706
+ * @returns {number|null} Calculated aggregate value
5707
+ */
5708
+ calculateAggregate(column, data) {
5709
+ if (!column.aggregate || !data || data.length === 0) {
5710
+ return null;
5711
+ }
5712
+
5713
+ const field = column.field;
5714
+ const aggregateType = column.aggregate.toLowerCase();
5715
+
5716
+ // Extract numeric values from data
5717
+ const values = data
5718
+ .map(item => item[field])
5719
+ .filter(val => val !== null && val !== undefined && !isNaN(parseFloat(val)))
5720
+ .map(val => parseFloat(val));
5721
+
5722
+ if (values.length === 0 && aggregateType !== 'count') {
5723
+ return null;
5724
+ }
5725
+
5726
+ switch (aggregateType) {
5727
+ case 'sum':
5728
+ return values.reduce((sum, val) => sum + val, 0);
5729
+
5730
+ case 'avg':
5731
+ case 'average':
5732
+ return values.length > 0 ? values.reduce((sum, val) => sum + val, 0) / values.length : null;
5733
+
5734
+ case 'count':
5735
+ return data.length;
5736
+
5737
+ case 'min':
5738
+ return values.length > 0 ? Math.min(...values) : null;
5739
+
5740
+ case 'max':
5741
+ return values.length > 0 ? Math.max(...values) : null;
5742
+
5743
+ default:
5744
+ console.warn(`DivTable: Unknown aggregate type '${aggregateType}' for column '${field}'`);
5745
+ return null;
5746
+ }
5747
+ }
5748
+
5749
+ /**
5750
+ * Get the data set to use for aggregation, considering selection state
5751
+ * For header summary: all filtered data, or selected rows if any are selected
5752
+ * @param {Array} dataToRender - The currently displayed data
5753
+ * @returns {Array} Data set to use for aggregation
5754
+ */
5755
+ getAggregationDataSet(dataToRender) {
5756
+ // If there are selected rows, aggregate only selected rows
5757
+ if (this.selectedRows.size > 0) {
5758
+ return dataToRender.filter(item => {
5759
+ const itemId = String(item[this.primaryKeyField]);
5760
+ return this.selectedRows.has(itemId);
5761
+ });
5762
+ }
5763
+ // Otherwise aggregate all displayed data
5764
+ return dataToRender;
5765
+ }
5766
+
5767
+ /**
5768
+ * Format aggregate value for display
5769
+ * @param {number|null} value - The aggregate value
5770
+ * @param {Object} column - Column definition
5771
+ * @returns {string} Formatted value string
5772
+ */
5773
+ formatAggregateValue(value, column) {
5774
+ if (value === null || value === undefined) {
5775
+ return '';
5776
+ }
5777
+
5778
+ // Use column's render function if available for formatting
5779
+ // But only pass the value, not row data
5780
+ if (typeof column.aggregateRender === 'function') {
5781
+ return column.aggregateRender(value);
5782
+ }
5783
+
5784
+ // Default formatting based on aggregate type
5785
+ const aggregateType = column.aggregate.toLowerCase();
5786
+
5787
+ if (aggregateType === 'count') {
5788
+ return String(value);
5789
+ }
5790
+
5791
+ // For numeric values, use locale formatting
5792
+ if (typeof value === 'number') {
5793
+ // Check if it's a decimal that needs precision
5794
+ if (aggregateType === 'avg' || aggregateType === 'average') {
5795
+ return value.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
5796
+ }
5797
+ return value.toLocaleString();
5798
+ }
5799
+
5800
+ return String(value);
5801
+ }
5802
+
5803
+ /**
5804
+ * Create the header summary row (grand total row below header)
5805
+ * @param {Array} dataToRender - Data to calculate aggregates from
5806
+ * @returns {HTMLElement} Summary row element
5807
+ */
5808
+ createHeaderSummaryRow(dataToRender) {
5809
+ const summaryRow = document.createElement('div');
5810
+ summaryRow.className = 'div-table-row summary-row header-summary';
5811
+
5812
+ const compositeColumns = this.getCompositeColumns();
5813
+ const aggregationData = this.getAggregationDataSet(dataToRender);
5814
+
5815
+ // Build grid template matching header
5816
+ let gridTemplate = '';
5817
+ if (this.showCheckboxes) {
5818
+ gridTemplate = '40px '; // Checkbox column
5819
+ }
5820
+
5821
+ compositeColumns.forEach(composite => {
5822
+ const firstCol = composite.columns[0];
5823
+ const responsive = firstCol.responsive || {};
5824
+ switch (responsive.size) {
5825
+ case 'fixed-narrow': gridTemplate += '80px '; break;
5826
+ case 'fixed-medium': gridTemplate += '120px '; break;
5827
+ case 'flexible-small': gridTemplate += '1fr '; break;
5828
+ case 'flexible-medium': gridTemplate += '2fr '; break;
5829
+ case 'flexible-large': gridTemplate += '3fr '; break;
5830
+ default: gridTemplate += '1fr ';
5831
+ }
5832
+ });
5833
+
5834
+ summaryRow.style.gridTemplateColumns = gridTemplate.trim();
5835
+
5836
+ // Empty checkbox cell
5837
+ if (this.showCheckboxes) {
5838
+ const emptyCell = document.createElement('div');
5839
+ emptyCell.className = 'div-table-cell checkbox-column summary-cell';
5840
+ summaryRow.appendChild(emptyCell);
5841
+ }
5842
+
5843
+ // Create cells for each composite column
5844
+ compositeColumns.forEach(composite => {
5845
+ const cell = document.createElement('div');
5846
+ cell.className = 'div-table-cell summary-cell';
5847
+
5848
+ if (composite.compositeName) {
5849
+ // Composite column - check each sub-column for aggregates
5850
+ cell.classList.add('composite-cell');
5851
+
5852
+ composite.columns.forEach((col, idx) => {
5853
+ const subCell = document.createElement('div');
5854
+ subCell.className = 'composite-sub-cell';
5855
+
5856
+ if (col.aggregate) {
5857
+ const aggregateValue = this.calculateAggregate(col, aggregationData);
5858
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
5859
+ subCell.innerHTML = formattedValue;
5860
+ subCell.classList.add('aggregate-value');
5861
+ }
5862
+
5863
+ cell.appendChild(subCell);
5864
+ });
5865
+ } else {
5866
+ // Single column
5867
+ const col = composite.columns[0];
5868
+
5869
+ if (col.aggregate) {
5870
+ const aggregateValue = this.calculateAggregate(col, aggregationData);
5871
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
5872
+ cell.innerHTML = formattedValue;
5873
+ cell.classList.add('aggregate-value');
5874
+ }
5875
+ }
5876
+
5877
+ summaryRow.appendChild(cell);
5878
+ });
5879
+
5880
+ return summaryRow;
5881
+ }
5882
+
5883
+ /**
5884
+ * Create a group summary row (subtotal row after group items)
5885
+ * @param {Object} group - Group object with items array
5886
+ * @returns {HTMLElement} Group summary row element
5887
+ */
5888
+ createGroupSummaryRow(group) {
5889
+ const summaryRow = document.createElement('div');
5890
+ summaryRow.className = 'div-table-row summary-row group-summary';
5891
+ summaryRow.dataset.groupKey = group.key;
5892
+
5893
+ const compositeColumns = this.getCompositeColumns();
5894
+
5895
+ // For group summary, aggregate only the items in this group
5896
+ // But also respect selection: if items are selected, only aggregate selected items in this group
5897
+ let groupData = group.items;
5898
+ if (this.selectedRows.size > 0) {
5899
+ groupData = group.items.filter(item => {
5900
+ const itemId = String(item[this.primaryKeyField]);
5901
+ return this.selectedRows.has(itemId);
5902
+ });
5903
+ }
5904
+
5905
+ // Build grid template matching body rows
5906
+ let gridTemplate = '';
5907
+ if (this.showCheckboxes) {
5908
+ gridTemplate = '40px ';
5909
+ }
5910
+
5911
+ compositeColumns.forEach(composite => {
5912
+ const firstCol = composite.columns[0];
5913
+ const responsive = firstCol.responsive || {};
5914
+ switch (responsive.size) {
5915
+ case 'fixed-narrow': gridTemplate += '80px '; break;
5916
+ case 'fixed-medium': gridTemplate += '120px '; break;
5917
+ case 'flexible-small': gridTemplate += '1fr '; break;
5918
+ case 'flexible-medium': gridTemplate += '2fr '; break;
5919
+ case 'flexible-large': gridTemplate += '3fr '; break;
5920
+ default: gridTemplate += '1fr ';
5921
+ }
5922
+ });
5923
+
5924
+ summaryRow.style.gridTemplateColumns = gridTemplate.trim();
5925
+
5926
+ // Empty checkbox cell
5927
+ if (this.showCheckboxes) {
5928
+ const emptyCell = document.createElement('div');
5929
+ emptyCell.className = 'div-table-cell checkbox-column summary-cell';
5930
+ summaryRow.appendChild(emptyCell);
5931
+ }
5932
+
5933
+ // Create cells for each composite column
5934
+ compositeColumns.forEach(composite => {
5935
+ const cell = document.createElement('div');
5936
+ cell.className = 'div-table-cell summary-cell';
4864
5937
 
4865
- // Update filtered data if no active query
4866
- if (!this.currentQuery.trim()) {
4867
- this.filteredData = [...this.data];
5938
+ if (composite.compositeName) {
5939
+ cell.classList.add('composite-cell');
5940
+
5941
+ composite.columns.forEach(col => {
5942
+ const subCell = document.createElement('div');
5943
+ subCell.className = 'composite-sub-cell';
5944
+
5945
+ if (col.aggregate) {
5946
+ const aggregateValue = this.calculateAggregate(col, groupData);
5947
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
5948
+ subCell.innerHTML = formattedValue;
5949
+ subCell.classList.add('aggregate-value');
5950
+ }
5951
+
5952
+ cell.appendChild(subCell);
5953
+ });
4868
5954
  } else {
4869
- // Re-apply query to include new/updated data
4870
- this.applyQuery(this.currentQuery);
5955
+ const col = composite.columns[0];
5956
+
5957
+ if (col.aggregate) {
5958
+ const aggregateValue = this.calculateAggregate(col, groupData);
5959
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
5960
+ cell.innerHTML = formattedValue;
5961
+ cell.classList.add('aggregate-value');
5962
+ }
4871
5963
  }
4872
5964
 
4873
- // Only update info section and re-render if not skipped
4874
- // (loadNextPage will handle this after setting loading state correctly)
4875
- if (!skipInfoUpdate) {
4876
- this.updateInfoSection();
4877
- this.render();
4878
- } else {
4879
- // Still need to render the data, just skip the info section update
4880
- this.render();
4881
- }
4882
- }
5965
+ summaryRow.appendChild(cell);
5966
+ });
4883
5967
 
4884
- return {
4885
- added: addedCount,
4886
- updated: updatedCount,
4887
- skipped: invalid.length,
4888
- invalid
4889
- };
5968
+ return summaryRow;
4890
5969
  }
4891
5970
 
4892
- replaceData(newData) {
4893
- if (!newData || !Array.isArray(newData)) {
4894
- console.warn('replaceData requires a valid array');
4895
- return { success: false, message: 'Invalid data provided' };
5971
+ /**
5972
+ * Create header summary row pair for fixed columns layout
5973
+ * @param {Array} dataToRender - Data to calculate aggregates from
5974
+ * @returns {Object} Object with fixedSummary and scrollSummary elements
5975
+ */
5976
+ createHeaderSummaryRowWithFixedColumns(dataToRender) {
5977
+ const { fixedColumns, scrollColumns } = this.splitColumnsForFixedLayout();
5978
+ const aggregationData = this.getAggregationDataSet(dataToRender);
5979
+
5980
+ // Fixed section summary row
5981
+ const fixedSummary = document.createElement('div');
5982
+ fixedSummary.className = 'div-table-row div-table-fixed-row summary-row header-summary';
5983
+
5984
+ let fixedGridTemplate = '';
5985
+ if (this.showCheckboxes) {
5986
+ fixedGridTemplate = '40px ';
4896
5987
  }
4897
-
4898
- // Validate data integrity and check for duplicates within the new data
4899
- const duplicates = [];
4900
- const seenIds = new Set();
4901
- const validRecords = [];
4902
5988
 
4903
- for (const record of newData) {
4904
- if (!record || typeof record !== 'object') {
4905
- console.warn('replaceData: Skipping invalid record', record);
4906
- continue;
4907
- }
4908
-
4909
- // Ensure the record has a primary key
4910
- if (!record[this.primaryKeyField]) {
4911
- console.warn(`replaceData: Skipping record without ${this.primaryKeyField}`, record);
4912
- continue;
4913
- }
4914
-
4915
- // Check for duplicate primary key within the new data
4916
- const recordId = String(record[this.primaryKeyField]);
4917
- if (seenIds.has(recordId)) {
4918
- duplicates.push(recordId);
4919
- console.warn(`replaceData: Skipping duplicate ${this.primaryKeyField} '${recordId}' within new data`);
4920
- continue;
4921
- }
4922
-
4923
- seenIds.add(recordId);
4924
- validRecords.push(record);
5989
+ fixedColumns.forEach(composite => {
5990
+ fixedGridTemplate += this.getColumnGridSize(composite) + ' ';
5991
+ });
5992
+
5993
+ fixedSummary.style.gridTemplateColumns = fixedGridTemplate.trim();
5994
+
5995
+ // Empty checkbox cell in fixed section
5996
+ if (this.showCheckboxes) {
5997
+ const emptyCell = document.createElement('div');
5998
+ emptyCell.className = 'div-table-cell checkbox-column summary-cell';
5999
+ fixedSummary.appendChild(emptyCell);
4925
6000
  }
4926
-
4927
- this.data = validRecords;
4928
- this.isLoadingState = false;
4929
- this.clearRefreshButtonLoadingState();
4930
6001
 
4931
- this.queryEngine.setObjects(this.data);
6002
+ // Fixed columns cells
6003
+ fixedColumns.forEach(composite => {
6004
+ const cell = this.createSummaryCell(composite, aggregationData);
6005
+ fixedSummary.appendChild(cell);
6006
+ });
4932
6007
 
4933
- this.updateQueryEditorIfNeeded();
6008
+ // Scroll section summary row
6009
+ const scrollSummary = document.createElement('div');
6010
+ scrollSummary.className = 'div-table-row summary-row header-summary';
4934
6011
 
4935
- if (this.currentQuery && this.currentQuery.trim()) {
4936
- this.applyQuery(this.currentQuery);
4937
- } else {
4938
- this.filteredData = [...this.data];
6012
+ let scrollGridTemplate = '';
6013
+ scrollColumns.forEach(composite => {
6014
+ scrollGridTemplate += this.getColumnGridSize(composite) + ' ';
6015
+ });
6016
+
6017
+ scrollSummary.style.gridTemplateColumns = scrollGridTemplate.trim();
6018
+
6019
+ // Scroll columns cells
6020
+ scrollColumns.forEach(composite => {
6021
+ const cell = this.createSummaryCell(composite, aggregationData);
6022
+ scrollSummary.appendChild(cell);
6023
+ });
6024
+
6025
+ return { fixedSummary, scrollSummary };
6026
+ }
6027
+
6028
+ /**
6029
+ * Create group summary row pair for fixed columns layout
6030
+ * @param {Object} group - Group object with items array
6031
+ * @returns {Object} Object with fixedSummary and scrollSummary elements
6032
+ */
6033
+ createGroupSummaryRowWithFixedColumns(group) {
6034
+ const { fixedColumns, scrollColumns } = this.splitColumnsForFixedLayout();
6035
+
6036
+ // Get data for this group, considering selection
6037
+ let groupData = group.items;
6038
+ if (this.selectedRows.size > 0) {
6039
+ groupData = group.items.filter(item => {
6040
+ const itemId = String(item[this.primaryKeyField]);
6041
+ return this.selectedRows.has(itemId);
6042
+ });
4939
6043
  }
4940
6044
 
4941
- this.selectedRows.clear();
6045
+ // Fixed section summary row
6046
+ const fixedSummary = document.createElement('div');
6047
+ fixedSummary.className = 'div-table-row div-table-fixed-row summary-row group-summary';
6048
+ fixedSummary.dataset.groupKey = group.key;
4942
6049
 
4943
- this.virtualScrollingState = {
4944
- scrollTop: 0,
4945
- displayStartIndex: 0,
4946
- displayEndIndex: Math.min(this.pageSize, this.data.length),
4947
- isLoading: false,
4948
- };
6050
+ let fixedGridTemplate = '';
6051
+ if (this.showCheckboxes) {
6052
+ fixedGridTemplate = '40px ';
6053
+ }
4949
6054
 
4950
- // Reset pagination to first page (zero-indexed)
4951
- this.currentPage = 0;
4952
- this.startId = 1;
6055
+ fixedColumns.forEach(composite => {
6056
+ fixedGridTemplate += this.getColumnGridSize(composite) + ' ';
6057
+ });
4953
6058
 
4954
- // Update hasMoreData flag based on whether we have less data than totalRecords
4955
- if (this.virtualScrolling && this.totalRecords) {
4956
- this.hasMoreData = validRecords.length < this.totalRecords;
6059
+ fixedSummary.style.gridTemplateColumns = fixedGridTemplate.trim();
6060
+
6061
+ // Empty checkbox cell
6062
+ if (this.showCheckboxes) {
6063
+ const emptyCell = document.createElement('div');
6064
+ emptyCell.className = 'div-table-cell checkbox-column summary-cell';
6065
+ fixedSummary.appendChild(emptyCell);
4957
6066
  }
4958
6067
 
4959
- // Update info display and re-render
4960
- this.updateInfoSection();
4961
- this.render();
6068
+ // Fixed columns cells
6069
+ fixedColumns.forEach(composite => {
6070
+ const cell = this.createSummaryCell(composite, groupData);
6071
+ fixedSummary.appendChild(cell);
6072
+ });
4962
6073
 
4963
- return {
4964
- success: true,
4965
- totalProvided: newData.length,
4966
- validRecords: validRecords.length,
4967
- skipped: newData.length - validRecords.length,
4968
- duplicates
4969
- };
6074
+ // Scroll section summary row
6075
+ const scrollSummary = document.createElement('div');
6076
+ scrollSummary.className = 'div-table-row summary-row group-summary';
6077
+ scrollSummary.dataset.groupKey = group.key;
6078
+
6079
+ let scrollGridTemplate = '';
6080
+ scrollColumns.forEach(composite => {
6081
+ scrollGridTemplate += this.getColumnGridSize(composite) + ' ';
6082
+ });
6083
+
6084
+ scrollSummary.style.gridTemplateColumns = scrollGridTemplate.trim();
6085
+
6086
+ // Scroll columns cells
6087
+ scrollColumns.forEach(composite => {
6088
+ const cell = this.createSummaryCell(composite, groupData);
6089
+ scrollSummary.appendChild(cell);
6090
+ });
6091
+
6092
+ return { fixedSummary, scrollSummary };
4970
6093
  }
4971
6094
 
4972
- // Loading placeholder management
4973
- resetToLoading() {
4974
- this.isLoadingState = true;
4975
- this.data = [];
4976
- this.filteredData = [];
4977
- this.selectedRows.clear();
4978
- this.currentQuery = '';
6095
+ /**
6096
+ * Create a summary cell for a composite column
6097
+ * @param {Object} composite - Composite column definition
6098
+ * @param {Array} data - Data to aggregate
6099
+ * @returns {HTMLElement} Cell element
6100
+ */
6101
+ createSummaryCell(composite, data) {
6102
+ const cell = document.createElement('div');
6103
+ cell.className = 'div-table-cell summary-cell';
4979
6104
 
4980
- // Update Monaco editor if it exists
4981
- if (this.queryEditor?.editor) {
4982
- this.queryEditor.editor.setValue('');
6105
+ if (composite.compositeName) {
6106
+ cell.classList.add('composite-cell');
6107
+
6108
+ composite.columns.forEach(col => {
6109
+ const subCell = document.createElement('div');
6110
+ subCell.className = 'composite-sub-cell';
6111
+
6112
+ if (col.aggregate) {
6113
+ const aggregateValue = this.calculateAggregate(col, data);
6114
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
6115
+ subCell.innerHTML = formattedValue;
6116
+ subCell.classList.add('aggregate-value');
6117
+ // Apply column alignment
6118
+ if (col.align) {
6119
+ subCell.style.textAlign = col.align;
6120
+ }
6121
+ }
6122
+
6123
+ cell.appendChild(subCell);
6124
+ });
6125
+ } else {
6126
+ const col = composite.columns[0];
6127
+
6128
+ // Apply column alignment to summary cell
6129
+ if (col.align) {
6130
+ cell.style.textAlign = col.align;
6131
+ cell.style.justifyContent = col.align === 'right' ? 'flex-end' :
6132
+ col.align === 'center' ? 'center' : 'flex-start';
6133
+ }
6134
+
6135
+ if (col.aggregate) {
6136
+ const aggregateValue = this.calculateAggregate(col, data);
6137
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
6138
+ cell.innerHTML = formattedValue;
6139
+ cell.classList.add('aggregate-value');
6140
+ }
4983
6141
  }
4984
6142
 
4985
- // Update the query engine with empty data
4986
- this.queryEngine.setObjects([]);
6143
+ return cell;
6144
+ }
6145
+
6146
+ /**
6147
+ * Update all summary rows (called when selection changes)
6148
+ * Re-calculates aggregates based on current selection state
6149
+ */
6150
+ updateSummaryRows() {
6151
+ if (!this.hasAggregateColumns()) return;
6152
+ if (!this.showHeaderSummary && !this.showGroupSummary) return;
4987
6153
 
4988
- // Re-render to show loading placeholder
4989
- this.render();
6154
+ // Get current data to render
6155
+ let dataToRender = this.filteredData;
6156
+ if (this.showOnlySelected && this.selectedRows.size > 0) {
6157
+ dataToRender = this.filteredData.filter(item => {
6158
+ const itemId = String(item[this.primaryKeyField]);
6159
+ return this.selectedRows.has(itemId);
6160
+ });
6161
+ }
6162
+
6163
+ const aggregationData = this.getAggregationDataSet(dataToRender);
6164
+
6165
+ // Update header summary row
6166
+ if (this.showHeaderSummary) {
6167
+ this.updateHeaderSummaryValues(aggregationData);
6168
+ }
6169
+
6170
+ // Update group summary rows
6171
+ if (this.showGroupSummary && this.groupByField) {
6172
+ this.updateGroupSummaryValues(dataToRender);
6173
+ }
4990
6174
  }
4991
6175
 
4992
- setLoadingState(isLoading) {
4993
- this.isLoadingState = Boolean(isLoading);
4994
- this.render(); // Re-render to show/hide loading placeholder
6176
+ /**
6177
+ * Update aggregate values in the header summary row
6178
+ * @param {Array} aggregationData - Data to aggregate
6179
+ */
6180
+ updateHeaderSummaryValues(aggregationData) {
6181
+ const summaryRows = this.fixedColumns > 0
6182
+ ? [
6183
+ this.fixedBodyContainer?.querySelector('.header-summary'),
6184
+ this.scrollBodyContainer?.querySelector('.header-summary')
6185
+ ].filter(Boolean)
6186
+ : [this.bodyContainer?.querySelector('.header-summary')].filter(Boolean);
6187
+
6188
+ summaryRows.forEach(summaryRow => {
6189
+ const aggregateCells = summaryRow.querySelectorAll('.aggregate-value');
6190
+ const aggregateColumns = this.getAggregateColumns();
6191
+
6192
+ aggregateCells.forEach((cell, index) => {
6193
+ if (aggregateColumns[index]) {
6194
+ const col = aggregateColumns[index];
6195
+ const aggregateValue = this.calculateAggregate(col, aggregationData);
6196
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
6197
+ cell.innerHTML = formattedValue;
6198
+ }
6199
+ });
6200
+ });
6201
+ }
6202
+
6203
+ /**
6204
+ * Update aggregate values in all group summary rows
6205
+ * @param {Array} dataToRender - Current displayed data
6206
+ */
6207
+ updateGroupSummaryValues(dataToRender) {
6208
+ const groups = this.groupData(dataToRender);
6209
+
6210
+ groups.forEach(group => {
6211
+ let groupData = group.items;
6212
+ if (this.selectedRows.size > 0) {
6213
+ groupData = group.items.filter(item => {
6214
+ const itemId = String(item[this.primaryKeyField]);
6215
+ return this.selectedRows.has(itemId);
6216
+ });
6217
+ }
6218
+
6219
+ const groupSummaries = this.fixedColumns > 0
6220
+ ? [
6221
+ this.fixedBodyContainer?.querySelector(`.group-summary[data-group-key="${group.key}"]`),
6222
+ this.scrollBodyContainer?.querySelector(`.group-summary[data-group-key="${group.key}"]`)
6223
+ ].filter(Boolean)
6224
+ : [this.bodyContainer?.querySelector(`.group-summary[data-group-key="${group.key}"]`)].filter(Boolean);
6225
+
6226
+ groupSummaries.forEach(summaryRow => {
6227
+ const aggregateCells = summaryRow.querySelectorAll('.aggregate-value');
6228
+ const aggregateColumns = this.getAggregateColumns();
6229
+
6230
+ aggregateCells.forEach((cell, index) => {
6231
+ if (aggregateColumns[index]) {
6232
+ const col = aggregateColumns[index];
6233
+ const aggregateValue = this.calculateAggregate(col, groupData);
6234
+ const formattedValue = this.formatAggregateValue(aggregateValue, col);
6235
+ cell.innerHTML = formattedValue;
6236
+ }
6237
+ });
6238
+ });
6239
+ });
4995
6240
  }
4996
6241
  }
4997
6242