@freegridjs/core 1.9.1 → 1.9.3

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,23 @@ function computeSpans(rows, def) {
1650
1882
  }
1651
1883
  return { start, end };
1652
1884
  }
1885
+ function mergeBlocks(rows, defs) {
1886
+ const n = rows.length;
1887
+ const block = new Int32Array(n);
1888
+ if (!n || !defs.length) return block;
1889
+ const starts = defs.map((def) => computeSpans(rows, def).start);
1890
+ let b = 0;
1891
+ for (let i = 1; i < n; i++) {
1892
+ for (let k = 0; k < starts.length; k++) {
1893
+ if (starts[k][i] === i) {
1894
+ b++;
1895
+ break;
1896
+ }
1897
+ }
1898
+ block[i] = b;
1899
+ }
1900
+ return block;
1901
+ }
1653
1902
 
1654
1903
  // src/viewport.ts
1655
1904
  function rowRange(scrollTop, height, rowHeight, count, overscan = 4) {
@@ -2166,7 +2415,9 @@ var Renderer = class {
2166
2415
  const def = c.node.def;
2167
2416
  if (def.wrapHeaderText ?? this.grid.options.wrapHeaderText) cell.classList.add("fg-header-wrap");
2168
2417
  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");
2418
+ const headAlign = def.align ?? (def.type === "number" ? "right" : "left");
2419
+ if (headAlign === "right") cell.classList.add("fg-align-right");
2420
+ else if (headAlign === "center") cell.classList.add("fg-align-center");
2170
2421
  const si = sort.findIndex((s) => s.colId === c.id);
2171
2422
  cell.setAttribute("aria-sort", si < 0 ? "none" : sort[si].dir === "asc" ? "ascending" : "descending");
2172
2423
  if (isSortable(def)) cell.classList.add("fg-sortable");
@@ -2271,6 +2522,50 @@ var Renderer = class {
2271
2522
  else this.paintRow(row.el, node, i, cols, isSel, focusCol, hits, spans, range.first);
2272
2523
  }
2273
2524
  }
2525
+ if (spans.size) this.stickMergedText(geo.top, geo.ch || Number.MAX_SAFE_INTEGER / 4);
2526
+ }
2527
+ /**
2528
+ * A vertically merged cell taller than the view keeps its text in sight: the text moves down to the top of the
2529
+ * visible part of the block and stops in the block's last row. Runs on every scroll, also for rows not repainted.
2530
+ */
2531
+ stickMergedText(viewTop, viewHeight) {
2532
+ for (const r of REGIONS3) {
2533
+ const ends = /* @__PURE__ */ new Map();
2534
+ for (const [i, row] of this.painted[r]) {
2535
+ let end = -1;
2536
+ for (const c of row.el.querySelectorAll(".fg-cell-merged")) end = Math.max(end, Number(c.dataset.mergeEnd));
2537
+ if (end > i) ends.set(i, end);
2538
+ }
2539
+ const z = /* @__PURE__ */ new Map();
2540
+ for (const i of [...ends.keys()].sort((a, b) => b - a)) {
2541
+ let top = 0;
2542
+ for (const [j, zj] of z) if (j > i && j <= ends.get(i)) top = Math.max(top, zj);
2543
+ z.set(i, top + 1);
2544
+ }
2545
+ for (const [i, row] of this.painted[r]) {
2546
+ const want = z.has(i) ? String(z.get(i)) : "";
2547
+ if (row.el.style.zIndex !== want && (want || row.el.querySelector(".fg-cell-merged, .fg-cell-merge-hidden"))) row.el.style.zIndex = want;
2548
+ }
2549
+ for (const row of this.painted[r].values()) {
2550
+ for (const c of row.el.querySelectorAll(".fg-cell-merged")) {
2551
+ const blockTop = Number(c.dataset.mergeTop);
2552
+ const last = Number(c.dataset.mergeLast);
2553
+ if (!Number.isFinite(blockTop) || !Number.isFinite(last)) continue;
2554
+ let top = Math.min(last, Math.max(0, viewTop - blockTop));
2555
+ let bottom = 0;
2556
+ if (c.classList.contains("fg-cell-merged-middle") || c.classList.contains("fg-cell-merged-bottom")) {
2557
+ const height = Number(c.dataset.mergeHeight);
2558
+ const room = height - last;
2559
+ bottom = Math.max(0, Math.min(height - top - room, blockTop + height - (viewTop + viewHeight)));
2560
+ top = Math.min(top, height - bottom - room);
2561
+ }
2562
+ const shift = `${Math.round(top)}px`;
2563
+ if (c.style.getPropertyValue("--fg-merge-shift") !== shift) c.style.setProperty("--fg-merge-shift", shift);
2564
+ const shiftBottom = `${Math.round(bottom)}px`;
2565
+ if (c.style.getPropertyValue("--fg-merge-shift-bottom") !== shiftBottom) c.style.setProperty("--fg-merge-shift-bottom", shiftBottom);
2566
+ }
2567
+ }
2568
+ }
2274
2569
  }
