@loadbare/app 0.25.0 → 0.26.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 (55) hide show
  1. package/dist/build/assemble.d.ts +1 -2
  2. package/dist/build/assemble.d.ts.map +1 -1
  3. package/dist/build/assemble.js +11 -53
  4. package/dist/build/assemble.js.map +1 -1
  5. package/dist/build/cli.d.ts +4 -10
  6. package/dist/build/cli.d.ts.map +1 -1
  7. package/dist/build/cli.js +11 -33
  8. package/dist/build/cli.js.map +1 -1
  9. package/dist/build/declared.js +1 -1
  10. package/dist/build/declared.js.map +1 -1
  11. package/dist/core/lb-constants.d.ts +8 -18
  12. package/dist/core/lb-constants.d.ts.map +1 -1
  13. package/dist/core/lb-constants.js +32 -72
  14. package/dist/core/lb-constants.js.map +1 -1
  15. package/dist/core/lb-types.d.ts +12 -11
  16. package/dist/core/lb-types.d.ts.map +1 -1
  17. package/dist/core/lb-types.js +7 -0
  18. package/dist/core/lb-types.js.map +1 -1
  19. package/dist/hub/lb-apply.d.ts +10 -37
  20. package/dist/hub/lb-apply.d.ts.map +1 -1
  21. package/dist/hub/lb-apply.js +139 -522
  22. package/dist/hub/lb-apply.js.map +1 -1
  23. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  24. package/dist/hub/lb-hub.browser.js +115 -126
  25. package/dist/hub/lb-hub.browser.js.map +1 -1
  26. package/dist/hub/lb-lifecycle.d.ts +2 -2
  27. package/dist/hub/lb-lifecycle.d.ts.map +1 -1
  28. package/dist/hub/lb-lifecycle.js +9 -17
  29. package/dist/hub/lb-lifecycle.js.map +1 -1
  30. package/dist/server/lb-express.d.ts.map +1 -1
  31. package/dist/server/lb-express.js +3 -3
  32. package/dist/server/lb-express.js.map +1 -1
  33. package/dist/server/lb-server.d.ts +35 -19
  34. package/dist/server/lb-server.d.ts.map +1 -1
  35. package/dist/server/lb-server.js +141 -88
  36. package/dist/server/lb-server.js.map +1 -1
  37. package/docs/binding-cases.md +150 -0
  38. package/docs/comparison.md +18 -29
  39. package/docs/miswarts.md +2 -2
  40. package/docs/reference/css.md +2 -15
  41. package/docs/reference/custom-elements.md +6 -8
  42. package/docs/reference/data-binding.md +37 -180
  43. package/docs/reference/names.md +10 -26
  44. package/docs/reference/page-files.md +1 -12
  45. package/docs/reference/server.md +0 -1
  46. package/docs/testing.md +2 -5
  47. package/package.json +1 -1
  48. package/skills/loadbare-app/SKILL.md +52 -54
  49. package/skills/loadbare-app/references/css.md +2 -15
  50. package/skills/loadbare-app/references/custom-elements.md +6 -8
  51. package/skills/loadbare-app/references/data-binding.md +37 -180
  52. package/skills/loadbare-app/references/miswarts.md +2 -2
  53. package/skills/loadbare-app/references/names.md +10 -26
  54. package/skills/loadbare-app/references/page-files.md +1 -12
  55. package/skills/loadbare-app/references/server.md +0 -1
@@ -1,45 +1,10 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Landing — see docs/reference/data-binding.md.
4
- *
5
- * The hub lands a response item's rows on every element whose `lb-query`
6
- * names its query. The item says the query's kind and key, so the markup
7
- * says neither:
8
- *
9
- * kind row template the hub
10
- * row no lands the row on the element itself
11
- * row yes lands one live row
12
- * rows yes lands one live row per row
13
- * rows no lands nothing
14
- *
15
- * Filling a live row and filling an element a `row` lands on are the same
16
- * operation, and the only difference is how much of the page the root
17
- * covers.
18
- *
19
- * An answer is kept after it lands. An element that names a query and
20
- * arrives after that query's answer — a picker in a new live row, a ghost
21
- * row a custom element built — is filled from the kept answer at the end of
22
- * the next landing, so a query need not land again, and need not land after
23
- * the one whose rows hold it, just to fill what is new.
24
- *
25
- * Every custom element landing places is told so once the landing is done,
26
- * and every one it removes is told so once it is gone — see lb-lifecycle.
27
- *
28
- * A `rows` query is placed by its order: the user's, from the choice its
29
- * element's `lb-order` names, or the one the server declared, or, with
30
- * neither, the order its rows arrived in. Groups are built from the markup's
31
- * group templates as the order breaks, and every change to a list — a row
32
- * created, changed, moved or leaving, a group appearing or leaving — is one
33
- * a stylesheet can see. A row a filter the user chose does not pass is
34
- * hidden.
35
- *
36
- * A condition is the one other thing landing moves. An element whose
37
- * `lb-show` column is off stands in the document inside a template, and
38
- * landing reaches in there as it reaches everywhere else, so a branch that
39
- * returns is already current. Nothing but landing reaches in.
3
+ * Landing: rows from response items onto the elements that name their query.
4
+ * `applyRows` fills a row template; `applyRow` fills one element.
40
5
  */
41
- import { AGGREGATES, ATTR_COLUMN, ATTR_FILTER, ATTR_ORDER, CHOICES_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_COUNT, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_MAX, ATTR_MIN, 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_SUM, ATTR_URL_PARM, KIND_ROW, LIVE_GROUP, LIVE_ROW, ORDER_DESCENDING, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, ATTR_SELECT, ATTR_ROW_SELECTED, } from "../core/lb-constants.js";
42
- import { compareRows, compareValues, isPatch, parseFilter, parseOrder, passes, readAs, } from "../core/lb-types.js";
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";
43
8
  import { attachPlaced, detachRemoved, LEAVING, PARKED, placed, upgradeAbsent, } from "./lb-lifecycle.js";
