@freegridjs/core 1.9.2 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1540,6 +1540,238 @@ var FilterRow = class {
1540
1540
  }
1541
1541
  };
1542
1542
 
1543
+ // src/conditional.ts
1544
+ var NUMERIC = (v) => {
1545
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
1546
+ if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
1547
+ if (v instanceof Date) return v.getTime();
1548
+ return null;
1549
+ };
1550
+ var STAT_TYPES = /* @__PURE__ */ new Set(["top", "bottom", "aboveAverage", "belowAverage", "duplicate", "unique", "dataBar", "colorScale", "iconSet"]);
1551
+ var ICON_SETS = {
1552
+ // high, middle, low
1553
+ arrows: [{ char: "\u25B2", color: "#16a34a", label: "up" }, { char: "\u25B6", color: "#d97706", label: "level" }, { char: "\u25BC", color: "#dc2626", label: "down" }],
1554
+ traffic: [{ char: "\u25CF", color: "#16a34a", label: "good" }, { char: "\u25CF", color: "#f59e0b", label: "fair" }, { char: "\u25CF", color: "#dc2626", label: "poor" }],
1555
+ signs: [{ char: "\u2714", color: "#16a34a", label: "good" }, { char: "!", color: "#d97706", label: "fair" }, { char: "\u2716", color: "#dc2626", label: "poor" }]
1556
+ };
1557
+ function hex(c) {
1558
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
1559
+ if (!m) return null;
1560
+ const h = m[1].length === 3 ? [...m[1]].map((x) => x + x).join("") : m[1];
1561
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
1562
+ }
1563
+ function mixColor(a, b, t) {
1564
+ const x = hex(a), y = hex(b);
1565
+ if (!x || !y) return t < 0.5 ? a : b;
1566
+ const c = x.map((v, i) => Math.round(v + (y[i] - v) * Math.max(0, Math.min(1, t))));
1567
+ return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
1568
+ }
1569
+ function textOf(v) {
1570
+ return v == null ? "" : v instanceof Date ? v.toISOString() : String(v);
1571
+ }
1572
+ function valueMatches(rule, value) {
1573
+ const op = rule.op ?? "eq";
1574
+ if (op === "blank") return isBlank(value);
1575
+ if (op === "notBlank") return !isBlank(value);
1576
+ if (op === "contains" || op === "notContains" || op === "startsWith" || op === "endsWith") {
1577
+ const s2 = textOf(value).toLowerCase(), q = textOf(rule.value).toLowerCase();
1578
+ const hit = op === "startsWith" ? s2.startsWith(q) : op === "endsWith" ? s2.endsWith(q) : s2.includes(q);
1579
+ return op === "notContains" ? !hit : hit;
1580
+ }
1581
+ if (isBlank(value)) return op === "ne";
1582
+ const a = NUMERIC(value), b = NUMERIC(rule.value), c = NUMERIC(rule.valueTo);
1583
+ if (a !== null && b !== null) {
1584
+ switch (op) {
1585
+ case "eq":
1586
+ return a === b;
1587
+ case "ne":
1588
+ return a !== b;
1589
+ case "gt":
1590
+ return a > b;
1591
+ case "gte":
1592
+ return a >= b;
1593
+ case "lt":
1594
+ return a < b;
1595
+ case "lte":
1596
+ return a <= b;
1597
+ case "between":
1598
+ case "notBetween": {
1599
+ if (c === null) return false;
1600
+ const inside = a >= Math.min(b, c) && a <= Math.max(b, c);
1601
+ return op === "between" ? inside : !inside;
1602
+ }
1603
+ }
1604
+ }
1605
+ const s = textOf(value).toLowerCase(), t = textOf(rule.value).toLowerCase(), u = textOf(rule.valueTo).toLowerCase();
1606
+ switch (op) {
1607
+ case "eq":
1608
+ return s === t;
1609
+ case "ne":
1610
+ return s !== t;
1611
+ case "gt":
1612
+ return s > t;
1613
+ case "gte":
1614
+ return s >= t;
1615
+ case "lt":
1616
+ return s < t;
1617
+ case "lte":
1618
+ return s <= t;
1619
+ case "between":
1620
+ return s >= t && s <= u;
1621
+ case "notBetween":
1622
+ return s < t || s > u;
1623
+ }
1624
+ return false;
1625
+ }
1626
+ var ConditionalFormatter = class {
1627
+ constructor(deps) {
1628
+ this.deps = deps;
1629
+ this.stats = /* @__PURE__ */ new Map();
1630
+ this.statsKey = [];
1631
+ this.rowCache = /* @__PURE__ */ new WeakMap();
1632
+ }
1633
+ /** True when any rule looks at other rows (an update to one row can restyle others). */
1634
+ dependsOnOtherRows() {
1635
+ return this.deps.rules().some((r) => r && (STAT_TYPES.has(r.type ?? "value") || r.type === "formula"));
1636
+ }
1637
+ fresh() {
1638
+ const key = [this.deps.rows(), this.deps.version(), this.deps.rules()];
1639
+ if (key.every((k, i) => k === this.statsKey[i])) return;
1640
+ this.statsKey = key;
1641
+ this.stats.clear();
1642
+ this.rowCache = /* @__PURE__ */ new WeakMap();
1643
+ }
1644
+ statsOf(colId) {
1645
+ let s = this.stats.get(colId);
1646
+ if (!s) {
1647
+ const nums = [];
1648
+ const counts = /* @__PURE__ */ new Map();
1649
+ for (const row of this.deps.rows()) {
1650
+ const v = this.deps.value(colId, row);
1651
+ const n = NUMERIC(v);
1652
+ if (n !== null) nums.push(n);
1653
+ if (!isBlank(v)) {
1654
+ const k = setKey(v);
1655
+ counts.set(k, (counts.get(k) ?? 0) + 1);
1656
+ }
1657
+ }
1658
+ nums.sort((a, b) => a - b);
1659
+ const sum = nums.reduce((a, b) => a + b, 0);
1660
+ s = { min: nums[0] ?? 0, max: nums[nums.length - 1] ?? 0, avg: nums.length ? sum / nums.length : 0, sorted: nums, counts };
1661
+ this.stats.set(colId, s);
1662
+ }
1663
+ return s;
1664
+ }
1665
+ test(rule, colId, value, row) {
1666
+ const type = rule.type ?? "value";
1667
+ switch (type) {
1668
+ case "value":
1669
+ return valueMatches(rule, value);
1670
+ case "formula": {
1671
+ if (typeof rule.formula !== "string") return false;
1672
+ const r = this.deps.formula(rule.formula.startsWith("=") ? rule.formula : `=${rule.formula}`, row);
1673
+ return r === true || typeof r === "number" && r !== 0;
1674
+ }
1675
+ case "top":
1676
+ case "bottom": {
1677
+ const n = NUMERIC(value);
1678
+ if (n === null) return false;
1679
+ const s = this.statsOf(colId);
1680
+ if (!s.sorted.length) return false;
1681
+ const k = Math.max(1, rule.percent ? Math.ceil(s.sorted.length * (rule.rank ?? 10) / 100) : Math.floor(rule.rank ?? 10));
1682
+ return type === "top" ? n >= s.sorted[Math.max(0, s.sorted.length - k)] : n <= s.sorted[Math.min(s.sorted.length - 1, k - 1)];
1683
+ }
1684
+ case "aboveAverage":
1685
+ case "belowAverage": {
1686
+ const n = NUMERIC(value);
1687
+ if (n === null) return false;
1688
+ const avg = this.statsOf(colId).avg;
1689
+ return type === "aboveAverage" ? n > avg : n < avg;
1690
+ }
1691
+ case "duplicate":
1692
+ case "unique": {
1693
+ if (isBlank(value)) return false;
1694
+ const c = this.statsOf(colId).counts.get(setKey(value)) ?? 0;
1695
+ return type === "duplicate" ? c > 1 : c === 1;
1696
+ }
1697
+ }
1698
+ return false;
1699
+ }
1700
+ /** Style from rules with `row: true`, for every cell of the row. */
1701
+ rowStyle(row) {
1702
+ if (this.rowCache.has(row)) return this.rowCache.get(row);
1703
+ let style = null;
1704
+ for (const rule of this.deps.rules()) {
1705
+ if (!rule?.row || !Array.isArray(rule.columns) || !rule.columns.length) continue;
1706
+ const colId = rule.columns[0];
1707
+ if (this.test(rule, colId, this.deps.value(colId, row), row)) {
1708
+ style = mergeStyle(style ?? {}, rule.style);
1709
+ if (rule.stopIfTrue) break;
1710
+ }
1711
+ }
1712
+ this.rowCache.set(row, style);
1713
+ return style;
1714
+ }
1715
+ /** Format of one cell; null when no rule applies. */
1716
+ cell(colId, row, value) {
1717
+ const rules = this.deps.rules();
1718
+ if (!rules.length) return null;
1719
+ this.fresh();
1720
+ let out = null;
1721
+ const rs = this.rowStyle(row);
1722
+ if (rs) out = { style: { ...rs } };
1723
+ for (const rule of rules) {
1724
+ if (!rule || rule.row || !Array.isArray(rule.columns) || !rule.columns.includes(colId)) continue;
1725
+ const type = rule.type ?? "value";
1726
+ if (type === "dataBar" || type === "colorScale" || type === "iconSet") {
1727
+ const n = NUMERIC(value);
1728
+ if (n === null) continue;
1729
+ const s = this.statsOf(colId);
1730
+ const lo = typeof rule.min === "number" ? rule.min : s.min;
1731
+ const hi = typeof rule.max === "number" ? rule.max : s.max;
1732
+ const t = hi === lo ? 1 : Math.max(0, Math.min(1, (n - lo) / (hi - lo)));
1733
+ out ?? (out = { style: {} });
1734
+ if (type === "dataBar") {
1735
+ const zero = lo < 0 && hi > 0 ? -lo / (hi - lo) : lo >= 0 ? 0 : 1;
1736
+ const at = hi === lo ? 1 : t;
1737
+ out.bar = { from: Math.min(zero, at), to: Math.max(zero, at), color: rule.color || "var(--fg-accent)" };
1738
+ } else if (type === "colorScale") {
1739
+ const cs = Array.isArray(rule.colors) && rule.colors.length >= 2 ? rule.colors : ["#f8696b", "#ffeb84", "#63be7b"];
1740
+ const bg = cs.length >= 3 ? t < 0.5 ? mixColor(cs[0], cs[1], t * 2) : mixColor(cs[1], cs[2], (t - 0.5) * 2) : mixColor(cs[0], cs[1], t);
1741
+ out.style = { ...out.style, background: bg };
1742
+ } else {
1743
+ const set = ICON_SETS[rule.icons ?? "arrows"] ?? ICON_SETS.arrows;
1744
+ out.icon = t >= 2 / 3 ? set[0] : t >= 1 / 3 ? set[1] : set[2];
1745
+ }
1746
+ continue;
1747
+ }
1748
+ if (this.test(rule, colId, value, row)) {
1749
+ out ?? (out = { style: {} });
1750
+ out.style = mergeStyle(out.style, rule.style);
1751
+ if (rule.stopIfTrue) break;
1752
+ }
1753
+ }
1754
+ return out;
1755
+ }
1756
+ };
1757
+ function styleAlign(s) {
1758
+ const a = s?.align;
1759
+ return a === "left" || a === "center" || a === "right" ? a : void 0;
1760
+ }
1761
+ function mergeStyle(base, add) {
1762
+ if (!add || typeof add !== "object") return { ...base };
1763
+ const out = { ...base, ...add };
1764
+ if (!styleAlign(add)) {
1765
+ if (styleAlign(base)) out.align = base.align;
1766
+ else delete out.align;
1767
+ }
1768
+ return out;
1769
+ }
1770
+ function argbOf(c) {
1771
+ const x = c ? hex(c) : null;
1772
+ return x ? `FF${x.map((v) => v.toString(16).padStart(2, "0")).join("").toUpperCase()}` : void 0;
1773
+ }
1774
+
1543
1775
  // src/summary.ts