2275
2570
  /**
2276
2571
  * Same row, only the column window moved: drop the cells that left and paint the ones that came in.
@@ -2349,9 +2644,24 @@ var Renderer = class {
2349
2644
  cellEl.style.top = `${-(this.rowTop(i) - this.rowTop(s.start[i]))}px`;
2350
2645
  cellEl.style.height = `${this.rowTop(s.end[i]) + this.rowH(s.end[i]) - this.rowTop(s.start[i])}px`;
2351
2646
  cellEl.style.bottom = "auto";
2647
+ cellEl.dataset.mergeTop = String(this.rowTop(s.start[i]));
2648
+ cellEl.dataset.mergeLast = String(this.rowTop(s.end[i]) - this.rowTop(s.start[i]));
2649
+ cellEl.dataset.mergeEnd = String(s.end[i]);
2650
+ cellEl.dataset.mergeHeight = String(this.rowTop(s.end[i]) + this.rowH(s.end[i]) - this.rowTop(s.start[i]));
2651
+ cellEl.classList.toggle("fg-cell-merged-middle", col.def.mergeAlign === "middle");
2652
+ cellEl.classList.toggle("fg-cell-merged-bottom", col.def.mergeAlign === "bottom");
2352
2653
  drawsMerge = true;
2353
2654
  } else cellEl.classList.add("fg-cell-merge-hidden");
2354
2655
  }
2656
+ if (!cellEl.classList.contains("fg-cell-merged") && cellEl.dataset.mergeTop !== void 0) {
2657
+ delete cellEl.dataset.mergeTop;
2658
+ delete cellEl.dataset.mergeLast;
2659
+ delete cellEl.dataset.mergeEnd;
2660
+ delete cellEl.dataset.mergeHeight;
2661
+ cellEl.classList.remove("fg-cell-merged-middle", "fg-cell-merged-bottom");
2662
+ cellEl.style.removeProperty("--fg-merge-shift");
2663
+ cellEl.style.removeProperty("--fg-merge-shift-bottom");
2664
+ }
2355
2665
  if (!isSystemCol(col.id) && bounds.some((b) => col.index >= b.c1 && col.index <= b.c2)) cellEl.classList.add("fg-cell-range");
2356
2666
  });
2357
2667
  rowEl.style.zIndex = drawsMerge ? "1" : "";
@@ -2390,7 +2700,8 @@ var Renderer = class {
2390
2700
  const def = col.def;
2391
2701
  const value = getCellValue(def, node.data);
2392
2702
  const formatted = getFormatter(def, locale.code)(value);
2393
- cls += ` fg-align-${def.align ?? (def.type === "number" ? "right" : def.type === "boolean" ? "center" : "left")}`;
2703
+ const cf = i >= 0 && !node.kind ? this.grid.cellFormat(node.data, col.id, value) : null;
2704
+ cls += ` fg-align-${styleAlign(cf?.style) ?? def.align ?? (def.type === "number" ? "right" : def.type === "boolean" ? "center" : "left")}`;
2394
2705
  const params = { value, formatted, row: node.data, rowIndex: i, column: def };
2395
2706
  if (def.cellClass) {
2396
2707
  try {
@@ -2405,7 +2716,6 @@ var Renderer = class {
2405
2716
  if (typeof def.editable === "function" && i >= 0 && !cellEditable(def, node.data, i)) cls += " fg-cell-readonly";
2406
2717
  if (i >= 0) cls += this.grid.finder.classFor(i, col.id);
2407
2718
  cls += this.grid.flashClass(node.id, col.id);
2408
- const cf = i >= 0 && !node.kind ? this.grid.cellFormat(node.data, col.id, value) : null;
2409
2719
  if (cf?.bar) cls += " fg-cf-bar";
2410
2720
  cell.className = cls;
2411
2721
  this.applyFormat(cell, cf);
@@ -3298,6 +3608,8 @@ var en = {
3298
3608
  style: "Format",
3299
3609
  wholeRow: "Whole row",
3300
3610
  count: "Count",
3611
+ align: "Align",
3612
+ aligns: { none: "Default", left: "Left", center: "Center", right: "Right" },
3301
3613
  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" },
3302
3614
  ops: { notBetween: "Not between", notContains: "Does not contain" },
3303
3615
  styles: { red: "Light red fill", yellow: "Yellow fill", green: "Green fill", blue: "Blue fill", redText: "Red text", bold: "Bold" }
@@ -5830,225 +6142,6 @@ ${xref}
5830
6142
  return out;
5831
6143
  }
5832
6144
 
5833
- // src/conditional.ts
5834
- var NUMERIC = (v) => {
5835
- if (typeof v === "number") return Number.isFinite(v) ? v : null;
5836
- if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
5837
- if (v instanceof Date) return v.getTime();
5838
- return null;
5839
- };
5840
- var STAT_TYPES = /* @__PURE__ */ new Set(["top", "bottom", "aboveAverage", "belowAverage", "duplicate", "unique", "dataBar", "colorScale", "iconSet"]);
5841
- var ICON_SETS = {
5842
- // high, middle, low
5843
- arrows: [{ char: "\u25B2", color: "#16a34a", label: "up" }, { char: "\u25B6", color: "#d97706", label: "level" }, { char: "\u25BC", color: "#dc2626", label: "down" }],
5844
- traffic: [{ char: "\u25CF", color: "#16a34a", label: "good" }, { char: "\u25CF", color: "#f59e0b", label: "fair" }, { char: "\u25CF", color: "#dc2626", label: "poor" }],
5845
- signs: [{ char: "\u2714", color: "#16a34a", label: "good" }, { char: "!", color: "#d97706", label: "fair" }, { char: "\u2716", color: "#dc2626", label: "poor" }]
5846
- };
5847
- function hex(c) {
5848
- const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
5849
- if (!m) return null;
5850
- const h = m[1].length === 3 ? [...m[1]].map((x) => x + x).join("") : m[1];
5851
- return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
5852
- }
5853
- function mixColor(a, b, t) {
5854
- const x = hex(a), y = hex(b);
5855
- if (!x || !y) return t < 0.5 ? a : b;
5856
- const c = x.map((v, i) => Math.round(v + (y[i] - v) * Math.max(0, Math.min(1, t))));
5857
- return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
5858
- }
5859
- function textOf(v) {
5860
- return v == null ? "" : v instanceof Date ? v.toISOString() : String(v);
5861
- }
5862
- function valueMatches(rule, value) {
5863
- const op = rule.op ?? "eq";
5864
- if (op === "blank") return isBlank(value);
5865
- if (op === "notBlank") return !isBlank(value);
5866
- if (op === "contains" || op === "notContains" || op === "startsWith" || op === "endsWith") {
5867
- const s2 = textOf(value).toLowerCase(), q = textOf(rule.value).toLowerCase();
5868
- const hit = op === "startsWith" ? s2.startsWith(q) : op === "endsWith" ? s2.endsWith(q) : s2.includes(q);
5869
- return op === "notContains" ? !hit : hit;
5870
- }
5871
- if (isBlank(value)) return op === "ne";
5872
- const a = NUMERIC(value), b = NUMERIC(rule.value), c = NUMERIC(rule.valueTo);
5873
- if (a !== null && b !== null) {
5874
- switch (op) {
5875
- case "eq":
5876
- return a === b;
5877
- case "ne":
5878
- return a !== b;
5879
- case "gt":
5880
- return a > b;
5881
- case "gte":
5882
- return a >= b;
5883
- case "lt":
5884
- return a < b;
5885
- case "lte":
5886
- return a <= b;
5887
- case "between":
5888
- case "notBetween": {
5889
- if (c === null) return false;
5890
- const inside = a >= Math.min(b, c) && a <= Math.max(b, c);
5891
- return op === "between" ? inside : !inside;
5892
- }
5893
- }
5894
- }
5895
- const s = textOf(value).toLowerCase(), t = textOf(rule.value).toLowerCase(), u = textOf(rule.valueTo).toLowerCase();
5896
- switch (op) {
5897
- case "eq":
5898
- return s === t;
5899
- case "ne":
5900
- return s !== t;
5901
- case "gt":
5902
- return s > t;
5903
- case "gte":
5904
- return s >= t;
5905
- case "lt":
5906
- return s < t;
5907
- case "lte":
5908
- return s <= t;
5909
- case "between":
5910
- return s >= t && s <= u;
5911
- case "notBetween":
5912
- return s < t || s > u;
5913
- }
5914
- return false;
5915
- }
5916
- var ConditionalFormatter = class {
5917
- constructor(deps) {
5918
- this.deps = deps;
5919
- this.stats = /* @__PURE__ */ new Map();
5920
- this.statsKey = [];
5921
- this.rowCache = /* @__PURE__ */ new WeakMap();
5922
- }
5923
- /** True when any rule looks at other rows (an update to one row can restyle others). */
5924
- dependsOnOtherRows() {
5925
- return this.deps.rules().some((r) => r && (STAT_TYPES.has(r.type ?? "value") || r.type === "formula"));
5926
- }
5927
- fresh() {
5928
- const key = [this.deps.rows(), this.deps.version(), this.deps.rules()];
5929
- if (key.every((k, i) => k === this.statsKey[i])) return;
5930
- this.statsKey = key;
5931
- this.stats.clear();
5932
- this.rowCache = /* @__PURE__ */ new WeakMap();
5933
- }
5934
- statsOf(colId) {
5935
- let s = this.stats.get(colId);
5936
- if (!s) {
5937
- const nums = [];
5938
- const counts = /* @__PURE__ */ new Map();
5939
- for (const row of this.deps.rows()) {
5940
- const v = this.deps.value(colId, row);
5941
- const n = NUMERIC(v);
5942
- if (n !== null) nums.push(n);
5943
- if (!isBlank(v)) {
5944
- const k = setKey(v);
5945
- counts.set(k, (counts.get(k) ?? 0) + 1);
5946
- }
5947
- }
5948
- nums.sort((a, b) => a - b);
5949
- const sum = nums.reduce((a, b) => a + b, 0);
5950
- s = { min: nums[0] ?? 0, max: nums[nums.length - 1] ?? 0, avg: nums.length ? sum / nums.length : 0, sorted: nums, counts };
5951
- this.stats.set(colId, s);
5952
- }
5953
- return s;
5954
- }
5955
- test(rule, colId, value, row) {
5956
- const type = rule.type ?? "value";
5957
- switch (type) {
5958
- case "value":
5959
- return valueMatches(rule, value);
5960
- case "formula": {
5961
- if (typeof rule.formula !== "string") return false;
5962
- const r = this.deps.formula(rule.formula.startsWith("=") ? rule.formula : `=${rule.formula}`, row);
5963
- return r === true || typeof r === "number" && r !== 0;
5964
- }
5965
- case "top":
5966
- case "bottom": {
5967
- const n = NUMERIC(value);
5968
- if (n === null) return false;
5969
- const s = this.statsOf(colId);
5970
- if (!s.sorted.length) return false;
5971
- const k = Math.max(1, rule.percent ? Math.ceil(s.sorted.length * (rule.rank ?? 10) / 100) : Math.floor(rule.rank ?? 10));
5972
- return type === "top" ? n >= s.sorted[Math.max(0, s.sorted.length - k)] : n <= s.sorted[Math.min(s.sorted.length - 1, k - 1)];
5973
- }
5974
- case "aboveAverage":
5975
- case "belowAverage": {
5976
- const n = NUMERIC(value);
5977
- if (n === null) return false;
5978
- const avg = this.statsOf(colId).avg;
5979
- return type === "aboveAverage" ? n > avg : n < avg;
5980
- }
5981
- case "duplicate":
5982
- case "unique": {
5983
- if (isBlank(value)) return false;
5984
- const c = this.statsOf(colId).counts.get(setKey(value)) ?? 0;
5985
- return type === "duplicate" ? c > 1 : c === 1;
5986
- }
5987
- }
5988
- return false;
5989
- }
5990
- /** Style from rules with `row: true`, for every cell of the row. */
5991
- rowStyle(row) {
5992
- if (this.rowCache.has(row)) return this.rowCache.get(row);
5993
- let style = null;
5994
- for (const rule of this.deps.rules()) {
5995
- if (!rule?.row || !Array.isArray(rule.columns) || !rule.columns.length) continue;
5996
- const colId = rule.columns[0];
5997
- if (this.test(rule, colId, this.deps.value(colId, row), row)) {
5998
- style = { ...style ?? {}, ...rule.style };
5999
- if (rule.stopIfTrue) break;
6000
- }
6001
- }
6002
- this.rowCache.set(row, style);
6003
- return style;
6004
- }
6005
- /** Format of one cell; null when no rule applies. */
6006
- cell(colId, row, value) {
6007
- const rules = this.deps.rules();
6008
- if (!rules.length) return null;
6009
- this.fresh();
6010
- let out = null;
6011
- const rs = this.rowStyle(row);
6012
- if (rs) out = { style: { ...rs } };
6013
- for (const rule of rules) {
6014
- if (!rule || rule.row || !Array.isArray(rule.columns) || !rule.columns.includes(colId)) continue;
6015
- const type = rule.type ?? "value";
6016
- if (type === "dataBar" || type === "colorScale" || type === "iconSet") {
6017
- const n = NUMERIC(value);
6018
- if (n === null) continue;
6019
- const s = this.statsOf(colId);
6020
- const lo = typeof rule.min === "number" ? rule.min : s.min;
6021
- const hi = typeof rule.max === "number" ? rule.max : s.max;
6022
- const t = hi === lo ? 1 : Math.max(0, Math.min(1, (n - lo) / (hi - lo)));
6023
- out ?? (out = { style: {} });
6024
- if (type === "dataBar") {
6025
- const zero = lo < 0 && hi > 0 ? -lo / (hi - lo) : lo >= 0 ? 0 : 1;
6026
- const at = hi === lo ? 1 : t;
6027
- out.bar = { from: Math.min(zero, at), to: Math.max(zero, at), color: rule.color || "var(--fg-accent)" };
6028
- } else if (type === "colorScale") {
6029
- const cs = Array.isArray(rule.colors) && rule.colors.length >= 2 ? rule.colors : ["#f8696b", "#ffeb84", "#63be7b"];
6030
- 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);
6031
- out.style = { ...out.style, background: bg };
6032
- } else {
6033
- const set = ICON_SETS[rule.icons ?? "arrows"] ?? ICON_SETS.arrows;
6034
- out.icon = t >= 2 / 3 ? set[0] : t >= 1 / 3 ? set[1] : set[2];
6035
- }
6036
- continue;
6037
- }
6038
- if (this.test(rule, colId, value, row)) {
6039
- out ?? (out = { style: {} });
6040
- out.style = { ...out.style, ...rule.style };
6041
- if (rule.stopIfTrue) break;
6042
- }
6043
- }
6044
- return out;
6045
- }
6046
- };
6047
- function argbOf(c) {
6048
- const x = c ? hex(c) : null;
6049
- return x ? `FF${x.map((v) => v.toString(16).padStart(2, "0")).join("").toUpperCase()}` : void 0;
6050
- }
6051
-
6052
6145
  // src/rows/sort.ts
