@loadbare/app 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/build/assemble.d.ts.map +1 -1
  2. package/dist/build/assemble.js +37 -1
  3. package/dist/build/assemble.js.map +1 -1
  4. package/dist/core/lb-constants.d.ts +14 -2
  5. package/dist/core/lb-constants.d.ts.map +1 -1
  6. package/dist/core/lb-constants.js +42 -6
  7. package/dist/core/lb-constants.js.map +1 -1
  8. package/dist/hub/lb-apply.d.ts +5 -2
  9. package/dist/hub/lb-apply.d.ts.map +1 -1
  10. package/dist/hub/lb-apply.js +402 -78
  11. package/dist/hub/lb-apply.js.map +1 -1
  12. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  13. package/dist/hub/lb-hub.browser.js +1 -2
  14. package/dist/hub/lb-hub.browser.js.map +1 -1
  15. package/dist/hub/lb-lifecycle.d.ts +2 -2
  16. package/dist/hub/lb-lifecycle.d.ts.map +1 -1
  17. package/dist/hub/lb-lifecycle.js +6 -5
  18. package/dist/hub/lb-lifecycle.js.map +1 -1
  19. package/docs/comparison.md +29 -22
  20. package/docs/miswarts.md +2 -2
  21. package/docs/opinions.md +2 -2
  22. package/docs/reference/chrome.md +8 -10
  23. package/docs/reference/css.md +20 -8
  24. package/docs/reference/custom-elements.md +19 -14
  25. package/docs/reference/data-binding.md +163 -61
  26. package/docs/reference/names.md +23 -8
  27. package/docs/reference/page-files.md +25 -16
  28. package/docs/reference/server.md +3 -3
  29. package/docs/roadmap.md +2 -2
  30. package/docs/testing.md +8 -6
  31. package/package.json +1 -1
  32. package/skills/loadbare-app/SKILL.md +16 -7
  33. package/skills/loadbare-app/references/chrome.md +8 -10
  34. package/skills/loadbare-app/references/css.md +20 -8
  35. package/skills/loadbare-app/references/custom-elements.md +19 -14
  36. package/skills/loadbare-app/references/data-binding.md +163 -61
  37. package/skills/loadbare-app/references/miswarts.md +2 -2
  38. package/skills/loadbare-app/references/names.md +23 -8
  39. package/skills/loadbare-app/references/page-files.md +25 -16
  40. package/skills/loadbare-app/references/server.md +3 -3
@@ -3,8 +3,8 @@
3
3
  * Landing: rows from response items onto the elements that name their query.
4
4
  * `applyRows` fills a row template; `applyRow` fills one element.
5
5
  */
6
- import { ATTR_COLUMN, ATTR_FILTER, FILTERS_COLUMN_QUERY, FILTERS_QUERY, SORT_COLUMN_ORDER, SORT_COLUMN_QUERY, SORT_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_KEY_VALUE, ATTR_QUERY, ATTR_QUERY_ORDER, ATTR_QUERY_ROW_COUNT, ATTR_MANUAL, MANUAL_FILTER, MANUAL_ORDER, ATTR_QUERY_UNPLACED_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_ROW_UNPLACED, ATTR_SHOW, ATTR_URL_PARM, KIND_ROW, LIVE_ROW, ORDER_DESCENDING, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, } from "../core/lb-constants.js";
7
- import { compareRows, isPatch, parseFilter, parseOrder, passes, oneRow, } from "../core/lb-types.js";
6
+ import { AGGREGATE_COUNT, AGGREGATE_MAX, AGGREGATE_MIN, AGGREGATE_SUM, ATTR_AGGREGATE, ATTR_COLUMN, ATTR_FILTER, FILTERS_COLUMN_QUERY, FILTERS_QUERY, SORT_COLUMN_ORDER, SORT_COLUMN_QUERY, SORT_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_QUERY, ATTR_QUERY_ORDER, ATTR_QUERY_ROW_COUNT, ATTR_MANUAL, MANUAL_FILTER, MANUAL_ORDER, ATTR_QUERY_UNPLACED_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_ROW_UNPLACED, ATTR_SHOW, ATTR_URL_PARM, KIND_ROW, LIVE_GROUP, LIVE_ROW, ORDER_DESCENDING, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, } from "../core/lb-constants.js";
7
+ import { compareRows, compareValues, isPatch, parseFilter, parseOrder, passes, readAs, oneRow, } from "../core/lb-types.js";
8
8
  import { attachPlaced, detachRemoved, LEAVING, PARKED, placed, upgradeAbsent, } from "./lb-lifecycle.js";