44
9
  /** A DocumentFragment's nodeType. Tier 3 has no `Node` global to read it from. */
45
10
  const FRAGMENT_NODE = 11;
@@ -239,7 +204,6 @@ function setText(el, text) {
239
204
  if (!same)
240
205
  el.textContent = text;
241
206
  }
242
- /** Set an attribute, unless it already has that value. */
243
207
  function setStamp(el, name, value) {
244
208
  if (el.getAttribute(name) !== value)
245
209
  el.setAttribute(name, value);
@@ -258,14 +222,10 @@ function setStamp(el, name, value) {
258
222
  * up from an element inside it stops at the top of that content, which has
259
223
  * no parent, and the template it stands for was in scope already.
260
224
  */
261
- function inScope(root, el, stop) {
262
- if (stop && el !== root && el.matches(stop))
263
- return false;
225
+ function inScope(root, el) {
264
226
  for (let node = el.parentElement; node && node !== root;) {
265
227
  if (node.hasAttribute(ATTR_QUERY))
266
228
  return false;
267
- if (stop && node.matches(stop))
268
- return false;
269
229
  node = node.parentElement;
270
230
  }
271
231
  return true;
@@ -292,15 +252,15 @@ export function within(root, selector) {
292
252
  * stale or with its rows empty. A template holding one is in root's query by
293
253
  * the ordinary rule, and its content is searched as if it stood there.
294
254
  */
295
- function below(root, selector, stop) {
255
+ function below(root, selector) {
296
256
  const found = [];
297
257
  for (const el of root.querySelectorAll(selector)) {
298
- if (inScope(root, el, stop))
258
+ if (inScope(root, el))
299
259
  found.push(el);
300
260
  }
301
261
  for (const parked of root.querySelectorAll(PARKED)) {
302
- if (inScope(root, parked, stop)) {
303
- found.push(...below(parked.content, selector, stop));
262
+ if (inScope(root, parked)) {
263
+ found.push(...below(parked.content, selector));
304
264
  }
305
265
  }
306
266
  return found;
@@ -317,14 +277,6 @@ function everywhere(root, selector) {
317
277
  }
318
278
  return found;
319
279
  }
320
- /**
321
- * Tick or clear an `lb-select` checkbox, and stamp the live row it selects
322
- * to match.
323
- */
324
- export function select(box, on) {
325
- box.checked = on;
326
- box.closest(LIVE_ROW)?.toggleAttribute(ATTR_ROW_SELECTED, on);
327
- }
328
280
  /**
329
281
  * Put an element in the document or take it out, as its `lb-show` column
330
282
  * says.
@@ -347,12 +299,6 @@ function present(el, on) {
347
299
  }
348
300
  if (on || el.parentNode?.nodeType === FRAGMENT_NODE)
349
301
  return;
350
- // A selection hidden is a selection abandoned: a tick kept out of sight
351
- // would come back stale, and nothing else could clear it.
352
- for (const box of [el, ...el.querySelectorAll(`[${ATTR_SELECT}]`)]) {
353
- if (box.hasAttribute(ATTR_SELECT))
354
- select(box, false);
355
- }
356
302
  const parked = el.ownerDocument.createElement("template");
357
303
  parked.setAttribute(ATTR_SHOW, el.getAttribute(ATTR_SHOW));
358
304
  el.replaceWith(parked);
@@ -386,8 +332,9 @@ function addPlace(map, name, el) {
386
332
  */
387
333
  function note(places, el) {
388
334
  const column = el.getAttribute(ATTR_COLUMN);
389
- if (column !== null)
335
+ if (column !== null) {
390
336
  addPlace(places.columns, column, el);
337
+ }
391
338
  if (el.hasAttribute(ATTR_URL_PARM))
392
339
  places.links.push(el);
393
340
  const show = el.getAttribute(ATTR_SHOW);
@@ -422,11 +369,11 @@ function noteRoot(places, root) {
422
369
  * when `self`, and every one below it short of `stop`, absent branches
423
370
  * included.
424
371
  */
425
- function search(root, self, stop) {
372
+ function search(root, self) {
426
373
  const places = noPlaces();
427
374
  if (self)
428
375
  noteRoot(places, root);
429
- for (const el of below(root, PLACE, stop))
376
+ for (const el of below(root, PLACE))
430
377
  note(places, el);
431
378
  return places;
432
379
  }
@@ -515,11 +462,9 @@ function fill(places, row) {
515
462
  * it displays. An element a `row` lands on is not its own ancestor, so its
516
463
  * own `lb-column` reads from the row around it and is left alone here. Root's
517
464
  * own `lb-show` never counts.
518
- *
519
- * `stop` passes over everything matching it and inside it.
520
465
  */
521
- export function applyRow(root, row, self = false, stop) {
522
- const places = search(root, self, stop);
466
+ export function applyRow(root, row, self = false) {
467
+ const places = search(root, self);
523
468
  fill(places, row);
524
469
  link(places, row);
525
470
  }
@@ -636,107 +581,45 @@ export function focusIn(root) {
636
581
  }
637
582
  }
638
583
  const lists = new WeakMap();
639
- /** Elements already told their group templates outrun their order. */
640
- const warned = new WeakSet();
641
- /**
642
- * What a group's heading and aggregates stop at: the rows and groups inside
643
- * it, live or leaving, which are filled from rows of their own.
644
- */
645
- const SCAFFOLD_STOP = `${LIVE_ROW}, ${LIVE_GROUP}, ${LEAVING}`;
646
- /** An element carrying any aggregate. */
647
- const AGGREGATE = AGGREGATES.map((name) => `[${name}]`).join(", ");
648
584
  /**
649
- * The template a group's contents go in front of: the first `<template>` in
650
- * the content outside any nested `lb-query`, and not one holding an absent
651
- * branch.
652
- */
653
- function slotIn(root) {
654
- for (const t of root.querySelectorAll(`template:not([${ATTR_SHOW}])`)) {
655
- if (inScope(root, t))
656
- return t;
657
- }
658
- return null;
659
- }
660
- /**
661
- * The aggregates in root's scaffolding: root itself when `self`, and every
662
- * one below it that is not inside a row or a group of its own.
663
- */
664
- function aggregatesIn(root, self) {
665
- const found = below(root, AGGREGATE, SCAFFOLD_STOP);
666
- if (self && root.matches(AGGREGATE))
667
- found.unshift(root);
668
- return found;
669
- }
670
- /**
671
- * The list an element holds, found from its first template the first time
672
- * rows land on it, and kept after that: once groups exist, their slots are
673
- * templates too, and they come first in the document.
585
+ * The list an element holds, found from its row template the first time
586
+ * rows land on it, and kept after that.
674
587
  */
675
588
  function listFor(el, template) {
676
589
  const found = lists.get(el);
677
590
  if (found)
678
591
  return found;
679
- const levels = [];
680
- let at = template;
681
- while (at.hasAttribute(ATTR_GROUP)) {
682
- levels.push(at);
683
- const next = slotIn(at.content);
684
- if (!next) {
685
- console.error(`lb-hub: a group template in <${el.localName}> holds no nested ` +
686
- `template for its contents`);
687
- return null;
688
- }
689
- at = next;
690
- }
691
592
  const list = {
692
593
  top: template,
693
- levels,
694
- rowTemplate: at,
594
+ rowTemplate: template,
695
595
  byKey: new Map(),
596
+ rows: [],
696
597
  unentered: [],
697
- root: {
698
- id: "",
699
- nodes: [],
700
- slot: template,
701
- children: [],
702
- rows: [],
703
- heads: [],
704
- aggregates: aggregatesIn(el, false),
705
- hidden: false,
706
- },
707
598
  };
708
599
  // Live rows already in the markup, as a page rendered ahead of its data
709
600
  // ships them, are the list's from the start, in the order they stand.
710
- if (levels.length === 0) {
711
- for (const row of el.querySelectorAll(LIVE_ROW)) {
712
- if (!inScope(el, row))
713
- continue;
714
- const key = row.getAttribute(ATTR_KEY_VALUE);
715
- if (list.byKey.has(key)) {
716
- console.error(`lb-hub: <${el.localName}> shows two rows with the key '${key}'`);
717
- }
718
- const entry = {
719
- key,
720
- el: row,
721
- places: search(row, true),
722
- data: {},
723
- placed: {},
724
- hidden: false,
725
- unplaced: false,
726
- };
727
- list.byKey.set(key, entry);
728
- list.root.rows.push(entry);
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}'`);
729
607
  }
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);
730
619
  }
731
620
  lists.set(el, list);
732
621
  return list;
733
622
  }
734
- /** The live rows of a group and everything under it, in order. */
735
- function rowsUnder(group) {
736
- return group.children.length > 0
737
- ? group.children.flatMap(rowsUnder)
738
- : group.rows;
739
- }
740
623
  /**
741
624
  * A new live row for a key, copied from the row template: upgraded, stamped
742
625
  * with its key, its places found by the template's paths, and no values yet.
@@ -769,11 +652,11 @@ function create(list, doc, key) {
769
652
  }
770
653
  /**
771
654
  * Put the rows not yet entered after every row: immediately after the list's
772
- * first template, which the rows and groups land in front of, so placing
773
- * them never moves these.
655
+ * row template, which the rows land in front of, so placing them never moves
656
+ * these.
774
657
  */
775
658
  function standLast(list) {
776
- let at = list.root.slot;
659
+ let at = list.top;
777
660
  for (const row of list.unentered) {
778
661
  if (at.nextElementSibling !== row)
779
662
  at.after(row);
@@ -782,7 +665,7 @@ function standLast(list) {
782
665
  }
783
666
  /** Stamp a list's count: the rows it shows, and its rows not yet entered. */
784
667
  function recount(el, list) {
785
- const showing = rowsUnder(list.root).filter((entry) => !entry.hidden);
668
+ const showing = list.rows.filter((entry) => !entry.hidden);
786
669
  setStamp(el, ATTR_QUERY_ROW_COUNT, String(showing.length + list.unentered.length));
787
670
  }
788
671
  /**
@@ -847,115 +730,6 @@ function nameRadios(row, key) {
847
730
  el.setAttribute("name", `${name}-${key}`);
848
731
  }
849
732
  }
850
- /** A group's identity among its siblings: its column and its value there. */
851
- function groupId(column, value) {
852
- return `${column}\u0000${JSON.stringify(value ?? null)}`;
853
- }
854
- /** A new group, cloned from the group template for its level and stamped. */
855
- function makeGroup(doc, template, id, term, value) {
856
- // Imported rather than cloned, for the reason a live row is.
857
- const fragment = doc.importNode(template.content, true);
858
- upgradeAbsent(fragment, doc);
859
- const slot = slotIn(fragment);
860
- const nodes = [...fragment.childNodes];
861
- const heads = [];
862
- const aggregates = [];
863
- for (const node of nodes) {
864
- if (node.nodeType !== ELEMENT_NODE || node === slot)
865
- continue;
866
- const el = node;
867
- placed(el);
868
- el.setAttribute(ATTR_GROUP_LIVE, "");
869
- if (term)
870
- el.setAttribute(ATTR_GROUP_COLUMN, term.column);
871
- if (value === null || value === undefined) {
872
- el.removeAttribute(ATTR_GROUP_VALUE);
873
- }
874
- else {
875
- el.setAttribute(ATTR_GROUP_VALUE, String(value));
876
- }
877
- // A heading's own column is filled, as a live row's is, unless it holds
878
- // the group's contents, which filling it would replace.
879
- heads.push(search(el, !el.contains(slot), SCAFFOLD_STOP));
880
- aggregates.push(...aggregatesIn(el, true));
881
- }
882
- return {
883
- id,
884
- nodes,
885
- slot,
886
- children: [],
887
- rows: [],
888
- heads,
889
- aggregates,
890
- hidden: false,
891
- };
892
- }
893
- /**
894
- * Put a sequence of items in the document in the order given, in front of
895
- * `end`, where `old` is the order they stood in. The run they share at the
896
- * start and the run they share at the end stay where they are, and what lies
897
- * between goes in as one fragment. An item not in `old` is new, and one in
898
- * `old` that is not given has gone somewhere else, or is leaving.
899
- */
900
- function placeRun(old, next, end, nodesOf) {
901
- const most = Math.min(old.length, next.length);
902
- let start = 0;
903
- while (start < most && old[start] === next[start])
904
- start++;
905
- let tail = 0;
906
- while (tail < most - start &&
907
- old[old.length - 1 - tail] === next[next.length - 1 - tail]) {
908
- tail++;
909
- }
910
- const middle = next.slice(start, next.length - tail);
911
- if (middle.length === 0)
912
- return;
913
- const ref = tail > 0 ? nodesOf(next[next.length - tail])[0] : end;
914
- const fragment = ref.ownerDocument.createDocumentFragment();
915
- for (const item of middle)
916
- fragment.append(...nodesOf(item));
917
- ref.parentNode.insertBefore(fragment, ref);
918
- }
919
- /**
920
- * Place a list's live rows in the order given, with groups made where the
921
- * order breaks, kept while their run goes on, and gone where it no longer
922
- * does. What leaves is returned, to leave once everything is placed.
923
- */
924
- function arrange(list, doc, order, terms) {
925
- const retired = [];
926
- const visit = (parent, level, entries) => {
927
- if (level === list.levels.length) {
928
- placeRun(parent.rows, entries, parent.slot, (entry) => [entry.el]);
929
- parent.rows = entries;
930
- return;
931
- }
932
- const term = terms[level];
933
- const runs = [];
934
- for (const entry of entries) {
935
- const value = term ? entry.placed[term.column] : null;
936
- const id = groupId(term?.column ?? "", value);
937
- const last = runs.at(-1);
938
- if (last?.id === id)
939
- last.entries.push(entry);
940
- else
941
- runs.push({ id, value, entries: [entry] });
942
- }
943
- // Each group already here goes on with the first run that has its id.
944
- const pool = new Map();
945
- for (const child of parent.children) {
946
- pool.set(child.id, [...(pool.get(child.id) ?? []), child]);
947
- }
948
- const next = runs.map((run) => pool.get(run.id)?.shift() ??
949
- makeGroup(doc, list.levels[level], run.id, term, run.value));
950
- for (const left of pool.values())
951
- retired.push(...left);
952
- placeRun(parent.children, next, parent.slot, (group) => group.nodes);
953
- parent.children = next;
954
- runs.forEach((run, i) => visit(next[i], level + 1, run.entries));
955
- };
956
- visit(list.root, 0, order);
957
- return retired;
958
- }
959
733
  /**
960
734
  * The rows that moved: rows whose values changed, and which now stand
961
735
  * somewhere else among the rows whose values did not. A neighbour a moved
@@ -995,6 +769,35 @@ function movedRows(before, after, changed) {
995
769
  }
996
770
  return moved;
997
771
  }
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
+ }
998
801
  /** Mark an element as leaving: no longer live, and taking no input. */
999
802
  function leaving(el, live, stamp) {
1000
803
  el.removeAttribute(live);
@@ -1002,17 +805,6 @@ function leaving(el, live, stamp) {
1002
805
  el.setAttribute(stamp, "");
1003
806
  el.setAttribute("inert", "");
1004
807
  }
1005
- /** A group whose rows are all gone, and every group inside it, leave. */
1006
- function leaveGroup(group, nodes) {
1007
- for (const child of group.children)
1008
- leaveGroup(child, nodes);
1009
- for (const node of group.nodes) {
1010
- if (node.nodeType === ELEMENT_NODE) {
1011
- leaving(node, ATTR_GROUP_LIVE, ATTR_GROUP_LEAVING);
1012
- }
1013
- }
1014
- nodes.push(...group.nodes);
1015
- }
1016
808
  /**
1017
809
  * Take nodes out of the document once whatever their stylesheet started on
1018
810
  * them has finished: at once when nothing is running. Asking for the running
@@ -1063,117 +855,11 @@ class Stamps {
1063
855
  el.setAttribute(name, "");
1064
856
  }
1065
857
  }
1066
- /** A plain decimal number, as JSON or Postgres writes one. */
1067
- const DECIMAL = /^[+-]?\d+(\.\d+)?$/;
1068
- function decimalOf(value) {
1069
- const text = typeof value === "number" ? String(value) : value;
1070
- if (typeof text !== "string" || !DECIMAL.test(text.trim()))
1071
- return null;
1072
- const [whole, fraction = ""] = text.trim().split(".");
1073
- return { digits: BigInt(whole + fraction), scale: fraction.length };
1074
- }
1075
- function rescale(d, scale) {
1076
- return d.digits * 10n ** BigInt(scale - d.scale);
1077
- }
1078
- function decimalText(digits, scale) {
1079
- const negative = digits < 0n;
1080
- const text = (negative ? -digits : digits)
1081
- .toString()
1082
- .padStart(scale + 1, "0");
1083
- const whole = text.slice(0, text.length - scale);
1084
- const fraction = scale > 0 ? `.${text.slice(text.length - scale)}` : "";
1085
- return `${negative ? "-" : ""}${whole}${fraction}`;
1086
- }
1087
- /** Aggregates already warned about, so a bad value is reported once. */
1088
- const reported = new Set();
1089
- /**
1090
- * One aggregate over rows. A sum and an average are exact, carried as
1091
- * decimals with as many places as the most precise value, and an average
1092
- * keeps up to two places more. Null and empty values are left out, as SQL
1093
- * leaves them out; a value that is not a number is left out and reported.
1094
- */
1095
- function aggregateOf(name, column, rows, type) {
1096
- if (name === ATTR_COUNT)
1097
- return rows.length;
1098
- const values = rows
1099
- .map((row) => row[column])
1100
- .filter((v) => v !== null && v !== undefined && v !== "");
1101
- if (values.length === 0)
1102
- return null;
1103
- if (name === ATTR_MIN || name === ATTR_MAX) {
1104
- const sign = name === ATTR_MIN ? 1 : -1;
1105
- return values.reduce((a, b) => sign * compareValues(readAs(b, type), readAs(a, type)) < 0 ? b : a);
1106
- }
1107
- const decimals = [];
1108
- for (const value of values) {
1109
- const d = decimalOf(value);
1110
- if (d)
1111
- decimals.push(d);
1112
- else if (!reported.has(`${name}:${column}`)) {
1113
- reported.add(`${name}:${column}`);
1114
- console.warn(`lb-hub: ${name}="${column}" left out ${JSON.stringify(value)}, ` +
1115
- `which is not a number`);
1116
- }
1117
- }
1118
- if (decimals.length === 0)
1119
- return null;
1120
- const scale = Math.max(...decimals.map((d) => d.scale));
1121
- const sum = decimals.reduce((total, d) => total + rescale(d, scale), 0n);
1122
- if (name === ATTR_SUM)
1123
- return decimalText(sum, scale);
1124
- // An average, rounded half away from zero at two places past the values',
1125
- // which are then dropped while they are zeros.
1126
- const scaled = sum * 100n;
1127
- const n = BigInt(decimals.length);
1128
- const sign = scaled < 0n ? -1n : 1n;
1129
- let text = decimalText((2n * scaled + sign * n) / (2n * n), scale + 2);
1130
- for (let i = 0; i < 2 && text.endsWith("0"); i++)
1131
- text = text.slice(0, -1);
1132
- return text.endsWith(".") ? text.slice(0, -1) : text;
1133
- }
1134
- /** Set each aggregate element from rows, each column read as its type. */
1135
- function aggregate(elements, rows, types) {
1136
- for (const el of elements) {
1137
- const name = AGGREGATES.find((a) => el.hasAttribute(a));
1138
- const column = el.getAttribute(name) ?? "";
1139
- land(el, aggregateOf(name, column, rows, types[column]));
1140
- }
1141
- }
1142
858
  /** Show or hide an element for a filter, saying so only when it changes. */
1143
859
  function hide(el, hidden) {
1144
860
  if (el.hasAttribute("hidden") !== hidden)
1145
861
  el.toggleAttribute("hidden", hidden);
1146
862
  }
1147
- /**
1148
- * Finish a group and everything under it, from the rows it shows: each
1149
- * heading filled and linked from the first, as it was when last placed by
1150
- * the order,
1151
- * each aggregate set from all of them, and a group hidden when a filter hides
1152
- * every row in it. The rows it shows are returned.
1153
- */
1154
- function finish(group, types) {
1155
- const shown = group.children.length > 0
1156
- ? group.children.flatMap((child) => finish(child, types))
1157
- : group.rows.filter((entry) => !entry.hidden);
1158
- const rows = shown.map((entry) => entry.data);
1159
- const hidden = rows.length === 0 && rowsUnder(group).length > 0;
1160
- if (hidden !== group.hidden) {
1161
- group.hidden = hidden;
1162
- for (const node of group.nodes) {
1163
- if (node.nodeType === ELEMENT_NODE && node !== group.slot) {
1164
- hide(node, hidden);
1165
- }
1166
- }
1167
- }
1168
- if (shown[0]) {
1169
- for (const head of group.heads) {
1170
- fill(head, shown[0].placed);
1171
- link(head, shown[0].placed);
1172
- }
1173
- }
1174
- aggregate(group.aggregates, rows, types);
1175
- return shown;
1176
- }
1177
863
  /** The order to place rows by, or none when it cannot be read. */
1178
864
  function termsOf(el, order) {
1179
865
  try {
@@ -1205,73 +891,46 @@ function filtered(el) {
1205
891
  .filter((column) => column !== "");
1206
892
  }
1207
893
  /**
1208
- * The order an element's rows are placed by: the user's, from the column of
1209
- * `lb-choices` its `lb-order` names, else the one the server declared.
894
+ * The order an element's rows are placed by: its query's row of `lb-sort`.
1210
895
  */
1211
- function orderFor(el, options) {
1212
- const column = el.getAttribute(ATTR_ORDER);
1213
- const chosen = column ? choiceIn(options.choices, column) : undefined;
1214
- return chosen ?? options.order;
896
+ function sortOf(state, query) {
897
+ const row = state.answers.get(SORT_QUERY)?.rows?.find((r) => r[SORT_COLUMN_QUERY] === query);
898
+ const order = row?.[SORT_COLUMN_ORDER];
899
+ return typeof order === "string" && order !== "" ? order : undefined;
900
+ }
901
+ /** The query's row of `lb-filters`, when it has one. */
902
+ function filtersOf(state, query) {
903
+ return state.answers.get(FILTERS_QUERY)?.rows?.find((r) => r[FILTERS_COLUMN_QUERY] === query);
1215
904
  }
1216
905
  /**
1217
906
  * Whether a row passes every filter the user chose for the element: for each
1218
- * column its `lb-filter` names, the filter in the column of `lb-choices` of
1219
- * the same name.
907
+ * column its `lb-filter` names, the filter in the query's row of
908
+ * `lb-filters` of the same name.
1220
909
  */
1221
- function filterFor(el, choices, types) {
1222
- const filters = [];
910
+ function filterFor(el, filters, types) {
911
+ const terms = [];
1223
912
  for (const column of filtered(el)) {
1224
- const text = choiceIn(choices, column);
1225
- const terms = text === undefined ? [] : parseFilter(text);
1226
- if (terms.length > 0)
1227
- filters.push([column, terms]);
913
+ const text = choiceIn(filters, column);
914
+ const parsed = text === undefined ? [] : parseFilter(text);
915
+ if (parsed.length > 0)
916
+ terms.push([column, parsed]);
1228
917
  }
1229
- return (row) => filters.every(([column, terms]) => passes(row[column], terms, types[column]));
918
+ return (row) => terms.every(([column, parsed]) => passes(row[column], parsed, types[column]));
1230
919
  }
1231
- /**
1232
- * Hide or show a row for a filter. A selection hidden is a selection
1233
- * abandoned, as it is for `lb-show`.
1234
- */
1235
920
  function filterRow(entry, hidden) {
1236
921
  if (entry.hidden === hidden)
1237
922
  return;
1238
923
  entry.hidden = hidden;
1239
924
  hide(entry.el, hidden);
1240
- if (!hidden)
1241
- return;
1242
- for (const box of [entry.el, ...entry.el.querySelectorAll(`[${ATTR_SELECT}]`)]) {
1243
- if (box.hasAttribute(ATTR_SELECT))
1244
- select(box, false);
1245
- }
1246
925
  }
1247
926
  /** Whether a row's columns as landed differ from what they were. */
1248
927
  function differs(before, after) {
1249
928
  return Object.entries(after).some(([column, value]) => !same(before[column], value));
1250
929
  }
1251
930
  /**
1252
- * Land rows through a row template.
1253
- *
1254
- * Two loops do the work, on rows in no document. The creation loop makes a
1255
- * live row for each key not yet shown, with its key and no values. The
1256
- * landing loop lands each arriving row's values on its live row, as a `row`
1257
- * lands, passing over a column that already shows its value. Then every live
1258
- * row is placed by the order in one fragment, and the browser styles once.
1259
- *
1260
- * All rows decide membership: a row whose key did not arrive leaves. A patch
1261
- * disturbs only what it names — a row it did not mention keeps its contents,
1262
- * and keeps its place unless a row the patch named now sorts around it.
1263
- *
1264
- * With no order, all rows show in the order they arrived, and a patch's new
1265
- * rows go last. A row that stands where it stood is not touched, so landing
1266
- * the same rows again moves nothing. A row a filter the user chose does not
1267
- * pass is `hidden`.
1268
- *
1269
- * A landing that answers a request leaves alone each side `lb-manual` names
1270
- * on the element, whole or a patch. Under `order`, nothing already showing
1271
- * moves, and a new row goes last in its group. Under `filter`, a row the
1272
- * answer changed or created stays showing. A row left where the order or
1273
- * the filter would not leave it is stamped unplaced until the rows are next
1274
- * placed.
931
+ * Land rows through a row template: a live row per key, placed by the order.
932
+ * A patch disturbs only what it names — rows it leaves out keep their
933
+ * contents and their place.
1275
934
  */
1276
935
  export function applyRows(el, template, keyColumn, result, options = {}) {
1277
936
  const list = listFor(el, template);
@@ -1280,12 +939,11 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1280
939
  const doc = options.doc ?? el.ownerDocument;
1281
940
  const host = el;
1282
941
  const whole = !isPatch(result);
1283
- const shown = rowsUnder(list.root);
942
+ const shown = list.rows;
1284
943
  const focused = focusedRow(list, doc);
1285
944
  // What a request did is said by this landing alone.
1286
945
  for (const entry of shown)
1287
946
  entry.el.removeAttribute(ATTR_ROW_REQUESTED);
1288
- // The rows arriving, by key.
1289
947
  const arriving = new Map();
1290
948
  for (const data of whole ? result : (result.rows ?? [])) {
1291
949
  if (data[keyColumn] === undefined) {
@@ -1310,7 +968,6 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1310
968
  const gone = whole
1311
969
  ? shown.filter((entry) => !arriving.has(entry.key))
1312
970
  : dropped.flatMap((key) => list.byKey.get(key) ?? []);
1313
- // The creation loop.
1314
971
  const created = new Set();
1315
972
  for (const key of arriving.keys()) {
1316
973
  if (!list.byKey.has(key))
@@ -1341,13 +998,7 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1341
998
  // already showing with what is new after it. A sort keeps rows that
1342
999
  // compare equal in this order.
1343
1000
  const stayed = shown.filter((entry) => list.byKey.get(entry.key) === entry);
1344
- const terms = termsOf(el, orderFor(el, options));
1345
- if (list.levels.length > terms.length && !warned.has(el)) {
1346
- warned.add(el);
1347
- console.warn(`lb-hub: <${el.localName}> has ${list.levels.length} group ` +
1348
- `template(s) and an order of ${terms.length} term(s); a level with ` +
1349
- `no term shows its rows as one group`);
1350
- }
1001
+ const terms = termsOf(el, options.order);
1351
1002
  const sort = (entries) => terms.length === 0
1352
1003
  ? entries
1353
1004
  : entries
@@ -1355,7 +1006,7 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1355
1006
  .sort((a, b) => compareRows(a.entry.data, b.entry.data, terms, options.types) ||
1356
1007
  a.i - b.i)
1357
1008
  .map(({ entry }) => entry);
1358
- const passing = filterFor(el, options.choices, options.types ?? {});
1009
+ const passing = filterFor(el, options.filters, options.types ?? {});
1359
1010
  const manual = options.requested ? manualOf(el) : NO_MANUAL;
1360
1011
  const touched = new Set([...changed, ...created]);
1361
1012
  // A row left out of place stays touched until the rows are placed again.
@@ -1365,11 +1016,10 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1365
1016
  let order;
1366
1017
  let out = new Set();
1367
1018
  if (manual.order) {
1368
- // What already shows stays where it stands, and what is new goes last
1369
- // in the group it belongs to.
1019
+ // What already shows stays where it stands, and what is new goes last.
1370
1020
  for (const entry of created)
1371
1021
  entry.placed = entry.data;
1372
- order = heldOrder(stayed, created, groupTerms(list, terms));
1022
+ order = [...stayed, ...created];
1373
1023
  out = movedRows(order, sort(order), touched);
1374
1024
  }
1375
1025
  else {
@@ -1383,15 +1033,12 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1383
1033
  const kept = manual.filter && touched.has(entry) && !entry.hidden && fails;
1384
1034
  const hides = fails && !kept;
1385
1035
  filterRow(entry, hides);
1386
- // A row stands in the group it was placed in until it is placed again.
1387
- const moves = manual.order && regrouped(entry, list, terms);
1388
- if (!moves)
1389
- entry.placed = entry.data;
1036
+ entry.placed = entry.data;
1390
1037
  // A row nobody sees stands nowhere the user would notice.
1391
- entry.unplaced = !hides && (out.has(entry) || kept || moves);
1038
+ entry.unplaced = !hides && (out.has(entry) || kept);
1392
1039
  }
1393
1040
  const moved = movedRows(stayed, order, changed);
1394
- const retired = arrange(list, doc, order, terms);
1041
+ placeRows(list, order);
1395
1042
  refocus(list, focused);
1396
1043
  const stamps = new Stamps();
1397
1044
  for (const key of arriving.keys()) {
@@ -1421,7 +1068,7 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1421
1068
  if (entry.unplaced)
1422
1069
  unplaced++;
1423
1070
  }
1424
- const rows = finish(list.root, options.types ?? {});
1071
+ const rows = list.rows.filter((entry) => !entry.hidden);
1425
1072
  // Counted from what the hub holds, which is what the rows show.
1426
1073
  setStamp(el, ATTR_QUERY_ROW_COUNT, String(rows.length + list.unentered.length));
1427
1074
  standLast(list);
@@ -1431,8 +1078,6 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1431
1078
  else
1432
1079
  setStamp(el, ATTR_QUERY_ORDER, orderText(terms));
1433
1080
  const departing = gone.map((entry) => entry.el);
1434
- for (const group of retired)
1435
- leaveGroup(group, departing);
1436
1081
  if (departing.length > 0)
1437
1082
  depart(departing);
1438
1083
  host.lbRowsLanded?.();
@@ -1445,39 +1090,6 @@ function manualOf(el) {
1445
1090
  filter: sides.includes(MANUAL_FILTER),
1446
1091
  };
1447
1092
  }
1448
- /** The order's terms a list's groups break on, outermost first. */
1449
- function groupTerms(list, terms) {
1450
- return terms.slice(0, list.levels.length);
1451
- }
1452
- /** The group a row's columns put it in, as one string. */
1453
- function groupOf(row, terms) {
1454
- return terms.map((term) => groupId(term.column, row[term.column])).join("\u0001");
1455
- }
1456
- /** Whether a row's columns now put it in another group than it stands in. */
1457
- function regrouped(entry, list, terms) {
1458
- const by = groupTerms(list, terms);
1459
- return groupOf(entry.data, by) !== groupOf(entry.placed, by);
1460
- }
1461
- /**
1462
- * The rows of a list the user is working in: those showing where they
1463
- * stand, and each new one after the last row of its group, or last of all
1464
- * when its group shows no row.
1465
- */
1466
- function heldOrder(stayed, created, by) {
1467
- const order = [...stayed];
1468
- for (const entry of created) {
1469
- const group = groupOf(entry.placed, by);
1470
- let at = order.length;
1471
- if (by.length > 0) {
1472
- while (at > 0 && groupOf(order[at - 1].placed, by) !== group)
1473
- at--;
1474
- if (at === 0)
1475
- at = order.length;
1476
- }
1477
- order.splice(at, 0, entry);
1478
- }
1479
- return order;
1480
- }
1481
1093
  function focusedRow(list, doc) {
1482
1094
  const active = doc.activeElement;
1483
1095
  const row = active?.closest(LIVE_ROW);
@@ -1503,7 +1115,7 @@ function refocus(list, focused) {
1503
1115
  active.scrollIntoView?.({ block: "nearest" });
1504
1116
  return;
1505
1117
  }
1506
- const first = rowsUnder(list.root).find((row) => !row.hidden);
1118
+ const first = list.rows.find((row) => !row.hidden);
1507
1119
  if (first)
1508
1120
  focusIn(first.el);
1509
1121
  }
@@ -1511,7 +1123,7 @@ function refocus(list, focused) {
1511
1123
  function landItem(el, item, options = {}) {
1512
1124
  const template = lists.get(el)?.top ?? templateIn(el);
1513
1125
  if (item.kind === KIND_ROW) {
1514
- const row = item.row;
1126
+ const row = oneRow(item);
1515
1127
  if (template) {
1516
1128
  applyRows(el, template, item.key, row ? [row] : [], options);
1517
1129
  return;
@@ -1523,8 +1135,8 @@ function landItem(el, item, options = {}) {
1523
1135
  el.removeAttribute(ATTR_KEY_VALUE);
1524
1136
  return;
1525
1137
  }
1526
- applyRow(el, row);
1527
1138
  const key = row[item.key];
1139
+ applyRow(el, row);
1528
1140
  if (key !== undefined && key !== null) {
1529
1141
  setStamp(el, ATTR_KEY_VALUE, String(key));
1530
1142
  }
@@ -1563,9 +1175,9 @@ export function keptAnswer(root, query) {
1563
1175
  function rowsOptions(state, item) {
1564
1176
  const declared = state.answers.get(item.query) ?? item;
1565
1177
  return {
1566
- order: declared.order,
1178
+ order: sortOf(state, item.query) ?? declared.order,
1567
1179
  types: declared.types,
1568
- choices: state.answers.get(CHOICES_QUERY)?.row ?? undefined,
1180
+ filters: filtersOf(state, item.query),
1569
1181
  doc: state.doc,
1570
1182
  };
1571
1183
  }
@@ -1619,38 +1231,41 @@ function keep(answers, item) {
1619
1231
  rows: patched(before.rows ?? [], item.key, item.patch),
1620
1232
  });
1621
1233
  }
1622
- /** The columns of `lb-choices` an answer to it changes. */
1623
- function chosen(state, item) {
1624
- const before = state.answers.get(CHOICES_QUERY)?.row ?? {};
1625
- const after = item.row ?? {};
1626
- const columns = new Set();
1627
- for (const column of new Set([...Object.keys(before), ...Object.keys(after)])) {
1628
- if (choiceIn(before, column) !== choiceIn(after, column))
1629
- columns.add(column);
1234
+ /** The queries whose sort or filters an answer changes. */
1235
+ function changedQueries(state, item) {
1236
+ const rowsOf = (answer) => {
1237
+ const map = new Map();
1238
+ for (const row of answer?.rows ?? []) {
1239
+ const query = row[SORT_COLUMN_QUERY];
1240
+ if (typeof query === "string")
1241
+ map.set(query, row);
1242
+ }
1243
+ return map;
1244
+ };
1245
+ const before = rowsOf(state.answers.get(item.query));
1246
+ const after = rowsOf(item);
1247
+ const queries = new Set();
1248
+ for (const query of new Set([...before.keys(), ...after.keys()])) {
1249
+ if (JSON.stringify(before.get(query)) !== JSON.stringify(after.get(query))) {
1250
+ queries.add(query);
1251
+ }
1630
1252
  }
1631
- return columns;
1632
- }
1633
- /** Whether an element's order or filter reads any of the columns. */
1634
- function reads(el, columns) {
1635
- const order = el.getAttribute(ATTR_ORDER);
1636
- return ((order !== null && columns.has(order)) ||
1637
- filtered(el).some((column) => columns.has(column)));
1253
+ return queries;
1638
1254
  }
1639
1255
  /**
1640
- * Place again the rows of every element whose order or filter reads a choice
1641
- * that changed. Each lands its query's kept answer again, whole, so it shows
1642
- * what a round trip carrying the choice would have, and the server is asked
1643
- * for nothing.
1256
+ * Place again the rows of every query whose sort or filters changed. Each
1257
+ * lands its query's kept answer again, whole, so it shows what a round trip
1258
+ * carrying the change would have, and the server is asked for nothing.
1644
1259
  */
1645
- function rechoose(root, state, columns) {
1646
- for (const [query, item] of state.answers) {
1647
- if (item.kind === KIND_ROW)
1260
+ function rechoose(root, state, queries) {
1261
+ for (const query of queries) {
1262
+ const item = state.answers.get(query);
1263
+ if (!item || item.kind === KIND_ROW)
1648
1264
  continue;
1649
1265
  for (const el of everywhere(root, `[${ATTR_QUERY}="${query}"]`)) {
1650
1266
  if (!state.landed.has(el) || el.closest(LEAVING))
1651
1267
  continue;
1652
- if (reads(el, columns))
1653
- landItem(el, item, rowsOptions(state, item));
1268
+ landItem(el, item, rowsOptions(state, item));
1654
1269
  }
1655
1270
  }
1656
1271
  }
@@ -1677,14 +1292,16 @@ function fillArrivals(root, state) {
1677
1292
  /**
1678
1293
  * Land response items. Every answer arrives through here, and is kept for
1679
1294
  * `root`, so that what arrives after it is filled from it — see forget().
1680
- * An answer to `lb-choices` places again the rows its choices change. Once
1681
- * everything has landed, each custom element placed since the last landing
1682
- * is attached.
1295
+ * An answer to `lb-sort` or `lb-filters` places again the rows it changes.
1296
+ * Once everything has landed, each custom element placed since the last
1297
+ * landing is attached.
1683
1298
  */
1684
1299
  export function applyResponse(root, items, options = {}) {
1685
1300
  const state = keptFor(root);
1686
1301
  for (const item of items) {
1687
- const choices = item.query === CHOICES_QUERY ? chosen(state, item) : new Set();
1302
+ const changed = item.query === SORT_QUERY || item.query === FILTERS_QUERY
1303
+ ? changedQueries(state, item)
1304
+ : new Set();
1688
1305
  keep(state.answers, item);
1689
1306
  const targets = everywhere(root, `[${ATTR_QUERY}="${item.query}"]`).filter((el) => !el.closest(LEAVING));
1690
1307
  if (targets.length === 0 &&
@@ -1699,8 +1316,8 @@ export function applyResponse(root, items, options = {}) {
1699
1316
  requested: options.requested,
1700
1317
  });
1701
1318
  }
1702
- if (choices.size > 0)
1703
- rechoose(root, state, choices);
1319
+ if (changed.size > 0)
1320
+ rechoose(root, state, changed);
1704
1321
  }
1705
1322
  fillArrivals(root, state);
1706
1323
  attachPlaced();