@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.
- package/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +37 -1
- package/dist/build/assemble.js.map +1 -1
- package/dist/core/lb-constants.d.ts +14 -2
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +42 -6
- package/dist/core/lb-constants.js.map +1 -1
- package/dist/hub/lb-apply.d.ts +5 -2
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +402 -78
- package/dist/hub/lb-apply.js.map +1 -1
- package/dist/hub/lb-hub.browser.d.ts.map +1 -1
- package/dist/hub/lb-hub.browser.js +1 -2
- package/dist/hub/lb-hub.browser.js.map +1 -1
- package/dist/hub/lb-lifecycle.d.ts +2 -2
- package/dist/hub/lb-lifecycle.d.ts.map +1 -1
- package/dist/hub/lb-lifecycle.js +6 -5
- package/dist/hub/lb-lifecycle.js.map +1 -1
- package/docs/comparison.md +29 -22
- package/docs/miswarts.md +2 -2
- package/docs/opinions.md +2 -2
- package/docs/reference/chrome.md +8 -10
- package/docs/reference/css.md +20 -8
- package/docs/reference/custom-elements.md +19 -14
- package/docs/reference/data-binding.md +163 -61
- package/docs/reference/names.md +23 -8
- package/docs/reference/page-files.md +25 -16
- package/docs/reference/server.md +3 -3
- package/docs/roadmap.md +2 -2
- package/docs/testing.md +8 -6
- package/package.json +1 -1
- package/skills/loadbare-app/SKILL.md +16 -7
- package/skills/loadbare-app/references/chrome.md +8 -10
- package/skills/loadbare-app/references/css.md +20 -8
- package/skills/loadbare-app/references/custom-elements.md +19 -14
- package/skills/loadbare-app/references/data-binding.md +163 -61
- package/skills/loadbare-app/references/miswarts.md +2 -2
- package/skills/loadbare-app/references/names.md +23 -8
- package/skills/loadbare-app/references/page-files.md +25 -16
- package/skills/loadbare-app/references/server.md +3 -3
package/dist/hub/lb-apply.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
586
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1442
|
+
const first = rowsUnder(list.root).find((row) => !row.hidden);
|
|
1119
1443
|
if (first)
|
|
1120
1444
|
focusIn(first.el);
|
|
1121
1445
|
}
|