@zengrid/core 1.2.2 → 1.3.0

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