@zengrid/core 1.2.2 → 1.3.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.
Files changed (105) hide show
  1. package/dist/index.cjs.js +1879 -1095
  2. package/dist/index.esm.js +1936 -1161
  3. package/dist/package.json +2 -2
  4. package/dist/src/datetime-core/formatting/date-formatter.d.ts +2 -0
  5. package/dist/src/datetime-core/formatting/date-formatter.d.ts.map +1 -1
  6. package/dist/src/datetime-core/theming/datetime.css +463 -407
  7. package/dist/src/editing/checkbox/checkbox-dom.d.ts.map +1 -1
  8. package/dist/src/editing/datetime/date-editor/date-editor.d.ts.map +1 -1
  9. package/dist/src/editing/datetime/date-range-editor/date-range-calendar.d.ts.map +1 -1
  10. package/dist/src/editing/editor-manager/editor-manager.d.ts +23 -1
  11. package/dist/src/editing/editor-manager/editor-manager.d.ts.map +1 -1
  12. package/dist/src/editing/number/number-editor.d.ts.map +1 -1
  13. package/dist/src/editing/select/select-editor.d.ts.map +1 -1
  14. package/dist/src/features/column-drag/drag-mouse-handler.d.ts +1 -0
  15. package/dist/src/features/column-drag/drag-mouse-handler.d.ts.map +1 -1
  16. package/dist/src/features/copy-paste/clipboard-manager.d.ts +24 -2
  17. package/dist/src/features/copy-paste/clipboard-manager.d.ts.map +1 -1
  18. package/dist/src/features/loading/loading-indicator.d.ts.map +1 -1
  19. package/dist/src/features/loading/loading.styles.css +346 -342
  20. package/dist/src/features/selection/selection-manager.d.ts +14 -0
  21. package/dist/src/features/selection/selection-manager.d.ts.map +1 -1
  22. package/dist/src/grid/grid-core.d.ts.map +1 -1
  23. package/dist/src/grid/grid-setup.d.ts.map +1 -1
  24. package/dist/src/index.d.ts +9 -2
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/plugins/clipboard/clipboard-plugin.d.ts +21 -0
  27. package/dist/src/plugins/clipboard/clipboard-plugin.d.ts.map +1 -0
  28. package/dist/src/plugins/clipboard/index.d.ts +3 -0
  29. package/dist/src/plugins/clipboard/index.d.ts.map +1 -0
  30. package/dist/src/plugins/column/column-plugin.d.ts.map +1 -1
  31. package/dist/src/plugins/dom/dom-plugin.d.ts.map +1 -1
  32. package/dist/src/plugins/editing/editing-plugin.d.ts.map +1 -1
  33. package/dist/src/plugins/filter-ui/field-state-utils.d.ts +6 -0
  34. package/dist/src/plugins/filter-ui/field-state-utils.d.ts.map +1 -0
  35. package/dist/src/plugins/filter-ui/filter-chip-bar.d.ts +16 -0
  36. package/dist/src/plugins/filter-ui/filter-chip-bar.d.ts.map +1 -0
  37. package/dist/src/plugins/filter-ui/filter-chip-descriptors.d.ts +4 -0
  38. package/dist/src/plugins/filter-ui/filter-chip-descriptors.d.ts.map +1 -0
  39. package/dist/src/plugins/filter-ui/filter-popup-dom.d.ts +32 -0
  40. package/dist/src/plugins/filter-ui/filter-popup-dom.d.ts.map +1 -0
  41. package/dist/src/plugins/filter-ui/filter-popup.d.ts +3 -0
  42. package/dist/src/plugins/filter-ui/filter-popup.d.ts.map +1 -0
  43. package/dist/src/plugins/filter-ui/filter-ui-operators.d.ts +9 -0
  44. package/dist/src/plugins/filter-ui/filter-ui-operators.d.ts.map +1 -0
  45. package/dist/src/plugins/filter-ui/filter-ui-types.d.ts +56 -0
  46. package/dist/src/plugins/filter-ui/filter-ui-types.d.ts.map +1 -0
  47. package/dist/src/plugins/filter-ui/grid-filter-ui.d.ts +20 -0
  48. package/dist/src/plugins/filter-ui/grid-filter-ui.d.ts.map +1 -0
  49. package/dist/src/plugins/filter-ui/index.d.ts +1 -0
  50. package/dist/src/plugins/filter-ui/index.d.ts.map +1 -1
  51. package/dist/src/plugins/header/header-dom-operations.d.ts +6 -0
  52. package/dist/src/plugins/header/header-dom-operations.d.ts.map +1 -1
  53. package/dist/src/plugins/header/header-plugin.d.ts.map +1 -1
  54. package/dist/src/plugins/header/header-registry-setup.d.ts.map +1 -1
  55. package/dist/src/plugins/header/header-rendering.d.ts.map +1 -1
  56. package/dist/src/plugins/header/header-types.d.ts +8 -1
  57. package/dist/src/plugins/header/header-types.d.ts.map +1 -1
  58. package/dist/src/plugins/index.d.ts +4 -0
  59. package/dist/src/plugins/index.d.ts.map +1 -1
  60. package/dist/src/plugins/keyboard/index.d.ts +3 -0
  61. package/dist/src/plugins/keyboard/index.d.ts.map +1 -0
  62. package/dist/src/plugins/keyboard/keyboard-nav-utils.d.ts +13 -0
  63. package/dist/src/plugins/keyboard/keyboard-nav-utils.d.ts.map +1 -0
  64. package/dist/src/plugins/keyboard/keyboard-plugin.d.ts +17 -0
  65. package/dist/src/plugins/keyboard/keyboard-plugin.d.ts.map +1 -0
  66. package/dist/src/plugins/lifecycle/lifecycle-plugin.d.ts +1 -0
  67. package/dist/src/plugins/lifecycle/lifecycle-plugin.d.ts.map +1 -1
  68. package/dist/src/plugins/pagination/pagination-plugin.d.ts.map +1 -1
  69. package/dist/src/plugins/rendering/rendering-plugin.d.ts.map +1 -1
  70. package/dist/src/plugins/selection/selection-plugin.d.ts.map +1 -1
  71. package/dist/src/reactive/store.d.ts +1 -0
  72. package/dist/src/reactive/store.d.ts.map +1 -1
  73. package/dist/src/reactive/types.d.ts +1 -0
  74. package/dist/src/reactive/types.d.ts.map +1 -1
  75. package/dist/src/rendering/cache/renderer-cache.interface.d.ts +6 -0
  76. package/dist/src/rendering/cache/renderer-cache.interface.d.ts.map +1 -1
  77. package/dist/src/rendering/cell-positioner/cell-positioner.d.ts +21 -0
  78. package/dist/src/rendering/cell-positioner/cell-positioner.d.ts.map +1 -1
  79. package/dist/src/rendering/headers/header-renderer.interface.d.ts +6 -0
  80. package/dist/src/rendering/headers/header-renderer.interface.d.ts.map +1 -1
  81. package/dist/src/rendering/headers/renderers/sortable-header-renderer.d.ts +4 -0
  82. package/dist/src/rendering/headers/renderers/sortable-header-renderer.d.ts.map +1 -1
  83. package/dist/src/rendering/renderers/dropdown/dropdown-dom.d.ts.map +1 -1
  84. package/dist/src/rendering/renderers/dropdown/dropdown-renderer.d.ts +2 -0
  85. package/dist/src/rendering/renderers/dropdown/dropdown-renderer.d.ts.map +1 -1
  86. package/dist/src/rendering/renderers/dropdown/dropdown-state.d.ts.map +1 -1
  87. package/dist/src/rendering/renderers/renderer.interface.d.ts +7 -0
  88. package/dist/src/rendering/renderers/renderer.interface.d.ts.map +1 -1
  89. package/dist/src/styles/a11y.css +20 -0
  90. package/dist/src/styles/calendar.css +291 -0
  91. package/dist/src/styles/cell-overflow.css +129 -0
  92. package/dist/src/styles/cell-renderers.css +90 -0
  93. package/dist/src/styles/date-picker.css +125 -0
  94. package/dist/src/styles/dropdown.css +156 -0
  95. package/dist/src/styles/filter.css +166 -0
  96. package/dist/src/styles/grid.css +91 -287
  97. package/dist/src/styles/header.css +60 -0
  98. package/dist/src/styles/overlays.css +44 -0
  99. package/dist/src/styles/tokens.css +86 -0
  100. package/dist/src/styles.css +24 -1112
  101. package/dist/src/types/grid.d.ts +32 -2
  102. package/dist/src/types/grid.d.ts.map +1 -1
  103. package/package.json +3 -3
  104. package/dist/src/grid/filter-ui.d.ts +0 -71
  105. package/dist/src/grid/filter-ui.d.ts.map +0 -1
package/dist/index.cjs.js CHANGED
@@ -1065,6 +1065,9 @@ function activatePhantom(key, realValue) {
1065
1065
  phantomSubscribers.delete(key);
1066
1066
  }
1067
1067
  }
1068
+ function hasPlaceholder(key) {
1069
+ return placeholders.has(key);
1070
+ }
1068
1071
  // --- Ownership helpers ---
