@freegridjs/core 1.9.2 → 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,21 +2522,47 @@ var Renderer = class {
2271
2522
  else this.paintRow(row.el, node, i, cols, isSel, focusCol, hits, spans, range.first);
2272
2523
  }
2273
2524
  }
2274
- if (spans.size) this.stickMergedText(top);
2525
+ if (spans.size) this.stickMergedText(geo.top, geo.ch || Number.MAX_SAFE_INTEGER / 4);
2275
2526
  }
2276
2527
  /**
2277
2528
  * A vertically merged cell taller than the view keeps its text in sight: the text moves down to the top of the
2278
2529
  * visible part of the block and stops in the block's last row. Runs on every scroll, also for rows not repainted.
2279
2530
  */
2280
- stickMergedText(viewTop) {
2531
+ stickMergedText(viewTop, viewHeight) {
2281
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
+ }
2282
2549
  for (const row of this.painted[r].values()) {
2283
2550
  for (const c of row.el.querySelectorAll(".fg-cell-merged")) {
2284
2551
  const blockTop = Number(c.dataset.mergeTop);
2285
2552
  const last = Number(c.dataset.mergeLast);
2286
2553
  if (!Number.isFinite(blockTop) || !Number.isFinite(last)) continue;
2287
- const shift = `${Math.round(Math.min(last, Math.max(0, viewTop - blockTop)))}px`;
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`;
2288
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);
2289
2566
  }
2290
2567
  }
2291
2568
  }
@@ -2369,13 +2646,21 @@ var Renderer = class {
2369
2646
  cellEl.style.bottom = "auto";
2370
2647
  cellEl.dataset.mergeTop = String(this.rowTop(s.start[i]));
2371
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");
2372
2653
  drawsMerge = true;
2373
2654
  } else cellEl.classList.add("fg-cell-merge-hidden");
2374
2655
  }
2375
2656
  if (!cellEl.classList.contains("fg-cell-merged") && cellEl.dataset.mergeTop !== void 0) {
2376
2657
  delete cellEl.dataset.mergeTop;
2377
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");
2378
2662
  cellEl.style.removeProperty("--fg-merge-shift");
2663
+ cellEl.style.removeProperty("--fg-merge-shift-bottom");
2379
2664
  }
2380
2665
  if (!isSystemCol(col.id) && bounds.some((b) => col.index >= b.c1 && col.index <= b.c2)) cellEl.classList.add("fg-cell-range");
2381
2666
  });
@@ -2415,7 +2700,8 @@ var Renderer = class {
2415
2700
  const def = col.def;
2416
2701
  const value = getCellValue(def, node.data);
2417
2702
  const formatted = getFormatter(def, locale.code)(value);
2418
- 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")}`;
2419
2705
  const params = { value, formatted, row: node.data, rowIndex: i, column: def };
2420
2706
  if (def.cellClass) {
2421
2707
  try {
@@ -2430,7 +2716,6 @@ var Renderer = class {
2430
2716
  if (typeof def.editable === "function" && i >= 0 && !cellEditable(def, node.data, i)) cls += " fg-cell-readonly";
2431
2717
  if (i >= 0) cls += this.grid.finder.classFor(i, col.id);
2432
2718
  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
2719
  if (cf?.bar) cls += " fg-cf-bar";
2435
2720
  cell.className = cls;
2436
2721
  this.applyFormat(cell, cf);
@@ -3323,6 +3608,8 @@ var en = {
3323
3608
  style: "Format",
3324
3609
  wholeRow: "Whole row",
3325
3610
  count: "Count",
3611
+ align: "Align",
3612
+ aligns: { none: "Default", left: "Left", center: "Center", right: "Right" },
3326
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" },
3327
3614
  ops: { notBetween: "Not between", notContains: "Does not contain" },
3328
3615
  styles: { red: "Light red fill", yellow: "Yellow fill", green: "Green fill", blue: "Blue fill", redText: "Red text", bold: "Bold" }
@@ -5855,225 +6142,6 @@ ${xref}
5855
6142
  return out;
5856
6143
  }
5857
6144
 
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
6145
  // src/rows/sort.ts
6078
6146
  var PLAIN_NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
6079
6147
  function numeric(v) {
@@ -6118,7 +6186,7 @@ function compareGroupValues(a, b, collator, dir) {
6118
6186
  if (ba || bb) return ba === bb ? 0 : ba ? 1 : -1;
6119
6187
  return compareNonBlank(a, b, collator) * dir;
6120
6188
  }
6121
- function sortNodes(nodes, sort, resolve, collator) {
6189
+ function sortNodes(nodes, sort, resolve, collator, blocks) {
6122
6190
  const keys = sort.map((s) => ({ def: resolve(s.colId), mul: s.dir === "desc" ? -1 : 1 })).filter((k) => !!k.def);
6123
6191
  if (!keys.length) return nodes;
6124
6192
  const values = keys.map((k) => nodes.map((n) => {
@@ -6127,6 +6195,7 @@ function sortNodes(nodes, sort, resolve, collator) {
6127
6195
  }));
6128
6196
  const order = nodes.map((_, i) => i);
6129
6197
  order.sort((i, j) => {
6198
+ if (blocks && blocks[i] !== blocks[j]) return blocks[i] - blocks[j];
6130
6199
  for (let k = 0; k < keys.length; k++) {
6131
6200
  const { def, mul } = keys[k];
6132
6201
  const a = values[k][i], b = values[k][j];
@@ -6721,6 +6790,8 @@ function printModel(grid, opts = {}) {
6721
6790
  if (s.bold) cell.bold = true;
6722
6791
  if (s.italic) cell.italic = true;
6723
6792
  if (s.strike) cell.strike = true;
6793
+ const align2 = styleAlign(s);
6794
+ if (align2) cell.align = align2;
6724
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" };
6725
6796
  if (cf.icon) cell.icon = { char: cf.icon.char, color: cf.icon.color };
6726
6797
  }
@@ -7037,6 +7108,8 @@ function buildXlsxSheet(grid, opts = {}) {
7037
7108
  if (color) style.color = color;
7038
7109
  if (fill) style.fill = fill;
7039
7110
  if (cf.style.bold) style.bold = true;
7111
+ const align = styleAlign(cf.style);
7112
+ if (align) style.align = align;
7040
7113
  }
7041
7114
  if (!Object.keys(style).length) return cell;
7042
7115
  return cell !== null && typeof cell === "object" && !(cell instanceof Date) ? { ...cell, ...style } : { v: cell, ...style };
@@ -7669,7 +7742,7 @@ var PAID_FEATURES = {
7669
7742
  };
7670
7743
 
7671
7744
  // src/version.ts
7672
- var version = "1.9.2";
7745
+ var version = "1.9.3";
7673
7746
 
7674
7747
  // src/license.ts
7675
7748
  var PUBLIC_KEY = { kty: "EC", crv: "P-256", x: "sUTKkglZmk-HMKPNub8p7AeLBKesHCU8irIkw9rJlbE", y: "j0U_aKRDR7iApY9UpaD3hUemhBLZJwDv9-QiT_hUyw8" };
@@ -8242,7 +8315,9 @@ var RowModel = class {
8242
8315
  this.stats.filterRuns++;
8243
8316
  }
8244
8317
  if (sortChanged) {
8245
- 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);
8246
8321
  this.stats.sortRuns++;
8247
8322
  }
8248
8323
  if (displayChanged) this.full = d ? this.buildDisplay(d, input.sort, collator) : this.sorted;
@@ -9259,7 +9334,7 @@ var FormatDialog = class {
9259
9334
  const node = grid.columns.getNode(colId);
9260
9335
  if (!node || node.children || isSystemCol(colId)) return;
9261
9336
  const loc = grid.store.state.locale;
9262
- const cf = loc.cf;
9337
+ const cf = { ...en_default.cf, ...loc.cf, aligns: { ...en_default.cf.aligns, ...loc.cf?.aligns } };
9263
9338
  const label = node.def.header ?? node.def.field ?? colId;
9264
9339
  const popup = el("div", `fg-menu fg-cf-dialog fg-theme-${grid.store.state.theme}`, { role: "dialog", "aria-label": `${cf.title}: ${label}` });
9265
9340
  grid.applyFontTo(popup);
@@ -9301,9 +9376,14 @@ var FormatDialog = class {
9301
9376
  if (r2.style?.background) sample.style.background = r2.style.background;
9302
9377
  if (r2.style?.color) sample.style.color = r2.style.color;
9303
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
+ }
9304
9384
  if (r2.type === "colorScale") sample.style.background = `linear-gradient(to right, ${(r2.colors ?? ["#f8696b", "#ffeb84", "#63be7b"]).join(",")})`;
9305
9385
  const text2 = el("span", "fg-cf-rule-text");
9306
- 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]}` : ""}`;
9307
9387
  text2.title = text2.textContent;
9308
9388
  const del = el("button", "fg-chooser-close", { type: "button", "aria-label": `${loc.close}: ${text2.textContent}` });
9309
9389
  del.textContent = "\xD7";
@@ -9355,10 +9435,17 @@ var FormatDialog = class {
9355
9435
  });
9356
9436
  swatches.append(b);
9357
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
+ }
9358
9445
  const rowLabel = el("label", "fg-cf-wide fg-cf-row");
9359
9446
  const rowBox = el("input", "", { type: "checkbox" });
9360
9447
  rowLabel.append(rowBox, ` ${cf.wholeRow}`);
9361
- form.append(typeSel, opSel, v1, v2, formula, rank, pctLabel, swatches, rowLabel);
9448
+ form.append(typeSel, opSel, v1, v2, formula, rank, pctLabel, swatches, alignSel, rowLabel);
9362
9449
  const sync = () => {
9363
9450
  const t = typeSel.value;
9364
9451
  const op = opSel.value;
@@ -9367,7 +9454,7 @@ var FormatDialog = class {
9367
9454
  v2.hidden = t !== "value" || op !== "between" && op !== "notBetween";
9368
9455
  formula.hidden = t !== "formula";
9369
9456
  rank.hidden = pctLabel.hidden = t !== "top" && t !== "bottom";
9370
- swatches.hidden = SCALES.includes(t);
9457
+ swatches.hidden = alignSel.hidden = SCALES.includes(t);
9371
9458
  rowLabel.hidden = t !== "value" && t !== "formula";
9372
9459
  };
9373
9460
  typeSel.addEventListener("change", sync);
@@ -9395,7 +9482,11 @@ var FormatDialog = class {
9395
9482
  rule.rank = Math.max(1, Math.floor(Number(rank.value) || 10));
9396
9483
  if (pct.checked) rule.percent = true;
9397
9484
  }
9398
- 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
+ }
9399
9490
  if (rowBox.checked && (type === "value" || type === "formula")) rule.row = true;
9400
9491
  return rule;
9401
9492
  };
@@ -9424,7 +9515,12 @@ var FormatDialog = class {
9424
9515
  const r = grid.renderer.root.getBoundingClientRect();
9425
9516
  const w = popup.offsetWidth || 340;
9426
9517
  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`;
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";
9428
9524
  this.popup = popup;
9429
9525
  typeSel.focus();
9430
9526
  const onDown = (e) => {
@@ -9941,6 +10037,9 @@ var Grid = class {
9941
10037
  }
9942
10038
  };
9943
10039
  this.density = "normal";
10040
+ // ── merge, aggregates, fill, pinned rows ────────────────────
10041
+ /** `sortWithinMerges` is on. */
10042
+ this.sortWithin = false;
9944
10043
  /** Count / sum / min / max over the selected range (numbers only for the math). */
9945
10044
  this.aggMemo = null;
9946
10045
  /** Bumped on every selection change (group row checkboxes depend on their rows). */
@@ -9987,8 +10086,11 @@ var Grid = class {
9987
10086
  quickColumns: () => this.columns.displayed().filter((c) => !isSystemCol(c.id)).map((c) => c.def),
9988
10087
  format: (def, v) => getFormatter(def, this.store.state.locale.code)(v),
9989
10088
  collator: () => this.collator(),
9990
- textVersion: () => `${this.store.state.locale.code}|${this.editVersion}`
10089
+ textVersion: () => `${this.store.state.locale.code}|${this.editVersion}`,
10090
+ sortBlocks: (sort) => this.sortBlocks(sort)
9991
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");
9992
10094
  if (options.groupBy?.length && this.allow(this.rowModel.server ? "serverGroups" : "grouping")) this.groupBy = this.validGroupBy(options.groupBy);
9993
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] }));
9994
10096
  this.defaultExpanded = options.treeData || this.paid ? options.groupDefaultExpanded ?? 0 : 0;
@@ -10333,8 +10435,9 @@ var Grid = class {
10333
10435
  const st = this.store.state;
10334
10436
  if (this.pivotCfg || this.options.treeData || st.quickFilter || st.advancedFilter || this.groupBy.length) return true;
10335
10437
  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;
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) => {
10338
10441
  if (!def) return false;
10339
10442
  if (def.valueGetter || def.formulas || def.formula) return true;
10340
10443
  return def.field != null && fields.has(def.field);
@@ -10651,6 +10754,7 @@ var Grid = class {
10651
10754
  this.groupBy = next;
10652
10755
  this.page = 0;
10653
10756
  this.groupPanel?.refresh();
10757
+ if (this.sortWithin) this.rowModel.invalidate();
10654
10758
  this.displayChanged();
10655
10759
  this.events.emit("groupChange", { groupBy: [...next] });
10656
10760
  }
@@ -11221,7 +11325,32 @@ var Grid = class {
11221
11325
  });
11222
11326
  return out;
11223
11327
  }
11224
- // ── 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
+ }
11225
11354
  /** Merge blocks for every `merge` column over the displayed rows (memoized). */
11226
11355
  mergeSpans() {
11227
11356
  if (this.rowModel.server || !this.columns.displayed().some((c) => c.def.merge || c.def.rowSpan)) return NO_SPANS;
@@ -11861,6 +11990,8 @@ var Grid = class {
11861
11990
  validate: guard(() => this.validateAll()),
11862
11991
  setDensity: guard((d) => this.setDensity(d)),
11863
11992
  getDensity: guard(() => this.density),
11993
+ setSortWithinMerges: guard((on) => this.setSortWithinMerges(on)),
11994
+ getSortWithinMerges: guard(() => this.sortWithin),
11864
11995
  showOverlay: guard((kind) => {
11865
11996
  this.forcedOverlay = kind === "loading" || kind === "noRows" ? kind : null;
11866
11997
  this.renderer.invalidate();