1544
1776
  function numbers(values) {
1545
1777
  const out = [];
@@ -1650,6 +1882,28 @@ function computeSpans(rows, def) {
1650
1882
  }
1651
1883
  return { start, end };
1652
1884
  }
1885
+ function nestedSpans(list, n) {
1886
+ const start = new Int32Array(n);
1887
+ const end = new Int32Array(n);
1888
+ let s = 0;
1889
+ for (let i = 1; i <= n; i++) {
1890
+ if (i < n && !list.some((sp) => sp.start[i] === i)) continue;
1891
+ for (let k = s; k < i; k++) {
1892
+ start[k] = s;
1893
+ end[k] = i - 1;
1894
+ }
1895
+ s = i;
1896
+ }
1897
+ return { start, end };
1898
+ }
1899
+ function mergeBlocks(rows, defs) {
1900
+ const n = rows.length;
1901
+ const block = new Int32Array(n);
1902
+ if (!n || !defs.length) return block;
1903
+ const { start } = nestedSpans(defs.map((def) => computeSpans(rows, def)), n);
1904
+ for (let i = 1; i < n; i++) block[i] = block[i - 1] + (start[i] === i ? 1 : 0);
1905
+ return block;
1906
+ }
1653
1907
 
1654
1908
  // src/viewport.ts