6053
6146
  var PLAIN_NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
6054
6147
  function numeric(v) {
@@ -6093,7 +6186,7 @@ function compareGroupValues(a, b, collator, dir) {
6093
6186
  if (ba || bb) return ba === bb ? 0 : ba ? 1 : -1;
6094
6187
  return compareNonBlank(a, b, collator) * dir;
6095
6188
  }
6096
- function sortNodes(nodes, sort, resolve, collator) {
6189
+ function sortNodes(nodes, sort, resolve, collator, blocks) {
6097
6190
  const keys = sort.map((s) => ({ def: resolve(s.colId), mul: s.dir === "desc" ? -1 : 1 })).filter((k) => !!k.def);
6098
6191
  if (!keys.length) return nodes;
6099
6192
  const values = keys.map((k) => nodes.map((n) => {
@@ -6102,6 +6195,7 @@ function sortNodes(nodes, sort, resolve, collator) {
6102
6195
  }));
6103
6196
  const order = nodes.map((_, i) => i);
6104
6197
  order.sort((i, j) => {
6198
+ if (blocks && blocks[i] !== blocks[j]) return blocks[i] - blocks[j];
6105
6199
  for (let k = 0; k < keys.length; k++) {
6106
6200
  const { def, mul } = keys[k];
6107
6201
  const a = values[k][i], b = values[k][j];
@@ -6696,6 +6790,8 @@ function printModel(grid, opts = {}) {
6696
6790
  if (s.bold) cell.bold = true;
6697
6791
  if (s.italic) cell.italic = true;
6698
6792
  if (s.strike) cell.strike = true;
6793
+ const align2 = styleAlign(s);
6794
+ if (align2) cell.align = align2;
6699
6795
  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" };
6700
6796
  if (cf.icon) cell.icon = { char: cf.icon.char, color: cf.icon.color };
6701
6797
  }
@@ -7012,6 +7108,8 @@ function buildXlsxSheet(grid, opts = {}) {
7012
7108
  if (color) style.color = color;
7013
7109
  if (fill) style.fill = fill;
7014
7110
  if (cf.style.bold) style.bold = true;
7111
+ const align = styleAlign(cf.style);
7112
+ if (align) style.align = align;
7015
7113
  }
7016
7114
  if (!Object.keys(style).length) return cell;
7017
7115
  return cell !== null && typeof cell === "object" && !(cell instanceof Date) ? { ...cell, ...style } : { v: cell, ...style };
@@ -7644,7 +7742,7 @@ var PAID_FEATURES = {
7644
7742
  };
7645
7743
 
7646
7744
  // src/version.ts
7647
- var version = "1.9.1";
7745
+ var version = "1.9.3";
7648
7746
 
7649
7747
  // src/license.ts
7650
7748
  var PUBLIC_KEY = { kty: "EC", crv: "P-256", x: "sUTKkglZmk-HMKPNub8p7AeLBKesHCU8irIkw9rJlbE", y: "j0U_aKRDR7iApY9UpaD3hUemhBLZJwDv9-QiT_hUyw8" };
@@ -8217,7 +8315,9 @@ var RowModel = class {
8217
8315
  this.stats.filterRuns++;
8218
8316
  }
8219
8317
  if (sortChanged) {
8220
- this.sorted = sortNodes(this.filtered, input.sort, (id) => this.deps.columnDef(id), collator);
8318
+ const blockDefs = input.sort.length ? this.deps.sortBlocks?.(input.sort) : null;
8319
+ const blocks = blockDefs?.length ? mergeBlocks(this.filtered.map((n) => n.data), blockDefs) : void 0;
8320
+ this.sorted = sortNodes(this.filtered, input.sort, (id) => this.deps.columnDef(id), collator, blocks);
8221
8321
  this.stats.sortRuns++;
8222
8322
  }
8223
8323
  if (displayChanged) this.full = d ? this.buildDisplay(d, input.sort, collator) : this.sorted;
@@ -9234,7 +9334,7 @@ var FormatDialog = class {
9234
9334
  const node = grid.columns.getNode(colId);
9235
9335
  if (!node || node.children || isSystemCol(colId)) return;
9236
9336
  const loc = grid.store.state.locale;
9237
- const cf = loc.cf;
9337
+ const cf = { ...en_default.cf, ...loc.cf, aligns: { ...en_default.cf.aligns, ...loc.cf?.aligns } };
9238
9338
  const label = node.def.header ?? node.def.field ?? colId;
9239
9339
  const popup = el("div", `fg-menu fg-cf-dialog fg-theme-${grid.store.state.theme}`, { role: "dialog", "aria-label": `${cf.title}: ${label}` });
9240
9340
  grid.applyFontTo(popup);
@@ -9276,9 +9376,14 @@ var FormatDialog = class {
9276
9376
  if (r2.style?.background) sample.style.background = r2.style.background;
9277
9377
  if (r2.style?.color) sample.style.color = r2.style.color;
9278
9378
  if (r2.style?.bold) sample.style.fontWeight = "700";
9379
+ const align = styleAlign(r2.style);
9380
+ if (align && align !== "center") {
9381
+ sample.style.justifyItems = align === "left" ? "start" : "end";
9382
+ sample.style.padding = "0 3px";
9383
+ }
9279
9384
  if (r2.type === "colorScale") sample.style.background = `linear-gradient(to right, ${(r2.colors ?? ["#f8696b", "#ffeb84", "#63be7b"]).join(",")})`;
9280
9385
  const text2 = el("span", "fg-cf-rule-text");
9281
- text2.textContent = `${describe(r2)}${r2.row ? ` \xB7 ${cf.wholeRow}` : ""}`;
9386
+ text2.textContent = `${describe(r2)}${r2.row ? ` \xB7 ${cf.wholeRow}` : ""}${align ? ` \xB7 ${cf.align}: ${cf.aligns[align]}` : ""}`;
9282
9387
  text2.title = text2.textContent;
9283
9388
  const del = el("button", "fg-chooser-close", { type: "button", "aria-label": `${loc.close}: ${text2.textContent}` });
9284
9389
  del.textContent = "\xD7";
@@ -9330,10 +9435,17 @@ var FormatDialog = class {
9330
9435
  });
9331
9436
  swatches.append(b);
9332
9437
  });
9438
+ const alignSel = el("select", "fg-filter-op fg-cf-wide fg-cf-align", { "aria-label": cf.align, title: cf.align });
9439
+ for (const a of ["", "left", "center", "right"]) {
9440
+ const o = el("option");
9441
+ o.value = a;
9442
+ o.textContent = a ? `${cf.align}: ${cf.aligns[a]}` : `${cf.align}: ${cf.aligns.none}`;
9443
+ alignSel.append(o);
9444
+ }
9333
9445
  const rowLabel = el("label", "fg-cf-wide fg-cf-row");
9334
9446
  const rowBox = el("input", "", { type: "checkbox" });
9335
9447
  rowLabel.append(rowBox, ` ${cf.wholeRow}`);
9336
- form.append(typeSel, opSel, v1, v2, formula, rank, pctLabel, swatches, rowLabel);
9448
+ form.append(typeSel, opSel, v1, v2, formula, rank, pctLabel, swatches, alignSel, rowLabel);
9337
9449
  const sync = () => {
9338
9450
  const t = typeSel.value;
9339
9451
  const op = opSel.value;
@@ -9342,7 +9454,7 @@ var FormatDialog = class {
9342
9454
  v2.hidden = t !== "value" || op !== "between" && op !== "notBetween";
9343
9455
  formula.hidden = t !== "formula";
9344
9456
  rank.hidden = pctLabel.hidden = t !== "top" && t !== "bottom";
9345
- swatches.hidden = SCALES.includes(t);
9457
+ swatches.hidden = alignSel.hidden = SCALES.includes(t);
9346
9458
  rowLabel.hidden = t !== "value" && t !== "formula";
9347
9459
  };
9348
9460
  typeSel.addEventListener("change", sync);
@@ -9370,7 +9482,11 @@ var FormatDialog = class {
9370
9482
  rule.rank = Math.max(1, Math.floor(Number(rank.value) || 10));
9371
9483
  if (pct.checked) rule.percent = true;
9372
9484
  }
9373
- if (!SCALES.includes(type)) rule.style = { ...CF_PRESETS[preset].style };
9485
+ if (!SCALES.includes(type)) {
9486
+ rule.style = { ...CF_PRESETS[preset].style };
9487
+ const a = styleAlign({ align: alignSel.value });
9488
+ if (a) rule.style.align = a;
9489
+ }
9374
9490
  if (rowBox.checked && (type === "value" || type === "formula")) rule.row = true;
9375
9491
  return rule;
9376
9492
  };
@@ -9399,7 +9515,12 @@ var FormatDialog = class {
9399
9515
  const r = grid.renderer.root.getBoundingClientRect();
9400
9516
  const w = popup.offsetWidth || 340;
9401
9517
  popup.style.left = `${Math.max(4, Math.min(r.left + 24, (window.innerWidth || 1024) - w - 4))}px`;
9402
- popup.style.top = `${Math.max(4, r.top + 24)}px`;
9518
+ const vh = window.innerHeight || 768;
9519
+ const h = popup.offsetHeight || 0;
9520
+ const top = Math.max(4, Math.min(r.top + 24, vh - h - 4));
9521
+ popup.style.top = `${top}px`;
9522
+ popup.style.maxHeight = `${Math.max(160, vh - top - 8)}px`;
9523
+ popup.style.overflowY = "auto";
9403
9524
  this.popup = popup;
9404
9525
  typeSel.focus();
9405
9526
  const onDown = (e) => {
@@ -9916,6 +10037,9 @@ var Grid = class {
9916
10037
  }
9917
10038
  };
9918
10039
  this.density = "normal";
10040
+ // ── merge, aggregates, fill, pinned rows ────────────────────
10041
+ /** `sortWithinMerges` is on. */
10042
+ this.sortWithin = false;
9919
10043
  /** Count / sum / min / max over the selected range (numbers only for the math). */
9920
10044
  this.aggMemo = null;
9921
10045
  /** Bumped on every selection change (group row checkboxes depend on their rows). */
@@ -9962,8 +10086,11 @@ var Grid = class {
9962
10086
  quickColumns: () => this.columns.displayed().filter((c) => !isSystemCol(c.id)).map((c) => c.def),
9963
10087
  format: (def, v) => getFormatter(def, this.store.state.locale.code)(v),
9964
10088
  collator: () => this.collator(),
9965
- textVersion: () => `${this.store.state.locale.code}|${this.editVersion}`
10089
+ textVersion: () => `${this.store.state.locale.code}|${this.editVersion}`,
10090
+ sortBlocks: (sort) => this.sortBlocks(sort)
9966
10091
  });
10092
+ this.sortWithin = !!options.sortWithinMerges;
10093
+ if (this.sortWithin && this.rowModel.server) warn("sortWithinMerges is ignored with a datasource: the server sorts the rows");
9967
10094
  if (options.groupBy?.length && this.allow(this.rowModel.server ? "serverGroups" : "grouping")) this.groupBy = this.validGroupBy(options.groupBy);
9968
10095
  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] }));
9969
10096
  this.defaultExpanded = options.treeData || this.paid ? options.groupDefaultExpanded ?? 0 : 0;
@@ -10308,8 +10435,9 @@ var Grid = class {
10308
10435
  const st = this.store.state;
10309
10436
  if (this.pivotCfg || this.options.treeData || st.quickFilter || st.advancedFilter || this.groupBy.length) return true;
10310
10437
  const ids = [...st.sort.map((x) => x.colId), ...Object.keys(st.filters)];
10311
- return ids.some((id) => {
10312
- const def = this.columns.getNode(id)?.def;
10438
+ const defs = ids.map((id) => this.columns.getNode(id)?.def);
10439
+ if (st.sort.length) defs.push(...this.sortBlocks(st.sort) ?? []);
10440
+ return defs.some((def) => {
10313
10441
  if (!def) return false;
10314
10442
  if (def.valueGetter || def.formulas || def.formula) return true;
10315
10443
  return def.field != null && fields.has(def.field);
@@ -10626,6 +10754,7 @@ var Grid = class {
10626
10754
  this.groupBy = next;
10627
10755
  this.page = 0;
10628
10756
  this.groupPanel?.refresh();
10757
+ if (this.sortWithin) this.rowModel.invalidate();
10629
10758
  this.displayChanged();
10630
10759
  this.events.emit("groupChange", { groupBy: [...next] });
10631
10760
  }
@@ -11196,7 +11325,32 @@ var Grid = class {
11196
11325
  });
11197
11326
  return out;
11198
11327
  }
11199
- // ── merge, aggregates, fill, pinned rows ────────────────────
11328
+ /**
11329
+ * Merge columns the client sort must stay inside: the displayed `merge` columns that are
11330
+ * not sort keys. Nothing (a plain sort) when the option is off, merging is not allowed,
11331
+ * or rows are grouped, pivoted or shown as a tree.
11332
+ */
11333
+ sortBlocks(sort) {
11334
+ if (!this.sortWithin || !this.paid) return null;
11335
+ if (this.pivotCfg || this.options.treeData || this.groupBy.length) {
11336
+ warn("sortWithinMerges is ignored while rows are grouped, pivoted or shown as a tree");
11337
+ return null;
11338
+ }
11339
+ const keys = new Set(sort.map((s) => s.colId));
11340
+ return this.columns.displayed().filter((c) => c.def.merge && !c.def.rowSpan && !isSystemCol(c.id) && !keys.has(c.id)).map((c) => c.def);
11341
+ }
11342
+ setSortWithinMerges(on) {
11343
+ const next = !!on;
11344
+ if (next === this.sortWithin) return;
11345
+ this.sortWithin = next;
11346
+ if (this.rowModel.server) {
11347
+ if (next) warn("sortWithinMerges is ignored with a datasource: the server sorts the rows");
11348
+ return;
11349
+ }
11350
+ this.rowModel.invalidate();
11351
+ this.updateRows();
11352
+ this.renderer.invalidate({ rows: true });
11353
+ }
11200
11354
  /** Merge blocks for every `merge` column over the displayed rows (memoized). */
11201
11355
  mergeSpans() {
11202
11356
  if (this.rowModel.server || !this.columns.displayed().some((c) => c.def.merge || c.def.rowSpan)) return NO_SPANS;
@@ -11836,6 +11990,8 @@ var Grid = class {
11836
11990
  validate: guard(() => this.validateAll()),
11837
11991
  setDensity: guard((d) => this.setDensity(d)),
11838
11992
  getDensity: guard(() => this.density),
11993
+ setSortWithinMerges: guard((on) => this.setSortWithinMerges(on)),
11994
+ getSortWithinMerges: guard(() => this.sortWithin),
11839
11995
  showOverlay: guard((kind) => {
11840
11996
  this.forcedOverlay = kind === "loading" || kind === "noRows" ? kind : null;
11841
11997
  this.renderer.invalidate();