9
9
  /** A DocumentFragment's nodeType. Tier 3 has no `Node` global to read it from. */
10
10
  const FRAGMENT_NODE = 11;
@@ -204,6 +204,7 @@ function setText(el, text) {
204
204
  if (!same)
205
205
  el.textContent = text;
206
206
  }
207
+ /** Set an attribute, unless it already has that value. */
207
208
  function setStamp(el, name, value) {
208
209
  if (el.getAttribute(name) !== value)
209
210
  el.setAttribute(name, value);
@@ -222,10 +223,14 @@ function setStamp(el, name, value) {
222
223
  * up from an element inside it stops at the top of that content, which has
223
224
  * no parent, and the template it stands for was in scope already.
224
225
  */
225
- function inScope(root, el) {
226
+ function inScope(root, el, stop) {
227
+ if (stop && el !== root && el.matches(stop))
228
+ return false;
226
229
  for (let node = el.parentElement; node && node !== root;) {
227
230
  if (node.hasAttribute(ATTR_QUERY))
228
231
  return false;
232
+ if (stop && node.matches(stop))
233
+ return false;
229
234
  node = node.parentElement;
230
235
  }
231
236
  return true;
@@ -252,15 +257,15 @@ export function within(root, selector) {
252
257
  * stale or with its rows empty. A template holding one is in root's query by
253
258
  * the ordinary rule, and its content is searched as if it stood there.
254
259
  */
255
- function below(root, selector) {
260
+ function below(root, selector, stop) {
256
261
  const found = [];
257
262
  for (const el of root.querySelectorAll(selector)) {
258
- if (inScope(root, el))
263
+ if (inScope(root, el, stop))
259
264
  found.push(el);
260
265
  }
261
266
  for (const parked of root.querySelectorAll(PARKED)) {
262
- if (inScope(root, parked)) {
263
- found.push(...below(parked.content, selector));
267
+ if (inScope(root, parked, stop)) {
268
+ found.push(...below(parked.content, selector, stop));
264
269
  }
265
270
  }
266
271
  return found;
@@ -332,7 +337,8 @@ function addPlace(map, name, el) {
332
337
  */
333
338
  function note(places, el) {
334
339
  const column = el.getAttribute(ATTR_COLUMN);
335
- if (column !== null) {
340
+ // An aggregate's column is the one it totals, which no row lands on.
341
+ if (column !== null && !el.hasAttribute(ATTR_AGGREGATE)) {
336
342
  addPlace(places.columns, column, el);
337
343
  }
338
344
  if (el.hasAttribute(ATTR_URL_PARM))
@@ -358,7 +364,7 @@ const PLACE = `[${ATTR_COLUMN}], [${ATTR_SHOW}], [${ATTR_URL_PARM}]`;
358
364
  * count, as the row is its own. Its own `lb-show` never does.
359
365
  */
360
366
  function noteRoot(places, root) {
361
- if (root.hasAttribute(ATTR_COLUMN)) {
367
+ if (root.hasAttribute(ATTR_COLUMN) && !root.hasAttribute(ATTR_AGGREGATE)) {
362
368
  addPlace(places.columns, root.getAttribute(ATTR_COLUMN), root);
363
369
  }
364
370
  if (root.hasAttribute(ATTR_URL_PARM))
@@ -369,11 +375,11 @@ function noteRoot(places, root) {
369
375
  * when `self`, and every one below it short of `stop`, absent branches
370
376
  * included.
371
377
  */
372
- function search(root, self) {
378
+ function search(root, self, stop) {
373
379
  const places = noPlaces();
374
380
  if (self)
375
381
  noteRoot(places, root);
376
- for (const el of below(root, PLACE))
382
+ for (const el of below(root, PLACE, stop))
377
383
  note(places, el);
378
384
  return places;
379
385
  }
@@ -462,9 +468,11 @@ function fill(places, row) {
462
468
  * it displays. An element a `row` lands on is not its own ancestor, so its
463
469
  * own `lb-column` reads from the row around it and is left alone here. Root's
464
470
  * own `lb-show` never counts.
471
+ *
472
+ * `stop` passes over everything matching it and inside it.
465
473
  */
466
- export function applyRow(root, row, self = false) {
467
- const places = search(root, self);
474
+ export function applyRow(root, row, self = false, stop) {
475
+ const places = search(root, self, stop);
468
476
  fill(places, row);
469
477
  link(places, row);
470
478
  }
@@ -537,7 +545,7 @@ function link(places, row) {
537
545
  */
538
546
  export function blankRow(root) {
539
547
  const row = {};
540
- for (const el of below(root, `[${ATTR_COLUMN}]`)) {
548
+ for (const el of below(root, `[${ATTR_COLUMN}]:not([${ATTR_AGGREGATE}])`)) {
541
549
  row[el.getAttribute(ATTR_COLUMN)] = null;
542
550
  }
543
551
  for (const el of below(root, `[${ATTR_SHOW}]`)) {
@@ -581,45 +589,108 @@ export function focusIn(root) {
581
589
  }
582
590
  }
583
591
  const lists = new WeakMap();
592
+ /** Elements already told their group templates outrun their order. */
593
+ const warned = new WeakSet();
584
594
  /**
585
- * The list an element holds, found from its row template the first time
586
- * rows land on it, and kept after that.
595
+ * What a group's heading and aggregates stop at: the rows and groups inside
596
+ * it, live or leaving, which are filled from rows of their own.
597
+ */
598
+ const SCAFFOLD_STOP = `${LIVE_ROW}, ${LIVE_GROUP}, ${LEAVING}`;
599
+ /** An element carrying any aggregate. */
600
+ const AGGREGATE = `[${ATTR_AGGREGATE}]`;
601
+ /**
602
+ * The template a group's contents go in front of: the first `<template>` in
603
+ * the content outside any nested `lb-query`, and not one holding an absent
604
+ * branch.
605
+ */
606
+ function slotIn(root) {
607
+ for (const t of root.querySelectorAll(`template:not([${ATTR_SHOW}])`)) {
608
+ if (inScope(root, t))
609
+ return t;
610
+ }
611
+ return null;
612
+ }
613
+ /**
614
+ * The aggregates in root's scaffolding: root itself when `self`, and every
615
+ * one below it that is not inside a row or a group of its own.
616
+ */
617
+ function aggregatesIn(root, self) {
618
+ const found = below(root, AGGREGATE, SCAFFOLD_STOP);
619
+ if (self && root.matches(AGGREGATE))
620
+ found.unshift(root);
621
+ return found;
622
+ }
623
+ /**
624
+ * The list an element holds, found from its first template the first time
625
+ * rows land on it, and kept after that: once groups exist, their slots are
626
+ * templates too, and they come first in the document.
587
627
  */
588
628
  function listFor(el, template) {
589
629
  const found = lists.get(el);
590
630
  if (found)
591
631
  return found;
632
+ const levels = [];
633
+ let at = template;
634
+ while (at.hasAttribute(ATTR_GROUP)) {
635
+ levels.push(at);
636
+ const next = slotIn(at.content);
637
+ if (!next) {
638
+ console.error(`lb-hub: a group template in <${el.localName}> holds no nested ` +
639
+ `template for its contents`);
640
+ return null;
641
+ }
642
+ at = next;
643
+ }
592
644
  const list = {
593
645
  top: template,
594
- rowTemplate: template,
646
+ levels,
647
+ rowTemplate: at,
595
648
  byKey: new Map(),
596
- rows: [],
597
649
  unentered: [],
650
+ root: {
651
+ id: "",
652
+ nodes: [],
653
+ slot: template,
654
+ children: [],
655
+ rows: [],
656
+ heads: [],
657
+ aggregates: aggregatesIn(el, false),
658
+ hidden: false,
659
+ },
598
660
  };
599
661
  // Live rows already in the markup, as a page rendered ahead of its data
600
662
  // ships them, are the list's from the start, in the order they stand.
601
- for (const row of el.querySelectorAll(LIVE_ROW)) {
602
- if (!inScope(el, row))
603
- continue;
604
- const key = row.getAttribute(ATTR_KEY_VALUE);
605
- if (list.byKey.has(key)) {
606
- console.error(`lb-hub: <${el.localName}> shows two rows with the key '${key}'`);
663
+ if (levels.length === 0) {
664
+ for (const row of el.querySelectorAll(LIVE_ROW)) {
665
+ if (!inScope(el, row))
666
+ continue;
667
+ const key = row.getAttribute(ATTR_KEY_VALUE);
668
+ if (list.byKey.has(key)) {
669
+ console.error(`lb-hub: <${el.localName}> shows two rows with the key '${key}'`);
670
+ }
671
+ const entry = {
672
+ key,
673
+ el: row,
674
+ places: search(row, true),
675
+ data: {},
676
+ placed: {},
677
+ hidden: false,
678
+ unplaced: false,
679
+ };
680
+ list.byKey.set(key, entry);
681
+ list.root.rows.push(entry);
607
682
  }
608
- const entry = {
609
- key,
610
- el: row,
611
- places: search(row, true),
612
- data: {},
613
- placed: {},
614
- hidden: false,
615
- unplaced: false,
616
- };
617
- list.byKey.set(key, entry);
618
- list.rows.push(entry);
619
683
  }
620
684
  lists.set(el, list);
621
685
  return list;
622
686
  }
687
+ /** The live rows of a group and everything under it, in order. */
688
+ function rowsUnder(group) {
689
+ return group.children.length > 0
690
+ ? group.children.flatMap(rowsUnder)
691
+ : group.rows;
692
+ }
693
+ /**
623
694
  /**
624
695
  * A new live row for a key, copied from the row template: upgraded, stamped
625
696
  * with its key, its places found by the template's paths, and no values yet.
@@ -652,11 +723,11 @@ function create(list, doc, key) {
652
723
  }
653
724
  /**
654
725
  * Put the rows not yet entered after every row: immediately after the list's
655
- * row template, which the rows land in front of, so placing them never moves
656
- * these.
726
+ * first template, which the rows and groups land in front of, so placing
727
+ * them never moves these.
657
728
  */
658
729
  function standLast(list) {
659
- let at = list.top;
730
+ let at = list.root.slot;
660
731
  for (const row of list.unentered) {
661
732
  if (at.nextElementSibling !== row)
662
733
  at.after(row);
@@ -665,7 +736,7 @@ function standLast(list) {
665
736
  }
666
737
  /** Stamp a list's count: the rows it shows, and its rows not yet entered. */
667
738
  function recount(el, list) {
668
- const showing = list.rows.filter((entry) => !entry.hidden);
739
+ const showing = rowsUnder(list.root).filter((entry) => !entry.hidden);
669
740
  setStamp(el, ATTR_QUERY_ROW_COUNT, String(showing.length + list.unentered.length));
670
741
  }
671
742
  /**
@@ -730,6 +801,121 @@ function nameRadios(row, key) {
730
801
  el.setAttribute("name", `${name}-${key}`);
731
802
  }
732
803
  }
804
+ /** A group's identity among its siblings: its column and its value there. */
805
+ function groupId(column, value) {
806
+ return `${column}\u0000${JSON.stringify(value ?? null)}`;
807
+ }
808
+ /** A new group, cloned from the group template for its level and stamped. */
809
+ function makeGroup(doc, template, id, term, value) {
810
+ // Imported rather than cloned, for the reason a live row is.
811
+ const fragment = doc.importNode(template.content, true);
812
+ upgradeAbsent(fragment, doc);
813
+ const slot = slotIn(fragment);
814
+ const nodes = [...fragment.childNodes];
815
+ const heads = [];
816
+ const aggregates = [];
817
+ for (const node of nodes) {
818
+ if (node.nodeType !== ELEMENT_NODE || node === slot)
819
+ continue;
820
+ const el = node;
821
+ placed(el);
822
+ el.setAttribute(ATTR_GROUP_LIVE, "");
823
+ if (term)
824
+ el.setAttribute(ATTR_GROUP_COLUMN, term.column);
825
+ if (value === null || value === undefined) {
826
+ el.removeAttribute(ATTR_GROUP_VALUE);
827
+ }
828
+ else {
829
+ el.setAttribute(ATTR_GROUP_VALUE, String(value));
830
+ }
831
+ // A heading's own column is filled, as a live row's is, unless it holds
832
+ // the group's contents, which filling it would replace.
833
+ heads.push(search(el, !el.contains(slot), SCAFFOLD_STOP));
834
+ aggregates.push(...aggregatesIn(el, true));
835
+ }
836
+ return {
837
+ id,
838
+ nodes,
839
+ slot,
840
+ children: [],
841
+ rows: [],
842
+ heads,
843
+ aggregates,
844
+ hidden: false,
845
+ };
846
+ }
847
+ /**
848
+ * Put a sequence of items in the document in the order given, in front of
849
+ * `end`, where `old` is the order they stood in. The run they share at the
850
+ * start and the run they share at the end stay where they are, and what lies
851
+ * between goes in as one fragment. An item not in `old` is new, and one in
852
+ * `old` that is not given has gone somewhere else, or is leaving.
853
+ */
854
+ function placeRun(old, next, end, nodesOf) {
855
+ const most = Math.min(old.length, next.length);
856
+ let start = 0;
857
+ while (start < most && old[start] === next[start])
858
+ start++;
859
+ let tail = 0;
860
+ while (tail < most - start &&
861
+ old[old.length - 1 - tail] === next[next.length - 1 - tail]) {
862
+ tail++;
863
+ }
864
+ const middle = next.slice(start, next.length - tail);
865
+ if (middle.length === 0)
866
+ return;
867
+ const ref = tail > 0 ? nodesOf(next[next.length - tail])[0] : end;
868
+ const fragment = ref.ownerDocument.createDocumentFragment();
869
+ for (const item of middle)
870
+ fragment.append(...nodesOf(item));
871
+ ref.parentNode.insertBefore(fragment, ref);
872
+ }
873
+ /**
874
+ * Place a list's live rows in the order given, with groups made where the
875
+ * order breaks, kept while their run goes on, and gone where it no longer
876
+ * does. What leaves is returned, to leave once everything is placed.
877
+ */
878
+ function arrange(list, doc, order, terms) {
879
+ const retired = [];
880
+ const visit = (parent, level, entries) => {
881
+ if (level === list.levels.length) {
882
+ placeRun(parent.rows, entries, parent.slot, (entry) => [entry.el]);
883
+ parent.rows = entries;
884
+ return;
885
+ }
886
+ const term = terms[level];
887
+ const runs = [];
888
+ for (const entry of entries) {
889
+ const value = term ? entry.placed[term.column] : null;
890
+ const id = groupId(term?.column ?? "", value);
891
+ const last = runs.at(-1);
892
+ if (last?.id === id)
893
+ last.entries.push(entry);
894
+ else
895
+ runs.push({ id, value, entries: [entry] });
896
+ }
897
+ // Each group already here goes on with the first run that has its id.
898
+ const pool = new Map();
899
+ for (const child of parent.children) {
900
+ pool.set(child.id, [...(pool.get(child.id) ?? []), child]);
901
+ }
902
+ const next = runs.map((run) => pool.get(run.id)?.shift() ??
903
+ makeGroup(doc, list.levels[level], run.id, term, run.value));
904
+ for (const left of pool.values())
905
+ retired.push(...left);
906
+ placeRun(parent.children, next, parent.slot, (group) => group.nodes);
907
+ parent.children = next;
908
+ runs.forEach((run, i) => visit(next[i], level + 1, run.entries));
909
+ };
910
+ visit(list.root, 0, order);
911
+ return retired;
912
+ }
913
+ /**
914
+ * The rows that moved: rows whose values changed, and which now stand
915
+ * somewhere else among the rows whose values did not. A neighbour a moved
916
+ * row passed did not move, and neither did a row whose new values left it
917
+ * where it was.
918
+
733
919
  /**
734
920
  * The rows that moved: rows whose values changed, and which now stand
735
921
  * somewhere else among the rows whose values did not. A neighbour a moved
@@ -769,35 +955,6 @@ function movedRows(before, after, changed) {
769
955
  }
770
956
  return moved;
771
957
  }
772
- /**
773
- * Put a list's live rows in the order given, in front of the row template.
774
- * The run they share at the start and the run they share at the end stay
775
- * where they are, and what lies between goes in as one fragment.
776
- */
777
- function placeRows(list, next) {
778
- const old = list.rows;
779
- const end = list.top;
780
- const most = Math.min(old.length, next.length);
781
- let start = 0;
782
- while (start < most && old[start] === next[start])
783
- start++;
784
- let tail = 0;
785
- while (tail < most - start &&
786
- old[old.length - 1 - tail] === next[next.length - 1 - tail]) {
787
- tail++;
788
- }
789
- const middle = next.slice(start, next.length - tail);
790
- if (middle.length === 0) {
791
- list.rows = next;
792
- return;
793
- }
794
- const ref = tail > 0 ? next[next.length - tail].el : end;
795
- const fragment = ref.ownerDocument.createDocumentFragment();
796
- for (const item of middle)
797
- fragment.append(item.el);
798
- ref.parentNode.insertBefore(fragment, ref);
799
- list.rows = next;
800
- }
801
958
  /** Mark an element as leaving: no longer live, and taking no input. */
802
959
  function leaving(el, live, stamp) {
803
960
  el.removeAttribute(live);
@@ -805,6 +962,17 @@ function leaving(el, live, stamp) {
805
962
  el.setAttribute(stamp, "");
806
963
  el.setAttribute("inert", "");
807
964
  }
965
+ /** A group whose rows are all gone, and every group inside it, leave. */
966
+ function leaveGroup(group, nodes) {
967
+ for (const child of group.children)
968
+ leaveGroup(child, nodes);
969
+ for (const node of group.nodes) {
970
+ if (node.nodeType === ELEMENT_NODE) {
971
+ leaving(node, ATTR_GROUP_LIVE, ATTR_GROUP_LEAVING);
972
+ }
973
+ }
974
+ nodes.push(...group.nodes);
975
+ }
808
976
  /**
809
977
  * Take nodes out of the document once whatever their stylesheet started on
810
978
  * them has finished: at once when nothing is running. Asking for the running
@@ -855,11 +1023,121 @@ class Stamps {
855
1023
  el.setAttribute(name, "");
856
1024
  }
857
1025
  }
1026
+ /** A plain decimal number, as JSON or Postgres writes one. */
1027
+ const DECIMAL = /^[+-]?\d+(\.\d+)?$/;
1028
+ function decimalOf(value) {
1029
+ const text = typeof value === "number" ? String(value) : value;
1030
+ if (typeof text !== "string" || !DECIMAL.test(text.trim()))
1031
+ return null;
1032
+ const [whole, fraction = ""] = text.trim().split(".");
1033
+ return { digits: BigInt(whole + fraction), scale: fraction.length };
1034
+ }
1035
+ function rescale(d, scale) {
1036
+ return d.digits * 10n ** BigInt(scale - d.scale);
1037
+ }
1038
+ function decimalText(digits, scale) {
1039
+ const negative = digits < 0n;
1040
+ const text = (negative ? -digits : digits)
1041
+ .toString()
1042
+ .padStart(scale + 1, "0");
1043
+ const whole = text.slice(0, text.length - scale);
1044
+ const fraction = scale > 0 ? `.${text.slice(text.length - scale)}` : "";
1045
+ return `${negative ? "-" : ""}${whole}${fraction}`;
1046
+ }
1047
+ /** Aggregates already warned about, so a bad value is reported once. */
1048
+ const reported = new Set();
1049
+ /**
1050
+ * One aggregate over rows. A sum and an average are exact, carried as
1051
+ * decimals with as many places as the most precise value, and an average
1052
+ * keeps up to two places more. Null and empty values are left out, as SQL
1053
+ * leaves them out; a value that is not a number is left out and reported.
1054
+ */
1055
+ function aggregateOf(name, column, rows, type) {
1056
+ if (name === AGGREGATE_COUNT && column === "")
1057
+ return rows.length;
1058
+ const values = rows
1059
+ .map((row) => row[column])
1060
+ .filter((v) => v !== null && v !== undefined && v !== "");
1061
+ // As SQL's count(column): the values that are not null.
1062
+ if (name === AGGREGATE_COUNT)
1063
+ return values.length;
1064
+ if (values.length === 0)
1065
+ return null;
1066
+ if (name === AGGREGATE_MIN || name === AGGREGATE_MAX) {
1067
+ const sign = name === AGGREGATE_MIN ? 1 : -1;
1068
+ return values.reduce((a, b) => sign * compareValues(readAs(b, type), readAs(a, type)) < 0 ? b : a);
1069
+ }
1070
+ const decimals = [];
1071
+ for (const value of values) {
1072
+ const d = decimalOf(value);
1073
+ if (d)
1074
+ decimals.push(d);
1075
+ else if (!reported.has(`${name}:${column}`)) {
1076
+ reported.add(`${name}:${column}`);
1077
+ console.warn(`lb-hub: ${ATTR_AGGREGATE}="${name}" ${ATTR_COLUMN}="${column}" ` +
1078
+ `left out ${JSON.stringify(value)}, ` +
1079
+ `which is not a number`);
1080
+ }
1081
+ }
1082
+ if (decimals.length === 0)
1083
+ return null;
1084
+ const scale = Math.max(...decimals.map((d) => d.scale));
1085
+ const sum = decimals.reduce((total, d) => total + rescale(d, scale), 0n);
1086
+ if (name === AGGREGATE_SUM)
1087
+ return decimalText(sum, scale);
1088
+ // An average, rounded half away from zero at two places past the values',
1089
+ // which are then dropped while they are zeros.
1090
+ const scaled = sum * 100n;
1091
+ const n = BigInt(decimals.length);
1092
+ const sign = scaled < 0n ? -1n : 1n;
1093
+ let text = decimalText((2n * scaled + sign * n) / (2n * n), scale + 2);
1094
+ for (let i = 0; i < 2 && text.endsWith("0"); i++)
1095
+ text = text.slice(0, -1);
1096
+ return text.endsWith(".") ? text.slice(0, -1) : text;
1097
+ }
1098
+ /** Set each aggregate element from rows, each column read as its type. */
1099
+ function aggregate(elements, rows, types) {
1100
+ for (const el of elements) {
1101
+ const name = el.getAttribute(ATTR_AGGREGATE) ?? "";
1102
+ const column = el.getAttribute(ATTR_COLUMN) ?? "";
1103
+ land(el, aggregateOf(name, column, rows, types[column]));
1104
+ }
1105
+ }
858
1106
  /** Show or hide an element for a filter, saying so only when it changes. */
859
1107
  function hide(el, hidden) {
860
1108
  if (el.hasAttribute("hidden") !== hidden)
861
1109
  el.toggleAttribute("hidden", hidden);
862
1110
  }
1111
+ /**
1112
+ * Finish a group and everything under it, from the rows it shows: each
1113
+ * heading filled and linked from the first, as it was when last placed by
1114
+ * the order,
1115
+ * each aggregate set from all of them, and a group hidden when a filter hides
1116
+ * every row in it. The rows it shows are returned.
1117
+ */
1118
+ function finish(group, types) {
1119
+ const shown = group.children.length > 0
1120
+ ? group.children.flatMap((child) => finish(child, types))
1121
+ : group.rows.filter((entry) => !entry.hidden);
1122
+ const rows = shown.map((entry) => entry.data);
1123
+ const hidden = rows.length === 0 && rowsUnder(group).length > 0;
1124
+ if (hidden !== group.hidden) {
1125
+ group.hidden = hidden;
1126
+ for (const node of group.nodes) {
1127
+ if (node.nodeType === ELEMENT_NODE && node !== group.slot) {
1128
+ hide(node, hidden);
1129
+ }
1130
+ }
1131
+ }
1132
+ if (shown[0]) {
1133
+ for (const head of group.heads) {
1134
+ fill(head, shown[0].placed);
1135
+ link(head, shown[0].placed);
1136
+ }
1137
+ }
1138
+ aggregate(group.aggregates, rows, types);
1139
+ return shown;
1140
+ }
863
1141
  /** The order to place rows by, or none when it cannot be read. */
864
1142
  function termsOf(el, order) {
865
1143
  try {
@@ -876,7 +1154,7 @@ function orderText(terms) {
876
1154
  .map((term) => (term.descending ? ORDER_DESCENDING : "") + term.column)
877
1155
  .join(",");
878
1156
  }
879
- /** The user's choice in a column of `lb-choices`, when one is made. */
1157
+ /** The filter in a column of the query's `lb-filters` row, when one is made. */
880
1158
  function choiceIn(choices, column) {
881
1159
  const value = choices?.[column];
882
1160
  return value === null || value === undefined || value === ""
@@ -928,7 +1206,8 @@ function differs(before, after) {
928
1206
  return Object.entries(after).some(([column, value]) => !same(before[column], value));
929
1207
  }
930
1208
  /**
931
- * Land rows through a row template: a live row per key, placed by the order.
1209
+ * Land rows through a row template: a live row per key, placed by the order,
1210
+ * with groups built from the markup's group templates where the order breaks.
932
1211
  * A patch disturbs only what it names — rows it leaves out keep their
933
1212
  * contents and their place.
934
1213
  */
@@ -939,7 +1218,7 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
939
1218
  const doc = options.doc ?? el.ownerDocument;
940
1219
  const host = el;
941
1220
  const whole = !isPatch(result);
942
- const shown = list.rows;
1221
+ const shown = rowsUnder(list.root);
943
1222
  const focused = focusedRow(list, doc);
944
1223
  // What a request did is said by this landing alone.
945
1224
  for (const entry of shown)
@@ -999,6 +1278,12 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
999
1278
  // compare equal in this order.
1000
1279
  const stayed = shown.filter((entry) => list.byKey.get(entry.key) === entry);
1001
1280
  const terms = termsOf(el, options.order);
1281
+ if (list.levels.length > terms.length && !warned.has(el)) {
1282
+ warned.add(el);
1283
+ console.warn(`lb-hub: <${el.localName}> has ${list.levels.length} group ` +
1284
+ `template(s) and an order of ${terms.length} term(s); a level with ` +
1285
+ `no term shows its rows as one group`);
1286
+ }
1002
1287
  const sort = (entries) => terms.length === 0
1003
1288
  ? entries
1004
1289
  : entries
@@ -1016,10 +1301,11 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1016
1301
  let order;
1017
1302
  let out = new Set();
1018
1303
  if (manual.order) {
1019
- // What already shows stays where it stands, and what is new goes last.
1304
+ // What already shows stays where it stands, and what is new goes last
1305
+ // in the group it belongs to.
1020
1306
  for (const entry of created)
1021
1307
  entry.placed = entry.data;
1022
- order = [...stayed, ...created];
1308
+ order = heldOrder(stayed, created, groupTerms(list, terms));
1023
1309
  out = movedRows(order, sort(order), touched);
1024
1310
  }
1025
1311
  else {
@@ -1033,12 +1319,15 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1033
1319
  const kept = manual.filter && touched.has(entry) && !entry.hidden && fails;
1034
1320
  const hides = fails && !kept;
1035
1321
  filterRow(entry, hides);
1036
- entry.placed = entry.data;
1322
+ // A row stands in the group it was placed in until it is placed again.
1323
+ const moves = manual.order && regrouped(entry, list, terms);
1324
+ if (!moves)
1325
+ entry.placed = entry.data;
1037
1326
  // A row nobody sees stands nowhere the user would notice.
1038
- entry.unplaced = !hides && (out.has(entry) || kept);
1327
+ entry.unplaced = !hides && (out.has(entry) || kept || moves);
1039
1328
  }
1040
1329
  const moved = movedRows(stayed, order, changed);
1041
- placeRows(list, order);
1330
+ const retired = arrange(list, doc, order, terms);
1042
1331
  refocus(list, focused);
1043
1332
  const stamps = new Stamps();
1044
1333
  for (const key of arriving.keys()) {
@@ -1068,7 +1357,7 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1068
1357
  if (entry.unplaced)
1069
1358
  unplaced++;
1070
1359
  }
1071
- const rows = list.rows.filter((entry) => !entry.hidden);
1360
+ const rows = finish(list.root, options.types ?? {});
1072
1361
  // Counted from what the hub holds, which is what the rows show.
1073
1362
  setStamp(el, ATTR_QUERY_ROW_COUNT, String(rows.length + list.unentered.length));
1074
1363
  standLast(list);
@@ -1078,6 +1367,8 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1078
1367
  else
1079
1368
  setStamp(el, ATTR_QUERY_ORDER, orderText(terms));
1080
1369
  const departing = gone.map((entry) => entry.el);
1370
+ for (const group of retired)
1371
+ leaveGroup(group, departing);
1081
1372
  if (departing.length > 0)
1082
1373
  depart(departing);
1083
1374
  host.lbRowsLanded?.();
@@ -1090,6 +1381,39 @@ function manualOf(el) {
1090
1381
  filter: sides.includes(MANUAL_FILTER),
1091
1382
  };
1092
1383
  }
1384
+ /** The order's terms a list's groups break on, outermost first. */
1385
+ function groupTerms(list, terms) {
1386
+ return terms.slice(0, list.levels.length);
1387
+ }
1388
+ /** The group a row's columns put it in, as one string. */
1389
+ function groupOf(row, terms) {
1390
+ return terms.map((term) => groupId(term.column, row[term.column])).join("\u0001");
1391
+ }
1392
+ /** Whether a row's columns now put it in another group than it stands in. */
1393
+ function regrouped(entry, list, terms) {
1394
+ const by = groupTerms(list, terms);
1395
+ return groupOf(entry.data, by) !== groupOf(entry.placed, by);
1396
+ }
1397
+ /**
1398
+ * The rows of a list the user is working in: those showing where they
1399
+ * stand, and each new one after the last row of its group, or last of all
1400
+ * when its group shows no row.
1401
+ */
1402
+ function heldOrder(stayed, created, by) {
1403
+ const order = [...stayed];
1404
+ for (const entry of created) {
1405
+ const group = groupOf(entry.placed, by);
1406
+ let at = order.length;
1407
+ if (by.length > 0) {
1408
+ while (at > 0 && groupOf(order[at - 1].placed, by) !== group)
1409
+ at--;
1410
+ if (at === 0)
1411
+ at = order.length;
1412
+ }
1413
+ order.splice(at, 0, entry);
1414
+ }
1415
+ return order;
1416
+ }
1093
1417
  function focusedRow(list, doc) {
1094
1418
  const active = doc.activeElement;
1095
1419
  const row = active?.closest(LIVE_ROW);
@@ -1115,7 +1439,7 @@ function refocus(list, focused) {
1115
1439
  active.scrollIntoView?.({ block: "nearest" });
1116
1440
  return;
1117
1441
  }
1118
- const first = list.rows.find((row) => !row.hidden);
1442
+ const first = rowsUnder(list.root).find((row) => !row.hidden);
1119
1443
  if (first)
1120
1444
  focusIn(first.el);
1121
1445
  }