1655
1909
  function rowRange(scrollTop, height, rowHeight, count, overscan = 4) {
@@ -2166,7 +2420,9 @@ var Renderer = class {
2166
2420
  const def = c.node.def;
2167
2421
  if (def.wrapHeaderText ?? this.grid.options.wrapHeaderText) cell.classList.add("fg-header-wrap");
2168
2422
  cell.setAttribute("aria-colindex", String(c.column.index + 1));
2169
- if ((def.align ?? (def.type === "number" ? "right" : "left")) === "right") cell.classList.add("fg-align-right");
2423
+ const headAlign = def.align ?? (def.type === "number" ? "right" : "left");
2424
+ if (headAlign === "right") cell.classList.add("fg-align-right");
2425
+ else if (headAlign === "center") cell.classList.add("fg-align-center");
2170
2426
  const si = sort.findIndex((s) => s.colId === c.id);
2171
2427
  cell.setAttribute("aria-sort", si < 0 ? "none" : sort[si].dir === "asc" ? "ascending" : "descending");
2172
2428
  if (isSortable(def)) cell.classList.add("fg-sortable");
@@ -2213,6 +2469,7 @@ var Renderer = class {
2213
2469
  const { selected, focus } = store.state;
2214
2470
  const allBounds = this.grid.allRangeBounds();
2215
2471
  const spans = this.grid.mergeSpans();
2472
+ const blocks = spans.has(SELECT_COL_ID);
2216
2473
  const { geo } = this;
2217
2474
  const top = Math.max(0, geo.top - this.topOffset);
2218
2475
  const range = this.grid.options.autoHeight && !rowModel.server ? { first: 0, last: count - 1 } : this.offsets ? rowRangeByOffsets(this.offsets, top, geo.ch, count, ROW_OVERSCAN) : rowRange(top, geo.ch, this.opts.rowHeight, count, ROW_OVERSCAN);
@@ -2241,8 +2498,9 @@ var Renderer = class {
2241
2498
  const hits = allBounds.filter((b) => i >= b.r1 && i <= b.r2);
2242
2499
  const inRange = hits.map((b) => `${b.c1}-${b.c2}`).join(",");
2243
2500
  const merge2 = this.mergeState(spans, cols, i, range.first);
2501
+ const block = blocks && !node.kind ? this.grid.blockSelection(i) ?? "" : "";
2244
2502
  const pos = node.kind === "detail" ? `${geo.left}:${geo.cw}` : "";
2245
- const base = `${node.id}|${this.version}.${this.rowVersions.get(node.id) ?? 0}|${isSel ? 1 : 0}${node.kind === "group" ? `:${this.grid.selectionVersion}` : ""}|${focusCol}|${inRange}|${merge2}|${pos}`;
2503
+ const base = `${node.id}|${this.version}.${this.rowVersions.get(node.id) ?? 0}|${isSel ? 1 : 0}${node.kind === "group" ? `:${this.grid.selectionVersion}` : ""}${block}|${focusCol}|${inRange}|${merge2}|${pos}`;
2246
2504
  const key = `${colKey}|${base}`;
2247
2505
  let row = map.get(i);
2248
2506
  if (!row) {
@@ -2271,21 +2529,47 @@ var Renderer = class {
2271
2529
  else this.paintRow(row.el, node, i, cols, isSel, focusCol, hits, spans, range.first);
2272
2530
  }
2273
2531
  }
2274
- if (spans.size) this.stickMergedText(top);
2532
+ if (spans.size) this.stickMergedText(geo.top, geo.ch || Number.MAX_SAFE_INTEGER / 4);
2275
2533
  }
2276
2534
  /**
2277
2535
  * A vertically merged cell taller than the view keeps its text in sight: the text moves down to the top of the
2278
2536
  * visible part of the block and stops in the block's last row. Runs on every scroll, also for rows not repainted.
2279
2537
  */
2280
- stickMergedText(viewTop) {
2538
+ stickMergedText(viewTop, viewHeight) {
2281
2539
  for (const r of REGIONS3) {
2540
+ const ends = /* @__PURE__ */ new Map();
2541
+ for (const [i, row] of this.painted[r]) {
2542
+ let end = -1;
2543
+ for (const c of row.el.querySelectorAll(".fg-cell-merged")) end = Math.max(end, Number(c.dataset.mergeEnd));
2544
+ if (end > i) ends.set(i, end);
2545
+ }
2546
+ const z = /* @__PURE__ */ new Map();
2547
+ for (const i of [...ends.keys()].sort((a, b) => b - a)) {
2548
+ let top = 0;
2549
+ for (const [j, zj] of z) if (j > i && j <= ends.get(i)) top = Math.max(top, zj);
2550
+ z.set(i, top + 1);
2551
+ }
2552
+ for (const [i, row] of this.painted[r]) {
2553
+ const want = z.has(i) ? String(z.get(i)) : "";
2554
+ if (row.el.style.zIndex !== want && (want || row.el.querySelector(".fg-cell-merged, .fg-cell-merge-hidden"))) row.el.style.zIndex = want;
2555
+ }
2282
2556
  for (const row of this.painted[r].values()) {
2283
2557
  for (const c of row.el.querySelectorAll(".fg-cell-merged")) {
2284
2558
  const blockTop = Number(c.dataset.mergeTop);
2285
2559
  const last = Number(c.dataset.mergeLast);
2286
2560
  if (!Number.isFinite(blockTop) || !Number.isFinite(last)) continue;
2287
- const shift = `${Math.round(Math.min(last, Math.max(0, viewTop - blockTop)))}px`;
2561
+ let top = Math.min(last, Math.max(0, viewTop - blockTop));
2562
+ let bottom = 0;
2563
+ if (c.classList.contains("fg-cell-merged-middle") || c.classList.contains("fg-cell-merged-bottom")) {
2564
+ const height = Number(c.dataset.mergeHeight);
2565
+ const room = height - last;
2566
+ bottom = Math.max(0, Math.min(height - top - room, blockTop + height - (viewTop + viewHeight)));
2567
+ top = Math.min(top, height - bottom - room);
2568
+ }
2569
+ const shift = `${Math.round(top)}px`;
2288
2570
  if (c.style.getPropertyValue("--fg-merge-shift") !== shift) c.style.setProperty("--fg-merge-shift", shift);
2571
+ const shiftBottom = `${Math.round(bottom)}px`;
2572
+ if (c.style.getPropertyValue("--fg-merge-shift-bottom") !== shiftBottom) c.style.setProperty("--fg-merge-shift-bottom", shiftBottom);
2289
2573
  }
2290
2574
  }
2291
2575
  }
@@ -2369,13 +2653,21 @@ var Renderer = class {
2369
2653
  cellEl.style.bottom = "auto";
2370
2654
  cellEl.dataset.mergeTop = String(this.rowTop(s.start[i]));
2371
2655
  cellEl.dataset.mergeLast = String(this.rowTop(s.end[i]) - this.rowTop(s.start[i]));
2656
+ cellEl.dataset.mergeEnd = String(s.end[i]);
2657
+ cellEl.dataset.mergeHeight = String(this.rowTop(s.end[i]) + this.rowH(s.end[i]) - this.rowTop(s.start[i]));
2658
+ cellEl.classList.toggle("fg-cell-merged-middle", col.def.mergeAlign === "middle");
2659
+ cellEl.classList.toggle("fg-cell-merged-bottom", col.def.mergeAlign === "bottom");
2372
2660
  drawsMerge = true;
2373
2661
  } else cellEl.classList.add("fg-cell-merge-hidden");
2374
2662
  }
2375
2663
  if (!cellEl.classList.contains("fg-cell-merged") && cellEl.dataset.mergeTop !== void 0) {
2376
2664
  delete cellEl.dataset.mergeTop;
2377
2665
  delete cellEl.dataset.mergeLast;
2666
+ delete cellEl.dataset.mergeEnd;
2667
+ delete cellEl.dataset.mergeHeight;
2668
+ cellEl.classList.remove("fg-cell-merged-middle", "fg-cell-merged-bottom");
2378
2669
  cellEl.style.removeProperty("--fg-merge-shift");
2670
+ cellEl.style.removeProperty("--fg-merge-shift-bottom");
2379
2671
  }
2380
2672
  if (!isSystemCol(col.id) && bounds.some((b) => col.index >= b.c1 && col.index <= b.c2)) cellEl.classList.add("fg-cell-range");
2381
2673
  });
@@ -2405,7 +2697,15 @@ var Renderer = class {
2405
2697
  cell.className = `${cls} fg-cell-select`;
2406
2698
  const box = el("input", "fg-checkbox", { type: "checkbox", tabindex: "-1", "aria-label": locale.selectRow });
2407
2699
  box.checked = selected;
2408
- if (i >= 0 && !node.kind && !this.grid.rowSelectable(node.data)) {
2700
+ const block = i >= 0 && !node.kind ? this.grid.blockSelection(i) : null;
2701
+ if (block) {
2702
+ box.checked = block === "all";
2703
+ box.indeterminate = block === "some";
2704
+ if (block === "off") {
2705
+ box.disabled = true;
2706
+ cell.classList.add("fg-cell-select-locked");
2707
+ }
2708
+ } else if (i >= 0 && !node.kind && !this.grid.rowSelectable(node.data)) {
2409
2709
  box.disabled = true;
2410
2710
  cell.classList.add("fg-cell-select-locked");
2411
2711
  }
@@ -2415,7 +2715,8 @@ var Renderer = class {
2415
2715
  const def = col.def;
2416
2716
  const value = getCellValue(def, node.data);
2417
2717
  const formatted = getFormatter(def, locale.code)(value);
2418
- cls += ` fg-align-${def.align ?? (def.type === "number" ? "right" : def.type === "boolean" ? "center" : "left")}`;
2718
+ const cf = i >= 0 && !node.kind ? this.grid.cellFormat(node.data, col.id, value) : null;
2719
+ cls += ` fg-align-${styleAlign(cf?.style) ?? def.align ?? (def.type === "number" ? "right" : def.type === "boolean" ? "center" : "left")}`;
2419
2720
  const params = { value, formatted, row: node.data, rowIndex: i, column: def };
2420
2721
  if (def.cellClass) {
2421
2722
  try {
@@ -2430,7 +2731,6 @@ var Renderer = class {
2430
2731
  if (typeof def.editable === "function" && i >= 0 && !cellEditable(def, node.data, i)) cls += " fg-cell-readonly";
2431
2732
  if (i >= 0) cls += this.grid.finder.classFor(i, col.id);
2432
2733
  cls += this.grid.flashClass(node.id, col.id);
2433
- const cf = i >= 0 && !node.kind ? this.grid.cellFormat(node.data, col.id, value) : null;
2434
2734
  if (cf?.bar) cls += " fg-cf-bar";
2435
2735
  cell.className = cls;
2436
2736
  this.applyFormat(cell, cf);
@@ -3323,6 +3623,8 @@ var en = {
3323
3623
  style: "Format",
3324
3624
  wholeRow: "Whole row",
3325
3625
  count: "Count",
3626
+ align: "Align",
3627
+ aligns: { none: "Default", left: "Left", center: "Center", right: "Right" },
3326
3628
  types: { value: "Cell value", formula: "Formula", top: "Top", bottom: "Bottom", aboveAverage: "Above average", belowAverage: "Below average", duplicate: "Duplicate values", unique: "Unique values", dataBar: "Data bars", colorScale: "Color scale", iconSet: "Icon set" },
3327
3629
  ops: { notBetween: "Not between", notContains: "Does not contain" },
3328
3630
  styles: { red: "Light red fill", yellow: "Yellow fill", green: "Green fill", blue: "Blue fill", redText: "Red text", bold: "Bold" }
@@ -5855,225 +6157,6 @@ ${xref}
5855
6157
  return out;
5856
6158
  }
5857
6159
 
5858
- // src/conditional.ts
5859
- var NUMERIC = (v) => {
5860
- if (typeof v === "number") return Number.isFinite(v) ? v : null;
5861
- if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
5862
- if (v instanceof Date) return v.getTime();
5863
- return null;
5864
- };
5865
- var STAT_TYPES = /* @__PURE__ */ new Set(["top", "bottom", "aboveAverage", "belowAverage", "duplicate", "unique", "dataBar", "colorScale", "iconSet"]);
5866
- var ICON_SETS = {
5867
- // high, middle, low
5868
- arrows: [{ char: "\u25B2", color: "#16a34a", label: "up" }, { char: "\u25B6", color: "#d97706", label: "level" }, { char: "\u25BC", color: "#dc2626", label: "down" }],
5869
- traffic: [{ char: "\u25CF", color: "#16a34a", label: "good" }, { char: "\u25CF", color: "#f59e0b", label: "fair" }, { char: "\u25CF", color: "#dc2626", label: "poor" }],
5870
- signs: [{ char: "\u2714", color: "#16a34a", label: "good" }, { char: "!", color: "#d97706", label: "fair" }, { char: "\u2716", color: "#dc2626", label: "poor" }]
5871
- };
5872
- function hex(c) {
5873
- const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
5874
- if (!m) return null;
5875
- const h = m[1].length === 3 ? [...m[1]].map((x) => x + x).join("") : m[1];
5876
- return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
5877
- }
5878
- function mixColor(a, b, t) {
5879
- const x = hex(a), y = hex(b);
5880
- if (!x || !y) return t < 0.5 ? a : b;
5881
- const c = x.map((v, i) => Math.round(v + (y[i] - v) * Math.max(0, Math.min(1, t))));
5882
- return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
5883
- }
5884
- function textOf(v) {
5885
- return v == null ? "" : v instanceof Date ? v.toISOString() : String(v);
5886
- }
5887
- function valueMatches(rule, value) {
5888
- const op = rule.op ?? "eq";
5889
- if (op === "blank") return isBlank(value);
5890
- if (op === "notBlank") return !isBlank(value);
5891
- if (op === "contains" || op === "notContains" || op === "startsWith" || op === "endsWith") {
5892
- const s2 = textOf(value).toLowerCase(), q = textOf(rule.value).toLowerCase();
5893
- const hit = op === "startsWith" ? s2.startsWith(q) : op === "endsWith" ? s2.endsWith(q) : s2.includes(q);
5894
- return op === "notContains" ? !hit : hit;
5895
- }
5896
- if (isBlank(value)) return op === "ne";
5897
- const a = NUMERIC(value), b = NUMERIC(rule.value), c = NUMERIC(rule.valueTo);
5898
- if (a !== null && b !== null) {
5899
- switch (op) {
5900
- case "eq":
5901
- return a === b;
5902
- case "ne":
5903
- return a !== b;
5904
- case "gt":
5905
- return a > b;
5906
- case "gte":
5907
- return a >= b;
5908
- case "lt":
5909
- return a < b;
5910
- case "lte":
5911
- return a <= b;
5912
- case "between":
5913
- case "notBetween": {
5914
- if (c === null) return false;
5915
- const inside = a >= Math.min(b, c) && a <= Math.max(b, c);
5916
- return op === "between" ? inside : !inside;
5917
- }
5918
- }
5919
- }
5920
- const s = textOf(value).toLowerCase(), t = textOf(rule.value).toLowerCase(), u = textOf(rule.valueTo).toLowerCase();
5921
- switch (op) {
5922
- case "eq":
5923
- return s === t;
5924
- case "ne":
5925
- return s !== t;
5926
- case "gt":
5927
- return s > t;
5928
- case "gte":
5929
- return s >= t;
5930
- case "lt":
5931
- return s < t;
5932
- case "lte":
5933
- return s <= t;
5934
- case "between":
5935
- return s >= t && s <= u;
5936
- case "notBetween":
5937
- return s < t || s > u;
5938
- }
5939
- return false;
5940
- }
5941
- var ConditionalFormatter = class {
5942
- constructor(deps) {
5943
- this.deps = deps;
5944
- this.stats = /* @__PURE__ */ new Map();
5945
- this.statsKey = [];
5946
- this.rowCache = /* @__PURE__ */ new WeakMap();
5947
- }
5948
- /** True when any rule looks at other rows (an update to one row can restyle others). */
5949
- dependsOnOtherRows() {
5950
- return this.deps.rules().some((r) => r && (STAT_TYPES.has(r.type ?? "value") || r.type === "formula"));
5951
- }
5952
- fresh() {
5953
- const key = [this.deps.rows(), this.deps.version(), this.deps.rules()];
5954
- if (key.every((k, i) => k === this.statsKey[i])) return;
5955
- this.statsKey = key;
5956
- this.stats.clear();
5957
- this.rowCache = /* @__PURE__ */ new WeakMap();
5958
- }
5959
- statsOf(colId) {
5960
- let s = this.stats.get(colId);
5961
- if (!s) {
5962
- const nums = [];
5963
- const counts = /* @__PURE__ */ new Map();
5964
- for (const row of this.deps.rows()) {
5965
- const v = this.deps.value(colId, row);
5966
- const n = NUMERIC(v);
5967
- if (n !== null) nums.push(n);
5968
- if (!isBlank(v)) {
5969
- const k = setKey(v);
5970
- counts.set(k, (counts.get(k) ?? 0) + 1);
5971
- }
5972
- }
5973
- nums.sort((a, b) => a - b);
5974
- const sum = nums.reduce((a, b) => a + b, 0);
5975
- s = { min: nums[0] ?? 0, max: nums[nums.length - 1] ?? 0, avg: nums.length ? sum / nums.length : 0, sorted: nums, counts };
5976
- this.stats.set(colId, s);
5977
- }
5978
- return s;
5979
- }
5980
- test(rule, colId, value, row) {
5981
- const type = rule.type ?? "value";
5982
- switch (type) {
5983
- case "value":
5984
- return valueMatches(rule, value);
5985
- case "formula": {
5986
- if (typeof rule.formula !== "string") return false;
5987
- const r = this.deps.formula(rule.formula.startsWith("=") ? rule.formula : `=${rule.formula}`, row);
5988
- return r === true || typeof r === "number" && r !== 0;
5989
- }
5990
- case "top":
5991
- case "bottom": {
5992
- const n = NUMERIC(value);
5993
- if (n === null) return false;
5994
- const s = this.statsOf(colId);
5995
- if (!s.sorted.length) return false;
5996
- const k = Math.max(1, rule.percent ? Math.ceil(s.sorted.length * (rule.rank ?? 10) / 100) : Math.floor(rule.rank ?? 10));
5997
- return type === "top" ? n >= s.sorted[Math.max(0, s.sorted.length - k)] : n <= s.sorted[Math.min(s.sorted.length - 1, k - 1)];
5998
- }
5999
- case "aboveAverage":
6000
- case "belowAverage": {
6001
- const n = NUMERIC(value);
6002
- if (n === null) return false;
6003
- const avg = this.statsOf(colId).avg;
6004
- return type === "aboveAverage" ? n > avg : n < avg;
6005
- }
6006
- case "duplicate":
6007
- case "unique": {
6008
- if (isBlank(value)) return false;
6009
- const c = this.statsOf(colId).counts.get(setKey(value)) ?? 0;
6010
- return type === "duplicate" ? c > 1 : c === 1;
6011
- }
6012
- }
6013
- return false;
6014
- }
6015
- /** Style from rules with `row: true`, for every cell of the row. */
6016
- rowStyle(row) {
6017
- if (this.rowCache.has(row)) return this.rowCache.get(row);
6018
- let style = null;
6019
- for (const rule of this.deps.rules()) {
6020
- if (!rule?.row || !Array.isArray(rule.columns) || !rule.columns.length) continue;
6021
- const colId = rule.columns[0];
6022
- if (this.test(rule, colId, this.deps.value(colId, row), row)) {
6023
- style = { ...style ?? {}, ...rule.style };
6024
- if (rule.stopIfTrue) break;
6025
- }
6026
- }
6027
- this.rowCache.set(row, style);
6028
- return style;
6029
- }
6030
- /** Format of one cell; null when no rule applies. */
6031
- cell(colId, row, value) {
6032
- const rules = this.deps.rules();
6033
- if (!rules.length) return null;
6034
- this.fresh();
6035
- let out = null;
6036
- const rs = this.rowStyle(row);
6037
- if (rs) out = { style: { ...rs } };
6038
- for (const rule of rules) {
6039
- if (!rule || rule.row || !Array.isArray(rule.columns) || !rule.columns.includes(colId)) continue;
6040
- const type = rule.type ?? "value";
6041
- if (type === "dataBar" || type === "colorScale" || type === "iconSet") {
6042
- const n = NUMERIC(value);
6043
- if (n === null) continue;
6044
- const s = this.statsOf(colId);
6045
- const lo = typeof rule.min === "number" ? rule.min : s.min;
6046
- const hi = typeof rule.max === "number" ? rule.max : s.max;
6047
- const t = hi === lo ? 1 : Math.max(0, Math.min(1, (n - lo) / (hi - lo)));
6048
- out ?? (out = { style: {} });
6049
- if (type === "dataBar") {
6050
- const zero = lo < 0 && hi > 0 ? -lo / (hi - lo) : lo >= 0 ? 0 : 1;
6051
- const at = hi === lo ? 1 : t;
6052
- out.bar = { from: Math.min(zero, at), to: Math.max(zero, at), color: rule.color || "var(--fg-accent)" };
6053
- } else if (type === "colorScale") {
6054
- const cs = Array.isArray(rule.colors) && rule.colors.length >= 2 ? rule.colors : ["#f8696b", "#ffeb84", "#63be7b"];
6055
- const bg = cs.length >= 3 ? t < 0.5 ? mixColor(cs[0], cs[1], t * 2) : mixColor(cs[1], cs[2], (t - 0.5) * 2) : mixColor(cs[0], cs[1], t);
6056
- out.style = { ...out.style, background: bg };
6057
- } else {
6058
- const set = ICON_SETS[rule.icons ?? "arrows"] ?? ICON_SETS.arrows;
6059
- out.icon = t >= 2 / 3 ? set[0] : t >= 1 / 3 ? set[1] : set[2];
6060
- }
6061
- continue;
6062
- }
6063
- if (this.test(rule, colId, value, row)) {
6064
- out ?? (out = { style: {} });
6065
- out.style = { ...out.style, ...rule.style };
6066
- if (rule.stopIfTrue) break;
6067
- }
6068
- }
6069
- return out;
6070
- }
6071
- };
6072
- function argbOf(c) {
6073
- const x = c ? hex(c) : null;
6074
- return x ? `FF${x.map((v) => v.toString(16).padStart(2, "0")).join("").toUpperCase()}` : void 0;
6075
- }
6076
-
6077
6160
  // src/rows/sort.ts
6078
6161
  var PLAIN_NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
6079
6162
  function numeric(v) {
@@ -6118,7 +6201,7 @@ function compareGroupValues(a, b, collator, dir) {
6118
6201
  if (ba || bb) return ba === bb ? 0 : ba ? 1 : -1;
6119
6202
  return compareNonBlank(a, b, collator) * dir;
6120
6203
  }
6121
- function sortNodes(nodes, sort, resolve, collator) {
6204
+ function sortNodes(nodes, sort, resolve, collator, blocks) {
6122
6205
  const keys = sort.map((s) => ({ def: resolve(s.colId), mul: s.dir === "desc" ? -1 : 1 })).filter((k) => !!k.def);
6123
6206
  if (!keys.length) return nodes;
6124
6207
  const values = keys.map((k) => nodes.map((n) => {
@@ -6127,6 +6210,7 @@ function sortNodes(nodes, sort, resolve, collator) {
6127
6210
  }));
6128
6211
  const order = nodes.map((_, i) => i);
6129
6212
  order.sort((i, j) => {
6213
+ if (blocks && blocks[i] !== blocks[j]) return blocks[i] - blocks[j];
6130
6214
  for (let k = 0; k < keys.length; k++) {
6131
6215
  const { def, mul } = keys[k];
6132
6216
  const a = values[k][i], b = values[k][j];
@@ -6721,6 +6805,8 @@ function printModel(grid, opts = {}) {
6721
6805
  if (s.bold) cell.bold = true;
6722
6806
  if (s.italic) cell.italic = true;
6723
6807
  if (s.strike) cell.strike = true;
6808
+ const align2 = styleAlign(s);
6809
+ if (align2) cell.align = align2;
6724
6810
  if (cf.bar) cell.bar = { from: cf.bar.from, to: cf.bar.to, color: /^#[0-9a-f]{3,8}$/i.test(cf.bar.color) ? cf.bar.color : "#93c5fd" };
6725
6811
  if (cf.icon) cell.icon = { char: cf.icon.char, color: cf.icon.color };
6726
6812
  }
@@ -7037,6 +7123,8 @@ function buildXlsxSheet(grid, opts = {}) {
7037
7123
  if (color) style.color = color;
7038
7124
  if (fill) style.fill = fill;
7039
7125
  if (cf.style.bold) style.bold = true;
7126
+ const align = styleAlign(cf.style);
7127
+ if (align) style.align = align;
7040
7128
  }
7041
7129
  if (!Object.keys(style).length) return cell;
7042
7130
  return cell !== null && typeof cell === "object" && !(cell instanceof Date) ? { ...cell, ...style } : { v: cell, ...style };
@@ -7669,7 +7757,7 @@ var PAID_FEATURES = {
7669
7757
  };
7670
7758
 
7671
7759
  // src/version.ts
7672
- var version = "1.9.2";
7760
+ var version = "1.9.4";
7673
7761
 
7674
7762
  // src/license.ts
7675
7763
  var PUBLIC_KEY = { kty: "EC", crv: "P-256", x: "sUTKkglZmk-HMKPNub8p7AeLBKesHCU8irIkw9rJlbE", y: "j0U_aKRDR7iApY9UpaD3hUemhBLZJwDv9-QiT_hUyw8" };
@@ -8217,6 +8305,8 @@ var RowModel = class {
8217
8305
  this.full = [];
8218
8306
  this.last = {};
8219
8307
  this.nodes = [];
8308
+ /** Ids given to the current rows (repeats of a getRowId value made unique). */
8309
+ this.assignedIds = /* @__PURE__ */ new WeakMap();
8220
8310
  this.filtered = [];
8221
8311
  this.sorted = [];
8222
8312
  this.display = [];
@@ -8235,14 +8325,32 @@ var RowModel = class {
8235
8325
  const displayChanged = sortChanged || d?.version !== this.last.displayVersion || d?.groupBy !== this.last.display?.groupBy;
8236
8326
  if (dataChanged) {
8237
8327
  const getId = this.deps.getRowId;
8238
- this.nodes = input.data.map((data, i) => ({ id: getId ? String(getId(data)) : autoId(data), data, sourceIndex: i }));
8328
+ if (getId) {
8329
+ const seen2 = /* @__PURE__ */ new Set();
8330
+ let dup = null;
8331
+ this.assignedIds = /* @__PURE__ */ new WeakMap();
8332
+ this.nodes = input.data.map((data, i) => {
8333
+ const raw = String(getId(data));
8334
+ let id = raw;
8335
+ if (seen2.has(id)) {
8336
+ dup ?? (dup = raw);
8337
+ for (let k = 2; seen2.has(id); k++) id = `${raw}#${k}`;
8338
+ }
8339
+ seen2.add(id);
8340
+ this.assignedIds.set(data, id);
8341
+ return { id, data, sourceIndex: i };
8342
+ });
8343
+ if (dup !== null) warn(`getRowId returned "${dup}" for more than one row; inside the grid the repeats are told apart as "${dup}#2" and so on, and updates by id reach the first of them. Return a unique id per row.`);
8344
+ } else this.nodes = input.data.map((data, i) => ({ id: autoId(data), data, sourceIndex: i }));
8239
8345
  }
8240
8346
  if (filterChanged) {
8241
8347
  this.filtered = this.filter(input);
8242
8348
  this.stats.filterRuns++;
8243
8349
  }
8244
8350
  if (sortChanged) {
8245
- this.sorted = sortNodes(this.filtered, input.sort, (id) => this.deps.columnDef(id), collator);
8351
+ const blockDefs = input.sort.length ? this.deps.sortBlocks?.(input.sort) : null;
8352
+ const blocks = blockDefs?.length ? mergeBlocks(this.filtered.map((n) => n.data), blockDefs) : void 0;
8353
+ this.sorted = sortNodes(this.filtered, input.sort, (id) => this.deps.columnDef(id), collator, blocks);
8246
8354
  this.stats.sortRuns++;
8247
8355
  }
8248
8356
  if (displayChanged) this.full = d ? this.buildDisplay(d, input.sort, collator) : this.sorted;
@@ -8286,7 +8394,8 @@ var RowModel = class {
8286
8394
  }
8287
8395
  /** Id of a row object as currently assigned. */
8288
8396
  idOf(row) {
8289
- return this.deps.getRowId ? String(this.deps.getRowId(row)) : autoId(row);
8397
+ if (!this.deps.getRowId) return autoId(row);
8398
+ return this.assignedIds.get(row) ?? String(this.deps.getRowId(row));
8290
8399
  }
8291
8400
  indexOfId(id) {
8292
8401
  if (!this.indexById) this.indexById = new Map(this.display.map((n, i) => [n.id, i]));
@@ -9259,7 +9368,7 @@ var FormatDialog = class {
9259
9368
  const node = grid.columns.getNode(colId);
9260
9369
  if (!node || node.children || isSystemCol(colId)) return;
9261
9370
  const loc = grid.store.state.locale;
9262
- const cf = loc.cf;
9371
+ const cf = { ...en_default.cf, ...loc.cf, aligns: { ...en_default.cf.aligns, ...loc.cf?.aligns } };
9263
9372
  const label = node.def.header ?? node.def.field ?? colId;
9264
9373
  const popup = el("div", `fg-menu fg-cf-dialog fg-theme-${grid.store.state.theme}`, { role: "dialog", "aria-label": `${cf.title}: ${label}` });
9265
9374
  grid.applyFontTo(popup);
@@ -9301,9 +9410,14 @@ var FormatDialog = class {
9301
9410
  if (r2.style?.background) sample.style.background = r2.style.background;
9302
9411
  if (r2.style?.color) sample.style.color = r2.style.color;
9303
9412
  if (r2.style?.bold) sample.style.fontWeight = "700";
9413
+ const align = styleAlign(r2.style);
9414
+ if (align && align !== "center") {
9415
+ sample.style.justifyItems = align === "left" ? "start" : "end";
9416
+ sample.style.padding = "0 3px";
9417
+ }
9304
9418
  if (r2.type === "colorScale") sample.style.background = `linear-gradient(to right, ${(r2.colors ?? ["#f8696b", "#ffeb84", "#63be7b"]).join(",")})`;
9305
9419
  const text2 = el("span", "fg-cf-rule-text");
9306
- text2.textContent = `${describe(r2)}${r2.row ? ` \xB7 ${cf.wholeRow}` : ""}`;
9420
+ text2.textContent = `${describe(r2)}${r2.row ? ` \xB7 ${cf.wholeRow}` : ""}${align ? ` \xB7 ${cf.align}: ${cf.aligns[align]}` : ""}`;
9307
9421
  text2.title = text2.textContent;
9308
9422
  const del = el("button", "fg-chooser-close", { type: "button", "aria-label": `${loc.close}: ${text2.textContent}` });
9309
9423
  del.textContent = "\xD7";
@@ -9355,10 +9469,17 @@ var FormatDialog = class {
9355
9469
  });
9356
9470
  swatches.append(b);
9357
9471
  });
9472
+ const alignSel = el("select", "fg-filter-op fg-cf-wide fg-cf-align", { "aria-label": cf.align, title: cf.align });
9473
+ for (const a of ["", "left", "center", "right"]) {
9474
+ const o = el("option");
9475
+ o.value = a;
9476
+ o.textContent = a ? `${cf.align}: ${cf.aligns[a]}` : `${cf.align}: ${cf.aligns.none}`;
9477
+ alignSel.append(o);
9478
+ }
9358
9479
  const rowLabel = el("label", "fg-cf-wide fg-cf-row");
9359
9480
  const rowBox = el("input", "", { type: "checkbox" });
9360
9481
  rowLabel.append(rowBox, ` ${cf.wholeRow}`);
9361
- form.append(typeSel, opSel, v1, v2, formula, rank, pctLabel, swatches, rowLabel);
9482
+ form.append(typeSel, opSel, v1, v2, formula, rank, pctLabel, swatches, alignSel, rowLabel);
9362
9483
  const sync = () => {
9363
9484
  const t = typeSel.value;
9364
9485
  const op = opSel.value;
@@ -9367,7 +9488,7 @@ var FormatDialog = class {
9367
9488
  v2.hidden = t !== "value" || op !== "between" && op !== "notBetween";
9368
9489
  formula.hidden = t !== "formula";
9369
9490
  rank.hidden = pctLabel.hidden = t !== "top" && t !== "bottom";
9370
- swatches.hidden = SCALES.includes(t);
9491
+ swatches.hidden = alignSel.hidden = SCALES.includes(t);
9371
9492
  rowLabel.hidden = t !== "value" && t !== "formula";
9372
9493
  };
9373
9494
  typeSel.addEventListener("change", sync);
@@ -9395,7 +9516,11 @@ var FormatDialog = class {
9395
9516
  rule.rank = Math.max(1, Math.floor(Number(rank.value) || 10));
9396
9517
  if (pct.checked) rule.percent = true;
9397
9518
  }
9398
- if (!SCALES.includes(type)) rule.style = { ...CF_PRESETS[preset].style };
9519
+ if (!SCALES.includes(type)) {
9520
+ rule.style = { ...CF_PRESETS[preset].style };
9521
+ const a = styleAlign({ align: alignSel.value });
9522
+ if (a) rule.style.align = a;
9523
+ }
9399
9524
  if (rowBox.checked && (type === "value" || type === "formula")) rule.row = true;
9400
9525
  return rule;
9401
9526
  };
@@ -9424,7 +9549,12 @@ var FormatDialog = class {
9424
9549
  const r = grid.renderer.root.getBoundingClientRect();
9425
9550
  const w = popup.offsetWidth || 340;
9426
9551
  popup.style.left = `${Math.max(4, Math.min(r.left + 24, (window.innerWidth || 1024) - w - 4))}px`;
9427
- popup.style.top = `${Math.max(4, r.top + 24)}px`;
9552
+ const vh = window.innerHeight || 768;
9553
+ const h = popup.offsetHeight || 0;
9554
+ const top = Math.max(4, Math.min(r.top + 24, vh - h - 4));
9555
+ popup.style.top = `${top}px`;
9556
+ popup.style.maxHeight = `${Math.max(160, vh - top - 8)}px`;
9557
+ popup.style.overflowY = "auto";
9428
9558
  this.popup = popup;
9429
9559
  typeSel.focus();
9430
9560
  const onDown = (e) => {
@@ -9941,6 +10071,14 @@ var Grid = class {
9941
10071
  }
9942
10072
  };
9943
10073
  this.density = "normal";
10074
+ // ── merge, aggregates, fill, pinned rows ────────────────────
10075
+ /** `sortWithinMerges` is on. */
10076
+ this.sortWithin = false;
10077
+ // ── mergeSelection: one checkbox per merged block ───────────
10078
+ /** 'rows' or the merge level whose blocks get one checkbox each. */
10079
+ this.mergeSel = "rows";
10080
+ this.selBlockCache = null;
10081
+ this.blockStateCache = null;
9944
10082
  /** Count / sum / min / max over the selected range (numbers only for the math). */
9945
10083
  this.aggMemo = null;
9946
10084
  /** Bumped on every selection change (group row checkboxes depend on their rows). */
@@ -9987,8 +10125,12 @@ var Grid = class {
9987
10125
  quickColumns: () => this.columns.displayed().filter((c) => !isSystemCol(c.id)).map((c) => c.def),
9988
10126
  format: (def, v) => getFormatter(def, this.store.state.locale.code)(v),
9989
10127
  collator: () => this.collator(),
9990
- textVersion: () => `${this.store.state.locale.code}|${this.editVersion}`
10128
+ textVersion: () => `${this.store.state.locale.code}|${this.editVersion}`,
10129
+ sortBlocks: (sort) => this.sortBlocks(sort)
9991
10130
  });
10131
+ this.sortWithin = !!options.sortWithinMerges;
10132
+ this.mergeSel = this.validMergeSelection(options.mergeSelection);
10133
+ if (this.sortWithin && this.rowModel.server) warn("sortWithinMerges is ignored with a datasource: the server sorts the rows");
9992
10134
  if (options.groupBy?.length && this.allow(this.rowModel.server ? "serverGroups" : "grouping")) this.groupBy = this.validGroupBy(options.groupBy);
9993
10135
  if (Array.isArray(options.conditionalFormats) && options.conditionalFormats.length && this.allow("conditional")) this.cfRules = options.conditionalFormats.filter((r) => r && typeof r === "object" && Array.isArray(r.columns)).map((r) => ({ ...r, columns: [...r.columns] }));
9994
10136
  this.defaultExpanded = options.treeData || this.paid ? options.groupDefaultExpanded ?? 0 : 0;
@@ -10333,8 +10475,9 @@ var Grid = class {
10333
10475
  const st = this.store.state;
10334
10476
  if (this.pivotCfg || this.options.treeData || st.quickFilter || st.advancedFilter || this.groupBy.length) return true;
10335
10477
  const ids = [...st.sort.map((x) => x.colId), ...Object.keys(st.filters)];
10336
- return ids.some((id) => {
10337
- const def = this.columns.getNode(id)?.def;
10478
+ const defs = ids.map((id) => this.columns.getNode(id)?.def);
10479
+ if (st.sort.length) defs.push(...this.sortBlocks(st.sort) ?? []);
10480
+ return defs.some((def) => {
10338
10481
  if (!def) return false;
10339
10482
  if (def.valueGetter || def.formulas || def.formula) return true;
10340
10483
  return def.field != null && fields.has(def.field);
@@ -10651,6 +10794,7 @@ var Grid = class {
10651
10794
  this.groupBy = next;
10652
10795
  this.page = 0;
10653
10796
  this.groupPanel?.refresh();
10797
+ if (this.sortWithin) this.rowModel.invalidate();
10654
10798
  this.displayChanged();
10655
10799
  this.events.emit("groupChange", { groupBy: [...next] });
10656
10800
  }
@@ -11221,23 +11365,205 @@ var Grid = class {
11221
11365
  });
11222
11366
  return out;
11223
11367
  }
11224
- // ── merge, aggregates, fill, pinned rows ────────────────────
11368
+ /**
11369
+ * Merge columns the client sort must stay inside: the displayed `merge` columns that are
11370
+ * not sort keys. Nothing (a plain sort) when the option is off, merging is not allowed,
11371
+ * or rows are grouped, pivoted or shown as a tree.
11372
+ */
11373
+ sortBlocks(sort) {
11374
+ if (!this.sortWithin || !this.paid) return null;
11375
+ if (this.pivotCfg || this.options.treeData || this.groupBy.length) {
11376
+ warn("sortWithinMerges is ignored while rows are grouped, pivoted or shown as a tree");
11377
+ return null;
11378
+ }
11379
+ const keys = new Set(sort.map((s) => s.colId));
11380
+ return this.columns.displayed().filter((c) => c.def.merge && !c.def.rowSpan && !isSystemCol(c.id) && !keys.has(c.id)).map((c) => c.def);
11381
+ }
11382
+ setSortWithinMerges(on) {
11383
+ const next = !!on;
11384
+ if (next === this.sortWithin) return;
11385
+ this.sortWithin = next;
11386
+ if (this.rowModel.server) {
11387
+ if (next) warn("sortWithinMerges is ignored with a datasource: the server sorts the rows");
11388
+ return;
11389
+ }
11390
+ this.rowModel.invalidate();
11391
+ this.updateRows();
11392
+ this.renderer.invalidate({ rows: true });
11393
+ }
11225
11394
  /** Merge blocks for every `merge` column over the displayed rows (memoized). */
11226
11395
  mergeSpans() {
11227
11396
  if (this.rowModel.server || !this.columns.displayed().some((c) => c.def.merge || c.def.rowSpan)) return NO_SPANS;
11228
11397
  if (!this.allow("merge")) return NO_SPANS;
11229
11398
  const rows = this.rowModel.rows();
11230
11399
  const ver = this.editVersion * 1e6 + this.columns.displayed().length;
11231
- if (this.spanCache && this.spanCache.rows === rows && this.spanCache.ver === ver) return this.spanCache.spans;
11400
+ const sel = this.columns.getColumn(SELECT_COL_ID) ? this.selectionBlocks() : null;
11401
+ if (this.spanCache && this.spanCache.rows === rows && this.spanCache.ver === ver && this.spanCache.sel === sel) return this.spanCache.spans;
11232
11402
  const spans = /* @__PURE__ */ new Map();
11233
11403
  const merged = this.columns.displayed().filter((c) => c.def.merge || c.def.rowSpan);
11234
11404
  if (merged.length && !rows.some((n) => n.kind)) {
11235
11405
  const data = rows.map((n) => n.data);
11236
11406
  for (const c of merged) spans.set(c.id, computeSpans(data, c.def));
11407
+ if (sel) {
11408
+ const n = rows.length, start = new Int32Array(n), end = new Int32Array(n);
11409
+ for (let i = 0; i < n; i++) {
11410
+ const g = sel.index.get(rows[i].id) ?? -1;
11411
+ start[i] = g < 0 ? i : Math.max(0, i - (g - sel.spans.start[g]));
11412
+ end[i] = g < 0 ? i : Math.min(n - 1, i + (sel.spans.end[g] - g));
11413
+ }
11414
+ spans.set(SELECT_COL_ID, { start, end });
11415
+ }
11237
11416
  }
11238
- this.spanCache = { rows, ver, spans };
11417
+ this.spanCache = { rows, ver, sel, spans };
11239
11418
  return spans;
11240
11419
  }
11420
+ validMergeSelection(v) {
11421
+ if (v === void 0 || v === null || v === "rows") return "rows";
11422
+ if (typeof v === "number" && Number.isInteger(v) && v >= 1) return v;
11423
+ warn("mergeSelection must be 'rows' or a whole number from 1; one checkbox per row is used");
11424
+ return "rows";
11425
+ }
11426
+ setMergeSelection(level) {
11427
+ const next = this.validMergeSelection(level);
11428
+ if (next === this.mergeSel) return;
11429
+ this.mergeSel = next;
11430
+ this.renderer.invalidate({ rows: true });
11431
+ }
11432
+ getMergeSelection() {
11433
+ return this.mergeSel;
11434
+ }
11435
+ /** The displayed merge columns that make the levels of `mergeSelection`, left to right. */
11436
+ mergeLevelColumns() {
11437
+ return this.columns.displayed().filter((c) => c.def.merge && !c.def.rowSpan && !isSystemCol(c.id));
11438
+ }
11439
+ /** Merge level whose blocks are checked together; 0 = one checkbox per row. */
11440
+ mergeSelectionLevel() {
11441
+ if (this.mergeSel === "rows") return 0;
11442
+ if (!this.options.rowSelection) {
11443
+ warn("mergeSelection needs rowSelection 'multiple' or 'single'");
11444
+ return 0;
11445
+ }
11446
+ if (!this.paid) return 0;
11447
+ if (this.rowModel.server || this.pivotCfg || this.options.treeData || this.groupBy.length) {
11448
+ warn("mergeSelection is ignored while rows are grouped, pivoted, shown as a tree or loaded from a datasource: one checkbox per row");
11449
+ return 0;
11450
+ }
11451
+ const m = this.mergeLevelColumns().length;
11452
+ if (!m) return 0;
11453
+ if (this.mergeSel > m) {
11454
+ warn(`mergeSelection: level ${this.mergeSel} asked for, but only ${m} merge column${m > 1 ? "s are" : " is"} shown; level ${m} is used`);
11455
+ return m;
11456
+ }
11457
+ return this.mergeSel;
11458
+ }
11459
+ /** Blocks of the selection level over every filtered, sorted row (not only the page); null for one checkbox per row. */
11460
+ selectionBlocks() {
11461
+ const level = this.mergeSelectionLevel();
11462
+ if (!level) return null;
11463
+ const rows = this.rowModel.dataRows(), disp = this.rowModel.rows();
11464
+ const cols = this.mergeLevelColumns().slice(0, level);
11465
+ const key = `${level}|${this.editVersion}|${cols.map((c2) => c2.id).join("")}`;
11466
+ const c = this.selBlockCache;
11467
+ if (c && c.rows === rows && c.disp === disp && c.key === key) return c.blocks;
11468
+ let blocks = null;
11469
+ if (!disp.some((n) => n.kind)) {
11470
+ const data = rows.map((n) => n.data);
11471
+ blocks = {
11472
+ level,
11473
+ cols,
11474
+ spans: nestedSpans(cols.map((col) => computeSpans(data, col.def)), rows.length),
11475
+ index: new Map(rows.map((n, i) => [n.id, i]))
11476
+ };
11477
+ }
11478
+ this.selBlockCache = { rows, disp, key, blocks };
11479
+ return blocks;
11480
+ }
11481
+ /** Checkbox of the block holding display row i: all, some or none of its selectable rows selected, or 'off' (none selectable). Null without block checkboxes. */
11482
+ blockSelection(i) {
11483
+ const sb = this.selectionBlocks();
11484
+ const node = sb && this.rowModel.getRow(i);
11485
+ const g = node ? sb.index.get(node.id) : void 0;
11486
+ if (!sb || g === void 0) return null;
11487
+ const sel = this.store.state.selected;
11488
+ let c = this.blockStateCache;
11489
+ if (!c || c.sel !== sel || c.blocks !== sb || c.ver !== this.editVersion) c = this.blockStateCache = { sel, blocks: sb, ver: this.editVersion, map: /* @__PURE__ */ new Map() };
11490
+ const a = sb.spans.start[g];
11491
+ let state = c.map.get(a);
11492
+ if (state === void 0) {
11493
+ const rows = this.rowModel.dataRows();
11494
+ let able = 0, on = 0;
11495
+ for (let k = a; k <= sb.spans.end[a]; k++) {
11496
+ if (!this.rowSelectable(rows[k].data)) continue;
11497
+ able++;
11498
+ if (sel.has(rows[k].id)) on++;
11499
+ }
11500
+ state = !able ? "off" : on === 0 ? "none" : on === able ? "all" : "some";
11501
+ c.map.set(a, state);
11502
+ }
11503
+ return state;
11504
+ }
11505
+ /** Selectable row ids from position a to b (inclusive) of the filtered rows. */
11506
+ blockIds(a, b) {
11507
+ const rows = this.rowModel.dataRows(), out = [];
11508
+ for (let k = a; k <= b; k++) if (this.rowSelectable(rows[k].data)) out.push(rows[k].id);
11509
+ return out;
11510
+ }
11511
+ /** selectFromClick with block checkboxes: the clicked row's whole block (Shift: every block from the anchor's). */
11512
+ selectBlockFromClick(sb, index, toggle, range) {
11513
+ const mode = this.options.rowSelection;
11514
+ const g = sb.index.get(this.rowModel.getRow(index).id);
11515
+ if (g === void 0) return;
11516
+ const { start, end } = sb.spans;
11517
+ const a = start[g], b = end[g];
11518
+ const ids = this.blockIds(a, b);
11519
+ if (!ids.length) return;
11520
+ const cur = this.store.state.selected;
11521
+ const anchorId = this.rowModel.dataRows()[a].id;
11522
+ if (mode === "single") {
11523
+ const all2 = ids.every((id) => cur.has(id));
11524
+ this.setSelected(toggle && all2 ? /* @__PURE__ */ new Set() : new Set(ids), anchorId);
11525
+ return;
11526
+ }
11527
+ const ag = this.store.state.anchorId == null ? void 0 : sb.index.get(this.store.state.anchorId);
11528
+ if (range && ag !== void 0) {
11529
+ const next = toggle ? new Set(cur) : /* @__PURE__ */ new Set();
11530
+ for (const id of this.blockIds(Math.min(start[ag], a), Math.max(end[ag], b))) next.add(id);
11531
+ this.setSelected(next);
11532
+ return;
11533
+ }
11534
+ if (toggle) {
11535
+ const next = new Set(cur);
11536
+ const all2 = ids.every((id) => next.has(id));
11537
+ for (const id of ids) all2 ? next.delete(id) : next.add(id);
11538
+ this.setSelected(next, anchorId);
11539
+ return;
11540
+ }
11541
+ this.setSelected(new Set(ids), anchorId);
11542
+ }
11543
+ /**
11544
+ * getSelectedGroups: blocks holding selected rows, in display order, with their merge values and selected rows.
11545
+ * allRows: every row of those blocks that passes the filters, selected or not (getSelectedGroupsAllRows).
11546
+ */
11547
+ selectedGroups(allRows = false) {
11548
+ const sb = this.selectionBlocks();
11549
+ const sel = this.store.state.selected;
11550
+ if (!sb || !sel.size) return [];
11551
+ const rows = this.rowModel.dataRows(), out = [];
11552
+ for (let a = 0; a < rows.length; a = sb.spans.end[a] + 1) {
11553
+ const picked = [];
11554
+ let any = false;
11555
+ for (let k = a; k <= sb.spans.end[a]; k++) {
11556
+ const on = sel.has(rows[k].id);
11557
+ any || (any = on);
11558
+ if (on || allRows) picked.push(rows[k].data);
11559
+ }
11560
+ if (!any) continue;
11561
+ const values = {};
11562
+ for (const c of sb.cols) values[c.def.field ?? c.id] = getCellValue(c.def, rows[a].data);
11563
+ out.push({ level: sb.level, values, rows: picked });
11564
+ }
11565
+ return out;
11566
+ }
11241
11567
  rangeAggregates() {
11242
11568
  const all2 = this.allRangeBounds();
11243
11569
  if (!all2.length || !this.paid) return null;
@@ -11673,7 +11999,13 @@ var Grid = class {
11673
11999
  const mode = this.options.rowSelection;
11674
12000
  if (!mode || index < 0 || index >= this.rowModel.getRowCount()) return;
11675
12001
  const target = this.rowModel.getRow(index);
11676
- if (target.kind || !this.rowSelectable(target.data)) return;
12002
+ if (target.kind) return;
12003
+ const blocks = this.selectionBlocks();
12004
+ if (blocks) {
12005
+ this.selectBlockFromClick(blocks, index, toggle, range);
12006
+ return;
12007
+ }
12008
+ if (!this.rowSelectable(target.data)) return;
11677
12009
  const r = nextSelection({
11678
12010
  selected: this.store.state.selected,
11679
12011
  anchorIndex: this.anchorIndex(),
@@ -11861,6 +12193,12 @@ var Grid = class {
11861
12193
  validate: guard(() => this.validateAll()),
11862
12194
  setDensity: guard((d) => this.setDensity(d)),
11863
12195
  getDensity: guard(() => this.density),
12196
+ setSortWithinMerges: guard((on) => this.setSortWithinMerges(on)),
12197
+ getSortWithinMerges: guard(() => this.sortWithin),
12198
+ setMergeSelection: guard((level) => this.setMergeSelection(level)),
12199
+ getMergeSelection: guard(() => this.getMergeSelection()),
12200
+ getSelectedGroups: guard(() => this.selectedGroups()),
12201
+ getSelectedGroupsAllRows: guard(() => this.selectedGroups(true)),
11864
12202
  showOverlay: guard((kind) => {
11865
12203
  this.forcedOverlay = kind === "loading" || kind === "noRows" ? kind : null;
11866
12204
  this.renderer.invalidate();