1069
1072
  function registerOwnership(owner, name) {
1070
1073
  let set = ownershipMap.get(owner);
@@ -1205,12 +1208,19 @@ class CellPositioner {
1205
1208
  constructor(options) {
1206
1209
  this.lastRange = null;
1207
1210
  this.renderedCells = new Map(); // key -> renderer name
1211
+ // The renderer instance actually applied to each pooled element. Keyed by the
1212
+ // element (not the cell key) so destroy() dispatches to the correct renderer
1213
+ // even for instance renderers, whose constructor.name is not in the registry.
1214
+ this.appliedRenderer = new WeakMap();
1208
1215
  // Height measurement state (single RAF batch)
1209
1216
  this.rafPending = false;
1210
1217
  this.rowsToMeasure = new Map(); // row -> auto-height cells
1211
1218
  this.heightCache = new Map(); // row -> measured height
1212
1219
  // Reactive subscription
1213
1220
  this.viewportSubscription = null;
1221
+ // Device-pixel-ratio (browser zoom) change tracking - re-snap edges on zoom
1222
+ this.dprQuery = null;
1223
+ this.dprListener = null;
1214
1224
  this.scroller = options.scroller;
1215
1225
  this.pool = options.pool;
1216
1226
  this.registry = options.registry;
@@ -1222,6 +1232,31 @@ class CellPositioner {
1222
1232
  this.isActive = options.isActive ?? (() => false);
1223
1233
  this.isEditing = options.isEditing ?? (() => false);
1224
1234
  this.rowHeightManager = options.rowHeightManager;
1235
+ this.setupDprListener();
1236
+ }
1237
+ /**
1238
+ * Re-render on device-pixel-ratio change (browser zoom) so cell edges re-snap to
1239
+ * whole device pixels. A `resolution` media query fires only when the DPR actually
1240
+ * changes - not on ordinary resize - and is bound to the current DPR, so it is
1241
+ * re-armed after every change.
1242
+ */
1243
+ setupDprListener() {
1244
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
1245
+ return;
1246
+ }
1247
+ const arm = () => {
1248
+ if (this.dprQuery && this.dprListener) {
1249
+ this.dprQuery.removeEventListener('change', this.dprListener);
1250
+ }
1251
+ const dpr = window.devicePixelRatio || 1;
1252
+ this.dprQuery = window.matchMedia(`(resolution: ${dpr}dppx)`);
1253
+ this.dprListener = () => {
1254
+ arm();
1255
+ this.refresh();
1256
+ };
1257
+ this.dprQuery.addEventListener('change', this.dprListener);
1258
+ };
1259
+ arm();
1225
1260
  }
1226
1261
  /**
1227
1262
  * Subscribe to viewport changes (reactive mode)
@@ -1294,12 +1329,29 @@ class CellPositioner {
1294
1329
  }
1295
1330
  return;
1296
1331
  }
1297
- for (let row = this.lastRange.startRow; row < this.lastRange.endRow; row++) {
1298
- for (let col = this.lastRange.startCol; col < this.lastRange.endCol; col++) {
1332
+ // The row/col count can shrink after lastRange was recorded (e.g. a backend
1333
+ // response with fewer rows). Clamp to current bounds so a stale out-of-range
1334
+ // index is never positioned (the height/width provider would throw), and
1335
+ // release the cells that fell outside so no ghost rows linger.
1336
+ const endRow = Math.min(this.lastRange.endRow, this.scroller.rowCount);
1337
+ const endCol = Math.min(this.lastRange.endCol, this.scroller.colCount);
1338
+ const startRow = Math.min(this.lastRange.startRow, endRow);
1339
+ const startCol = Math.min(this.lastRange.startCol, endCol);
1340
+ const activeKeys = new Set();
1341
+ for (let row = startRow; row < endRow; row++) {
1342
+ for (let col = startCol; col < endCol; col++) {
1299
1343
  const key = this.getCellKey(row, col);
1344
+ activeKeys.add(key);
1300
1345
  this.renderCell(row, col, key);
1301
1346
  }
1302
1347
  }
1348
+ this.pool.releaseExcept(activeKeys);
1349
+ for (const key of this.renderedCells.keys()) {
1350
+ if (!activeKeys.has(key)) {
1351
+ this.renderedCells.delete(key);
1352
+ }
1353
+ }
1354
+ this.lastRange = { startRow, endRow, startCol, endCol };
1303
1355
  }
1304
1356
  /**
1305
1357
  * Clear rendered cells tracking so next renderVisibleCells re-renders all cells
@@ -1332,6 +1384,12 @@ class CellPositioner {
1332
1384
  this.viewportSubscription();
1333
1385
  this.viewportSubscription = null;
1334
1386
  }
1387
+ // Remove device-pixel-ratio (zoom) listener
1388
+ if (this.dprQuery && this.dprListener) {
1389
+ this.dprQuery.removeEventListener('change', this.dprListener);
1390
+ this.dprQuery = null;
1391
+ this.dprListener = null;
1392
+ }
1335
1393
  this.pool.clear();
1336
1394
  this.renderedCells.clear();
1337
1395
  this.lastRange = null;
@@ -1345,10 +1403,22 @@ class CellPositioner {
1345
1403
  const value = this.getData(row, col);
1346
1404
  const column = this.getColumn?.(col);
1347
1405
  const rowData = this.getRowData?.(row);
1406
+ // Snap each cell EDGE (not its width) to a whole device pixel. The 1px inset
1407
+ // box-shadow that draws a cell border collapses to nothing when the edge lands on
1408
+ // a fractional device row - which is what happens at 100% zoom on fractional-DPR
1409
+ // displays (Windows 125%/150% scaling), causing borders to vanish "randomly".
1410
+ // Because offsets are prefix sums (offset(c) + width(c) === offset(c+1)), snapping
1411
+ // the left/right edges independently keeps neighbours exactly flush: no gaps or
1412
+ // overlaps, and it stays correct through column resizing and row-height changes.
1413
+ const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
1414
+ const left = Math.round(position.x * dpr) / dpr;
1415
+ const top = Math.round(position.y * dpr) / dpr;
1416
+ const width = Math.round((position.x + position.width) * dpr) / dpr - left;
1417
+ const height = Math.round((position.y + position.height) * dpr) / dpr - top;
1348
1418
  // Position the cell using batched style updates (cssText) to prevent layout thrashing
1349
1419
  // This is critical for scroll performance - individual style.property assignments
1350
1420
  // can trigger multiple style recalculations, while cssText batches them
1351
- element.style.cssText = `position: absolute; left: ${position.x}px; top: ${position.y}px; width: ${position.width}px; height: ${position.height}px;`;
1421
+ element.style.cssText = `position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;`;
1352
1422
  // For auto-height columns being measured, allow the cell to expand beyond the set height
1353
1423
  // This is only for the MEASUREMENT phase - after measurement, all cells use the measured height
1354
1424
  const needsMeasurement = this.rowHeightManager?.needsMeasurement(row);
@@ -1356,10 +1426,11 @@ class CellPositioner {
1356
1426
  // Override height constraint for measurement - let content expand
1357
1427
  // The browser will naturally reflow, and we'll measure in the next frame via RAF
1358
1428
  element.style.height = 'auto';
1359
- element.style.minHeight = `${position.height}px`;
1429
+ element.style.minHeight = `${height}px`;
1360
1430
  }
1361
1431
  element.setAttribute('data-row', row.toString());
1362
1432
  element.setAttribute('data-col', col.toString());
1433
+ element.classList.toggle('zg-cell-last-col', col === this.scroller.colCount - 1);
1363
1434
  // Get renderer - handle both string names and renderer instances
1364
1435
  let renderer;
1365
1436
  let rendererName;
@@ -1391,8 +1462,9 @@ class CellPositioner {
1391
1462
  isActive: this.isActive(row, col),
1392
1463
  isEditing: this.isEditing(row, col),
1393
1464
  };
1394
- // Generate cache key
1395
- const cacheKey = this.cache
1465
+ // Generate cache key (skip HTML-string caching for interactive renderers,
1466
+ // which lose event listeners / body-portaled DOM when restored via innerHTML)
1467
+ const cacheKey = this.cache && renderer.cacheable !== false
1396
1468
  ? RendererCache.generateKey(row, col, value, rendererName, {
1397
1469
  isSelected: params.isSelected,
1398
1470
  isActive: params.isActive,
@@ -1403,13 +1475,27 @@ class CellPositioner {
1403
1475
  if (cacheKey && this.cache) {
1404
1476
  const cached = this.cache.get(cacheKey);
1405
1477
  if (cached) {
1478
+ // Renderer changed for this recycled element: destroy the previous one so
1479
+ // its classes (alignment via zg-cell-text/zg-cell-number, or padding-0
1480
+ // containers like zg-cell-checkbox-container) are stripped before we apply
1481
+ // the cached content's classes additively.
1482
+ const prevRenderer = this.appliedRenderer.get(element);
1483
+ if (prevRenderer && prevRenderer !== renderer) {
1484
+ prevRenderer.destroy(element);
1485
+ }
1406
1486
  // Use cached content
1407
1487
  element.innerHTML = cached.html;
1408
1488
  // Apply cached classes
1409
1489
  if (cached.classes) {
1410
1490
  cached.classes.forEach((cls) => element.classList.add(cls));
1411
1491
  }
1492
+ // Re-apply inline styles the renderer set on the cell (the cssText
1493
+ // reset above wiped them, and innerHTML does not carry them).
1494
+ if (cached.styles) {
1495
+ cached.styles.forEach(([prop, val]) => element.style.setProperty(prop, val));
1496
+ }
1412
1497
  this.renderedCells.set(key, rendererName);
1498
+ this.appliedRenderer.set(element, renderer);
1413
1499
  // Apply state classes
1414
1500
  element.classList.toggle('zg-cell-selected', params.isSelected);
1415
1501
  element.classList.toggle('zg-cell-active', params.isActive);
@@ -1422,23 +1508,31 @@ class CellPositioner {
1422
1508
  return;
1423
1509
  }
1424
1510
  }
1425
- // Render or update (cache miss or no cache)
1426
- const lastRenderer = this.renderedCells.get(key);
1427
- // Capture classes before render for delta calculation
1511
+ // Render or update (cache miss or no cache). The render/update decision is
1512
+ // keyed per cell: the pool wipes an element's innerHTML on release, so a
1513
+ // recycled element must be re-rendered even when the renderer is unchanged.
1514
+ const lastRendererName = this.renderedCells.get(key);
1515
+ // Capture classes/styles before render for delta calculation.
1516
+ // Style snapshot is only needed when the result will be cached.
1428
1517
  const classesBefore = new Set(Array.from(element.classList));
1429
- if (lastRenderer !== rendererName || !lastRenderer) {
1430
- // Renderer changed or first render - destroy old and render new
1431
- if (lastRenderer) {
1432
- const oldRenderer = this.registry.get(lastRenderer);
1433
- oldRenderer.destroy(element);
1518
+ const stylesBefore = cacheKey ? this.snapshotStyles(element) : null;
1519
+ const didRender = lastRendererName !== rendererName || !lastRendererName;
1520
+ if (didRender) {
1521
+ // Renderer changed or first render - destroy the renderer actually applied
1522
+ // to this element (tracked by instance, so instance renderers dispatch to
1523
+ // the correct destroy) before rendering the new one.
1524
+ const prevRenderer = this.appliedRenderer.get(element);
1525
+ if (prevRenderer && prevRenderer !== renderer) {
1526
+ prevRenderer.destroy(element);
1434
1527
  }
1435
1528
  renderer.render(element, params);
1436
- this.renderedCells.set(key, rendererName);
1437
1529
  }
1438
1530
  else {
1439
1531
  // Same renderer - just update
1440
1532
  renderer.update(element, params);
1441
1533
  }
1534
+ this.renderedCells.set(key, rendererName);
1535
+ this.appliedRenderer.set(element, renderer);
1442
1536
  // Apply optional renderer class (before caching so it's included)
1443
1537
  if (renderer.getCellClass) {
1444
1538
  const rendererClass = renderer.getCellClass(params);
@@ -1446,8 +1540,14 @@ class CellPositioner {
1446
1540
  element.classList.add(rendererClass);
1447
1541
  }
1448
1542
  }
1449
- // Cache the rendered content with all classes added by renderer
1450
- if (cacheKey && this.cache) {
1543
+ // Cache the rendered content with all classes added by renderer.
1544
+ // Only cache from the render branch: there classesBefore/stylesBefore are a
1545
+ // clean baseline (pool reset + structural classes), so the delta captures
1546
+ // every renderer-owned class/style. In the update branch the element still
1547
+ // holds the renderer's classes from its prior render, so the delta would
1548
+ // omit them (e.g. zg-cell-number) and a later cache-hit on a freshly-pooled
1549
+ // element would drop that styling.
1550
+ if (cacheKey && this.cache && didRender) {
1451
1551
  // Calculate delta: classes added during render
1452
1552
  const addedClasses = [];
1453
1553
  element.classList.forEach((cls) => {
@@ -1455,9 +1555,11 @@ class CellPositioner {
1455
1555
  addedClasses.push(cls);
1456
1556
  }
1457
1557
  });
1558
+ const addedStyles = stylesBefore ? this.styleDelta(stylesBefore, element) : undefined;
1458
1559
  this.cache.set(cacheKey, {
1459
1560
  html: element.innerHTML,
1460
1561
  classes: addedClasses.length > 0 ? addedClasses : undefined,
1562
+ styles: addedStyles,
1461
1563
  });
1462
1564
  }
1463
1565
  // Apply state classes
@@ -1585,6 +1687,36 @@ class CellPositioner {
1585
1687
  // Add the configured overflow class
1586
1688
  element.classList.add(`zg-cell-overflow-${column.overflow.mode}`);
1587
1689
  }
1690
+ /**
1691
+ * Snapshot the inline style properties currently set on the cell element.
1692
+ * Used to diff against the post-render state so renderer-applied styles can
1693
+ * be cached and restored on cache hits.
1694
+ */
1695
+ snapshotStyles(element) {
1696
+ const style = element.style;
1697
+ const snapshot = new Map();
1698
+ for (let i = 0; i < style.length; i++) {
1699
+ const prop = style[i];
1700
+ snapshot.set(prop, style.getPropertyValue(prop));
1701
+ }
1702
+ return snapshot;
1703
+ }
1704
+ /**
1705
+ * Compute the inline styles added or changed since {@link snapshotStyles},
1706
+ * i.e. the styles the renderer set on the cell element.
1707
+ */
1708
+ styleDelta(before, element) {
1709
+ const style = element.style;
1710
+ const delta = [];
1711
+ for (let i = 0; i < style.length; i++) {
1712
+ const prop = style[i];
1713
+ const val = style.getPropertyValue(prop);
1714
+ if (before.get(prop) !== val) {
1715
+ delta.push([prop, val]);
1716
+ }
1717
+ }
1718
+ return delta.length > 0 ? delta : undefined;
1719
+ }
1588
1720
  getCellKey(row, col) {
1589
1721
  return `${row}-${col}`;
1590
1722
  }
@@ -3007,6 +3139,8 @@ function isValidDate(date) {
3007
3139
  * Supported tokens:
3008
3140
  * - YYYY: 4-digit year
3009
3141
  * - YY: 2-digit year
3142
+ * - MMMM: Full month name (January-December)
3143
+ * - MMM: Short month name (Jan-Dec)
3010
3144
  * - MM: 2-digit month (01-12)
3011
3145
  * - M: Month without leading zero (1-12)
3012
3146
  * - DD: 2-digit day (01-31)
@@ -3035,24 +3169,39 @@ function formatDate(date, format) {
3035
3169
  const hours12 = hours % 12 || 12;
3036
3170
  const ampm = hours >= 12 ? 'PM' : 'AM';
3037
3171
  const pad = (n) => String(n).padStart(2, '0');
3038
- return format
3039
- .replace('YYYY', String(year))
3040
- .replace('YY', String(year).slice(-2))
3041
- .replace('MM', pad(month))
3042
- .replace('M', String(month))
3043
- .replace('DD', pad(day))
3044
- .replace('D', String(day))
3045
- .replace('HH', pad(hours))
3046
- .replace('H', String(hours))
3047
- .replace('hh', pad(hours12))
3048
- .replace('h', String(hours12))
3049
- .replace('mm', pad(minutes))
3050
- .replace('m', String(minutes))
3051
- .replace('ss', pad(seconds))
3052
- .replace('s', String(seconds))
3053
- .replace('A', ampm)
3054
- .replace('a', ampm.toLowerCase());
3172
+ // Single-pass tokenizer: alternation is ordered longest-token-first so
3173
+ // multi-char tokens (YYYY/MMMM/MMM/MM) match before their shorter prefixes,
3174
+ // and each token is substituted exactly once — literal text is left intact.
3175
+ const tokens = {
3176
+ YYYY: String(year),
3177
+ YY: String(year).slice(-2),
3178
+ MMMM: MONTH_NAMES[month - 1],
3179
+ MMM: MONTH_NAMES_SHORT[month - 1],
3180
+ MM: pad(month),
3181
+ M: String(month),
3182
+ DD: pad(day),
3183
+ D: String(day),
3184
+ HH: pad(hours),
3185
+ H: String(hours),
3186
+ hh: pad(hours12),
3187
+ h: String(hours12),
3188
+ mm: pad(minutes),
3189
+ m: String(minutes),
3190
+ ss: pad(seconds),
3191
+ s: String(seconds),
3192
+ A: ampm,
3193
+ a: ampm.toLowerCase(),
3194
+ };
3195
+ return format.replace(/YYYY|YY|MMMM|MMM|MM|M|DD|D|HH|H|hh|h|mm|m|ss|s|A|a/g, (token) => tokens[token]);
3055
3196
  }
3197
+ const MONTH_NAMES = [
3198
+ 'January', 'February', 'March', 'April', 'May', 'June',
3199
+ 'July', 'August', 'September', 'October', 'November', 'December',
3200
+ ];
3201
+ const MONTH_NAMES_SHORT = [
3202
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
3203
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
3204
+ ];
3056
3205
  /**
3057
3206
  * Format a date for display (common formats)
3058
3207
  */
@@ -4780,6 +4929,13 @@ class TextHeaderRenderer {
4780
4929
  * ```
4781
4930
  */
4782
4931
  class SortableHeaderRenderer extends TextHeaderRenderer {
4932
+ constructor() {
4933
+ super(...arguments);
4934
+ /**
4935
+ * This renderer wires its own sort-click handler; skip the grid's fallback.
4936
+ */
4937
+ this.handlesSortInteraction = true;
4938
+ }
4783
4939
  /**
4784
4940
  * Initial render of sortable header
4785
4941
  */
@@ -4827,19 +4983,19 @@ class SortableHeaderRenderer extends TextHeaderRenderer {
4827
4983
  // Create sort indicator container
4828
4984
  const sortEl = document.createElement('span');
4829
4985
  sortEl.className = 'zg-sort-indicator';
4830
- sortEl.style.cssText = `
4831
- display: inline-flex;
4832
- flex-direction: column;
4833
- align-items: center;
4834
- justify-content: center;
4835
- margin-left: 2px;
4836
- font-size: 9px;
4837
- line-height: 1;
4838
- flex-shrink: 0;
4839
- width: 14px;
4840
- min-width: 14px;
4841
- max-width: 14px;
4842
- overflow: visible;
4986
+ sortEl.style.cssText = `
4987
+ display: inline-flex;
4988
+ flex-direction: column;
4989
+ align-items: center;
4990
+ justify-content: center;
4991
+ margin-left: 2px;
4992
+ font-size: 9px;
4993
+ line-height: 1;
4994
+ flex-shrink: 0;
4995
+ width: 14px;
4996
+ min-width: 14px;
4997
+ max-width: 14px;
4998
+ overflow: visible;
4843
4999
  `;
4844
5000
  // Update sort icon based on current state
4845
5001
  this.updateSortIcon(sortEl, params);
@@ -4869,9 +5025,9 @@ class SortableHeaderRenderer extends TextHeaderRenderer {
4869
5025
  const { sortDirection, sortPriority } = params;
4870
5026
  if (!sortDirection) {
4871
5027
  // Not sorted - show both arrows in muted state
4872
- sortEl.innerHTML = `
4873
- <span style="opacity: 0.3;">${sortIndicator?.ascIcon || SortableHeaderRenderer.DEFAULT_ASC_ICON}</span>
4874
- <span style="opacity: 0.3; margin-top: -2px;">${sortIndicator?.descIcon || SortableHeaderRenderer.DEFAULT_DESC_ICON}</span>
5028
+ sortEl.innerHTML = `
5029
+ <span style="opacity: 0.3;">${sortIndicator?.ascIcon || SortableHeaderRenderer.DEFAULT_ASC_ICON}</span>
5030
+ <span style="opacity: 0.3; margin-top: -2px;">${sortIndicator?.descIcon || SortableHeaderRenderer.DEFAULT_DESC_ICON}</span>
4875
5031
  `;
4876
5032
  sortEl.style.opacity = '0.5';
4877
5033
  }
@@ -4892,19 +5048,19 @@ class SortableHeaderRenderer extends TextHeaderRenderer {
4892
5048
  const priorityBadge = document.createElement('span');
4893
5049
  priorityBadge.className = 'zg-sort-priority';
4894
5050
  priorityBadge.textContent = String(sortPriority + 1);
4895
- priorityBadge.style.cssText = `
4896
- position: absolute;
4897
- top: 2px;
4898
- right: 2px;
4899
- font-size: 8px;
4900
- background: var(--zg-primary-color, #0066cc);
4901
- color: white;
4902
- border-radius: 50%;
4903
- width: 12px;
4904
- height: 12px;
4905
- display: flex;
4906
- align-items: center;
4907
- justify-content: center;
5051
+ priorityBadge.style.cssText = `
5052
+ position: absolute;
5053
+ top: 2px;
5054
+ right: 2px;
5055
+ font-size: 8px;
5056
+ background: var(--zg-primary-color, #0066cc);
5057
+ color: white;
5058
+ border-radius: 50%;
5059
+ width: 12px;
5060
+ height: 12px;
5061
+ display: flex;
5062
+ align-items: center;
5063
+ justify-content: center;
4908
5064
  `;
4909
5065
  sortEl.style.position = 'relative';
4910
5066
  sortEl.appendChild(priorityBadge);
@@ -9000,6 +9156,10 @@ class DragMouseHandler {
9000
9156
  constructor(options, callbacks, throttledDrag) {
9001
9157
  // Container reference
9002
9158
  this.container = null;
9159
+ // Source header cell captured at mousedown. Used to build the drag ghost so it
9160
+ // never depends on the transient mousemove target, which may have moved off the
9161
+ // header (onto a data cell or gap) by the time the drag threshold is crossed.
9162
+ this.sourceHeaderCell = null;
9003
9163
  this.enabled = options.enabled;
9004
9164
  this.dragThreshold = options.dragThreshold;
9005
9165
  this.onBeforeDrag = options.onBeforeDrag;
@@ -9028,6 +9188,7 @@ class DragMouseHandler {
9028
9188
  this.container = null;
9029
9189
  }
9030
9190
  this.removeGlobalListeners();
9191
+ this.sourceHeaderCell = null;
9031
9192
  }
9032
9193
  /**
9033
9194
  * Handle mousedown event on header
@@ -9042,6 +9203,7 @@ class DragMouseHandler {
9042
9203
  const columnId = headerCell.dataset['columnId'];
9043
9204
  if (!columnId)
9044
9205
  return;
9206
+ this.sourceHeaderCell = headerCell;
9045
9207
  // Check if column can be dragged
9046
9208
  if (!this.callbacks.canDragColumn(columnId))
9047
9209
  return;
@@ -9085,7 +9247,7 @@ class DragMouseHandler {
9085
9247
  if (state === 'pending') {
9086
9248
  const distance = this.callbacks.getDragDistance();
9087
9249
  if (distance >= this.dragThreshold) {
9088
- this.callbacks.onDragStart(e, e.target);
9250
+ this.callbacks.onDragStart(e, this.sourceHeaderCell ?? e.target);
9089
9251
  }
9090
9252
  }
9091
9253
  // Perform drag if in dragging state
@@ -9104,6 +9266,7 @@ class DragMouseHandler {
9104
9266
  this.callbacks.onMouseUp();
9105
9267
  // Remove global listeners
9106
9268
  this.removeGlobalListeners();
9269
+ this.sourceHeaderCell = null;
9107
9270
  }
9108
9271
  /**
9109
9272
  * Remove global mouse listeners
@@ -11343,6 +11506,14 @@ class GridStoreImpl {
11343
11506
  }
11344
11507
  const wrapped = createComputed(key, fn, owner, phase);
11345
11508
  this.computeds.set(key, wrapped);
11509
+ // If something read this key before it existed, it subscribed to a phantom
11510
+ // placeholder. Activate it so those dependents re-run and re-subscribe to
11511
+ // the real computed — otherwise effects that read a computed key early
11512
+ // (e.g. the rendering sync effect reading rows.viewIndices before
11513
+ // setupCoreComputeds) stay bound to a dead placeholder and never fire.
11514
+ if (hasPlaceholder(key)) {
11515
+ activatePhantom(key, wrapped.value);
11516
+ }
11346
11517
  }
11347
11518
  effect(name, fn, owner, phase = 0) {
11348
11519
  if (this.effects.has(name)) {
@@ -11408,6 +11579,9 @@ class GridStoreImpl {
11408
11579
  this.actions.set(name, { handler, owner, invalidates: meta?.invalidates });
11409
11580
  registerOwnership(owner, name);
11410
11581
  }
11582
+ hasAction(name) {
11583
+ return this.actions.has(name);
11584
+ }
11411
11585
  exec(name, ...args) {
11412
11586
  const reg = this.actions.get(name);
11413
11587
  if (!reg) {
@@ -12315,7 +12489,7 @@ function projectFilterAstToFieldState(ast) {
12315
12489
  const root = astGroupToFieldGroup(ast.root, warnings);
12316
12490
  const activeFields = new Set();
12317
12491
  if (root) {
12318
- collectActiveFields(root, activeFields);
12492
+ collectActiveFields$1(root, activeFields);
12319
12493
  }
12320
12494
  return {
12321
12495
  state: {
@@ -12547,12 +12721,12 @@ function astGroupToFieldGroup(group, warnings) {
12547
12721
  groups: groups.length > 0 ? groups : undefined,
12548
12722
  };
12549
12723
  }
12550
- function collectActiveFields(group, activeFields) {
12724
+ function collectActiveFields$1(group, activeFields) {
12551
12725
  for (const condition of group.conditions) {
12552
12726
  activeFields.add(condition.field);
12553
12727
  }
12554
12728
  for (const nested of group.groups ?? []) {
12555
- collectActiveFields(nested, activeFields);
12729
+ collectActiveFields$1(nested, activeFields);
12556
12730
  }
12557
12731
  }
12558
12732
  function normalizeConditionValue(operator, value) {
@@ -16577,6 +16751,30 @@ class SelectionManager {
16577
16751
  this.addSelection(selection);
16578
16752
  this.emitSelectionChange();
16579
16753
  }
16754
+ /**
16755
+ * Toggle a single cell in a multi-selection: remove it if that exact 1×1
16756
+ * cell is already selected, otherwise add it. Used for additive Ctrl/Cmd+click.
16757
+ * Returns the resulting selected state of the cell.
16758
+ */
16759
+ toggleCell(row, col) {
16760
+ const existing = this.findCellSelection(row, col);
16761
+ if (existing) {
16762
+ this.selections.delete(existing.id);
16763
+ this.rebuildIntervals();
16764
+ this.emitSelectionChange();
16765
+ return false;
16766
+ }
16767
+ this.addSelection({
16768
+ id: this.generateId(),
16769
+ type: 'cell',
16770
+ startRow: row,
16771
+ endRow: row,
16772
+ startCol: col,
16773
+ endCol: col,
16774
+ });
16775
+ this.emitSelectionChange();
16776
+ return true;
16777
+ }
16580
16778
  /**
16581
16779
  * Select a row range
16582
16780
  */
@@ -16762,6 +16960,32 @@ class SelectionManager {
16762
16960
  // Add to column interval tree
16763
16961
  this.colIntervals.insert({ start: selection.startCol, end: selection.endCol }, selection);
16764
16962
  }
16963
+ /**
16964
+ * Find an exact 1×1 cell selection at the given coordinates, if one exists.
16965
+ */
16966
+ findCellSelection(row, col) {
16967
+ for (const sel of this.selections.values()) {
16968
+ if (sel.type === 'cell' &&
16969
+ sel.startRow === row &&
16970
+ sel.endRow === row &&
16971
+ sel.startCol === col &&
16972
+ sel.endCol === col) {
16973
+ return sel;
16974
+ }
16975
+ }
16976
+ return undefined;
16977
+ }
16978
+ /**
16979
+ * Rebuild the interval trees from the current selection map (after a removal).
16980
+ */
16981
+ rebuildIntervals() {
16982
+ this.rowIntervals = new shared.IntervalTree({ balanced: true });
16983
+ this.colIntervals = new shared.IntervalTree({ balanced: true });
16984
+ for (const sel of this.selections.values()) {
16985
+ this.rowIntervals.insert({ start: sel.startRow, end: sel.endRow }, sel);
16986
+ this.colIntervals.insert({ start: sel.startCol, end: sel.endCol }, sel);
16987
+ }
16988
+ }
16765
16989
  /**
16766
16990
  * Generate unique ID for selections
16767
16991
  */
@@ -16850,6 +17074,11 @@ function createSelectionPlugin(options) {
16850
17074
  store.set('selection.active', { row, col });
16851
17075
  syncToStore();
16852
17076
  }, 'selection');
17077
+ store.action('selection:toggleCell', (row, col) => {
17078
+ const selected = mgr.toggleCell(row, col);
17079
+ store.set('selection.active', selected ? { row, col } : null);
17080
+ syncToStore();
17081
+ }, 'selection');
16853
17082
  store.action('selection:selectRange', (startRow, startCol, endRow, endCol, additive = false) => {
16854
17083
  mgr.selectRange(startRow, startCol, endRow, endCol, additive);
16855
17084
  syncToStore();
@@ -16903,6 +17132,13 @@ function createSelectionPlugin(options) {
16903
17132
  if (opts.onCellClick)
16904
17133
  opts.onCellClick(row, col);
16905
17134
  store.exec('selection:setActive', { row, col });
17135
+ // Triple-click the first column to select the entire row.
17136
+ if (event.detail >= 3 && col === 0) {
17137
+ const additiveRow = (event.ctrlKey || event.metaKey) && !!multiSel;
17138
+ store.exec('selection:selectRows', row, row, additiveRow);
17139
+ anchorCell = { row, col };
17140
+ return;
17141
+ }
16906
17142
  const additive = (event.ctrlKey || event.metaKey) && !!multiSel;
16907
17143
  if (event.shiftKey && anchorCell) {
16908
17144
  if (selType === 'row') {
@@ -16933,7 +17169,17 @@ function createSelectionPlugin(options) {
16933
17169
  store.exec('selection:selectRange', row, col, row, col, additive);
16934
17170
  break;
16935
17171
  default:
16936
- store.exec('selection:selectCell', row, col, additive);
17172
+ if (additive) {
17173
+ // Ctrl/Cmd+click toggles the individual cell in a multi-selection
17174
+ store.exec('selection:toggleCell', row, col);
17175
+ }
17176
+ else if (mgr.isSelected(row, col)) {
17177
+ // Plain click on the already-selected cell clears it
17178
+ store.exec('selection:clear');
17179
+ }
17180
+ else {
17181
+ store.exec('selection:selectCell', row, col);
17182
+ }
16937
17183
  }
16938
17184
  anchorCell = { row, col };
16939
17185
  }
@@ -16971,6 +17217,7 @@ function createSelectionPlugin(options) {
16971
17217
  // --- API ---
16972
17218
  api.register('selection', {
16973
17219
  selectCell: (row, col, additive) => store.exec('selection:selectCell', row, col, additive),
17220
+ toggleCell: (row, col) => store.exec('selection:toggleCell', row, col),
16974
17221
  selectRange: (startRow, startCol, endRow, endCol, additive) => store.exec('selection:selectRange', startRow, startCol, endRow, endCol, additive),
16975
17222
  selectRows: (startRow, endRow, additive) => store.exec('selection:selectRows', startRow, endRow, additive),
16976
17223
  selectColumns: (startCol, endCol, additive) => store.exec('selection:selectColumns', startCol, endCol, additive),
@@ -17144,8 +17391,8 @@ class TextEditor {
17144
17391
  // Inline styles for better UX
17145
17392
  input.style.width = '100%';
17146
17393
  input.style.height = '100%';
17147
- input.style.border = 'none';
17148
- input.style.outline = '2px solid #4CAF50';
17394
+ input.style.border = '2px solid #4CAF50';
17395
+ input.style.outline = 'none';
17149
17396
  input.style.padding = '4px 8px';
17150
17397
  input.style.fontSize = '13px';
17151
17398
  input.style.fontFamily = 'inherit';
@@ -17424,16 +17671,16 @@ class NumberEditor {
17424
17671
  this.input.placeholder = this.options.placeholder;
17425
17672
  }
17426
17673
  // Styling
17427
- this.input.style.cssText = `
17428
- width: 100%;
17429
- height: 100%;
17430
- border: 2px solid #4caf50;
17431
- padding: 0 8px;
17432
- font-family: inherit;
17433
- font-size: inherit;
17434
- outline: none;
17435
- box-sizing: border-box;
17436
- text-align: right;
17674
+ this.input.style.cssText = `
17675
+ width: 100%;
17676
+ height: 100%;
17677
+ border: 2px solid #4caf50;
17678
+ padding: 0 8px;
17679
+ font-family: inherit;
17680
+ font-size: inherit;
17681
+ outline: none;
17682
+ box-sizing: border-box;
17683
+ text-align: right;
17437
17684
  `;
17438
17685
  // Event handlers
17439
17686
  this.input.addEventListener('keydown', this.boundHandleKeyDown);
@@ -17507,13 +17754,14 @@ class NumberEditor {
17507
17754
  }
17508
17755
  }
17509
17756
  handleKeyDown(event) {
17510
- // Stop propagation to prevent grid navigation
17511
- event.stopPropagation();
17512
- // Allow arrow up/down to increment/decrement
17513
- if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
17514
- event.stopPropagation();
17515
- // Let default behavior handle increment/decrement
17757
+ // Let Enter/Escape/Tab bubble to the EditorManager so it can
17758
+ // commit/cancel/navigate. Stopping propagation here would swallow them.
17759
+ if (event.key === 'Enter' || event.key === 'Escape' || event.key === 'Tab') {
17760
+ return;
17516
17761
  }
17762
+ // Stop other keys (digits, arrows) from reaching grid navigation.
17763
+ // ArrowUp/Down keep their default increment/decrement behavior.
17764
+ event.stopPropagation();
17517
17765
  }
17518
17766
  }
17519
17767
 
@@ -17550,16 +17798,16 @@ class SelectEditor {
17550
17798
  this.select = document.createElement('select');
17551
17799
  this.select.className = 'zg-editor zg-select-editor';
17552
17800
  // Styling
17553
- this.select.style.cssText = `
17554
- width: 100%;
17555
- height: 100%;
17556
- border: 2px solid #4caf50;
17557
- padding: 0 8px;
17558
- font-family: inherit;
17559
- font-size: inherit;
17560
- outline: none;
17561
- box-sizing: border-box;
17562
- background: white;
17801
+ this.select.style.cssText = `
17802
+ width: 100%;
17803
+ height: 100%;
17804
+ border: 2px solid #4caf50;
17805
+ padding: 0 8px;
17806
+ font-family: inherit;
17807
+ font-size: inherit;
17808
+ outline: none;
17809
+ box-sizing: border-box;
17810
+ background: white;
17563
17811
  `;
17564
17812
  // Add empty option if allowed
17565
17813
  if (this.options.allowEmpty) {
@@ -17629,7 +17877,12 @@ class SelectEditor {
17629
17877
  }
17630
17878
  }
17631
17879
  handleKeyDown(event) {
17632
- // Stop propagation to prevent grid navigation
17880
+ // Let Enter/Escape/Tab bubble to the EditorManager so it can
17881
+ // commit/cancel/navigate. Stopping propagation here would swallow them.
17882
+ if (event.key === 'Enter' || event.key === 'Escape' || event.key === 'Tab') {
17883
+ return;
17884
+ }
17885
+ // Stop other keys from reaching grid navigation.
17633
17886
  event.stopPropagation();
17634
17887
  }
17635
17888
  }
@@ -17787,6 +18040,12 @@ class DateEditor {
17787
18040
  type: 'default',
17788
18041
  selectionDatesMode: 'single',
17789
18042
  enableJumpToSelectedDate: true,
18043
+ // Follow the editor's theme (default 'light') instead of the library's
18044
+ // 'system' default, which would render dark on a dark OS even when the
18045
+ // grid is on a light theme. themeAttrDetect:false stops the library from
18046
+ // auto-detecting a page attribute and overriding selectedTheme.
18047
+ selectedTheme: this.options.theme === 'auto' ? 'system' : this.options.theme || 'light',
18048
+ themeAttrDetect: false,
17790
18049
  dateMin: minDate ? formatDateForCalendar(minDate) : '1900-01-01',
17791
18050
  dateMax: maxDate ? formatDateForCalendar(maxDate) : '2099-12-31',
17792
18051
  onClickDate: (self, event) => {
@@ -19066,12 +19325,19 @@ async function initializeCalendars(startDiv, endDiv, options, currentValue, hand
19066
19325
  const { minDate, maxDate } = options;
19067
19326
  const dateMinStr = minDate ? formatDateForCalendar(minDate) : '1900-01-01';
19068
19327
  const dateMaxStr = maxDate ? formatDateForCalendar(maxDate) : '2099-12-31';
19328
+ // Follow the editor's theme (default 'light') instead of the library's
19329
+ // 'system' default, which would render dark on a dark OS even when the grid
19330
+ // is on a light theme. themeAttrDetect:false stops the library from
19331
+ // auto-detecting a page attribute and overriding selectedTheme.
19332
+ const selectedTheme = options.theme === 'auto' ? 'system' : options.theme || 'light';
19069
19333
  try {
19070
19334
  const { Calendar } = await import('vanilla-calendar-pro');
19071
19335
  const startOptions = {
19072
19336
  type: 'default',
19073
19337
  selectionDatesMode: 'single',
19074
19338
  enableJumpToSelectedDate: true,
19339
+ selectedTheme,
19340
+ themeAttrDetect: false,
19075
19341
  dateMin: dateMinStr,
19076
19342
  dateMax: dateMaxStr,
19077
19343
  selectedDates: currentValue.start
@@ -19103,6 +19369,8 @@ async function initializeCalendars(startDiv, endDiv, options, currentValue, hand
19103
19369
  type: 'default',
19104
19370
  selectionDatesMode: 'single',
19105
19371
  enableJumpToSelectedDate: true,
19372
+ selectedTheme,
19373
+ themeAttrDetect: false,
19106
19374
  dateMin: endDateMin,
19107
19375
  dateMax: dateMaxStr,
19108
19376
  selectedDates: currentValue.end
@@ -19358,9 +19626,17 @@ function createContainer$1(params, options) {
19358
19626
  label.className = options.className;
19359
19627
  label.style.display = 'flex';
19360
19628
  label.style.alignItems = 'center';
19629
+ label.style.justifyContent = 'center';
19361
19630
  label.style.cursor = options.disabled ? 'not-allowed' : 'pointer';
19362
19631
  label.style.padding = '8px';
19363
19632
  label.style.userSelect = 'none';
19633
+ // Fill the cell with an opaque, outlined surface so the underlying rendered
19634
+ // checkbox (e.g. CheckboxRenderer's centered box) does not show through.
19635
+ label.style.width = '100%';
19636
+ label.style.height = '100%';
19637
+ label.style.boxSizing = 'border-box';
19638
+ label.style.background = '#fff';
19639
+ label.style.border = '2px solid #4CAF50';
19364
19640
  // Set data attributes
19365
19641
  label.dataset['row'] = String(params.cell.row);
19366
19642
  label.dataset['col'] = String(params.cell.col);
@@ -19841,6 +20117,8 @@ class EditorManager {
19841
20117
  this.getCellElement = options.getCellElement;
19842
20118
  this.onEditStart = options.onEditStart;
19843
20119
  this.onEditEnd = options.onEditEnd;
20120
+ this.onCommit = options.onCommit;
20121
+ this.invalidEditMode = options.invalidEditMode ?? 'block';
19844
20122
  this.boundHandleEditorKeyDown = this.handleEditorKeyDown.bind(this);
19845
20123
  this.boundHandleClickOutside = this.handleClickOutside.bind(this);
19846
20124
  // Register default editors
@@ -19893,10 +20171,10 @@ class EditorManager {
19893
20171
  // Create editor container with correct positioning
19894
20172
  this.editorContainer = document.createElement('div');
19895
20173
  this.editorContainer.className = 'zg-editor-container';
19896
- this.editorContainer.style.cssText = `
19897
- position: absolute;
19898
- z-index: 10002;
19899
- overflow: visible;
20174
+ this.editorContainer.style.cssText = `
20175
+ position: absolute;
20176
+ z-index: 10002;
20177
+ overflow: visible;
19900
20178
  `;
19901
20179
  this.positionEditorContainer(cellElement);
19902
20180
  // Determine editor - supports both instance and string type
@@ -19973,16 +20251,37 @@ class EditorManager {
19973
20251
  const isValid = validation === undefined ||
19974
20252
  validation === true ||
19975
20253
  (typeof validation === 'object' && validation.valid);
19976
- if (!isValid) {
19977
- const message = typeof validation === 'object' ? validation.message : 'Invalid value';
19978
- alert(message || 'Invalid value');
20254
+ if (!isValid && this.invalidEditMode !== 'commit') {
20255
+ const message = (typeof validation === 'object' && validation.message) || 'Invalid value';
20256
+ if (this.invalidEditMode === 'revert') {
20257
+ // Discard the invalid input and restore the original value so focus can
20258
+ // move on. cancelEdit() emits cancel/end and cleans up.
20259
+ this.cancelEdit();
20260
+ }
20261
+ else {
20262
+ // 'block': keep the editor open and flag it invalid (never blocks with a
20263
+ // native alert(), which spammed on every blur and trapped the user).
20264
+ // Escape cancels; fixing the value lets the commit through.
20265
+ this.markInvalid(message);
20266
+ }
19979
20267
  return;
19980
20268
  }
19981
20269
  // Get new value
19982
20270
  const newValue = this.currentEditor.getValue();
19983
20271
  const cell = this.editingCell;
20272
+ const oldValue = this.originalValue;
19984
20273
  // Set value
19985
20274
  this.setValue(cell.row, cell.col, newValue);
20275
+ // 'commit' mode: the value failed validation but the caller opted to accept
20276
+ // it. Tag the cell (after the re-render from setValue) so the error stays
20277
+ // visible after the editor closes.
20278
+ if (!isValid) {
20279
+ this.flagInvalidCell(cell);
20280
+ }
20281
+ // Record the change for undo/redo (only when the value actually changed)
20282
+ if (this.onCommit && oldValue !== newValue) {
20283
+ this.onCommit(cell, oldValue, newValue);
20284
+ }
19986
20285
  // Emit events
19987
20286
  if (this.events) {
19988
20287
  this.events.emit('edit:commit', {
@@ -20053,6 +20352,36 @@ class EditorManager {
20053
20352
  this.originalValue = null;
20054
20353
  this.editorPopups.clear();
20055
20354
  }
20355
+ /**
20356
+ * Flag the current editor as invalid without blocking the UI.
20357
+ * Highlights the input in red and shows a visible inline message below it
20358
+ * (plus a tooltip); the editor stays open so the user can fix the value or
20359
+ * press Escape.
20360
+ */
20361
+ markInvalid(message) {
20362
+ if (!this.editorContainer)
20363
+ return;
20364
+ const field = this.editorContainer.querySelector('input, select, textarea');
20365
+ if (field) {
20366
+ field.style.borderColor = '#d32f2f';
20367
+ field.title = message;
20368
+ }
20369
+ let hint = this.editorContainer.querySelector('.zg-editor-error');
20370
+ if (!hint) {
20371
+ hint = document.createElement('div');
20372
+ hint.className = 'zg-editor-error';
20373
+ this.editorContainer.appendChild(hint);
20374
+ }
20375
+ hint.textContent = message;
20376
+ }
20377
+ /**
20378
+ * Mark a committed cell as holding an invalid value (used by 'commit' mode).
20379
+ * Adds `.zg-cell-invalid` so the error stays visible after the editor closes.
20380
+ */
20381
+ flagInvalidCell(cell) {
20382
+ const cellElement = this.getCellElement(cell.row, cell.col);
20383
+ cellElement?.classList.add('zg-cell-invalid');
20384
+ }
20056
20385
  /**
20057
20386
  * Get currently editing cell
20058
20387
  */
@@ -20229,6 +20558,20 @@ class EditorManager {
20229
20558
  }
20230
20559
  }
20231
20560
 
20561
+ /**
20562
+ * HeaderManager types and interfaces
20563
+ */
20564
+ /** Default header row height in pixels when `headerHeight` is not configured. */
20565
+ const DEFAULT_HEADER_HEIGHT = 40;
20566
+ /**
20567
+ * Resolve the header row height from grid options, falling back to the default.
20568
+ * A non-positive value falls back to the default to avoid a collapsed header.
20569
+ */
20570
+ function resolveHeaderHeight(options) {
20571
+ const h = options.headerHeight;
20572
+ return typeof h === 'number' && h > 0 ? h : DEFAULT_HEADER_HEIGHT;
20573
+ }
20574
+
20232
20575
  function createEditingPlugin(pluginOpts) {
20233
20576
  return {
20234
20577
  name: 'editing',
@@ -20262,24 +20605,28 @@ function createEditingPlugin(pluginOpts) {
20262
20605
  const orderedColumns = columnModel.getVisibleColumnsInOrder();
20263
20606
  return orderedColumns?.[viewCol]?.dataIndex ?? viewCol;
20264
20607
  };
20608
+ const setCellValue = (row, col, value) => {
20609
+ const dataRow = mapViewRowToDataRow(row);
20610
+ const dataCol = mapViewColToDataCol(col);
20611
+ const rowData = state.data[dataRow];
20612
+ if (Array.isArray(rowData)) {
20613
+ rowData[dataCol] = value;
20614
+ }
20615
+ store.exec('rendering:updateCells', [{ row, col }]);
20616
+ };
20265
20617
  if (!editorManager) {
20266
20618
  editorManager = new EditorManager({
20267
20619
  container,
20268
20620
  scrollContainer,
20621
+ events,
20622
+ headerHeight: resolveHeaderHeight(options),
20623
+ invalidEditMode: options.editing?.invalidEditMode,
20269
20624
  getValue: (row, col) => {
20270
20625
  const dataRow = mapViewRowToDataRow(row);
20271
20626
  const dataCol = mapViewColToDataCol(col);
20272
20627
  return getDataAccessor?.()?.getValue(dataRow, dataCol);
20273
20628
  },
20274
- setValue: (row, col, value) => {
20275
- const dataRow = mapViewRowToDataRow(row);
20276
- const dataCol = mapViewColToDataCol(col);
20277
- const rowData = state.data[dataRow];
20278
- if (Array.isArray(rowData)) {
20279
- rowData[dataCol] = value;
20280
- }
20281
- store.exec('rendering:updateCells', [{ row, col }]);
20282
- },
20629
+ setValue: setCellValue,
20283
20630
  getColumn: (col) => {
20284
20631
  const dataCol = mapViewColToDataCol(col);
20285
20632
  return options.columns?.[dataCol];
@@ -20298,6 +20645,11 @@ function createEditingPlugin(pluginOpts) {
20298
20645
  store.set('editing.active', null);
20299
20646
  store.exec('rendering:refreshSelectionClasses');
20300
20647
  },
20648
+ onCommit: (cell, oldValue, newValue) => {
20649
+ if (!store.hasAction('undoRedo:recordCellEdit'))
20650
+ return;
20651
+ store.exec('undoRedo:recordCellEdit', cell.row, cell.col, oldValue, newValue, setCellValue);
20652
+ },
20301
20653
  });
20302
20654
  store.exec('editing:bind', editorManager);
20303
20655
  eventUnsubs.push(events.on('scroll', () => {
@@ -20343,6 +20695,10 @@ function createEditingPlugin(pluginOpts) {
20343
20695
  container.addEventListener('keydown', keydownHandler);
20344
20696
  domCleanups.push(() => container.removeEventListener('keydown', keydownHandler));
20345
20697
  }, 'editing');
20698
+ // Edit lifecycle events (edit:start/commit/cancel/end) are emitted by the
20699
+ // EditorManager itself via the shared `events` emitter, so both interactive
20700
+ // (Enter/Tab/blur) and programmatic commits fire exactly once. These actions
20701
+ // only drive the manager and keep editing.active in sync.
20346
20702
  store.action('editing:startEdit', (cell) => {
20347
20703
  if (!editorManager)
20348
20704
  return;
@@ -20350,30 +20706,20 @@ function createEditingPlugin(pluginOpts) {
20350
20706
  return;
20351
20707
  editorManager.startEdit(cell);
20352
20708
  store.set('editing.active', cell);
20353
- api.fireEvent('edit:start', { cell, value: editorManager.getCurrentValue?.() });
20354
20709
  }, 'editing');
20355
- store.action('editing:commitEdit', (value) => {
20710
+ store.action('editing:commitEdit', () => {
20356
20711
  if (!editorManager)
20357
20712
  return;
20358
- const cell = store.get('editing.active');
20359
- if (!cell)
20713
+ if (!store.get('editing.active'))
20360
20714
  return;
20361
- const oldValue = editorManager.getOriginalValue?.();
20362
20715
  editorManager.commitEdit?.();
20363
20716
  store.set('editing.active', null);
20364
- api.fireEvent('edit:commit', { cell, oldValue, newValue: value });
20365
- api.fireEvent('edit:end', { cell, value, cancelled: false });
20366
20717
  }, 'editing');
20367
20718
  store.action('editing:cancelEdit', () => {
20368
20719
  if (!editorManager)
20369
20720
  return;
20370
- const cell = store.get('editing.active');
20371
20721
  editorManager.cancelEdit?.();
20372
20722
  store.set('editing.active', null);
20373
- if (cell) {
20374
- api.fireEvent('edit:cancel', { cell, value: undefined });
20375
- api.fireEvent('edit:end', { cell, value: undefined, cancelled: true });
20376
- }
20377
20723
  }, 'editing');
20378
20724
  api.register('editing', {
20379
20725
  startEdit: (cell) => store.exec('editing:startEdit', cell),
@@ -20402,6 +20748,663 @@ function createEditingPlugin(pluginOpts) {
20402
20748
  };
20403
20749
  }
20404
20750
 
20751
+ const NAV_KEYS = new Set([
20752
+ 'ArrowUp',
20753
+ 'ArrowDown',
20754
+ 'ArrowLeft',
20755
+ 'ArrowRight',
20756
+ 'PageUp',
20757
+ 'PageDown',
20758
+ 'Home',
20759
+ 'End',
20760
+ ]);
20761
+ /**
20762
+ * Whether keydown originated in a form control / editable element, in which case
20763
+ * the grid should let the element handle the key rather than navigate cells.
20764
+ */
20765
+ function isEditableTarget(target) {
20766
+ const el = target;
20767
+ if (!el || !el.tagName)
20768
+ return false;
20769
+ const tag = el.tagName;
20770
+ return (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true);
20771
+ }
20772
+ /**
20773
+ * Compute the destination cell for a navigation key, clamped to grid bounds.
20774
+ * Returns the same reference when the key does not move (already at an edge).
20775
+ */
20776
+ function nextCell(key, active, step, rows, cols) {
20777
+ let { row, col } = active;
20778
+ switch (key) {
20779
+ case 'ArrowUp':
20780
+ row = Math.max(0, row - 1);
20781
+ break;
20782
+ case 'ArrowDown':
20783
+ row = Math.min(rows - 1, row + 1);
20784
+ break;
20785
+ case 'ArrowLeft':
20786
+ col = Math.max(0, col - 1);
20787
+ break;
20788
+ case 'ArrowRight':
20789
+ col = Math.min(cols - 1, col + 1);
20790
+ break;
20791
+ case 'PageUp':
20792
+ row = Math.max(0, row - step);
20793
+ break;
20794
+ case 'PageDown':
20795
+ row = Math.min(rows - 1, row + step);
20796
+ break;
20797
+ case 'Home':
20798
+ col = 0;
20799
+ break;
20800
+ case 'End':
20801
+ col = cols - 1;
20802
+ break;
20803
+ default:
20804
+ return null;
20805
+ }
20806
+ if (row === active.row && col === active.col)
20807
+ return active;
20808
+ return { row, col };
20809
+ }
20810
+
20811
+ /**
20812
+ * KeyboardPlugin - Store-driven keyboard cell navigation.
20813
+ *
20814
+ * Maintains `state.activeCell` (the single source read by the rendering active-cell
20815
+ * highlight and the editing Enter-to-edit guard) and drives selection + scroll-into-view
20816
+ * as the active cell moves. Arrow/Page/Home/End move the cursor; Shift extends a range;
20817
+ * Ctrl/Cmd+A selects all. Enter/F2 editing is handled by the editing plugin's own keydown.
20818
+ */
20819
+ function createKeyboardPlugin(pluginOpts) {
20820
+ return {
20821
+ name: 'keyboard',
20822
+ phase: 46,
20823
+ dependencies: ['core'],
20824
+ setup(store, api) {
20825
+ const domCleanups = [];
20826
+ function colCount() {
20827
+ return pluginOpts?.options?.colCount ?? 0;
20828
+ }
20829
+ function rowCount() {
20830
+ return pluginOpts?.options?.rowCount ?? 0;
20831
+ }
20832
+ function getScroller() {
20833
+ const fn = api.getMethod('rendering', 'getScroller');
20834
+ return fn ? fn() : null;
20835
+ }
20836
+ function pageStep(scroller, scrollContainer) {
20837
+ if (scroller && scrollContainer) {
20838
+ const top = scrollContainer.scrollTop;
20839
+ const rows = scroller.getRowAtOffset(top + scrollContainer.clientHeight) -
20840
+ scroller.getRowAtOffset(top);
20841
+ if (rows > 0)
20842
+ return rows;
20843
+ }
20844
+ return 10;
20845
+ }
20846
+ function scrollIntoView(cell) {
20847
+ const scroller = getScroller();
20848
+ const scrollContainer = store.get('dom.scrollContainer');
20849
+ if (!scroller || !scrollContainer)
20850
+ return;
20851
+ const pos = scroller.getCellPosition(cell.row, cell.col);
20852
+ const st = scrollContainer.scrollTop;
20853
+ const sl = scrollContainer.scrollLeft;
20854
+ const ch = scrollContainer.clientHeight;
20855
+ const cw = scrollContainer.clientWidth;
20856
+ let newTop = st;
20857
+ let newLeft = sl;
20858
+ if (pos.y < st)
20859
+ newTop = pos.y;
20860
+ else if (pos.y + pos.height > st + ch)
20861
+ newTop = pos.y + pos.height - ch;
20862
+ if (pos.x < sl)
20863
+ newLeft = pos.x;
20864
+ else if (pos.x + pos.width > sl + cw)
20865
+ newLeft = pos.x + pos.width - cw;
20866
+ if (newTop !== st || newLeft !== sl) {
20867
+ scrollContainer.scrollTop = newTop;
20868
+ scrollContainer.scrollLeft = newLeft;
20869
+ if (store.hasAction('rendering:handleScroll')) {
20870
+ store.exec('rendering:handleScroll', newTop, newLeft);
20871
+ }
20872
+ }
20873
+ }
20874
+ let anchor = null;
20875
+ function setActive(cell, extend) {
20876
+ const state = pluginOpts?.state;
20877
+ if (state)
20878
+ state.activeCell = cell;
20879
+ if (extend && anchor && store.hasAction('selection:selectRange')) {
20880
+ store.exec('selection:selectRange', anchor.row, anchor.col, cell.row, cell.col);
20881
+ }
20882
+ else if (store.hasAction('selection:selectCell')) {
20883
+ store.exec('selection:selectCell', cell.row, cell.col);
20884
+ anchor = cell;
20885
+ }
20886
+ else {
20887
+ anchor = cell;
20888
+ }
20889
+ // selection:* already refreshes classes via lifecycle; refresh directly when
20890
+ // there is no selection plugin so the active-cell highlight still repaints.
20891
+ if (!store.hasAction('selection:selectCell') && store.hasAction('rendering:refreshSelectionClasses')) {
20892
+ store.exec('rendering:refreshSelectionClasses');
20893
+ }
20894
+ scrollIntoView(cell);
20895
+ }
20896
+ const handleKeyDown = (event) => {
20897
+ const state = pluginOpts?.state;
20898
+ if (!state || state.editingCell)
20899
+ return;
20900
+ if (isEditableTarget(event.target))
20901
+ return;
20902
+ const rows = rowCount();
20903
+ const cols = colCount();
20904
+ if (rows <= 0 || cols <= 0)
20905
+ return;
20906
+ const cmd = event.ctrlKey || event.metaKey;
20907
+ const key = event.key;
20908
+ if (cmd && (key === 'a' || key === 'A')) {
20909
+ if (store.hasAction('selection:selectAll')) {
20910
+ store.exec('selection:selectAll', rows);
20911
+ event.preventDefault();
20912
+ }
20913
+ return;
20914
+ }
20915
+ if (!NAV_KEYS.has(key))
20916
+ return;
20917
+ const active = state.activeCell;
20918
+ if (!active) {
20919
+ setActive({ row: 0, col: 0 }, false);
20920
+ event.preventDefault();
20921
+ return;
20922
+ }
20923
+ let target;
20924
+ if (cmd && key === 'Home') {
20925
+ target = { row: 0, col: 0 };
20926
+ }
20927
+ else if (cmd && key === 'End') {
20928
+ target = { row: rows - 1, col: cols - 1 };
20929
+ }
20930
+ else {
20931
+ const scrollContainer = store.get('dom.scrollContainer');
20932
+ const step = key === 'PageUp' || key === 'PageDown'
20933
+ ? pageStep(getScroller(), scrollContainer)
20934
+ : 1;
20935
+ target = nextCell(key, active, step, rows, cols);
20936
+ }
20937
+ if (!target)
20938
+ return;
20939
+ event.preventDefault();
20940
+ if (target === active)
20941
+ return;
20942
+ setActive(target, event.shiftKey);
20943
+ };
20944
+ const focusOnCellClick = (event) => {
20945
+ const container = pluginOpts?.container;
20946
+ const state = pluginOpts?.state;
20947
+ if (!container || (state && state.editingCell))
20948
+ return;
20949
+ const cell = event.target.closest('.zg-cell');
20950
+ if (cell)
20951
+ container.focus();
20952
+ };
20953
+ store.action('keyboard:setup', () => {
20954
+ const container = pluginOpts?.container;
20955
+ if (!container)
20956
+ return;
20957
+ if (!container.hasAttribute('tabindex')) {
20958
+ container.setAttribute('tabindex', '0');
20959
+ }
20960
+ container.addEventListener('keydown', handleKeyDown);
20961
+ container.addEventListener('mousedown', focusOnCellClick);
20962
+ domCleanups.push(() => container.removeEventListener('keydown', handleKeyDown));
20963
+ domCleanups.push(() => container.removeEventListener('mousedown', focusOnCellClick));
20964
+ }, 'keyboard');
20965
+ api.register('keyboard', {
20966
+ setActiveCell: (cell) => setActive(cell, false),
20967
+ getActiveCell: () => pluginOpts?.state?.activeCell ?? null,
20968
+ });
20969
+ return { teardown: [() => domCleanups.forEach((fn) => fn())] };
20970
+ },
20971
+ };
20972
+ }
20973
+
20974
+ /**
20975
+ * ClipboardManager - Handles copy/cut/paste operations
20976
+ *
20977
+ * Supports multiple formats:
20978
+ * - text/plain: TSV (tab-separated values)
20979
+ * - text/html: HTML table format
20980
+ *
20981
+ * Compatible with Excel and Google Sheets.
20982
+ *
20983
+ * @example
20984
+ * ```typescript
20985
+ * const clipboard = new ClipboardManager({
20986
+ * getValue: (row, col) => data[row][col],
20987
+ * setValue: (row, col, value) => { data[row][col] = value; },
20988
+ * });
20989
+ *
20990
+ * // Copy selection
20991
+ * await clipboard.copy([
20992
+ * { startRow: 0, startCol: 0, endRow: 2, endCol: 2 },
20993
+ * ]);
20994
+ *
20995
+ * // Paste at location
20996
+ * await clipboard.paste(5, 0);
20997
+ * ```
20998
+ */
20999
+ class ClipboardManager {
21000
+ constructor(options) {
21001
+ this.events = options.events;
21002
+ this.getValue = options.getValue;
21003
+ this.setValue = options.setValue;
21004
+ this.deleteValue = options.deleteValue;
21005
+ this.includeHeaders = options.includeHeaders ?? false;
21006
+ this.getColumnHeader = options.getColumnHeader;
21007
+ this.getRowCount = options.getRowCount;
21008
+ this.getColumnCount = options.getColumnCount;
21009
+ }
21010
+ /**
21011
+ * Clamp a range's end bounds to the real grid dimensions. Whole-row and
21012
+ * whole-column selections carry MAX_SAFE_INTEGER sentinels; iterating those
21013
+ * literally would freeze the UI and overflow array lengths.
21014
+ */
21015
+ clampRange(range) {
21016
+ const startRow = Math.max(0, range.startRow);
21017
+ const startCol = Math.max(0, range.startCol);
21018
+ let endRow = range.endRow;
21019
+ let endCol = range.endCol;
21020
+ if (this.getRowCount)
21021
+ endRow = Math.min(endRow, this.getRowCount() - 1);
21022
+ if (this.getColumnCount)
21023
+ endCol = Math.min(endCol, this.getColumnCount() - 1);
21024
+ return { startRow, startCol, endRow, endCol };
21025
+ }
21026
+ /**
21027
+ * Copy selected ranges to clipboard
21028
+ * @param ranges - Ranges to copy
21029
+ */
21030
+ async copy(ranges) {
21031
+ if (ranges.length === 0)
21032
+ return;
21033
+ const data = this.extractData(ranges);
21034
+ const tsvText = this.toTSV(data);
21035
+ const htmlText = this.toHTML(data);
21036
+ try {
21037
+ // Write to clipboard with multiple formats
21038
+ await navigator.clipboard.write([
21039
+ new ClipboardItem({
21040
+ 'text/plain': new Blob([tsvText], { type: 'text/plain' }),
21041
+ 'text/html': new Blob([htmlText], { type: 'text/html' }),
21042
+ }),
21043
+ ]);
21044
+ // Emit event
21045
+ if (this.events) {
21046
+ this.events.emit('copy', {
21047
+ ranges,
21048
+ data: tsvText,
21049
+ });
21050
+ }
21051
+ }
21052
+ catch (error) {
21053
+ console.error('Failed to copy to clipboard:', error);
21054
+ // Fallback to older API
21055
+ try {
21056
+ await navigator.clipboard.writeText(tsvText);
21057
+ }
21058
+ catch (fallbackError) {
21059
+ console.error('Fallback copy also failed:', fallbackError);
21060
+ }
21061
+ }
21062
+ }
21063
+ /**
21064
+ * Cut selected ranges to clipboard (copy + delete)
21065
+ * @param ranges - Ranges to cut
21066
+ */
21067
+ async cut(ranges) {
21068
+ if (ranges.length === 0)
21069
+ return;
21070
+ // Copy first
21071
+ await this.copy(ranges);
21072
+ // Then delete values
21073
+ for (const rawRange of ranges) {
21074
+ const range = this.clampRange(rawRange);
21075
+ for (let row = range.startRow; row <= range.endRow; row++) {
21076
+ for (let col = range.startCol; col <= range.endCol; col++) {
21077
+ if (this.deleteValue) {
21078
+ this.deleteValue(row, col);
21079
+ }
21080
+ else {
21081
+ this.setValue(row, col, null);
21082
+ }
21083
+ }
21084
+ }
21085
+ }
21086
+ // Emit event
21087
+ if (this.events) {
21088
+ this.events.emit('cut', {
21089
+ ranges,
21090
+ data: '', // Already emitted in copy
21091
+ });
21092
+ }
21093
+ }
21094
+ /**
21095
+ * Paste from clipboard at target location
21096
+ * @param targetRow - Target row
21097
+ * @param targetCol - Target column
21098
+ */
21099
+ async paste(targetRow, targetCol) {
21100
+ try {
21101
+ // Try to read multiple formats
21102
+ const items = await navigator.clipboard.read();
21103
+ for (const item of items) {
21104
+ // Prefer HTML format (preserves formatting)
21105
+ if (item.types.includes('text/html')) {
21106
+ const blob = await item.getType('text/html');
21107
+ const html = await blob.text();
21108
+ const data = this.parseHTML(html);
21109
+ this.pasteData(data, targetRow, targetCol);
21110
+ return;
21111
+ }
21112
+ // Fallback to plain text
21113
+ if (item.types.includes('text/plain')) {
21114
+ const blob = await item.getType('text/plain');
21115
+ const text = await blob.text();
21116
+ const data = this.parseTSV(text);
21117
+ this.pasteData(data, targetRow, targetCol);
21118
+ return;
21119
+ }
21120
+ }
21121
+ }
21122
+ catch (error) {
21123
+ console.error('Failed to paste from clipboard:', error);
21124
+ // Fallback to text API
21125
+ try {
21126
+ const text = await navigator.clipboard.readText();
21127
+ const data = this.parseTSV(text);
21128
+ this.pasteData(data, targetRow, targetCol);
21129
+ }
21130
+ catch (fallbackError) {
21131
+ console.error('Fallback paste also failed:', fallbackError);
21132
+ }
21133
+ }
21134
+ }
21135
+ /**
21136
+ * Extract a copyable matrix from one or more ranges. All ranges are projected
21137
+ * onto their combined bounding box; a cell that falls outside every selected
21138
+ * range is emitted empty. A single dense range reduces to that rectangle, so
21139
+ * discontiguous multi-selections copy with their relative layout preserved
21140
+ * (Excel/Sheets behaviour).
21141
+ */
21142
+ extractData(rawRanges) {
21143
+ const ranges = rawRanges.map((r) => this.clampRange(r));
21144
+ let minRow = Infinity;
21145
+ let maxRow = -Infinity;
21146
+ let minCol = Infinity;
21147
+ let maxCol = -Infinity;
21148
+ for (const r of ranges) {
21149
+ if (r.startRow < minRow)
21150
+ minRow = r.startRow;
21151
+ if (r.endRow > maxRow)
21152
+ maxRow = r.endRow;
21153
+ if (r.startCol < minCol)
21154
+ minCol = r.startCol;
21155
+ if (r.endCol > maxCol)
21156
+ maxCol = r.endCol;
21157
+ }
21158
+ const inSelection = (row, col) => ranges.some((r) => row >= r.startRow && row <= r.endRow && col >= r.startCol && col <= r.endCol);
21159
+ const data = [];
21160
+ // Include headers if requested
21161
+ if (this.includeHeaders && this.getColumnHeader) {
21162
+ const headers = [];
21163
+ for (let col = minCol; col <= maxCol; col++) {
21164
+ headers.push(this.getColumnHeader(col));
21165
+ }
21166
+ data.push(headers);
21167
+ }
21168
+ // Extract cell values across the bounding box
21169
+ for (let row = minRow; row <= maxRow; row++) {
21170
+ const rowData = [];
21171
+ for (let col = minCol; col <= maxCol; col++) {
21172
+ rowData.push(inSelection(row, col) ? this.getValue(row, col) : null);
21173
+ }
21174
+ data.push(rowData);
21175
+ }
21176
+ return data;
21177
+ }
21178
+ /**
21179
+ * Convert data to TSV format
21180
+ */
21181
+ toTSV(data) {
21182
+ return data.map((row) => row.map((cell) => this.formatCellForTSV(cell)).join('\t')).join('\n');
21183
+ }
21184
+ /**
21185
+ * Format cell value for TSV
21186
+ */
21187
+ formatCellForTSV(value) {
21188
+ if (value == null)
21189
+ return '';
21190
+ const str = String(value);
21191
+ // Escape tabs and newlines
21192
+ return str.replace(/\t/g, ' ').replace(/\n/g, ' ');
21193
+ }
21194
+ /**
21195
+ * Convert data to HTML table format
21196
+ */
21197
+ toHTML(data) {
21198
+ let html = '<table>';
21199
+ for (let i = 0; i < data.length; i++) {
21200
+ const row = data[i];
21201
+ const tag = i === 0 && this.includeHeaders ? 'th' : 'td';
21202
+ html += '<tr>';
21203
+ for (const cell of row) {
21204
+ html += `<${tag}>${this.escapeHTML(String(cell ?? ''))}</${tag}>`;
21205
+ }
21206
+ html += '</tr>';
21207
+ }
21208
+ html += '</table>';
21209
+ return html;
21210
+ }
21211
+ /**
21212
+ * Escape HTML special characters
21213
+ */
21214
+ escapeHTML(str) {
21215
+ return str
21216
+ .replace(/&/g, '&amp;')
21217
+ .replace(/</g, '&lt;')
21218
+ .replace(/>/g, '&gt;')
21219
+ .replace(/"/g, '&quot;')
21220
+ .replace(/'/g, '&#039;');
21221
+ }
21222
+ /**
21223
+ * Parse TSV text to 2D array
21224
+ */
21225
+ parseTSV(text) {
21226
+ const lines = text.split(/\r?\n/);
21227
+ return lines.map((line) => line.split('\t'));
21228
+ }
21229
+ /**
21230
+ * Parse HTML table to 2D array
21231
+ */
21232
+ parseHTML(html) {
21233
+ const parser = new DOMParser();
21234
+ const doc = parser.parseFromString(html, 'text/html');
21235
+ const table = doc.querySelector('table');
21236
+ if (!table) {
21237
+ // No table found, treat as plain text
21238
+ return this.parseTSV(doc.body.textContent || '');
21239
+ }
21240
+ const data = [];
21241
+ const rows = Array.from(table.querySelectorAll('tr'));
21242
+ for (const row of rows) {
21243
+ const cells = Array.from(row.querySelectorAll('td, th'));
21244
+ const rowData = [];
21245
+ for (const cell of cells) {
21246
+ rowData.push(cell.textContent || '');
21247
+ }
21248
+ if (rowData.length > 0) {
21249
+ data.push(rowData);
21250
+ }
21251
+ }
21252
+ return data;
21253
+ }
21254
+ /**
21255
+ * Paste data at target location
21256
+ */
21257
+ pasteData(data, targetRow, targetCol) {
21258
+ for (let i = 0; i < data.length; i++) {
21259
+ const row = data[i];
21260
+ for (let j = 0; j < row.length; j++) {
21261
+ const value = row[j];
21262
+ this.setValue(targetRow + i, targetCol + j, value);
21263
+ }
21264
+ }
21265
+ // Emit event
21266
+ if (this.events) {
21267
+ this.events.emit('paste', {
21268
+ cell: { row: targetRow, col: targetCol },
21269
+ data: this.toTSV(data),
21270
+ });
21271
+ }
21272
+ }
21273
+ }
21274
+
21275
+ /**
21276
+ * ClipboardPlugin - Wires copy/cut/paste (Ctrl/Cmd+C/X/V) to the ClipboardManager.
21277
+ *
21278
+ * Operates in view coordinates: ranges come from `selection.ranges` (falling back to a
21279
+ * single-cell range at `state.activeCell`), and get/set map view row/col to data row/col
21280
+ * the same way the editing plugin does, so TSV/HTML round-trips with Excel and Sheets.
21281
+ */
21282
+ function createClipboardPlugin(pluginOpts) {
21283
+ return {
21284
+ name: 'clipboard',
21285
+ phase: 47,
21286
+ dependencies: ['core'],
21287
+ setup(store, api) {
21288
+ const domCleanups = [];
21289
+ let manager = null;
21290
+ const mapViewRowToDataRow = (viewRow) => {
21291
+ const viewIndices = store.get('rows.viewIndices');
21292
+ return viewIndices?.[viewRow] ?? viewRow;
21293
+ };
21294
+ const mapViewColToDataCol = (viewCol) => {
21295
+ const columnModel = pluginOpts?.getColumnModel?.();
21296
+ if (!columnModel)
21297
+ return viewCol;
21298
+ const ordered = columnModel.getVisibleColumnsInOrder();
21299
+ return ordered?.[viewCol]?.dataIndex ?? viewCol;
21300
+ };
21301
+ const setCellValue = (row, col, value) => {
21302
+ const state = pluginOpts?.state;
21303
+ if (!state)
21304
+ return;
21305
+ const dataRow = mapViewRowToDataRow(row);
21306
+ const dataCol = mapViewColToDataCol(col);
21307
+ const rowData = state.data[dataRow];
21308
+ if (Array.isArray(rowData))
21309
+ rowData[dataCol] = value;
21310
+ store.exec('rendering:updateCells', [{ row, col }]);
21311
+ };
21312
+ function getManager() {
21313
+ if (manager)
21314
+ return manager;
21315
+ manager = new ClipboardManager({
21316
+ events: pluginOpts?.events,
21317
+ getValue: (row, col) => {
21318
+ const dataRow = mapViewRowToDataRow(row);
21319
+ const dataCol = mapViewColToDataCol(col);
21320
+ return pluginOpts?.getDataAccessor?.()?.getValue(dataRow, dataCol);
21321
+ },
21322
+ setValue: setCellValue,
21323
+ getRowCount: () => {
21324
+ const viewIndices = store.get('rows.viewIndices');
21325
+ if (viewIndices)
21326
+ return viewIndices.length;
21327
+ return pluginOpts?.state?.data?.length ?? 0;
21328
+ },
21329
+ getColumnCount: () => {
21330
+ const columnModel = pluginOpts?.getColumnModel?.();
21331
+ const ordered = columnModel?.getVisibleColumnsInOrder?.();
21332
+ return ordered?.length ?? pluginOpts?.options?.columns?.length ?? 0;
21333
+ },
21334
+ });
21335
+ return manager;
21336
+ }
21337
+ function currentRanges() {
21338
+ const ranges = store.get('selection.ranges');
21339
+ if (ranges && ranges.length > 0)
21340
+ return ranges;
21341
+ const active = pluginOpts?.state?.activeCell;
21342
+ if (active) {
21343
+ return [{ startRow: active.row, startCol: active.col, endRow: active.row, endCol: active.col }];
21344
+ }
21345
+ return [];
21346
+ }
21347
+ function pasteTarget() {
21348
+ const active = pluginOpts?.state?.activeCell;
21349
+ if (active)
21350
+ return active;
21351
+ const ranges = store.get('selection.ranges');
21352
+ if (ranges && ranges.length > 0) {
21353
+ return { row: ranges[0].startRow, col: ranges[0].startCol };
21354
+ }
21355
+ return null;
21356
+ }
21357
+ const handleKeyDown = (event) => {
21358
+ const state = pluginOpts?.state;
21359
+ if (!state || state.editingCell)
21360
+ return;
21361
+ if (!(event.ctrlKey || event.metaKey))
21362
+ return;
21363
+ if (isEditableTarget(event.target))
21364
+ return;
21365
+ const key = event.key.toLowerCase();
21366
+ if (key === 'c') {
21367
+ const ranges = currentRanges();
21368
+ if (ranges.length === 0)
21369
+ return;
21370
+ event.preventDefault();
21371
+ void getManager().copy(ranges);
21372
+ }
21373
+ else if (key === 'x') {
21374
+ const ranges = currentRanges();
21375
+ if (ranges.length === 0)
21376
+ return;
21377
+ event.preventDefault();
21378
+ void getManager().cut(ranges);
21379
+ }
21380
+ else if (key === 'v') {
21381
+ const target = pasteTarget();
21382
+ if (!target)
21383
+ return;
21384
+ event.preventDefault();
21385
+ void getManager().paste(target.row, target.col);
21386
+ }
21387
+ };
21388
+ store.action('clipboard:setup', () => {
21389
+ const container = pluginOpts?.container;
21390
+ if (!container)
21391
+ return;
21392
+ container.addEventListener('keydown', handleKeyDown);
21393
+ domCleanups.push(() => container.removeEventListener('keydown', handleKeyDown));
21394
+ }, 'clipboard');
21395
+ api.register('clipboard', {
21396
+ copy: () => getManager().copy(currentRanges()),
21397
+ cut: () => getManager().cut(currentRanges()),
21398
+ paste: () => {
21399
+ const target = pasteTarget();
21400
+ return target ? getManager().paste(target.row, target.col) : Promise.resolve();
21401
+ },
21402
+ });
21403
+ return { teardown: [() => domCleanups.forEach((fn) => fn())] };
21404
+ },
21405
+ };
21406
+ }
21407
+
20405
21408
  /**
20406
21409
  * UndoRedoManager - Undo/Redo functionality for grid operations
20407
21410
  *
@@ -20833,42 +21836,45 @@ function createDomPlugin(opts) {
20833
21836
  store.extend('dom.canvas', null, 'dom', 5);
20834
21837
  store.extend('dom.headerContainer', null, 'dom', 5);
20835
21838
  store.action('dom:setup', () => {
21839
+ if (options.hideLastColumnBorder) {
21840
+ container.classList.add('zg-hide-last-col-border');
21841
+ }
20836
21842
  // Create viewport
20837
21843
  viewport = document.createElement('div');
20838
21844
  viewport.className = 'zg-viewport';
20839
- viewport.style.cssText = `
20840
- position: relative;
20841
- width: 100%;
20842
- height: 100%;
20843
- overflow: hidden;
21845
+ viewport.style.cssText = `
21846
+ position: relative;
21847
+ width: 100%;
21848
+ height: 100%;
21849
+ overflow: hidden;
20844
21850
  `;
20845
21851
  // Create scroll container
20846
21852
  scrollContainer = document.createElement('div');
20847
21853
  scrollContainer.className = 'zg-scroll-container';
20848
- const headerHeight = options.columns && options.columns.length > 0 ? 40 : 0;
20849
- scrollContainer.style.cssText = `
20850
- position: absolute;
20851
- top: ${headerHeight}px;
20852
- left: 0;
20853
- right: 0;
20854
- bottom: 0;
20855
- overflow: auto;
21854
+ const headerHeight = options.columns && options.columns.length > 0 ? resolveHeaderHeight(options) : 0;
21855
+ scrollContainer.style.cssText = `
21856
+ position: absolute;
21857
+ top: ${headerHeight}px;
21858
+ left: 0;
21859
+ right: 0;
21860
+ bottom: 0;
21861
+ overflow: auto;
20856
21862
  `;
20857
21863
  // Create canvas
20858
21864
  canvas = document.createElement('div');
20859
21865
  canvas.className = 'zg-canvas';
20860
- canvas.style.cssText = `
20861
- position: relative;
20862
- pointer-events: none;
21866
+ canvas.style.cssText = `
21867
+ position: relative;
21868
+ pointer-events: none;
20863
21869
  `;
20864
21870
  // Create cells container
20865
21871
  const cellsContainer = document.createElement('div');
20866
21872
  cellsContainer.className = 'zg-cells';
20867
- cellsContainer.style.cssText = `
20868
- position: absolute;
20869
- top: 0;
20870
- left: 0;
20871
- pointer-events: auto;
21873
+ cellsContainer.style.cssText = `
21874
+ position: absolute;
21875
+ top: 0;
21876
+ left: 0;
21877
+ pointer-events: auto;
20872
21878
  `;
20873
21879
  // Create header container if columns defined
20874
21880
  if (options.columns && options.columns.length > 0) {
@@ -22701,16 +23707,6 @@ function createDropdownTrigger(displayText) {
22701
23707
  arrow.innerHTML = '▼';
22702
23708
  arrow.setAttribute('aria-hidden', 'true');
22703
23709
  trigger.appendChild(arrow);
22704
- trigger.style.width = '100%';
22705
- trigger.style.padding = '6px 8px';
22706
- trigger.style.border = '1px solid #ccc';
22707
- trigger.style.borderRadius = '4px';
22708
- trigger.style.backgroundColor = '#fff';
22709
- trigger.style.cursor = 'pointer';
22710
- trigger.style.textAlign = 'left';
22711
- trigger.style.display = 'flex';
22712
- trigger.style.justifyContent = 'space-between';
22713
- trigger.style.alignItems = 'center';
22714
23710
  return trigger;
22715
23711
  }
22716
23712
  /**
@@ -22722,13 +23718,7 @@ function createDropdownMenu$1(maxHeight) {
22722
23718
  menu.style.display = 'none';
22723
23719
  menu.style.position = 'absolute';
22724
23720
  menu.style.zIndex = '1000';
22725
- menu.style.backgroundColor = '#fff';
22726
- menu.style.border = '1px solid #ccc';
22727
- menu.style.borderRadius = '4px';
22728
- menu.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
22729
23721
  menu.style.maxHeight = `${maxHeight}px`;
22730
- menu.style.overflowY = 'auto';
22731
- menu.style.minWidth = '200px';
22732
23722
  return menu;
22733
23723
  }
22734
23724
  /**
@@ -22737,17 +23727,11 @@ function createDropdownMenu$1(maxHeight) {
22737
23727
  function createSearchInput$1() {
22738
23728
  const searchContainer = document.createElement('div');
22739
23729
  searchContainer.className = 'zg-dropdown-search';
22740
- searchContainer.style.padding = '8px';
22741
- searchContainer.style.borderBottom = '1px solid #eee';
22742
23730
  const searchInput = document.createElement('input');
22743
23731
  searchInput.type = 'text';
22744
23732
  searchInput.className = 'zg-dropdown-search-input';
22745
23733
  searchInput.placeholder = 'Search...';
22746
23734
  searchInput.setAttribute('aria-label', 'Search options');
22747
- searchInput.style.width = '100%';
22748
- searchInput.style.padding = '4px 8px';
22749
- searchInput.style.border = '1px solid #ccc';
22750
- searchInput.style.borderRadius = '3px';
22751
23735
  searchContainer.appendChild(searchInput);
22752
23736
  return searchContainer;
22753
23737
  }
@@ -22786,11 +23770,6 @@ function createGroupHeader(groupName) {
22786
23770
  groupHeader.className = 'zg-dropdown-group-header';
22787
23771
  groupHeader.textContent = groupName;
22788
23772
  groupHeader.setAttribute('role', 'presentation');
22789
- groupHeader.style.padding = '8px 12px';
22790
- groupHeader.style.fontSize = '12px';
22791
- groupHeader.style.fontWeight = 'bold';
22792
- groupHeader.style.color = '#666';
22793
- groupHeader.style.backgroundColor = '#f5f5f5';
22794
23773
  return groupHeader;
22795
23774
  }
22796
23775
  /**
@@ -22810,10 +23789,6 @@ function createOptionElement$1(option, isSelected, optionRenderer) {
22810
23789
  if (option.disabled) {
22811
23790
  optionEl.setAttribute('aria-disabled', 'true');
22812
23791
  }
22813
- optionEl.style.padding = '8px 12px';
22814
- optionEl.style.cursor = option.disabled ? 'not-allowed' : 'pointer';
22815
- optionEl.style.backgroundColor = isSelected ? '#e3f2fd' : '#fff';
22816
- optionEl.style.color = option.disabled ? '#999' : '#333';
22817
23792
  return optionEl;
22818
23793
  }
22819
23794
  /**
@@ -22823,9 +23798,6 @@ function createNoOptionsElement() {
22823
23798
  const noOptions = document.createElement('div');
22824
23799
  noOptions.className = 'zg-dropdown-no-options';
22825
23800
  noOptions.textContent = 'No options available';
22826
- noOptions.style.padding = '12px';
22827
- noOptions.style.color = '#999';
22828
- noOptions.style.textAlign = 'center';
22829
23801
  return noOptions;
22830
23802
  }
22831
23803
  /**
@@ -23042,8 +24014,13 @@ class DropdownState {
23042
24014
  viewportPadding: 8,
23043
24015
  },
23044
24016
  scrollOptions: {
24017
+ // Trigger lives in a virtualized cell. While the cell is on-screen, keep
24018
+ // the menu glued to it (reposition); the moment it scrolls out of the
24019
+ // viewport, close. closeOnOutOfView fires while the element is still in
24020
+ // the viewport, i.e. before the cell is recycled to another row, so the
24021
+ // menu never anchors to the wrong row.
23045
24022
  closeOnOutOfView: true,
23046
- repositionOnScroll: false,
24023
+ repositionOnScroll: true,
23047
24024
  },
23048
24025
  restoreFocusOnClose: true,
23049
24026
  restoreFocusTo: trigger,
@@ -23414,6 +24391,8 @@ class DropdownEventManager {
23414
24391
  */
23415
24392
  class DropdownRenderer {
23416
24393
  constructor(options = {}) {
24394
+ /** Interactive: attaches listeners and portals its menu to <body>; must not be HTML-cached. */
24395
+ this.cacheable = false;
23417
24396
  // Throw if options array is empty or missing
23418
24397
  if (!options.options || options.options.length === 0) {
23419
24398
  throw new Error('DropdownRenderer requires at least one option');
@@ -23620,8 +24599,16 @@ class LoadingIndicator {
23620
24599
  clearTimeout(this.minDisplayTimeout);
23621
24600
  this.minDisplayTimeout = null;
23622
24601
  }
23623
- // Remove existing indicator if present
23624
- this.hide();
24602
+ // Remove the existing indicator immediately. A new load is starting, so we
24603
+ // replace the node now rather than letting hide()'s minDisplayTime defer
24604
+ // removal — deferral would orphan the old node in the DOM (a stuck overlay)
24605
+ // once this.container is reassigned below. Matters when rapid loads fire
24606
+ // interleaved start/end events (e.g. a backend filter firing several
24607
+ // requests in one tick).
24608
+ if (this.container?.parentElement) {
24609
+ this.container.parentElement.removeChild(this.container);
24610
+ }
24611
+ this.container = null;
23625
24612
  // Create new indicator
23626
24613
  this.container = this.render(state);
23627
24614
  this.container.classList.add('zg-loading-active');
@@ -24013,7 +25000,10 @@ function createRenderingPlugin(opts) {
24013
25000
  loadingIndicator = new LoadingIndicator(options.loading);
24014
25001
  }
24015
25002
  function mapRowToDataIndex(row) {
24016
- const viewIndices = store.get('rows.viewIndices');
25003
+ // getUnphased: rows.viewIndices is a phase-Infinity computed, but rendering
25004
+ // runs at phase 30 (and can be driven synchronously from the phase-30 sync
25005
+ // effect). A phased read here would trip checkPhase and abort the render.
25006
+ const viewIndices = store.getUnphased('rows.viewIndices');
24017
25007
  if (viewIndices)
24018
25008
  return viewIndices[row];
24019
25009
  return row;
@@ -24244,15 +25234,18 @@ function createRenderingPlugin(opts) {
24244
25234
  store.action('rendering:registerRenderer', (name, renderer) => {
24245
25235
  registry.register(name, renderer);
24246
25236
  }, 'rendering');
24247
- // Reactive sync: when viewIndices changes, auto-update row count + canvas + cache + refresh
25237
+ // Single-responsibility viewport sync: resize the virtual scroller +
25238
+ // canvas to the current pipeline view, drop stale cache, and re-render.
25239
+ // This is the one shared implementation — explicit callers (sort,
25240
+ // pagination, init) run it synchronously via the action below; the
25241
+ // effect is a reactive safety net for pipeline changes that don't route
25242
+ // through an explicit call (e.g. filter, programmatic sort/filter).
24248
25243
  let lastViewIndicesRef;
24249
- store.effect('rendering:syncAfterPipeline', () => {
24250
- const viewIndices = store.get('rows.viewIndices');
24251
- if (viewIndices === lastViewIndicesRef)
24252
- return;
24253
- lastViewIndicesRef = viewIndices;
25244
+ function syncViewport() {
24254
25245
  if (!scroller)
24255
25246
  return;
25247
+ const viewIndices = store.getUnphased('rows.viewIndices');
25248
+ lastViewIndicesRef = viewIndices;
24256
25249
  const visibleCount = viewIndices ? viewIndices.length : options.rowCount;
24257
25250
  scroller.setRowCount(visibleCount);
24258
25251
  const canvas = store.get('dom.canvas');
@@ -24262,8 +25255,23 @@ function createRenderingPlugin(opts) {
24262
25255
  }
24263
25256
  if (cache)
24264
25257
  cache.clear();
24265
- if (positioner)
24266
- positioner.refresh();
25258
+ // Re-render against the freshly-clamped visible range with fresh data.
25259
+ // refresh() can't be used here: it reuses the previous range (out of
25260
+ // bounds when the page shrinks) and skips cells whose row/col key is
25261
+ // unchanged even though the underlying data changed (page switch).
25262
+ if (positioner) {
25263
+ positioner.clearRenderedCells();
25264
+ positioner.renderVisibleCells(state.scrollPosition.top, state.scrollPosition.left);
25265
+ }
25266
+ }
25267
+ store.action('rendering:syncAfterPipeline', syncViewport, 'rendering');
25268
+ store.effect('rendering:syncAfterPipeline:auto', () => {
25269
+ const viewIndices = store.getUnphased('rows.viewIndices');
25270
+ // Explicit callers advance lastViewIndicesRef, so this coalesces to a
25271
+ // no-op on the next frame and avoids a duplicate render.
25272
+ if (viewIndices === lastViewIndicesRef)
25273
+ return;
25274
+ syncViewport();
24267
25275
  }, 'rendering', 30);
24268
25276
  // Setup loading listeners (store unsubs for teardown)
24269
25277
  const eventUnsubs = [];
@@ -24290,6 +25298,7 @@ function createRenderingPlugin(opts) {
24290
25298
  clearCache: () => store.exec('rendering:clearCache'),
24291
25299
  handleScroll: (top, left) => store.exec('rendering:handleScroll', top, left),
24292
25300
  updateCanvasSize: () => store.exec('rendering:updateCanvasSize'),
25301
+ syncAfterPipeline: () => store.exec('rendering:syncAfterPipeline'),
24293
25302
  setRowCount: (count) => store.exec('rendering:setRowCount', count),
24294
25303
  setColCount: (count) => store.exec('rendering:setColCount', count),
24295
25304
  registerRenderer: (name, renderer) => store.exec('rendering:registerRenderer', name, renderer),
@@ -25534,6 +26543,7 @@ class HeaderRendererRegistry {
25534
26543
  * Register default header renderers
25535
26544
  */
25536
26545
  function registerDefaultRenderers(registry) {
26546
+ registry.setDefaultRenderer(new TextHeaderRenderer());
25537
26547
  registry.register('text', () => new TextHeaderRenderer());
25538
26548
  registry.register('sortable', () => new SortableHeaderRenderer());
25539
26549
  registry.register('filterable', () => new FilterableHeaderRenderer());
@@ -25707,8 +26717,17 @@ function renderHeaderCell(column, columnId, columnIndex, headerCellsContainer, r
25707
26717
  const element = document.createElement('div');
25708
26718
  element.dataset['columnId'] = columnId;
25709
26719
  element.dataset['columnIndex'] = String(columnIndex);
26720
+ // Reserve the column width before rendering so renderers that don't size the
26721
+ // cell themselves (e.g. custom renderers) don't collapse under the flex row.
26722
+ applyHeaderCellSizing(element, params);
25710
26723
  // Render
25711
26724
  renderer.render(element, params);
26725
+ // Custom renderers only own render/update/destroy. For sortable columns whose
26726
+ // renderer does not wire sort itself, delegate the sort click so header clicks
26727
+ // still toggle sort (and sort-driven header state keeps updating).
26728
+ if (column.sortable && !renderer.handlesSortInteraction && params.emit) {
26729
+ attachSortClickFallback(element, params);
26730
+ }
25712
26731
  // Add to container
25713
26732
  headerCellsContainer.appendChild(element);
25714
26733
  // Store metadata by column ID (for reactive updates)
@@ -25720,6 +26739,36 @@ function renderHeaderCell(column, columnId, columnIndex, headerCellsContainer, r
25720
26739
  lastParams: params,
25721
26740
  });
25722
26741
  }
26742
+ /**
26743
+ * Apply the structural sizing every header cell needs (column width, header
26744
+ * height, no flex shrink, border-box). Renderers may still override these, but
26745
+ * this baseline keeps custom renderers from collapsing to zero width.
26746
+ */
26747
+ function applyHeaderCellSizing(element, params) {
26748
+ element.style.width = `${params.width}px`;
26749
+ element.style.height = `${params.height}px`;
26750
+ element.style.flexShrink = '0';
26751
+ element.style.boxSizing = 'border-box';
26752
+ }
26753
+ /**
26754
+ * Attach a default sort-click handler for sortable columns whose renderer does
26755
+ * not handle the interaction itself. The element is freshly created per render
26756
+ * and discarded on refresh/destroy, so the listener needs no explicit teardown.
26757
+ */
26758
+ function attachSortClickFallback(element, params) {
26759
+ element.style.cursor = 'pointer';
26760
+ element.addEventListener('click', (e) => {
26761
+ const native = e;
26762
+ params.emit?.('header:sort:click', {
26763
+ columnIndex: params.columnIndex,
26764
+ column: params.column,
26765
+ nextDirection: params.sortDirection === 'asc' ? 'desc' : params.sortDirection === 'desc' ? null : 'asc',
26766
+ additive: !!(native.shiftKey || native.ctrlKey || native.metaKey),
26767
+ trigger: 'click',
26768
+ nativeEvent: native,
26769
+ });
26770
+ });
26771
+ }
25723
26772
  /**
25724
26773
  * Destroy all headers
25725
26774
  */
@@ -25815,6 +26864,9 @@ function updateHeaderByColumnId(columnId, headerCells, columnModel, config, head
25815
26864
  const filterState = config.getFilterState?.() ?? [];
25816
26865
  // Update params
25817
26866
  const params = buildRenderParams(metadata.lastParams.column, metadata.columnIndex, columnState.dataIndex, columnState.actualWidth, headerHeight, sortState, filterState, config.eventEmitter);
26867
+ // Keep the reserved width in sync (e.g. column resize) before the renderer
26868
+ // updates, so custom renderers that don't resize themselves stay correct.
26869
+ applyHeaderCellSizing(metadata.element, params);
25818
26870
  // Update the renderer
25819
26871
  metadata.renderer.update(metadata.element, params);
25820
26872
  metadata.lastParams = params;
@@ -25897,13 +26949,13 @@ function createHeaderPlugin(opts) {
25897
26949
  phase: 70,
25898
26950
  dependencies: ['core', 'dom', 'column'],
25899
26951
  setup(store, api) {
25900
- const { events, getColumnModel } = opts;
26952
+ const { events, getColumnModel, options } = opts;
25901
26953
  const registry = new HeaderRendererRegistry();
25902
26954
  const headerCells = new Map();
25903
26955
  let headerCellsContainer = null;
25904
26956
  let columnSubscription = null;
25905
26957
  const eventUnsubs = [];
25906
- const headerHeight = 40;
26958
+ const headerHeight = resolveHeaderHeight(options);
25907
26959
  // Register default renderers
25908
26960
  registerDefaultRenderers(registry);
25909
26961
  function getSortState() {
@@ -26481,6 +27533,25 @@ function createPaginationPlugin(opts) {
26481
27533
  store.extend('pagination.currentPage', 0, 'pagination', 80);
26482
27534
  store.extend('pagination.pageSize', options.pagination.pageSize ?? 100, 'pagination', 80);
26483
27535
  store.extend('pagination.totalPages', 0, 'pagination', 80);
27536
+ // Frontend pagination: slice the post-sort/filter view down to the
27537
+ // current page so virtual scrolling only renders that page. Backend
27538
+ // pagination is sliced by the data plugin, so no pipeline phase there.
27539
+ const isFrontend = resolveQueryExecutionMode(options.dataMode) !== 'backend';
27540
+ if (isFrontend) {
27541
+ store.computed('pipeline.pagination', () => {
27542
+ const upstream = store.getUnphased('pipeline.filter') ??
27543
+ store.getUnphased('pipeline.sort') ??
27544
+ store.getUnphased('rows.indices');
27545
+ if (!upstream)
27546
+ return undefined;
27547
+ const pageSize = store.getUnphased('pagination.pageSize');
27548
+ if (!Number.isFinite(pageSize) || pageSize <= 0)
27549
+ return upstream;
27550
+ const currentPage = store.getUnphased('pagination.currentPage');
27551
+ const start = Math.max(0, currentPage) * pageSize;
27552
+ return upstream.slice(start, start + pageSize);
27553
+ }, 'pagination', 30);
27554
+ }
26484
27555
  let paginationTop = null;
26485
27556
  let paginationBottom = null;
26486
27557
  function getTotalPages() {
@@ -26547,6 +27618,7 @@ function createPaginationPlugin(opts) {
26547
27618
  return;
26548
27619
  store.set('pagination.currentPage', page);
26549
27620
  updatePaginationUI();
27621
+ store.exec('rendering:syncAfterPipeline');
26550
27622
  if (options.onPageChange) {
26551
27623
  options.onPageChange(page, store.get('pagination.pageSize'));
26552
27624
  }
@@ -26579,6 +27651,7 @@ function createPaginationPlugin(opts) {
26579
27651
  store.set('pagination.pageSize', pageSize);
26580
27652
  store.set('pagination.currentPage', newPage);
26581
27653
  updatePaginationUI();
27654
+ store.exec('rendering:syncAfterPipeline');
26582
27655
  if (options.onPageChange) {
26583
27656
  options.onPageChange(store.get('pagination.currentPage'), pageSize);
26584
27657
  }
@@ -26754,7 +27827,7 @@ function createColumnPlugin(opts) {
26754
27827
  showPreview: options.columnResize?.showPreview,
26755
27828
  onColumnWidthsChange: options.onColumnWidthsChange,
26756
27829
  getScrollLeft: () => scrollContainer?.scrollLeft ?? 0,
26757
- getHeaderHeight: () => 40,
27830
+ getHeaderHeight: () => resolveHeaderHeight(options),
26758
27831
  getViewportHeight: () => scrollContainer?.clientHeight ?? 0,
26759
27832
  });
26760
27833
  }, 'column');
@@ -26932,13 +28005,13 @@ function createColumnPlugin(opts) {
26932
28005
  function measureHeaderTextWidth(text) {
26933
28006
  if (!headerMeasureElement) {
26934
28007
  headerMeasureElement = document.createElement('div');
26935
- headerMeasureElement.style.cssText = `
26936
- position: absolute;
26937
- visibility: hidden;
26938
- white-space: nowrap;
26939
- font-family: inherit;
26940
- font-size: inherit;
26941
- font-weight: 600;
28008
+ headerMeasureElement.style.cssText = `
28009
+ position: absolute;
28010
+ visibility: hidden;
28011
+ white-space: nowrap;
28012
+ font-family: inherit;
28013
+ font-size: inherit;
28014
+ font-weight: 600;
26942
28015
  `;
26943
28016
  document.body.appendChild(headerMeasureElement);
26944
28017
  }
@@ -27020,6 +28093,254 @@ function createColumnPlugin(opts) {
27020
28093
  };
27021
28094
  }
27022
28095
 
28096
+ function createFieldState(root) {
28097
+ return {
28098
+ root,
28099
+ activeFields: collectActiveFields(root),
28100
+ timestamp: Date.now(),
28101
+ };
28102
+ }
28103
+ function collectActiveFields(root) {
28104
+ if (!root)
28105
+ return [];
28106
+ const fields = new Set();
28107
+ const visit = (group) => {
28108
+ for (const condition of group.conditions) {
28109
+ fields.add(condition.field);
28110
+ }
28111
+ for (const nested of group.groups ?? []) {
28112
+ visit(nested);
28113
+ }
28114
+ };
28115
+ visit(root);
28116
+ return Array.from(fields);
28117
+ }
28118
+ function removeTopLevelCondition(root, conditionIndex) {
28119
+ if (!root || root.logic !== 'AND')
28120
+ return null;
28121
+ return normalizeFieldGroup({
28122
+ logic: root.logic,
28123
+ conditions: root.conditions.filter((_, index) => index !== conditionIndex),
28124
+ groups: root.groups ? [...root.groups] : undefined,
28125
+ });
28126
+ }
28127
+ function removeTopLevelGroup(root, groupIndex) {
28128
+ if (!root || root.logic !== 'AND')
28129
+ return null;
28130
+ return normalizeFieldGroup({
28131
+ logic: root.logic,
28132
+ conditions: [...root.conditions],
28133
+ groups: root.groups?.filter((_, index) => index !== groupIndex),
28134
+ });
28135
+ }
28136
+ function normalizeFieldGroup(group) {
28137
+ const normalizedGroups = group.groups?.filter((nested) => nested.conditions.length > 0 || (nested.groups?.length ?? 0) > 0);
28138
+ if (group.conditions.length === 0 && (normalizedGroups?.length ?? 0) === 0) {
28139
+ return null;
28140
+ }
28141
+ return {
28142
+ logic: group.logic,
28143
+ conditions: [...group.conditions],
28144
+ groups: normalizedGroups && normalizedGroups.length > 0 ? normalizedGroups : undefined,
28145
+ };
28146
+ }
28147
+
28148
+ function buildChipDescriptors(expression, labels, actions) {
28149
+ const chips = [];
28150
+ const fieldRoot = expression?.fieldState?.root ?? null;
28151
+ const quickFilter = expression?.quickFilter ?? null;
28152
+ if (fieldRoot) {
28153
+ chips.push(...buildFieldStateChips(fieldRoot, expression, labels, actions));
28154
+ }
28155
+ if (quickFilter?.query?.trim()) {
28156
+ chips.push({
28157
+ label: describeQuickFilter(quickFilter, labels),
28158
+ remove: () => actions.clearQuickFilter(),
28159
+ });
28160
+ }
28161
+ return chips;
28162
+ }
28163
+ function buildFieldStateChips(root, expression, labels, actions) {
28164
+ if (root.logic !== 'AND') {
28165
+ return [{ label: describeFieldGroup(root, labels), remove: () => actions.applyStructuredRoot(null) }];
28166
+ }
28167
+ const chips = [];
28168
+ root.conditions.forEach((condition, index) => {
28169
+ chips.push({
28170
+ label: describeFieldCondition(condition, labels),
28171
+ remove: () => actions.applyStructuredRoot(removeTopLevelCondition(expression.fieldState?.root, index)),
28172
+ });
28173
+ });
28174
+ (root.groups ?? []).forEach((group, index) => {
28175
+ chips.push({
28176
+ label: describeFieldGroup(group, labels),
28177
+ remove: () => actions.applyStructuredRoot(removeTopLevelGroup(expression.fieldState?.root, index)),
28178
+ });
28179
+ });
28180
+ return chips;
28181
+ }
28182
+ function describeQuickFilter(quickFilter, labels) {
28183
+ const query = formatValue(quickFilter.query);
28184
+ if (!quickFilter.columns || quickFilter.columns.length === 0) {
28185
+ return `Search ${query}`;
28186
+ }
28187
+ const columnLabels = quickFilter.columns.map((column) => labels.getColumnLabelByIndex(column));
28188
+ return `Search ${columnLabels.join(', ')} ${query}`;
28189
+ }
28190
+ function describeFieldGroup(group, labels, nested = false) {
28191
+ const parts = [
28192
+ ...group.conditions.map((condition) => describeFieldCondition(condition, labels)),
28193
+ ...(group.groups ?? []).map((child) => describeFieldGroup(child, labels, true)),
28194
+ ];
28195
+ if (parts.length === 0)
28196
+ return 'Filter group';
28197
+ const joined = parts.join(` ${group.logic} `);
28198
+ return nested && parts.length > 1 ? `(${joined})` : joined;
28199
+ }
28200
+ function describeFieldCondition(condition, labels) {
28201
+ const label = labels.getColumnLabelByField(condition.field);
28202
+ switch (condition.operator) {
28203
+ case 'eq':
28204
+ case 'equals':
28205
+ return `${label} = ${formatValue(condition.value)}`;
28206
+ case 'neq':
28207
+ case 'notEquals':
28208
+ return `${label} != ${formatValue(condition.value)}`;
28209
+ case 'contains':
28210
+ return `${label} contains ${formatValue(condition.value)}`;
28211
+ case 'notContains':
28212
+ return `${label} does not contain ${formatValue(condition.value)}`;
28213
+ case 'startsWith':
28214
+ return `${label} starts with ${formatValue(condition.value)}`;
28215
+ case 'endsWith':
28216
+ return `${label} ends with ${formatValue(condition.value)}`;
28217
+ case 'gt':
28218
+ case 'greaterThan':
28219
+ return `${label} > ${formatValue(condition.value)}`;
28220
+ case 'gte':
28221
+ case 'greaterThanOrEqual':
28222
+ return `${label} >= ${formatValue(condition.value)}`;
28223
+ case 'lt':
28224
+ case 'lessThan':
28225
+ return `${label} < ${formatValue(condition.value)}`;
28226
+ case 'lte':
28227
+ case 'lessThanOrEqual':
28228
+ return `${label} <= ${formatValue(condition.value)}`;
28229
+ case 'between':
28230
+ return `${label} between ${formatValue(condition.value)} and ${formatValue(condition.valueTo)}`;
28231
+ case 'in':
28232
+ return `${label} in ${formatValue(condition.value)}`;
28233
+ case 'notIn':
28234
+ return `${label} not in ${formatValue(condition.value)}`;
28235
+ case 'isNull':
28236
+ case 'blank':
28237
+ return `${label} is blank`;
28238
+ case 'isNotNull':
28239
+ case 'notBlank':
28240
+ return `${label} is not blank`;
28241
+ case 'regex':
28242
+ return `${label} matches ${formatValue(condition.value)}`;
28243
+ default:
28244
+ return condition.value === undefined
28245
+ ? `${label} ${condition.operator}`
28246
+ : `${label} ${condition.operator} ${formatValue(condition.value)}`;
28247
+ }
28248
+ }
28249
+ function formatValue(value) {
28250
+ if (Array.isArray(value))
28251
+ return `[${value.map((item) => formatValue(item)).join(', ')}]`;
28252
+ if (value instanceof Date)
28253
+ return value.toISOString();
28254
+ if (typeof value === 'string')
28255
+ return `"${value}"`;
28256
+ if (value === null)
28257
+ return 'null';
28258
+ if (value === undefined)
28259
+ return 'undefined';
28260
+ return String(value);
28261
+ }
28262
+
28263
+ class FilterChipBar {
28264
+ constructor(container, getFilterExpression, labels, actions) {
28265
+ this.container = container;
28266
+ this.getFilterExpression = getFilterExpression;
28267
+ this.labels = labels;
28268
+ this.actions = actions;
28269
+ this.chipBar = null;
28270
+ }
28271
+ render() {
28272
+ const chipBar = this.ensureChipBar();
28273
+ const chips = buildChipDescriptors(this.getFilterExpression(), this.labels, this.actions);
28274
+ chipBar.replaceChildren();
28275
+ if (chips.length === 0) {
28276
+ chipBar.style.display = 'none';
28277
+ this.updateLayout();
28278
+ return;
28279
+ }
28280
+ chipBar.style.display = '';
28281
+ chipBar.appendChild(createTextElement('span', 'zg-filter-chip-bar__title', 'Filters'));
28282
+ const chipList = document.createElement('div');
28283
+ chipList.className = 'zg-filter-chip-bar__list';
28284
+ for (const chip of chips) {
28285
+ chipList.appendChild(this.createChipElement(chip.label, chip.remove));
28286
+ }
28287
+ chipBar.appendChild(chipList);
28288
+ const clearAllButton = document.createElement('button');
28289
+ clearAllButton.type = 'button';
28290
+ clearAllButton.className = 'zg-filter-chip-bar__clear';
28291
+ clearAllButton.textContent = 'Clear all';
28292
+ clearAllButton.addEventListener('click', () => this.actions.clearAllFilters());
28293
+ chipBar.appendChild(clearAllButton);
28294
+ this.updateLayout();
28295
+ }
28296
+ destroy() {
28297
+ this.chipBar?.remove();
28298
+ this.chipBar = null;
28299
+ this.updateLayout();
28300
+ }
28301
+ ensureChipBar() {
28302
+ if (this.chipBar && this.chipBar.isConnected)
28303
+ return this.chipBar;
28304
+ const host = this.container.querySelector('.zg-viewport') ?? this.container;
28305
+ const chipBar = document.createElement('div');
28306
+ chipBar.className = 'zg-filter-chip-bar';
28307
+ chipBar.style.display = 'none';
28308
+ host.appendChild(chipBar);
28309
+ this.chipBar = chipBar;
28310
+ return chipBar;
28311
+ }
28312
+ createChipElement(label, remove) {
28313
+ const chip = document.createElement('div');
28314
+ chip.className = 'zg-filter-chip';
28315
+ chip.title = label;
28316
+ chip.appendChild(createTextElement('span', 'zg-filter-chip__label', label));
28317
+ const removeButton = document.createElement('button');
28318
+ removeButton.type = 'button';
28319
+ removeButton.className = 'zg-filter-chip__remove';
28320
+ removeButton.setAttribute('aria-label', `Remove filter ${label}`);
28321
+ removeButton.textContent = 'x';
28322
+ removeButton.addEventListener('click', remove);
28323
+ chip.appendChild(removeButton);
28324
+ return chip;
28325
+ }
28326
+ updateLayout() {
28327
+ const scrollContainer = this.container.querySelector('.zg-scroll-container');
28328
+ const header = this.container.querySelector('.zg-header');
28329
+ const headerHeight = header?.offsetHeight ?? 0;
28330
+ const chipHeight = this.chipBar && this.chipBar.style.display !== 'none' ? this.chipBar.offsetHeight : 0;
28331
+ if (this.chipBar)
28332
+ this.chipBar.style.top = `${headerHeight}px`;
28333
+ if (scrollContainer)
28334
+ scrollContainer.style.top = `${headerHeight + chipHeight}px`;
28335
+ }
28336
+ }
28337
+ function createTextElement(tag, className, text) {
28338
+ const element = document.createElement(tag);
28339
+ element.className = className;
28340
+ element.textContent = text;
28341
+ return element;
28342
+ }
28343
+
27023
28344
  const TEXT_OPERATORS = [
27024
28345
  { value: 'contains', label: 'Contains', valueCount: 1 },
27025
28346
  { value: 'equals', label: 'Equals', valueCount: 1 },
@@ -27049,315 +28370,311 @@ const DATE_OPERATORS = [
27049
28370
  { value: 'blank', label: 'Is blank', valueCount: 0 },
27050
28371
  { value: 'notBlank', label: 'Is not blank', valueCount: 0 },
27051
28372
  ];
28373
+ function getOperatorsForType(type) {
28374
+ switch (type) {
28375
+ case 'number':
28376
+ return NUMBER_OPERATORS;
28377
+ case 'date':
28378
+ return DATE_OPERATORS;
28379
+ default:
28380
+ return TEXT_OPERATORS;
28381
+ }
28382
+ }
28383
+ function getOperatorValueCount(operator, type) {
28384
+ return getOperatorsForType(type).find((op) => op.value === operator)?.valueCount ?? 1;
28385
+ }
28386
+ function buildCondition(operator, dropdownType, value, valueTo) {
28387
+ const valueCount = getOperatorValueCount(operator, dropdownType);
28388
+ if (valueCount === 0)
28389
+ return { operator };
28390
+ if (valueCount === 2) {
28391
+ return {
28392
+ operator,
28393
+ value: [parseInputValue(value, dropdownType), parseInputValue(valueTo, dropdownType)],
28394
+ };
28395
+ }
28396
+ return { operator, value: parseInputValue(value, dropdownType) };
28397
+ }
28398
+ function parseInputValue(value, dropdownType) {
28399
+ if (dropdownType === 'number') {
28400
+ const num = Number(value);
28401
+ return Number.isNaN(num) ? value : num;
28402
+ }
28403
+ if (dropdownType === 'date') {
28404
+ const ts = Date.parse(value);
28405
+ return Number.isNaN(ts) ? value : ts;
28406
+ }
28407
+ return value;
28408
+ }
28409
+
28410
+ function createTitle(options) {
28411
+ const title = document.createElement('div');
28412
+ title.className = 'zg-filter-popup__title';
28413
+ title.textContent = `Filter: ${options.getColumnLabel(options.columnDef, options.dataCol)}`;
28414
+ return title;
28415
+ }
28416
+ function createLogicRow(initial) {
28417
+ const row = document.createElement('div');
28418
+ row.className = 'zg-filter-popup__row';
28419
+ row.innerHTML = '<label>Match</label>';
28420
+ const select = document.createElement('select');
28421
+ select.className = 'zg-filter-popup__select';
28422
+ select.innerHTML = '<option value="AND">AND</option><option value="OR">OR</option>';
28423
+ select.value = initial;
28424
+ row.appendChild(select);
28425
+ return { row, select };
28426
+ }
28427
+ function createConditionRow(condition, operators, dropdownType, onRemove) {
28428
+ const element = document.createElement('div');
28429
+ element.className = 'zg-filter-popup__condition';
28430
+ const operatorSelect = createOperatorSelect(operators);
28431
+ const valueInput = createValueInput(dropdownType);
28432
+ const valueInputTo = createValueInput(dropdownType);
28433
+ valueInputTo.style.display = 'none';
28434
+ const removeBtn = createButton('zg-filter-popup__btn zg-filter-popup__btn--remove', 'Remove');
28435
+ appendRow(element, 'Operator', operatorSelect);
28436
+ appendRow(element, 'Value', valueInput, valueInputTo);
28437
+ const actions = document.createElement('div');
28438
+ actions.className = 'zg-filter-popup__condition-actions';
28439
+ actions.appendChild(removeBtn);
28440
+ element.appendChild(actions);
28441
+ const updateInputs = () => {
28442
+ const valueCount = operators.find((op) => op.value === operatorSelect.value)?.valueCount ?? 1;
28443
+ valueInput.style.display = valueCount === 0 ? 'none' : '';
28444
+ valueInputTo.style.display = valueCount === 2 ? '' : 'none';
28445
+ };
28446
+ operatorSelect.addEventListener('change', updateInputs);
28447
+ removeBtn.addEventListener('click', onRemove);
28448
+ applyConditionValue(condition, operatorSelect, valueInput, valueInputTo);
28449
+ updateInputs();
28450
+ return { element, operatorSelect, valueInput, valueInputTo, removeBtn };
28451
+ }
28452
+ function createValueInput(dropdownType) {
28453
+ const input = document.createElement('input');
28454
+ input.className = 'zg-filter-popup__input';
28455
+ input.type = dropdownType === 'number' ? 'number' : dropdownType === 'date' ? 'date' : 'text';
28456
+ return input;
28457
+ }
28458
+ function createOperatorSelect(operators) {
28459
+ const select = document.createElement('select');
28460
+ select.className = 'zg-filter-popup__select';
28461
+ operators.forEach((op) => {
28462
+ const option = document.createElement('option');
28463
+ option.value = op.value;
28464
+ option.textContent = op.label;
28465
+ select.appendChild(option);
28466
+ });
28467
+ return select;
28468
+ }
28469
+ function createButton(className, text) {
28470
+ const button = document.createElement('button');
28471
+ button.type = 'button';
28472
+ button.className = className;
28473
+ button.textContent = text;
28474
+ return button;
28475
+ }
28476
+ function appendRow(parent, label, ...children) {
28477
+ const row = document.createElement('div');
28478
+ row.className = 'zg-filter-popup__row';
28479
+ row.innerHTML = `<label>${label}</label>`;
28480
+ children.forEach((child) => row.appendChild(child));
28481
+ parent.appendChild(row);
28482
+ }
28483
+ function createActions() {
28484
+ const actions = document.createElement('div');
28485
+ actions.className = 'zg-filter-popup__actions';
28486
+ const applyBtn = createButton('zg-filter-popup__btn primary', 'Apply');
28487
+ const clearBtn = createButton('zg-filter-popup__btn', 'Clear');
28488
+ actions.appendChild(applyBtn);
28489
+ actions.appendChild(clearBtn);
28490
+ return { actions, applyBtn, clearBtn };
28491
+ }
28492
+ function applyConditionValue(condition, operatorSelect, valueInput, valueInputTo) {
28493
+ if (!condition)
28494
+ return;
28495
+ operatorSelect.value = condition.operator;
28496
+ if (Array.isArray(condition.value)) {
28497
+ valueInput.value = String(condition.value[0] ?? '');
28498
+ valueInputTo.value = String(condition.value[1] ?? '');
28499
+ }
28500
+ else if (condition.value != null) {
28501
+ valueInput.value = String(condition.value);
28502
+ }
28503
+ }
28504
+ function positionUnanchoredPopup(popup, options) {
28505
+ if (options.payload.anchorElement)
28506
+ return;
28507
+ const containerRect = options.container.getBoundingClientRect();
28508
+ popup.style.position = 'fixed';
28509
+ popup.style.top = `${containerRect.top + 12}px`;
28510
+ popup.style.left = `${containerRect.left + 12}px`;
28511
+ }
28512
+
28513
+ function openFilterPopup(options) {
28514
+ const dropdownType = options.payload.dropdownType ?? 'text';
28515
+ const operators = getOperatorsForType(dropdownType);
28516
+ const popup = document.createElement('div');
28517
+ popup.className = 'zg-filter-popup';
28518
+ popup.appendChild(createTitle(options));
28519
+ const logicRow = createLogicRow(options.existing?.logic ?? 'AND');
28520
+ popup.appendChild(logicRow.row);
28521
+ const conditionsContainer = document.createElement('div');
28522
+ conditionsContainer.className = 'zg-filter-popup__conditions';
28523
+ popup.appendChild(conditionsContainer);
28524
+ const conditionRows = [];
28525
+ const updateConditionControls = () => {
28526
+ const showLogic = conditionRows.length > 1;
28527
+ logicRow.row.style.display = showLogic ? '' : 'none';
28528
+ conditionRows.forEach((row) => {
28529
+ row.removeBtn.style.display = showLogic ? '' : 'none';
28530
+ });
28531
+ };
28532
+ const addCondition = (condition) => {
28533
+ const row = createConditionRow(condition, operators, dropdownType, () => {
28534
+ const index = conditionRows.findIndex((item) => item.element === row.element);
28535
+ if (index >= 0)
28536
+ conditionRows.splice(index, 1);
28537
+ row.element.remove();
28538
+ updateConditionControls();
28539
+ });
28540
+ conditionRows.push(row);
28541
+ conditionsContainer.appendChild(row.element);
28542
+ updateConditionControls();
28543
+ };
28544
+ const existingConditions = options.existing?.conditions?.length
28545
+ ? options.existing.conditions
28546
+ : null;
28547
+ existingConditions ? existingConditions.forEach(addCondition) : addCondition();
28548
+ const addConditionBtn = createButton('zg-filter-popup__btn zg-filter-popup__btn--add', 'Add condition');
28549
+ addConditionBtn.addEventListener('click', () => addCondition());
28550
+ popup.appendChild(addConditionBtn);
28551
+ const { actions, applyBtn, clearBtn } = createActions();
28552
+ popup.appendChild(actions);
28553
+ const applyFilter = () => {
28554
+ const conditions = conditionRows.map((row) => buildCondition(row.operatorSelect.value, dropdownType, row.valueInput.value, row.valueInputTo.value));
28555
+ if (conditions.length === 0)
28556
+ return;
28557
+ options.setColumnFilter(options.dataCol, conditions, logicRow.select.value);
28558
+ handle.close();
28559
+ };
28560
+ applyBtn.addEventListener('click', applyFilter);
28561
+ clearBtn.addEventListener('click', () => {
28562
+ options.clearColumnFilter(options.dataCol);
28563
+ handle.close();
28564
+ });
28565
+ let keyHandler = (e) => {
28566
+ if (e.key !== 'Enter')
28567
+ return;
28568
+ const active = document.activeElement;
28569
+ if (!active || !popup.contains(active))
28570
+ return;
28571
+ const tag = active.tagName.toLowerCase();
28572
+ if (tag === 'input' || tag === 'select') {
28573
+ e.preventDefault();
28574
+ applyFilter();
28575
+ }
28576
+ };
28577
+ popup.addEventListener('keydown', keyHandler);
28578
+ document.body.appendChild(popup);
28579
+ positionUnanchoredPopup(popup, options);
28580
+ const teardown = () => {
28581
+ if (keyHandler) {
28582
+ popup.removeEventListener('keydown', keyHandler);
28583
+ keyHandler = null;
28584
+ }
28585
+ popup.remove();
28586
+ };
28587
+ const handle = {
28588
+ close: () => {
28589
+ if (options.overlayManager.isOpen(popup)) {
28590
+ options.overlayManager.close(popup);
28591
+ return;
28592
+ }
28593
+ teardown();
28594
+ },
28595
+ destroy: teardown,
28596
+ };
28597
+ options.overlayManager.open({
28598
+ overlay: popup,
28599
+ anchor: options.payload.anchorElement,
28600
+ additionalElements: options.payload.anchorElement ? [options.payload.anchorElement] : [],
28601
+ restoreFocusOnClose: !!options.payload.anchorElement,
28602
+ restoreFocusTo: options.payload.anchorElement ?? null,
28603
+ closeOnEscape: true,
28604
+ positionOptions: { placement: 'bottom-start', offset: 4, viewportPadding: 8 },
28605
+ scrollOptions: { closeOnOutOfView: true, repositionOnScroll: false },
28606
+ onClose: teardown,
28607
+ });
28608
+ setTimeout(() => {
28609
+ const first = conditionRows[0];
28610
+ if (!first)
28611
+ return;
28612
+ if (first.valueInput.style.display !== 'none')
28613
+ first.valueInput.focus();
28614
+ else
28615
+ first.operatorSelect.focus();
28616
+ }, 0);
28617
+ return handle;
28618
+ }
28619
+
27052
28620
  class GridFilterUI {
27053
28621
  constructor(options) {
28622
+ this.options = options;
28623
+ this.overlayManager = OverlayManager.getInstance();
27054
28624
  this.popup = null;
27055
- this.chipBar = null;
27056
- this.popupKeyHandler = null;
27057
28625
  this.filterClickUnsub = null;
27058
28626
  this.filterChangeUnsub = null;
27059
- this.closePopup = () => {
27060
- const popup = this.popup;
27061
- if (!popup)
27062
- return;
27063
- if (this.overlayManager.isOpen(popup)) {
27064
- this.overlayManager.close(popup);
27065
- return;
27066
- }
27067
- this.teardownPopup();
27068
- };
27069
- this.container = options.container;
27070
- this.events = options.events;
27071
28627
  this.columns = options.columns ?? [];
27072
- this.getFilterState = options.getFilterState;
27073
- this.getFilterExpression = options.getFilterExpression;
27074
- this.setColumnFilter = options.setColumnFilter;
27075
- this.setFieldState = options.setFieldState;
27076
- this.clearColumnFilter = options.clearColumnFilter;
27077
- this.clearQuickFilter = options.clearQuickFilter;
27078
- this.clearAllFilters = options.clearAllFilters;
27079
- this.getColumnDef = options.getColumnDef;
27080
- this.mapVisualToDataCol = options.mapVisualToDataCol;
27081
- this.overlayManager = OverlayManager.getInstance();
27082
- this.filterClickUnsub = this.events.on('header:filter:click', (payload) => {
28628
+ this.chipBar = new FilterChipBar(options.container, options.getFilterExpression, {
28629
+ getColumnLabelByField: (field) => this.getColumnLabelByField(field),
28630
+ getColumnLabelByIndex: (index) => this.getColumnLabelByIndex(index),
28631
+ }, {
28632
+ clearQuickFilter: options.clearQuickFilter,
28633
+ clearAllFilters: options.clearAllFilters,
28634
+ applyStructuredRoot: (root) => options.setFieldState(createFieldState(root)),
28635
+ });
28636
+ this.filterClickUnsub = options.events.on('header:filter:click', (payload) => {
27083
28637
  this.openFilterPopup(payload);
27084
28638
  });
27085
- this.filterChangeUnsub = this.events.on('filter:change', () => {
27086
- this.renderFilterChips();
28639
+ this.filterChangeUnsub = options.events.on('filter:change', () => {
28640
+ this.chipBar.render();
27087
28641
  });
27088
- this.renderFilterChips();
28642
+ this.chipBar.render();
27089
28643
  }
27090
28644
  setColumns(columns) {
27091
28645
  this.columns = [...columns];
27092
28646
  this.closePopup();
27093
- this.renderFilterChips();
28647
+ this.chipBar.render();
28648
+ }
28649
+ destroy() {
28650
+ this.closePopup();
28651
+ this.filterClickUnsub?.();
28652
+ this.filterChangeUnsub?.();
28653
+ this.filterClickUnsub = null;
28654
+ this.filterChangeUnsub = null;
28655
+ this.chipBar.destroy();
27094
28656
  }
27095
28657
  openFilterPopup(payload) {
27096
- const dataCol = this.mapVisualToDataCol(payload.columnIndex);
27097
- const columnDef = this.getColumnDef(dataCol);
28658
+ const dataCol = this.options.mapVisualToDataCol(payload.columnIndex);
28659
+ const columnDef = this.options.getColumnDef(dataCol);
27098
28660
  if (!columnDef)
27099
28661
  return;
27100
28662
  this.closePopup();
27101
- const dropdownType = payload.dropdownType ?? 'text';
27102
- const operators = this.getOperatorsForType(dropdownType);
27103
- const existing = this.getFilterState().find((f) => f.column === dataCol);
27104
- const popup = document.createElement('div');
27105
- popup.className = 'zg-filter-popup';
27106
- const title = document.createElement('div');
27107
- title.className = 'zg-filter-popup__title';
27108
- title.textContent = `Filter: ${this.getColumnLabel(columnDef, dataCol)}`;
27109
- popup.appendChild(title);
27110
- const logicRow = document.createElement('div');
27111
- logicRow.className = 'zg-filter-popup__row';
27112
- logicRow.innerHTML = `<label>Match</label>`;
27113
- const logicSelect = document.createElement('select');
27114
- logicSelect.className = 'zg-filter-popup__select';
27115
- logicSelect.innerHTML = `
27116
- <option value="AND">AND</option>
27117
- <option value="OR">OR</option>
27118
- `;
27119
- logicSelect.value = existing?.logic ?? 'AND';
27120
- logicRow.appendChild(logicSelect);
27121
- popup.appendChild(logicRow);
27122
- const conditionsContainer = document.createElement('div');
27123
- conditionsContainer.className = 'zg-filter-popup__conditions';
27124
- popup.appendChild(conditionsContainer);
27125
- const conditionRows = [];
27126
- const createConditionRow = (condition) => {
27127
- const conditionEl = document.createElement('div');
27128
- conditionEl.className = 'zg-filter-popup__condition';
27129
- const operatorRow = document.createElement('div');
27130
- operatorRow.className = 'zg-filter-popup__row';
27131
- operatorRow.innerHTML = `<label>Operator</label>`;
27132
- const operatorSelect = document.createElement('select');
27133
- operatorSelect.className = 'zg-filter-popup__select';
27134
- operators.forEach((op) => {
27135
- const option = document.createElement('option');
27136
- option.value = op.value;
27137
- option.textContent = op.label;
27138
- operatorSelect.appendChild(option);
27139
- });
27140
- operatorRow.appendChild(operatorSelect);
27141
- conditionEl.appendChild(operatorRow);
27142
- const valueRow = document.createElement('div');
27143
- valueRow.className = 'zg-filter-popup__row';
27144
- valueRow.innerHTML = `<label>Value</label>`;
27145
- const valueInput = document.createElement('input');
27146
- valueInput.className = 'zg-filter-popup__input';
27147
- valueInput.type =
27148
- dropdownType === 'number' ? 'number' : dropdownType === 'date' ? 'date' : 'text';
27149
- const valueInputTo = document.createElement('input');
27150
- valueInputTo.className = 'zg-filter-popup__input';
27151
- valueInputTo.type = valueInput.type;
27152
- valueInputTo.style.display = 'none';
27153
- valueRow.appendChild(valueInput);
27154
- valueRow.appendChild(valueInputTo);
27155
- conditionEl.appendChild(valueRow);
27156
- const actionsRow = document.createElement('div');
27157
- actionsRow.className = 'zg-filter-popup__condition-actions';
27158
- const removeBtn = document.createElement('button');
27159
- removeBtn.type = 'button';
27160
- removeBtn.className = 'zg-filter-popup__btn zg-filter-popup__btn--remove';
27161
- removeBtn.textContent = 'Remove';
27162
- actionsRow.appendChild(removeBtn);
27163
- conditionEl.appendChild(actionsRow);
27164
- const updateValueInputs = () => {
27165
- const selected = operators.find((op) => op.value === operatorSelect.value);
27166
- const valueCount = selected?.valueCount ?? 1;
27167
- if (valueCount === 0) {
27168
- valueInput.style.display = 'none';
27169
- valueInputTo.style.display = 'none';
27170
- }
27171
- else if (valueCount === 2) {
27172
- valueInput.style.display = '';
27173
- valueInputTo.style.display = '';
27174
- }
27175
- else {
27176
- valueInput.style.display = '';
27177
- valueInputTo.style.display = 'none';
27178
- }
27179
- };
27180
- operatorSelect.addEventListener('change', updateValueInputs);
27181
- if (condition) {
27182
- operatorSelect.value = condition.operator;
27183
- if (Array.isArray(condition.value)) {
27184
- valueInput.value = String(condition.value[0] ?? '');
27185
- valueInputTo.value = String(condition.value[1] ?? '');
27186
- }
27187
- else if (condition.value != null) {
27188
- valueInput.value = String(condition.value);
27189
- }
27190
- }
27191
- updateValueInputs();
27192
- removeBtn.addEventListener('click', () => {
27193
- const index = conditionRows.findIndex((row) => row.element === conditionEl);
27194
- if (index >= 0) {
27195
- conditionRows.splice(index, 1);
27196
- }
27197
- conditionEl.remove();
27198
- updateConditionControls();
27199
- });
27200
- return {
27201
- element: conditionEl,
27202
- operatorSelect,
27203
- valueInput,
27204
- valueInputTo,
27205
- removeBtn,
27206
- };
27207
- };
27208
- const updateConditionControls = () => {
27209
- const showLogic = conditionRows.length > 1;
27210
- logicRow.style.display = showLogic ? '' : 'none';
27211
- conditionRows.forEach((row) => {
27212
- row.removeBtn.style.display = showLogic ? '' : 'none';
27213
- });
27214
- };
27215
- const addCondition = (condition) => {
27216
- const row = createConditionRow(condition);
27217
- conditionRows.push(row);
27218
- conditionsContainer.appendChild(row.element);
27219
- updateConditionControls();
27220
- };
27221
- const existingConditions = existing?.conditions && existing.conditions.length > 0 ? existing.conditions : null;
27222
- if (existingConditions) {
27223
- existingConditions.forEach((condition) => addCondition(condition));
27224
- }
27225
- else {
27226
- addCondition();
27227
- }
27228
- const addConditionBtn = document.createElement('button');
27229
- addConditionBtn.type = 'button';
27230
- addConditionBtn.className = 'zg-filter-popup__btn zg-filter-popup__btn--add';
27231
- addConditionBtn.textContent = 'Add condition';
27232
- addConditionBtn.addEventListener('click', () => addCondition());
27233
- popup.appendChild(addConditionBtn);
27234
- const actions = document.createElement('div');
27235
- actions.className = 'zg-filter-popup__actions';
27236
- const applyBtn = document.createElement('button');
27237
- applyBtn.type = 'button';
27238
- applyBtn.className = 'zg-filter-popup__btn primary';
27239
- applyBtn.textContent = 'Apply';
27240
- const clearBtn = document.createElement('button');
27241
- clearBtn.type = 'button';
27242
- clearBtn.className = 'zg-filter-popup__btn';
27243
- clearBtn.textContent = 'Clear';
27244
- actions.appendChild(applyBtn);
27245
- actions.appendChild(clearBtn);
27246
- popup.appendChild(actions);
27247
- const applyFilter = () => {
27248
- const conditions = [];
27249
- for (const row of conditionRows) {
27250
- const operator = row.operatorSelect.value;
27251
- const condition = this.buildCondition(operator, dropdownType, row.valueInput.value, row.valueInputTo.value);
27252
- if (condition) {
27253
- conditions.push(condition);
27254
- }
27255
- }
27256
- if (conditions.length === 0) {
27257
- return;
27258
- }
27259
- const logic = logicSelect.value ?? 'AND';
27260
- this.setColumnFilter(dataCol, conditions, logic);
27261
- this.closePopup();
27262
- };
27263
- applyBtn.addEventListener('click', applyFilter);
27264
- clearBtn.addEventListener('click', () => {
27265
- this.clearColumnFilter(dataCol);
27266
- this.closePopup();
28663
+ this.popup = openFilterPopup({
28664
+ container: this.options.container,
28665
+ overlayManager: this.overlayManager,
28666
+ payload,
28667
+ dataCol,
28668
+ columnDef,
28669
+ existing: this.options.getFilterState().find((filter) => filter.column === dataCol),
28670
+ setColumnFilter: this.options.setColumnFilter,
28671
+ clearColumnFilter: this.options.clearColumnFilter,
28672
+ getColumnLabel: (column, index) => this.getColumnLabel(column, index),
27267
28673
  });
27268
- this.popup = popup;
27269
- document.body.appendChild(popup);
27270
- this.popupKeyHandler = (e) => {
27271
- if (e.key === 'Enter') {
27272
- const active = document.activeElement;
27273
- if (!active || !popup.contains(active)) {
27274
- return;
27275
- }
27276
- const tag = active.tagName.toLowerCase();
27277
- if (tag === 'input' || tag === 'select') {
27278
- e.preventDefault();
27279
- applyFilter();
27280
- }
27281
- }
27282
- };
27283
- popup.addEventListener('keydown', this.popupKeyHandler);
27284
- if (!payload.anchorElement) {
27285
- const containerRect = this.container.getBoundingClientRect();
27286
- popup.style.position = 'fixed';
27287
- popup.style.top = `${containerRect.top + 12}px`;
27288
- popup.style.left = `${containerRect.left + 12}px`;
27289
- }
27290
- this.overlayManager.open({
27291
- overlay: popup,
27292
- anchor: payload.anchorElement,
27293
- additionalElements: payload.anchorElement ? [payload.anchorElement] : [],
27294
- restoreFocusOnClose: !!payload.anchorElement,
27295
- restoreFocusTo: payload.anchorElement ?? null,
27296
- closeOnEscape: true,
27297
- positionOptions: {
27298
- placement: 'bottom-start',
27299
- offset: 4,
27300
- viewportPadding: 8,
27301
- },
27302
- scrollOptions: {
27303
- closeOnOutOfView: true,
27304
- repositionOnScroll: false,
27305
- },
27306
- onClose: () => {
27307
- this.teardownPopup();
27308
- },
27309
- });
27310
- // Focus first input for usability
27311
- setTimeout(() => {
27312
- const first = conditionRows[0];
27313
- if (!first)
27314
- return;
27315
- if (first.valueInput.style.display !== 'none') {
27316
- first.valueInput.focus();
27317
- }
27318
- else {
27319
- first.operatorSelect.focus();
27320
- }
27321
- }, 0);
27322
- }
27323
- buildCondition(operator, dropdownType, value, valueTo) {
27324
- const valueCount = this.getOperatorValueCount(operator, dropdownType);
27325
- if (valueCount === 0) {
27326
- return { operator };
27327
- }
27328
- if (valueCount === 2) {
27329
- const parsed = [
27330
- this.parseInputValue(value, dropdownType),
27331
- this.parseInputValue(valueTo, dropdownType),
27332
- ];
27333
- return { operator, value: parsed };
27334
- }
27335
- return { operator, value: this.parseInputValue(value, dropdownType) };
27336
- }
27337
- parseInputValue(value, dropdownType) {
27338
- if (dropdownType === 'number') {
27339
- const num = Number(value);
27340
- return Number.isNaN(num) ? value : num;
27341
- }
27342
- if (dropdownType === 'date') {
27343
- const ts = Date.parse(value);
27344
- return Number.isNaN(ts) ? value : ts;
27345
- }
27346
- return value;
27347
- }
27348
- getOperatorsForType(type) {
27349
- switch (type) {
27350
- case 'number':
27351
- return NUMBER_OPERATORS;
27352
- case 'date':
27353
- return DATE_OPERATORS;
27354
- default:
27355
- return TEXT_OPERATORS;
27356
- }
27357
28674
  }
27358
- getOperatorValueCount(operator, type) {
27359
- const operators = this.getOperatorsForType(type);
27360
- return operators.find((op) => op.value === operator)?.valueCount ?? 1;
28675
+ closePopup() {
28676
+ this.popup?.close();
28677
+ this.popup = null;
27361
28678
  }
27362
28679
  getColumnLabel(column, index) {
27363
28680
  if (typeof column.header === 'string')
@@ -27366,308 +28683,13 @@ class GridFilterUI {
27366
28683
  }
27367
28684
  getColumnLabelByField(field) {
27368
28685
  const index = this.columns.findIndex((column) => column.field === field);
27369
- if (index < 0) {
28686
+ if (index < 0)
27370
28687
  return field;
27371
- }
27372
28688
  return this.getColumnLabel(this.columns[index], index);
27373
28689
  }
27374
28690
  getColumnLabelByIndex(index) {
27375
- const column = this.columns[index] ?? this.getColumnDef(index);
27376
- if (!column) {
27377
- return `Column ${index}`;
27378
- }
27379
- return this.getColumnLabel(column, index);
27380
- }
27381
- ensureChipBar() {
27382
- if (this.chipBar && this.chipBar.isConnected) {
27383
- return this.chipBar;
27384
- }
27385
- const host = this.container.querySelector('.zg-viewport') ?? this.container;
27386
- const chipBar = document.createElement('div');
27387
- chipBar.className = 'zg-filter-chip-bar';
27388
- chipBar.style.display = 'none';
27389
- host.appendChild(chipBar);
27390
- this.chipBar = chipBar;
27391
- return chipBar;
27392
- }
27393
- renderFilterChips() {
27394
- const chipBar = this.ensureChipBar();
27395
- const expression = this.getFilterExpression();
27396
- const chips = this.buildChipDescriptors(expression);
27397
- chipBar.replaceChildren();
27398
- if (chips.length === 0) {
27399
- chipBar.style.display = 'none';
27400
- this.updateChipBarLayout();
27401
- return;
27402
- }
27403
- chipBar.style.display = '';
27404
- const title = document.createElement('span');
27405
- title.className = 'zg-filter-chip-bar__title';
27406
- title.textContent = 'Filters';
27407
- chipBar.appendChild(title);
27408
- const chipList = document.createElement('div');
27409
- chipList.className = 'zg-filter-chip-bar__list';
27410
- for (const chip of chips) {
27411
- chipList.appendChild(this.createChipElement(chip.label, chip.remove));
27412
- }
27413
- chipBar.appendChild(chipList);
27414
- const clearAllButton = document.createElement('button');
27415
- clearAllButton.type = 'button';
27416
- clearAllButton.className = 'zg-filter-chip-bar__clear';
27417
- clearAllButton.textContent = 'Clear all';
27418
- clearAllButton.addEventListener('click', () => {
27419
- this.clearAllFilters();
27420
- });
27421
- chipBar.appendChild(clearAllButton);
27422
- this.updateChipBarLayout();
27423
- }
27424
- createChipElement(label, remove) {
27425
- const chip = document.createElement('div');
27426
- chip.className = 'zg-filter-chip';
27427
- chip.title = label;
27428
- const text = document.createElement('span');
27429
- text.className = 'zg-filter-chip__label';
27430
- text.textContent = label;
27431
- chip.appendChild(text);
27432
- const removeButton = document.createElement('button');
27433
- removeButton.type = 'button';
27434
- removeButton.className = 'zg-filter-chip__remove';
27435
- removeButton.setAttribute('aria-label', `Remove filter ${label}`);
27436
- removeButton.textContent = '×';
27437
- removeButton.addEventListener('click', () => {
27438
- remove();
27439
- });
27440
- chip.appendChild(removeButton);
27441
- return chip;
27442
- }
27443
- buildChipDescriptors(expression) {
27444
- const chips = [];
27445
- const fieldRoot = expression?.fieldState?.root ?? null;
27446
- const quickFilter = expression?.quickFilter ?? null;
27447
- if (fieldRoot) {
27448
- chips.push(...this.buildFieldStateChips(fieldRoot));
27449
- }
27450
- if (quickFilter?.query?.trim()) {
27451
- chips.push({
27452
- label: this.describeQuickFilter(quickFilter),
27453
- remove: () => this.clearQuickFilter(),
27454
- });
27455
- }
27456
- return chips;
27457
- }
27458
- buildFieldStateChips(root) {
27459
- if (root.logic !== 'AND') {
27460
- return [
27461
- {
27462
- label: this.describeFieldGroup(root),
27463
- remove: () => this.applyStructuredRoot(null),
27464
- },
27465
- ];
27466
- }
27467
- const chips = [];
27468
- root.conditions.forEach((condition, index) => {
27469
- chips.push({
27470
- label: this.describeFieldCondition(condition),
27471
- remove: () => this.removeTopLevelCondition(index),
27472
- });
27473
- });
27474
- (root.groups ?? []).forEach((group, index) => {
27475
- chips.push({
27476
- label: this.describeFieldGroup(group),
27477
- remove: () => this.removeTopLevelGroup(index),
27478
- });
27479
- });
27480
- return chips;
27481
- }
27482
- removeTopLevelCondition(conditionIndex) {
27483
- const root = this.getFilterExpression()?.fieldState?.root;
27484
- if (!root) {
27485
- return;
27486
- }
27487
- if (root.logic !== 'AND') {
27488
- this.applyStructuredRoot(null);
27489
- return;
27490
- }
27491
- const nextRoot = this.normalizeFieldGroup({
27492
- logic: root.logic,
27493
- conditions: root.conditions.filter((_, index) => index !== conditionIndex),
27494
- groups: root.groups ? [...root.groups] : undefined,
27495
- });
27496
- this.applyStructuredRoot(nextRoot);
27497
- }
27498
- removeTopLevelGroup(groupIndex) {
27499
- const root = this.getFilterExpression()?.fieldState?.root;
27500
- if (!root) {
27501
- return;
27502
- }
27503
- if (root.logic !== 'AND') {
27504
- this.applyStructuredRoot(null);
27505
- return;
27506
- }
27507
- const nextRoot = this.normalizeFieldGroup({
27508
- logic: root.logic,
27509
- conditions: [...root.conditions],
27510
- groups: root.groups?.filter((_, index) => index !== groupIndex),
27511
- });
27512
- this.applyStructuredRoot(nextRoot);
27513
- }
27514
- applyStructuredRoot(root) {
27515
- this.setFieldState({
27516
- root,
27517
- activeFields: this.collectActiveFields(root),
27518
- timestamp: Date.now(),
27519
- });
27520
- }
27521
- collectActiveFields(root) {
27522
- if (!root) {
27523
- return [];
27524
- }
27525
- const fields = new Set();
27526
- const visit = (group) => {
27527
- for (const condition of group.conditions) {
27528
- fields.add(condition.field);
27529
- }
27530
- for (const nested of group.groups ?? []) {
27531
- visit(nested);
27532
- }
27533
- };
27534
- visit(root);
27535
- return Array.from(fields);
27536
- }
27537
- normalizeFieldGroup(group) {
27538
- const normalizedGroups = group.groups?.filter((nested) => nested.conditions.length > 0 || (nested.groups?.length ?? 0) > 0);
27539
- if (group.conditions.length === 0 && (normalizedGroups?.length ?? 0) === 0) {
27540
- return null;
27541
- }
27542
- return {
27543
- logic: group.logic,
27544
- conditions: [...group.conditions],
27545
- groups: normalizedGroups && normalizedGroups.length > 0 ? normalizedGroups : undefined,
27546
- };
27547
- }
27548
- describeQuickFilter(quickFilter) {
27549
- const query = this.formatValue(quickFilter.query);
27550
- if (!quickFilter.columns || quickFilter.columns.length === 0) {
27551
- return `Search ${query}`;
27552
- }
27553
- const labels = quickFilter.columns.map((column) => this.getColumnLabelByIndex(column));
27554
- return `Search ${labels.join(', ')} ${query}`;
27555
- }
27556
- describeFieldGroup(group, nested = false) {
27557
- const parts = [
27558
- ...group.conditions.map((condition) => this.describeFieldCondition(condition)),
27559
- ...(group.groups ?? []).map((child) => this.describeFieldGroup(child, true)),
27560
- ];
27561
- if (parts.length === 0) {
27562
- return 'Filter group';
27563
- }
27564
- const joined = parts.join(` ${group.logic} `);
27565
- return nested && parts.length > 1 ? `(${joined})` : joined;
27566
- }
27567
- describeFieldCondition(condition) {
27568
- const label = this.getColumnLabelByField(condition.field);
27569
- switch (condition.operator) {
27570
- case 'eq':
27571
- case 'equals':
27572
- return `${label} = ${this.formatValue(condition.value)}`;
27573
- case 'neq':
27574
- case 'notEquals':
27575
- return `${label} != ${this.formatValue(condition.value)}`;
27576
- case 'contains':
27577
- return `${label} contains ${this.formatValue(condition.value)}`;
27578
- case 'notContains':
27579
- return `${label} does not contain ${this.formatValue(condition.value)}`;
27580
- case 'startsWith':
27581
- return `${label} starts with ${this.formatValue(condition.value)}`;
27582
- case 'endsWith':
27583
- return `${label} ends with ${this.formatValue(condition.value)}`;
27584
- case 'gt':
27585
- case 'greaterThan':
27586
- return `${label} > ${this.formatValue(condition.value)}`;
27587
- case 'gte':
27588
- case 'greaterThanOrEqual':
27589
- return `${label} >= ${this.formatValue(condition.value)}`;
27590
- case 'lt':
27591
- case 'lessThan':
27592
- return `${label} < ${this.formatValue(condition.value)}`;
27593
- case 'lte':
27594
- case 'lessThanOrEqual':
27595
- return `${label} <= ${this.formatValue(condition.value)}`;
27596
- case 'between':
27597
- return `${label} between ${this.formatValue(condition.value)} and ${this.formatValue(condition.valueTo)}`;
27598
- case 'in':
27599
- return `${label} in ${this.formatValue(condition.value)}`;
27600
- case 'notIn':
27601
- return `${label} not in ${this.formatValue(condition.value)}`;
27602
- case 'isNull':
27603
- case 'blank':
27604
- return `${label} is blank`;
27605
- case 'isNotNull':
27606
- case 'notBlank':
27607
- return `${label} is not blank`;
27608
- case 'regex':
27609
- return `${label} matches ${this.formatValue(condition.value)}`;
27610
- default:
27611
- return condition.value === undefined
27612
- ? `${label} ${condition.operator}`
27613
- : `${label} ${condition.operator} ${this.formatValue(condition.value)}`;
27614
- }
27615
- }
27616
- formatValue(value) {
27617
- if (Array.isArray(value)) {
27618
- return `[${value.map((item) => this.formatValue(item)).join(', ')}]`;
27619
- }
27620
- if (value instanceof Date) {
27621
- return value.toISOString();
27622
- }
27623
- if (typeof value === 'string') {
27624
- return `"${value}"`;
27625
- }
27626
- if (value === null) {
27627
- return 'null';
27628
- }
27629
- if (value === undefined) {
27630
- return 'undefined';
27631
- }
27632
- return String(value);
27633
- }
27634
- updateChipBarLayout() {
27635
- const scrollContainer = this.container.querySelector('.zg-scroll-container');
27636
- const header = this.container.querySelector('.zg-header');
27637
- const headerHeight = header?.offsetHeight ?? 0;
27638
- const chipHeight = this.chipBar && this.chipBar.style.display !== 'none' ? this.chipBar.offsetHeight : 0;
27639
- if (this.chipBar) {
27640
- this.chipBar.style.top = `${headerHeight}px`;
27641
- }
27642
- if (scrollContainer) {
27643
- scrollContainer.style.top = `${headerHeight + chipHeight}px`;
27644
- }
27645
- }
27646
- teardownPopup() {
27647
- if (this.popupKeyHandler && this.popup) {
27648
- this.popup.removeEventListener('keydown', this.popupKeyHandler);
27649
- this.popupKeyHandler = null;
27650
- }
27651
- if (this.popup) {
27652
- this.popup.remove();
27653
- this.popup = null;
27654
- }
27655
- }
27656
- destroy() {
27657
- this.closePopup();
27658
- if (this.filterClickUnsub) {
27659
- this.filterClickUnsub();
27660
- this.filterClickUnsub = null;
27661
- }
27662
- if (this.filterChangeUnsub) {
27663
- this.filterChangeUnsub();
27664
- this.filterChangeUnsub = null;
27665
- }
27666
- if (this.chipBar) {
27667
- this.chipBar.remove();
27668
- this.chipBar = null;
27669
- }
27670
- this.updateChipBarLayout();
28691
+ const column = this.columns[index] ?? this.options.getColumnDef(index);
28692
+ return column ? this.getColumnLabel(column, index) : `Column ${index}`;
27671
28693
  }
27672
28694
  }
27673
28695
 
@@ -27901,16 +28923,18 @@ function createLifecyclePlugin(opts) {
27901
28923
  setColumnModel(columnModel);
27902
28924
  // Initialize rendering
27903
28925
  store.exec('rendering:initialize', containerWidth, containerHeight);
28926
+ // Size the viewport to the initial pipeline view (e.g. first page)
28927
+ syncAfterPipeline();
27904
28928
  // Attach scroll + resize
27905
28929
  const scrollContainer = store.get('dom.scrollContainer');
27906
28930
  if (scrollContainer) {
27907
- if (api.getMethod('scroll', 'attach'))
28931
+ if (store.hasAction('scroll:attach'))
27908
28932
  store.exec('scroll:attach', scrollContainer);
27909
- if (api.getMethod('resize', 'attach'))
28933
+ if (store.hasAction('resize:attach'))
27910
28934
  store.exec('resize:attach', scrollContainer, containerWidth, containerHeight);
27911
28935
  }
27912
28936
  // Column resize
27913
- if (options.columnResize) {
28937
+ if (options.enableColumnResize !== false && store.hasAction('column:initResize')) {
27914
28938
  store.exec('column:initResize');
27915
28939
  }
27916
28940
  // Column drag
@@ -27924,14 +28948,14 @@ function createLifecyclePlugin(opts) {
27924
28948
  const headerApi = api.getMethod('header', 'getHeaderCellsContainer');
27925
28949
  const hc = headerApi ? headerApi() : null;
27926
28950
  if (hc) {
27927
- if (options.columnResize)
28951
+ if (options.enableColumnResize !== false && store.hasAction('column:attachResize'))
27928
28952
  store.exec('column:attachResize', hc);
27929
28953
  if (options.enableColumnDrag !== false)
27930
28954
  store.exec('column:attachDrag', hc);
27931
28955
  }
27932
28956
  }
27933
28957
  // Selection
27934
- if (api.getMethod('selection', 'attach') && scrollContainer) {
28958
+ if (store.hasAction('selection:attach') && scrollContainer) {
27935
28959
  store.exec('selection:attach', {
27936
28960
  container: scrollContainer,
27937
28961
  selectionType: options.selectionType ?? 'cell',
@@ -27946,17 +28970,14 @@ function createLifecyclePlugin(opts) {
27946
28970
  // Sync selection state
27947
28971
  eventUnsubs.push(events.on('selection:change', (payload) => {
27948
28972
  state.selection = payload.ranges ?? [];
27949
- if (payload.isSelected) {
27950
- // Expose selection checker for rendering
27951
- api.fireEvent('selection:checkerUpdate', payload.isSelected);
27952
- }
28973
+ opts.setSelectionChecker?.(payload.isSelected ?? null);
27953
28974
  if (options.onSelectionChange)
27954
28975
  options.onSelectionChange(state.selection);
27955
28976
  store.exec('rendering:refreshSelectionClasses');
27956
28977
  store.exec('lifecycle:updateHeaderCheckbox');
27957
28978
  }));
27958
28979
  }
27959
- // Sort header click bridge
28980
+ // Sort header click handler
27960
28981
  eventUnsubs.push(events.on('header:sort:click', (event) => {
27961
28982
  let dataCol = event.columnIndex;
27962
28983
  if (columnModel) {
@@ -27967,36 +28988,44 @@ function createLifecyclePlugin(opts) {
27967
28988
  store.exec('sort:toggle', dataCol, isAdditiveHeaderSortInteraction(event));
27968
28989
  syncAfterPipeline();
27969
28990
  }));
27970
- // Checkbox header bridge
28991
+ // Checkbox header handler
27971
28992
  eventUnsubs.push(events.on('header:checkbox:change', (event) => {
27972
- if (!api.getMethod('selection', 'clear'))
28993
+ if (!store.hasAction('selection:clear'))
27973
28994
  return;
27974
28995
  if (event.action === 'select-all')
27975
28996
  store.exec('selection:selectAll', options.rowCount);
27976
28997
  else
27977
28998
  store.exec('selection:clear');
27978
28999
  }));
27979
- // Viewport resize bridge
29000
+ // Viewport resize handler
27980
29001
  eventUnsubs.push(events.on('viewport:resized', () => {
27981
29002
  store.exec('rendering:updateScroller');
27982
29003
  store.exec('rendering:refresh');
27983
29004
  }));
27984
- // Scroll -> viewport bridge
29005
+ // Scroll viewport handler
27985
29006
  eventUnsubs.push(events.on('scroll', (payload) => {
27986
- if (api.getMethod('viewport', 'updateRange') && payload.visibleRange) {
29007
+ if (store.hasAction('viewport:updateRange') && payload.visibleRange) {
27987
29008
  store.exec('viewport:updateRange', payload.visibleRange);
27988
29009
  }
27989
29010
  }));
27990
29011
  // Filter UI
27991
- if (api.getMethod('filterUI', 'attach')) {
29012
+ if (store.hasAction('filterUI:attach')) {
27992
29013
  store.exec('filterUI:attach', container, events);
27993
29014
  }
27994
29015
  // Infinite scroll
27995
- if (api.getMethod('infiniteScroll', 'setup')) {
29016
+ if (store.hasAction('infiniteScroll:setup')) {
27996
29017
  store.exec('infiniteScroll:setup');
27997
29018
  }
27998
29019
  // Editing
27999
29020
  store.exec('editing:setup');
29021
+ // Keyboard navigation
29022
+ if (store.hasAction('keyboard:setup')) {
29023
+ store.exec('keyboard:setup');
29024
+ }
29025
+ // Clipboard (copy/cut/paste)
29026
+ if (store.hasAction('clipboard:setup')) {
29027
+ store.exec('clipboard:setup');
29028
+ }
28000
29029
  rendered = true;
28001
29030
  }
28002
29031
  // Render visible cells
@@ -28008,13 +29037,13 @@ function createLifecyclePlugin(opts) {
28008
29037
  }
28009
29038
  // Update viewport range
28010
29039
  const renderingApi = api.getMethod('rendering', 'getVisibleRange');
28011
- if (api.getMethod('viewport', 'updateRange') && renderingApi) {
29040
+ if (store.hasAction('viewport:updateRange') && renderingApi) {
28012
29041
  const initialRange = renderingApi();
28013
29042
  if (initialRange)
28014
29043
  store.exec('viewport:updateRange', initialRange);
28015
29044
  }
28016
29045
  // Update pagination
28017
- if (api.getMethod('pagination', 'update')) {
29046
+ if (store.hasAction('pagination:update')) {
28018
29047
  store.exec('pagination:update');
28019
29048
  }
28020
29049
  const ensureDataRange = api.getMethod('data', 'ensureVisibleRange');
@@ -28022,13 +29051,10 @@ function createLifecyclePlugin(opts) {
28022
29051
  void ensureDataRange('initial');
28023
29052
  }
28024
29053
  }, 'lifecycle');
29054
+ // Delegate to the single shared viewport-sync implementation in the
29055
+ // rendering plugin so sort, pagination, and init stay in lockstep.
28025
29056
  function syncAfterPipeline() {
28026
- const viewIndices = store.get('rows.viewIndices');
28027
- const visibleCount = viewIndices ? viewIndices.length : options.rowCount;
28028
- store.exec('rendering:setRowCount', visibleCount);
28029
- store.exec('rendering:updateCanvasSize');
28030
- store.exec('rendering:clearCache');
28031
- store.exec('rendering:refresh');
29057
+ store.exec('rendering:syncAfterPipeline');
28032
29058
  }
28033
29059
  store.action('lifecycle:syncAfterPipeline', () => syncAfterPipeline(), 'lifecycle');
28034
29060
  store.action('lifecycle:updateHeaderCheckbox', () => {
@@ -28138,8 +29164,6 @@ function createSlimContext(container, options) {
28138
29164
  data: [],
28139
29165
  selection: [],
28140
29166
  activeCell: null,
28141
- sortState: [],
28142
- filterState: [],
28143
29167
  scrollPosition: { top: 0, left: 0 },
28144
29168
  editingCell: null,
28145
29169
  };
@@ -28150,6 +29174,12 @@ function createSlimContext(container, options) {
28150
29174
  const pipelineRegistry = new PipelineRegistry();
28151
29175
  pipelineRegistry.registerPhase('sort', 10, 'pipeline.sort');
28152
29176
  pipelineRegistry.registerPhase('filter', 20, 'pipeline.filter');
29177
+ // Frontend pagination slices the post-sort/filter view; backend pagination
29178
+ // is handled by the data plugin, so no pipeline phase there.
29179
+ if (normalizedOptions.pagination?.enabled &&
29180
+ resolveQueryExecutionMode(normalizedOptions.dataMode) !== 'backend') {
29181
+ pipelineRegistry.registerPhase('pagination', 30, 'pipeline.pagination');
29182
+ }
28153
29183
  gridApi.register('options', {
28154
29184
  get: () => optionsManager.getOptions(),
28155
29185
  getSnapshot: () => optionsManager.getOptionsSnapshot(),
@@ -28224,6 +29254,21 @@ function installAllPlugins(ctx) {
28224
29254
  getColumnModel: () => columnModel,
28225
29255
  getDataAccessor: () => dataAccessor,
28226
29256
  }));
29257
+ // Phase 46: Keyboard navigation
29258
+ pluginHost.use(createKeyboardPlugin({
29259
+ options,
29260
+ state: ctx.state,
29261
+ container: ctx.container,
29262
+ }));
29263
+ // Phase 47: Clipboard (copy/cut/paste)
29264
+ pluginHost.use(createClipboardPlugin({
29265
+ options,
29266
+ state: ctx.state,
29267
+ container: ctx.container,
29268
+ events,
29269
+ getColumnModel: () => columnModel,
29270
+ getDataAccessor: () => dataAccessor,
29271
+ }));
28227
29272
  // Phase 50: Scroll, Viewport
28228
29273
  pluginHost.use(createScrollPlugin());
28229
29274
  pluginHost.use(createViewportPlugin());
@@ -28235,6 +29280,7 @@ function installAllPlugins(ctx) {
28235
29280
  }));
28236
29281
  // Phase 70: Header
28237
29282
  pluginHost.use(createHeaderPlugin({
29283
+ options,
28238
29284
  events,
28239
29285
  getColumnModel: () => columnModel,
28240
29286
  }));
@@ -28252,10 +29298,14 @@ function installAllPlugins(ctx) {
28252
29298
  state: ctx.state,
28253
29299
  container: ctx.container,
28254
29300
  events,
29301
+ getColumnModel: () => columnModel,
28255
29302
  getDataAccessor: () => dataAccessor,
28256
29303
  setColumnModel: (cm) => {
28257
29304
  columnModel = cm;
28258
29305
  },
29306
+ setSelectionChecker: (checker) => {
29307
+ selectionChecker = checker;
29308
+ },
28259
29309
  }));
28260
29310
  // Setup pipeline computeds
28261
29311
  ctx.pipelineRegistry.setupCoreComputeds(store);
@@ -28266,15 +29316,8 @@ function installAllPlugins(ctx) {
28266
29316
  ctx._setDataAccessor = (da) => {
28267
29317
  dataAccessor = da;
28268
29318
  };
28269
- ctx._setSelectionChecker = (sc) => {
28270
- selectionChecker = sc;
28271
- };
28272
29319
  ctx._getColumnModel = () => columnModel;
28273
29320
  ctx._getDataAccessor = () => dataAccessor;
28274
- // Wire selection checker updates from lifecycle plugin
28275
- events.on('selection:checkerUpdate', (checker) => {
28276
- selectionChecker = checker;
28277
- });
28278
29321
  // Install filter UI if columns + filter are present
28279
29322
  if (options.columns &&
28280
29323
  options.columns.length > 0 &&
@@ -29711,10 +30754,7 @@ class Grid {
29711
30754
  this._theme.destroyAutoTheme();
29712
30755
  this.ctx.pluginHost?.destroy?.();
29713
30756
  this.ctx.optionsManager?.destroy?.();
29714
- this.ctx.container.innerHTML = '';
29715
30757
  this.ctx.container.classList.remove('zg-grid');
29716
- this.ctx.state.data = [];
29717
- this.ctx.state.selection = [];
29718
30758
  this.ctx.isDestroyed = true;
29719
30759
  }
29720
30760
  // --- Options / Stats ---
@@ -30365,8 +31405,8 @@ function createSearchInput(options, placeholder) {
30365
31405
  input.setAttribute('aria-controls', 'dropdown-menu');
30366
31406
  input.style.width = '100%';
30367
31407
  input.style.height = '100%';
30368
- input.style.border = 'none';
30369
- input.style.outline = '2px solid #4CAF50';
31408
+ input.style.border = '2px solid #4CAF50';
31409
+ input.style.outline = 'none';
30370
31410
  input.style.padding = '4px 8px';
30371
31411
  input.style.fontSize = '13px';
30372
31412
  input.style.fontFamily = 'inherit';
@@ -30385,8 +31425,8 @@ function createDisplayElement(options, displayText) {
30385
31425
  display.setAttribute('role', 'button');
30386
31426
  display.style.width = '100%';
30387
31427
  display.style.height = '100%';
30388
- display.style.border = 'none';
30389
- display.style.outline = '2px solid #4CAF50';
31428
+ display.style.border = '2px solid #4CAF50';
31429
+ display.style.outline = 'none';
30390
31430
  display.style.padding = '4px 8px';
30391
31431
  display.style.fontSize = '13px';
30392
31432
  display.style.fontFamily = 'inherit';
@@ -33572,271 +34612,6 @@ class FocusManager {
33572
34612
  }
33573
34613
  }
33574
34614
 
33575
- /**
33576
- * ClipboardManager - Handles copy/cut/paste operations
33577
- *
33578
- * Supports multiple formats:
33579
- * - text/plain: TSV (tab-separated values)
33580
- * - text/html: HTML table format
33581
- *
33582
- * Compatible with Excel and Google Sheets.
33583
- *
33584
- * @example
33585
- * ```typescript
33586
- * const clipboard = new ClipboardManager({
33587
- * getValue: (row, col) => data[row][col],
33588
- * setValue: (row, col, value) => { data[row][col] = value; },
33589
- * });
33590
- *
33591
- * // Copy selection
33592
- * await clipboard.copy([
33593
- * { startRow: 0, startCol: 0, endRow: 2, endCol: 2 },
33594
- * ]);
33595
- *
33596
- * // Paste at location
33597
- * await clipboard.paste(5, 0);
33598
- * ```
33599
- */
33600
- class ClipboardManager {
33601
- constructor(options) {
33602
- this.events = options.events;
33603
- this.getValue = options.getValue;
33604
- this.setValue = options.setValue;
33605
- this.deleteValue = options.deleteValue;
33606
- this.includeHeaders = options.includeHeaders ?? false;
33607
- this.getColumnHeader = options.getColumnHeader;
33608
- }
33609
- /**
33610
- * Copy selected ranges to clipboard
33611
- * @param ranges - Ranges to copy
33612
- */
33613
- async copy(ranges) {
33614
- if (ranges.length === 0)
33615
- return;
33616
- // For simplicity, copy only the first range
33617
- // TODO: Support multiple ranges
33618
- const range = ranges[0];
33619
- const data = this.extractRangeData(range);
33620
- const tsvText = this.toTSV(data);
33621
- const htmlText = this.toHTML(data);
33622
- try {
33623
- // Write to clipboard with multiple formats
33624
- await navigator.clipboard.write([
33625
- new ClipboardItem({
33626
- 'text/plain': new Blob([tsvText], { type: 'text/plain' }),
33627
- 'text/html': new Blob([htmlText], { type: 'text/html' }),
33628
- }),
33629
- ]);
33630
- // Emit event
33631
- if (this.events) {
33632
- this.events.emit('copy', {
33633
- ranges,
33634
- data: tsvText,
33635
- });
33636
- }
33637
- }
33638
- catch (error) {
33639
- console.error('Failed to copy to clipboard:', error);
33640
- // Fallback to older API
33641
- try {
33642
- await navigator.clipboard.writeText(tsvText);
33643
- }
33644
- catch (fallbackError) {
33645
- console.error('Fallback copy also failed:', fallbackError);
33646
- }
33647
- }
33648
- }
33649
- /**
33650
- * Cut selected ranges to clipboard (copy + delete)
33651
- * @param ranges - Ranges to cut
33652
- */
33653
- async cut(ranges) {
33654
- if (ranges.length === 0)
33655
- return;
33656
- // Copy first
33657
- await this.copy(ranges);
33658
- // Then delete values
33659
- for (const range of ranges) {
33660
- for (let row = range.startRow; row <= range.endRow; row++) {
33661
- for (let col = range.startCol; col <= range.endCol; col++) {
33662
- if (this.deleteValue) {
33663
- this.deleteValue(row, col);
33664
- }
33665
- else {
33666
- this.setValue(row, col, null);
33667
- }
33668
- }
33669
- }
33670
- }
33671
- // Emit event
33672
- if (this.events) {
33673
- this.events.emit('cut', {
33674
- ranges,
33675
- data: '', // Already emitted in copy
33676
- });
33677
- }
33678
- }
33679
- /**
33680
- * Paste from clipboard at target location
33681
- * @param targetRow - Target row
33682
- * @param targetCol - Target column
33683
- */
33684
- async paste(targetRow, targetCol) {
33685
- try {
33686
- // Try to read multiple formats
33687
- const items = await navigator.clipboard.read();
33688
- for (const item of items) {
33689
- // Prefer HTML format (preserves formatting)
33690
- if (item.types.includes('text/html')) {
33691
- const blob = await item.getType('text/html');
33692
- const html = await blob.text();
33693
- const data = this.parseHTML(html);
33694
- this.pasteData(data, targetRow, targetCol);
33695
- return;
33696
- }
33697
- // Fallback to plain text
33698
- if (item.types.includes('text/plain')) {
33699
- const blob = await item.getType('text/plain');
33700
- const text = await blob.text();
33701
- const data = this.parseTSV(text);
33702
- this.pasteData(data, targetRow, targetCol);
33703
- return;
33704
- }
33705
- }
33706
- }
33707
- catch (error) {
33708
- console.error('Failed to paste from clipboard:', error);
33709
- // Fallback to text API
33710
- try {
33711
- const text = await navigator.clipboard.readText();
33712
- const data = this.parseTSV(text);
33713
- this.pasteData(data, targetRow, targetCol);
33714
- }
33715
- catch (fallbackError) {
33716
- console.error('Fallback paste also failed:', fallbackError);
33717
- }
33718
- }
33719
- }
33720
- /**
33721
- * Extract data from a range
33722
- */
33723
- extractRangeData(range) {
33724
- const data = [];
33725
- // Include headers if requested
33726
- if (this.includeHeaders && this.getColumnHeader) {
33727
- const headers = [];
33728
- for (let col = range.startCol; col <= range.endCol; col++) {
33729
- headers.push(this.getColumnHeader(col));
33730
- }
33731
- data.push(headers);
33732
- }
33733
- // Extract cell values
33734
- for (let row = range.startRow; row <= range.endRow; row++) {
33735
- const rowData = [];
33736
- for (let col = range.startCol; col <= range.endCol; col++) {
33737
- rowData.push(this.getValue(row, col));
33738
- }
33739
- data.push(rowData);
33740
- }
33741
- return data;
33742
- }
33743
- /**
33744
- * Convert data to TSV format
33745
- */
33746
- toTSV(data) {
33747
- return data.map((row) => row.map((cell) => this.formatCellForTSV(cell)).join('\t')).join('\n');
33748
- }
33749
- /**
33750
- * Format cell value for TSV
33751
- */
33752
- formatCellForTSV(value) {
33753
- if (value == null)
33754
- return '';
33755
- const str = String(value);
33756
- // Escape tabs and newlines
33757
- return str.replace(/\t/g, ' ').replace(/\n/g, ' ');
33758
- }
33759
- /**
33760
- * Convert data to HTML table format
33761
- */
33762
- toHTML(data) {
33763
- let html = '<table>';
33764
- for (let i = 0; i < data.length; i++) {
33765
- const row = data[i];
33766
- const tag = i === 0 && this.includeHeaders ? 'th' : 'td';
33767
- html += '<tr>';
33768
- for (const cell of row) {
33769
- html += `<${tag}>${this.escapeHTML(String(cell ?? ''))}</${tag}>`;
33770
- }
33771
- html += '</tr>';
33772
- }
33773
- html += '</table>';
33774
- return html;
33775
- }
33776
- /**
33777
- * Escape HTML special characters
33778
- */
33779
- escapeHTML(str) {
33780
- return str
33781
- .replace(/&/g, '&amp;')
33782
- .replace(/</g, '&lt;')
33783
- .replace(/>/g, '&gt;')
33784
- .replace(/"/g, '&quot;')
33785
- .replace(/'/g, '&#039;');
33786
- }
33787
- /**
33788
- * Parse TSV text to 2D array
33789
- */
33790
- parseTSV(text) {
33791
- const lines = text.split(/\r?\n/);
33792
- return lines.map((line) => line.split('\t'));
33793
- }
33794
- /**
33795
- * Parse HTML table to 2D array
33796
- */
33797
- parseHTML(html) {
33798
- const parser = new DOMParser();
33799
- const doc = parser.parseFromString(html, 'text/html');
33800
- const table = doc.querySelector('table');
33801
- if (!table) {
33802
- // No table found, treat as plain text
33803
- return this.parseTSV(doc.body.textContent || '');
33804
- }
33805
- const data = [];
33806
- const rows = Array.from(table.querySelectorAll('tr'));
33807
- for (const row of rows) {
33808
- const cells = Array.from(row.querySelectorAll('td, th'));
33809
- const rowData = [];
33810
- for (const cell of cells) {
33811
- rowData.push(cell.textContent || '');
33812
- }
33813
- if (rowData.length > 0) {
33814
- data.push(rowData);
33815
- }
33816
- }
33817
- return data;
33818
- }
33819
- /**
33820
- * Paste data at target location
33821
- */
33822
- pasteData(data, targetRow, targetCol) {
33823
- for (let i = 0; i < data.length; i++) {
33824
- const row = data[i];
33825
- for (let j = 0; j < row.length; j++) {
33826
- const value = row[j];
33827
- this.setValue(targetRow + i, targetCol + j, value);
33828
- }
33829
- }
33830
- // Emit event
33831
- if (this.events) {
33832
- this.events.emit('paste', {
33833
- cell: { row: targetRow, col: targetCol },
33834
- data: this.toTSV(data),
33835
- });
33836
- }
33837
- }
33838
- }
33839
-
33840
34615
  /**
33841
34616
  * Yield to the main thread via setTimeout(0).
33842
34617
  * Allows the browser to process pending events/rendering between chunks.
@@ -34304,8 +35079,10 @@ Object.defineProperty(exports, "SparseMatrix", {
34304
35079
  exports.ARIAManager = ARIAManager;
34305
35080
  exports.AdvancedCellRenderer = AdvancedCellRenderer;
34306
35081
  exports.AutoFitCalculator = AutoFitCalculator;
35082
+ exports.BatchCellEditCommand = BatchCellEditCommand;
34307
35083
  exports.ButtonRenderer = ButtonRenderer;
34308
35084
  exports.CSVExporter = CSVExporter;
35085
+ exports.CellEditCommand = CellEditCommand;
34309
35086
  exports.CellPool = CellPool;
34310
35087
  exports.CellPositioner = CellPositioner;
34311
35088
  exports.CheckboxEditor = CheckboxEditor;
@@ -34344,8 +35121,10 @@ exports.DropdownEditor = DropdownEditor;
34344
35121
  exports.DropdownRenderer = DropdownRenderer;
34345
35122
  exports.EditorManager = EditorManager;
34346
35123
  exports.EventEmitter = EventEmitter$1;
35124
+ exports.FilterChangeCommand = FilterChangeCommand;
34347
35125
  exports.FilterableHeaderRenderer = FilterableHeaderRenderer;
34348
35126
  exports.FocusManager = FocusManager;
35127
+ exports.GenericCommand = GenericCommand;
34349
35128
  exports.Grid = Grid;
34350
35129
  exports.GridApiImpl = GridApiImpl;
34351
35130
  exports.GridStore = GridStoreImpl;
@@ -34365,6 +35144,7 @@ exports.ResizePreview = ResizePreview;
34365
35144
  exports.ScrollModel = ScrollModel;
34366
35145
  exports.SelectRenderer = SelectRenderer;
34367
35146
  exports.SingleColumnSorter = SingleColumnSorter;
35147
+ exports.SortChangeCommand = SortChangeCommand;
34368
35148
  exports.SortStateManager = SortStateManager;
34369
35149
  exports.SortableHeaderRenderer = SortableHeaderRenderer;
34370
35150
  exports.TextEditor = TextEditor;
@@ -34374,6 +35154,7 @@ exports.ThemeBuilder = ThemeBuilder;
34374
35154
  exports.TimeEditor = TimeEditor;
34375
35155
  exports.TimePickerRenderer = TimePickerRenderer;
34376
35156
  exports.TimeRenderer = TimeRenderer;
35157
+ exports.UndoRedoManager = UndoRedoManager;
34377
35158
  exports.UniformHeightProvider = UniformHeightProvider;
34378
35159
  exports.UniformWidthProvider = UniformWidthProvider;
34379
35160
  exports.VERSION = VERSION;
@@ -34386,6 +35167,7 @@ exports.applyTheme = applyTheme;
34386
35167
  exports.containsCell = containsCell;
34387
35168
  exports.createAsyncFilterPlugin = createAsyncFilterPlugin;
34388
35169
  exports.createAsyncSortPlugin = createAsyncSortPlugin;
35170
+ exports.createClipboardPlugin = createClipboardPlugin;
34389
35171
  exports.createColumnGroupRenderer = createColumnGroupRenderer;
34390
35172
  exports.createColumnGroupRendererRegistry = createRendererRegistry;
34391
35173
  exports.createCorePlugin = createCorePlugin;
@@ -34401,9 +35183,11 @@ exports.createDevToolsConnector = createDevToolsConnector;
34401
35183
  exports.createDropdownRenderer = createDropdownRenderer;
34402
35184
  exports.createEditingPlugin = createEditingPlugin;
34403
35185
  exports.createFilterPlugin = createFilterPlugin;
35186
+ exports.createFilterUIPlugin = createFilterUIPlugin;
34404
35187
  exports.createHitTester = createHitTester;
34405
35188
  exports.createIdentityIndexMap = createIdentityIndexMap;
34406
35189
  exports.createIndexMap = createIndexMap;
35190
+ exports.createKeyboardPlugin = createKeyboardPlugin;
34407
35191
  exports.createLifecyclePlugin = createLifecyclePlugin;
34408
35192
  exports.createSelectionPlugin = createSelectionPlugin;
34409
35193
  exports.createSortPlugin = createSortPlugin;