@loadbare/app 0.12.0 → 0.14.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 +87 -1
- package/dist/build/assemble.js.map +1 -1
- package/dist/build/cli.js +2 -1
- package/dist/build/cli.js.map +1 -1
- package/dist/build/elements.d.ts +9 -1
- package/dist/build/elements.d.ts.map +1 -1
- package/dist/build/elements.js +50 -0
- package/dist/build/elements.js.map +1 -1
- package/dist/build/origins.d.ts +2 -0
- package/dist/build/origins.d.ts.map +1 -1
- package/dist/build/origins.js +1 -1
- package/dist/build/origins.js.map +1 -1
- package/dist/core/lb-constants.d.ts +34 -1
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +105 -0
- package/dist/core/lb-constants.js.map +1 -1
- package/dist/core/lb-types.d.ts +74 -18
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/core/lb-types.js +74 -11
- package/dist/core/lb-types.js.map +1 -1
- package/dist/hub/lb-apply.d.ts +57 -18
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +661 -63
- 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 +400 -33
- package/dist/hub/lb-hub.browser.js.map +1 -1
- package/dist/server/lb-express.d.ts +19 -7
- package/dist/server/lb-express.d.ts.map +1 -1
- package/dist/server/lb-express.js +62 -26
- package/dist/server/lb-express.js.map +1 -1
- package/dist/server/lb-server.d.ts +80 -11
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/server/lb-server.js +185 -33
- package/dist/server/lb-server.js.map +1 -1
- package/docs/TECHREF-1.0.md +584 -111
- package/docs/comparison.md +17 -13
- package/docs/possible-ideas.md +308 -0
- package/docs/reference/chrome.md +7 -1
- package/docs/reference/custom-elements.md +134 -39
- package/docs/reference/data-binding.md +405 -17
- package/docs/reference/page-files.md +101 -19
- package/docs/reference/server.md +24 -0
- package/docs/reference/widgets.md +118 -25
- package/docs/roadmap.md +17 -6
- package/docs/testing.md +21 -10
- package/package.json +2 -3
- package/skills/loadbare-app/SKILL.md +92 -30
- package/skills/loadbare-app/references/TECHREF-1.0.md +584 -111
- package/skills/loadbare-app/references/chrome.md +7 -1
- package/skills/loadbare-app/references/custom-elements.md +134 -39
- package/skills/loadbare-app/references/data-binding.md +405 -17
- package/skills/loadbare-app/references/page-files.md +101 -19
- package/skills/loadbare-app/references/server.md +24 -0
- package/skills/loadbare-app/references/widgets.md +118 -25
package/dist/hub/lb-apply.js
CHANGED
|
@@ -24,18 +24,38 @@
|
|
|
24
24
|
* shipped inside an absent branch has not upgraded, so it is filled again
|
|
25
25
|
* once it has, and places its rows itself.
|
|
26
26
|
*
|
|
27
|
+
* A `rows` query is placed by its order: the one the URL carries for it, or
|
|
28
|
+
* the one the server declared, or, with neither, the order its rows arrived
|
|
29
|
+
* in. Groups are built from the markup's group templates as the order breaks,
|
|
30
|
+
* and every change to a list — a row created, changed, moved or leaving, a
|
|
31
|
+
* group appearing or leaving — is one a stylesheet can see.
|
|
32
|
+
*
|
|
27
33
|
* A condition is the one other thing landing moves. An element whose
|
|
28
34
|
* `lb-show` column is off stands in the document inside a template, and
|
|
29
35
|
* landing reaches in there as it reaches everywhere else, so a branch that
|
|
30
36
|
* returns is already current. Nothing but landing reaches in.
|
|
31
37
|
*/
|
|
32
|
-
import { ATTR_COLUMN, ATTR_COLUMN_VALUE, ATTR_KEY_VALUE, ATTR_QUERY, ATTR_QUERY_ROW_COUNT, ATTR_ROW_LIVE, ATTR_SHOW, KIND_ROW, LIVE_ROW, SHOW_NOT, } from "../core/lb-constants.js";
|
|
33
|
-
import { isPatch, } from "../core/lb-types.js";
|
|
38
|
+
import { AGGREGATES, ATTR_COLUMN, 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_ROW_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_SHOW, ATTR_SUM, KIND_ROW, LIVE_GROUP, LIVE_ROW, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, ATTR_SELECT, ATTR_ROW_SELECTED, } from "../core/lb-constants.js";
|
|
39
|
+
import { compareRows, compareValues, isPatch, parseOrder, } from "../core/lb-types.js";
|
|
34
40
|
/** The template holding an absent branch — see ATTR_SHOW. */
|
|
35
41
|
const PARKED = `template[${ATTR_SHOW}]`;
|
|
36
42
|
/** A DocumentFragment's nodeType. Tier 3 has no `Node` global to read it from. */
|
|
37
43
|
const FRAGMENT_NODE = 11;
|
|
38
|
-
/**
|
|
44
|
+
/** An Element's nodeType, for the same reason. */
|
|
45
|
+
const ELEMENT_NODE = 1;
|
|
46
|
+
/** A row or a group on its way out. */
|
|
47
|
+
const LEAVING = `[${ATTR_ROW_LEAVING}], [${ATTR_GROUP_LEAVING}]`;
|
|
48
|
+
/**
|
|
49
|
+
* What a group's heading and aggregates stop at: the rows and groups inside
|
|
50
|
+
* it, live or leaving, which are filled from rows of their own.
|
|
51
|
+
*/
|
|
52
|
+
const SCAFFOLD_STOP = `${LIVE_ROW}, ${LIVE_GROUP}, ${LEAVING}`;
|
|
53
|
+
/** An element carrying any aggregate. */
|
|
54
|
+
const AGGREGATE = AGGREGATES.map((name) => `[${name}]`).join(", ");
|
|
55
|
+
/**
|
|
56
|
+
* The input types whose state is not their `value`, or cannot be set. A
|
|
57
|
+
* checkbox's state is `checked`, and it is a control by its own rule.
|
|
58
|
+
*/
|
|
39
59
|
const UNLANDED_INPUTS = ["checkbox", "radio", "file"];
|
|
40
60
|
/**
|
|
41
61
|
* A native control whose state is its `value`: a `<select>`, a
|
|
@@ -50,6 +70,14 @@ function isNativeControl(el) {
|
|
|
50
70
|
return (el.localName === "input" &&
|
|
51
71
|
!UNLANDED_INPUTS.includes(el.type));
|
|
52
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A checkbox, whose state is `checked` rather than `value`. HTML's quirk,
|
|
75
|
+
* and so the hub's: it lands and gathers by its checked state, so every other
|
|
76
|
+
* part of the vocabulary treats it as any control.
|
|
77
|
+
*/
|
|
78
|
+
function isCheckbox(el) {
|
|
79
|
+
return (el.localName === "input" && el.type === "checkbox");
|
|
80
|
+
}
|
|
53
81
|
/**
|
|
54
82
|
* A form-associated custom element with a `value` property. HTML marks one
|
|
55
83
|
* with `static formAssociated = true` on its class, and a control has a value.
|
|
@@ -59,12 +87,20 @@ export function isFormAssociated(el) {
|
|
|
59
87
|
return ctor.formAssociated === true && "value" in el;
|
|
60
88
|
}
|
|
61
89
|
/**
|
|
62
|
-
* A control: an `<input>`, `<select>` or `<textarea>`, or a
|
|
63
|
-
* custom element with a `value` property. Landing and
|
|
64
|
-
* same line, so a value read back is the one that landed
|
|
90
|
+
* A control: an `<input>`, `<select>` or `<textarea>`, a checkbox, or a
|
|
91
|
+
* form-associated custom element with a `value` property. Landing and
|
|
92
|
+
* gathering draw the same line, so a value read back is the one that landed
|
|
93
|
+
* there.
|
|
65
94
|
*/
|
|
66
95
|
export function isControl(el) {
|
|
67
|
-
return isNativeControl(el) || isFormAssociated(el);
|
|
96
|
+
return isNativeControl(el) || isCheckbox(el) || isFormAssociated(el);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What a control gathers: a checkbox `"true"` or `"false"`, so unchecked
|
|
100
|
+
* still says so, and any other control its `value`.
|
|
101
|
+
*/
|
|
102
|
+
export function controlValue(el) {
|
|
103
|
+
return isCheckbox(el) ? String(el.checked) : el.value;
|
|
68
104
|
}
|
|
69
105
|
/**
|
|
70
106
|
* Set an element from a column. A control's `value` is set. A custom element
|
|
@@ -76,7 +112,9 @@ function land(el, value) {
|
|
|
76
112
|
// Untouched, whatever the server sent: the browser decides what a
|
|
77
113
|
// non-string looks like. See Row in core/lb-types.ts.
|
|
78
114
|
if (value === null || value === undefined) {
|
|
79
|
-
if (
|
|
115
|
+
if (isCheckbox(el))
|
|
116
|
+
el.checked = false;
|
|
117
|
+
else if (isControl(el))
|
|
80
118
|
el.value = "";
|
|
81
119
|
else if (!el.localName.includes("-") && el.localName !== "input") {
|
|
82
120
|
el.textContent = "";
|
|
@@ -84,7 +122,12 @@ function land(el, value) {
|
|
|
84
122
|
el.removeAttribute(ATTR_COLUMN_VALUE);
|
|
85
123
|
return;
|
|
86
124
|
}
|
|
87
|
-
if (
|
|
125
|
+
if (isCheckbox(el)) {
|
|
126
|
+
// Checked by the rule `lb-show` is on by: only null and false are off,
|
|
127
|
+
// and no string is read.
|
|
128
|
+
el.checked = value !== false;
|
|
129
|
+
}
|
|
130
|
+
else if (isControl(el)) {
|
|
88
131
|
el.value = value;
|
|
89
132
|
}
|
|
90
133
|
else if (el.localName === "input") {
|
|
@@ -110,10 +153,14 @@ function land(el, value) {
|
|
|
110
153
|
* up from an element inside it stops at the top of that content, which has
|
|
111
154
|
* no parent, and the template it stands for was in scope already.
|
|
112
155
|
*/
|
|
113
|
-
function inScope(root, el) {
|
|
156
|
+
function inScope(root, el, stop) {
|
|
157
|
+
if (stop && el !== root && el.matches(stop))
|
|
158
|
+
return false;
|
|
114
159
|
for (let node = el.parentElement; node && node !== root;) {
|
|
115
160
|
if (node.hasAttribute(ATTR_QUERY))
|
|
116
161
|
return false;
|
|
162
|
+
if (stop && node.matches(stop))
|
|
163
|
+
return false;
|
|
117
164
|
node = node.parentElement;
|
|
118
165
|
}
|
|
119
166
|
return true;
|
|
@@ -140,15 +187,15 @@ export function within(root, selector) {
|
|
|
140
187
|
* stale or with its rows empty. A template holding one is in root's query by
|
|
141
188
|
* the ordinary rule, and its content is searched as if it stood there.
|
|
142
189
|
*/
|
|
143
|
-
function below(root, selector) {
|
|
190
|
+
function below(root, selector, stop) {
|
|
144
191
|
const found = [];
|
|
145
192
|
for (const el of root.querySelectorAll(selector)) {
|
|
146
|
-
if (inScope(root, el))
|
|
193
|
+
if (inScope(root, el, stop))
|
|
147
194
|
found.push(el);
|
|
148
195
|
}
|
|
149
196
|
for (const parked of root.querySelectorAll(PARKED)) {
|
|
150
|
-
if (inScope(root, parked)) {
|
|
151
|
-
found.push(...below(parked.content, selector));
|
|
197
|
+
if (inScope(root, parked, stop)) {
|
|
198
|
+
found.push(...below(parked.content, selector, stop));
|
|
152
199
|
}
|
|
153
200
|
}
|
|
154
201
|
return found;
|
|
@@ -165,6 +212,14 @@ function everywhere(root, selector) {
|
|
|
165
212
|
}
|
|
166
213
|
return found;
|
|
167
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Tick or clear an `lb-select` checkbox, and stamp the live row it selects
|
|
217
|
+
* to match.
|
|
218
|
+
*/
|
|
219
|
+
export function select(box, on) {
|
|
220
|
+
box.checked = on;
|
|
221
|
+
box.closest(LIVE_ROW)?.toggleAttribute(ATTR_ROW_SELECTED, on);
|
|
222
|
+
}
|
|
168
223
|
/**
|
|
169
224
|
* Put an element in the document or take it out, as its `lb-show` column
|
|
170
225
|
* says.
|
|
@@ -187,6 +242,12 @@ function present(el, on) {
|
|
|
187
242
|
}
|
|
188
243
|
if (on || el.parentNode?.nodeType === FRAGMENT_NODE)
|
|
189
244
|
return;
|
|
245
|
+
// A selection hidden is a selection abandoned: a tick kept out of sight
|
|
246
|
+
// would come back stale, and nothing else could clear it.
|
|
247
|
+
for (const box of [el, ...el.querySelectorAll(`[${ATTR_SELECT}]`)]) {
|
|
248
|
+
if (box.hasAttribute(ATTR_SELECT))
|
|
249
|
+
select(box, false);
|
|
250
|
+
}
|
|
190
251
|
const parked = el.ownerDocument.createElement("template");
|
|
191
252
|
parked.setAttribute(ATTR_SHOW, el.getAttribute(ATTR_SHOW));
|
|
192
253
|
el.replaceWith(parked);
|
|
@@ -201,10 +262,10 @@ function present(el, on) {
|
|
|
201
262
|
* ancestor, so its own `lb-column` reads from the row around it and is left
|
|
202
263
|
* alone here.
|
|
203
264
|
*/
|
|
204
|
-
export function applyRow(root, row, liveRow = false) {
|
|
265
|
+
export function applyRow(root, row, liveRow = false, stop) {
|
|
205
266
|
for (const [column, value] of Object.entries(row)) {
|
|
206
267
|
const selector = `[${ATTR_COLUMN}="${column}"]`;
|
|
207
|
-
const columns = below(root, selector);
|
|
268
|
+
const columns = below(root, selector, stop);
|
|
208
269
|
if (liveRow && root.matches(selector))
|
|
209
270
|
columns.unshift(root);
|
|
210
271
|
for (const el of columns)
|
|
@@ -215,14 +276,30 @@ export function applyRow(root, row, liveRow = false) {
|
|
|
215
276
|
// Never the root's own: a row template's root carries none, and the
|
|
216
277
|
// element a row lands on reads its condition from the row around it.
|
|
217
278
|
const on = value !== null && value !== false;
|
|
218
|
-
for (const el of below(root, `[${ATTR_SHOW}="${column}"]
|
|
279
|
+
for (const el of below(root, `[${ATTR_SHOW}="${column}"]`, stop)) {
|
|
219
280
|
present(el, on);
|
|
220
281
|
}
|
|
221
|
-
for (const el of below(root, `[${ATTR_SHOW}="${SHOW_NOT}${column}"]
|
|
282
|
+
for (const el of below(root, `[${ATTR_SHOW}="${SHOW_NOT}${column}"]`, stop)) {
|
|
222
283
|
present(el, !on);
|
|
223
284
|
}
|
|
224
285
|
}
|
|
225
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* A row naming every column root's query shows, each null: what landing it
|
|
289
|
+
* leaves is root as it stands with no row. Absent branches are counted, so a
|
|
290
|
+
* column only an absent element names is cleared too.
|
|
291
|
+
*/
|
|
292
|
+
export function blankRow(root) {
|
|
293
|
+
const row = {};
|
|
294
|
+
for (const el of below(root, `[${ATTR_COLUMN}]`)) {
|
|
295
|
+
row[el.getAttribute(ATTR_COLUMN)] = null;
|
|
296
|
+
}
|
|
297
|
+
for (const el of below(root, `[${ATTR_SHOW}]`)) {
|
|
298
|
+
const show = el.getAttribute(ATTR_SHOW);
|
|
299
|
+
row[show.startsWith(SHOW_NOT) ? show.slice(SHOW_NOT.length) : show] = null;
|
|
300
|
+
}
|
|
301
|
+
return row;
|
|
302
|
+
}
|
|
226
303
|
/**
|
|
227
304
|
* The row template: the first `<template>` among the element's descendants,
|
|
228
305
|
* outside any nested `lb-query`. A template holding an absent branch is not a
|
|
@@ -258,30 +335,467 @@ function showing(el) {
|
|
|
258
335
|
}
|
|
259
336
|
return rows;
|
|
260
337
|
}
|
|
338
|
+
const lists = new WeakMap();
|
|
339
|
+
/** Elements already told their group templates outrun their order. */
|
|
340
|
+
const warned = new WeakSet();
|
|
341
|
+
/**
|
|
342
|
+
* The template a group's contents go in front of: the first `<template>` in
|
|
343
|
+
* the content outside any nested `lb-query`, and not one holding an absent
|
|
344
|
+
* branch.
|
|
345
|
+
*/
|
|
346
|
+
function slotIn(root) {
|
|
347
|
+
for (const t of root.querySelectorAll(`template:not([${ATTR_SHOW}])`)) {
|
|
348
|
+
if (inScope(root, t))
|
|
349
|
+
return t;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The list an element holds, found from its first template the first time
|
|
355
|
+
* rows land on it, and kept after that: once groups exist, their slots are
|
|
356
|
+
* templates too, and they come first in the document.
|
|
357
|
+
*/
|
|
358
|
+
function listFor(el, template) {
|
|
359
|
+
const found = lists.get(el);
|
|
360
|
+
if (found)
|
|
361
|
+
return found;
|
|
362
|
+
const levels = [];
|
|
363
|
+
let at = template;
|
|
364
|
+
while (at.hasAttribute(ATTR_GROUP)) {
|
|
365
|
+
levels.push(at);
|
|
366
|
+
const next = slotIn(at.content);
|
|
367
|
+
if (!next) {
|
|
368
|
+
console.error(`lb-hub: a group template in <${el.localName}> holds no nested ` +
|
|
369
|
+
`template for its contents`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
at = next;
|
|
373
|
+
}
|
|
374
|
+
const list = {
|
|
375
|
+
top: template,
|
|
376
|
+
levels,
|
|
377
|
+
rowTemplate: at,
|
|
378
|
+
data: new Map(),
|
|
379
|
+
groupOf: new WeakMap(),
|
|
380
|
+
root: {
|
|
381
|
+
id: "",
|
|
382
|
+
nodes: [],
|
|
383
|
+
slot: template,
|
|
384
|
+
children: [],
|
|
385
|
+
rows: [],
|
|
386
|
+
placed: true,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
// Live rows already in the markup, as a page rendered ahead of its data
|
|
390
|
+
// ships them, are the list's from the start, in the order they stand.
|
|
391
|
+
if (levels.length === 0) {
|
|
392
|
+
for (const row of liveRows(el)) {
|
|
393
|
+
list.data.set(row, {});
|
|
394
|
+
list.groupOf.set(row, list.root);
|
|
395
|
+
list.root.rows.push(row);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
lists.set(el, list);
|
|
399
|
+
return list;
|
|
400
|
+
}
|
|
401
|
+
/** The live rows of a group and everything under it, in order. */
|
|
402
|
+
function rowsUnder(group) {
|
|
403
|
+
return group.children.length > 0
|
|
404
|
+
? group.children.flatMap(rowsUnder)
|
|
405
|
+
: group.rows;
|
|
406
|
+
}
|
|
407
|
+
/** A group's identity among its siblings: its column and its value there. */
|
|
408
|
+
function groupId(column, value) {
|
|
409
|
+
return `${column}\u0000${JSON.stringify(value ?? null)}`;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Make an attribute's change one a stylesheet sees, even when it is removed
|
|
413
|
+
* and added again in one landing: the style is computed between the two.
|
|
414
|
+
*/
|
|
415
|
+
function restamp(el, name, on, value = "") {
|
|
416
|
+
const had = el.hasAttribute(name);
|
|
417
|
+
if (had)
|
|
418
|
+
el.removeAttribute(name);
|
|
419
|
+
if (!on)
|
|
420
|
+
return;
|
|
421
|
+
if (had)
|
|
422
|
+
void el.ownerDocument.defaultView?.getComputedStyle(el).animationName;
|
|
423
|
+
el.setAttribute(name, value);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Take nodes out of the document once whatever their stylesheet started on
|
|
427
|
+
* them has finished: at once when nothing is running. Asking for the running
|
|
428
|
+
* animations computes the style, so an animation keyed to the leaving stamp
|
|
429
|
+
* just set is among them.
|
|
430
|
+
*/
|
|
431
|
+
function depart(nodes) {
|
|
432
|
+
const running = [];
|
|
433
|
+
for (const node of nodes) {
|
|
434
|
+
if (node.nodeType !== ELEMENT_NODE)
|
|
435
|
+
continue;
|
|
436
|
+
const el = node;
|
|
437
|
+
running.push(...(el.getAnimations?.({ subtree: true }) ?? []));
|
|
438
|
+
}
|
|
439
|
+
const go = () => {
|
|
440
|
+
for (const node of nodes)
|
|
441
|
+
node.remove();
|
|
442
|
+
};
|
|
443
|
+
if (running.length === 0)
|
|
444
|
+
go();
|
|
445
|
+
else
|
|
446
|
+
void Promise.allSettled(running.map((a) => a.finished)).then(go);
|
|
447
|
+
}
|
|
448
|
+
/** Mark an element as leaving: no longer live, and taking no input. */
|
|
449
|
+
function leaving(el, live, stamp) {
|
|
450
|
+
el.removeAttribute(live);
|
|
451
|
+
el.removeAttribute(ATTR_ROW_REQUESTED);
|
|
452
|
+
el.setAttribute(stamp, "");
|
|
453
|
+
el.setAttribute("inert", "");
|
|
454
|
+
}
|
|
455
|
+
/** A live row the answer took away: it leaves, and the list forgets it. */
|
|
456
|
+
function leaveRow(list, row) {
|
|
457
|
+
list.data.delete(row);
|
|
458
|
+
const group = list.groupOf.get(row);
|
|
459
|
+
if (group)
|
|
460
|
+
group.rows = group.rows.filter((r) => r !== row);
|
|
461
|
+
leaving(row, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
|
|
462
|
+
depart([row]);
|
|
463
|
+
}
|
|
464
|
+
/** A group with no rows left, and every group inside it, leave. */
|
|
465
|
+
function leaveGroup(group) {
|
|
466
|
+
for (const child of group.children)
|
|
467
|
+
leaveGroup(child);
|
|
468
|
+
for (const node of group.nodes) {
|
|
469
|
+
if (node.nodeType === ELEMENT_NODE) {
|
|
470
|
+
leaving(node, ATTR_GROUP_LIVE, ATTR_GROUP_LEAVING);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
depart(group.nodes);
|
|
474
|
+
}
|
|
475
|
+
/** A new group, cloned from the group template for its level and stamped. */
|
|
476
|
+
function makeGroup(doc, template, id, term, value) {
|
|
477
|
+
// Imported rather than cloned, for the reason a live row is.
|
|
478
|
+
const fragment = doc.importNode(template.content, true);
|
|
479
|
+
const slot = slotIn(fragment);
|
|
480
|
+
const nodes = [...fragment.childNodes];
|
|
481
|
+
for (const node of nodes) {
|
|
482
|
+
if (node.nodeType !== ELEMENT_NODE || node === slot)
|
|
483
|
+
continue;
|
|
484
|
+
const el = node;
|
|
485
|
+
el.setAttribute(ATTR_GROUP_LIVE, "");
|
|
486
|
+
if (term)
|
|
487
|
+
el.setAttribute(ATTR_GROUP_COLUMN, term.column);
|
|
488
|
+
if (value === null || value === undefined) {
|
|
489
|
+
el.removeAttribute(ATTR_GROUP_VALUE);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
el.setAttribute(ATTR_GROUP_VALUE, String(value));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { id, nodes, slot, children: [], rows: [], placed: false };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Which entries of a sequence of old positions keep their place: the run
|
|
499
|
+
* already in order that keeps the most rows whose values did not change,
|
|
500
|
+
* and then the most rows. Everything else moves, so the row that moves is the
|
|
501
|
+
* one whose values put it somewhere else, never a neighbour it passed, and a
|
|
502
|
+
* list landed again unchanged moves nothing. -1 is a row with no old
|
|
503
|
+
* position here.
|
|
504
|
+
*/
|
|
505
|
+
function inPlace(positions, changed) {
|
|
506
|
+
// Heaviest increasing run, by a running maximum over old positions. An
|
|
507
|
+
// unchanged row outweighs every changed row together.
|
|
508
|
+
const size = positions.length;
|
|
509
|
+
const heavy = size + 1;
|
|
510
|
+
// Indexed by old position, which may run past the rows placed now: rows
|
|
511
|
+
// that left the group in this landing kept theirs.
|
|
512
|
+
const span = Math.max(0, ...positions) + 1;
|
|
513
|
+
const tree = Array.from({ length: span + 1 }, () => ({ weight: 0, at: -1 }));
|
|
514
|
+
const best = new Array(size).fill(0);
|
|
515
|
+
const before = new Array(size).fill(-1);
|
|
516
|
+
let top = { weight: 0, at: -1 };
|
|
517
|
+
for (let i = 0; i < size; i++) {
|
|
518
|
+
const p = positions[i];
|
|
519
|
+
if (p < 0)
|
|
520
|
+
continue;
|
|
521
|
+
let prior = { weight: 0, at: -1 };
|
|
522
|
+
for (let k = p; k > 0; k -= k & -k) {
|
|
523
|
+
if (tree[k].weight > prior.weight)
|
|
524
|
+
prior = tree[k];
|
|
525
|
+
}
|
|
526
|
+
best[i] = prior.weight + (changed[i] ? 1 : heavy);
|
|
527
|
+
before[i] = prior.at;
|
|
528
|
+
const here = { weight: best[i], at: i };
|
|
529
|
+
for (let k = p + 1; k <= span; k += k & -k) {
|
|
530
|
+
if (here.weight > tree[k].weight)
|
|
531
|
+
tree[k] = here;
|
|
532
|
+
}
|
|
533
|
+
if (here.weight > top.weight)
|
|
534
|
+
top = here;
|
|
535
|
+
}
|
|
536
|
+
const keep = new Set();
|
|
537
|
+
for (let i = top.at; i >= 0; i = before[i])
|
|
538
|
+
keep.add(i);
|
|
539
|
+
return keep;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Put a row in front of `ref`. A row already in the document leaves a copy
|
|
543
|
+
* of itself behind, leaving, and is moved rather than taken out and put
|
|
544
|
+
* back, where the browser can, so the control the user is in keeps focus.
|
|
545
|
+
*/
|
|
546
|
+
function moveRow(row, ref, moved) {
|
|
547
|
+
const parent = ref.parentNode;
|
|
548
|
+
if (!parent)
|
|
549
|
+
return;
|
|
550
|
+
if (row.parentNode) {
|
|
551
|
+
const copy = row.cloneNode(true);
|
|
552
|
+
copy.removeAttribute(ATTR_ROW_CREATED);
|
|
553
|
+
copy.removeAttribute(ATTR_ROW_CHANGED);
|
|
554
|
+
copy.removeAttribute(ATTR_ROW_MOVED);
|
|
555
|
+
leaving(copy, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
|
|
556
|
+
row.parentNode.insertBefore(copy, row);
|
|
557
|
+
moved.add(row);
|
|
558
|
+
if (parent.moveBefore && row.isConnected && ref.isConnected) {
|
|
559
|
+
try {
|
|
560
|
+
parent.moveBefore(row, ref);
|
|
561
|
+
depart([copy]);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// Fall back to inserting, which the browser always allows.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
parent.insertBefore(row, ref);
|
|
569
|
+
depart([copy]);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
parent.insertBefore(row, ref);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Place a list's live rows in the order given: groups made where the order
|
|
576
|
+
* breaks and gone where it no longer does, and each row moved only when it
|
|
577
|
+
* is out of place. Groups never move. Their order follows the leading terms,
|
|
578
|
+
* so it changes only when the order does, and then every group at that level
|
|
579
|
+
* is made again.
|
|
580
|
+
*/
|
|
581
|
+
function arrange(list, doc, desired, terms, changed) {
|
|
582
|
+
const moved = new Set();
|
|
583
|
+
const retired = [];
|
|
584
|
+
const placeRows = (group, rows) => {
|
|
585
|
+
const was = new Map();
|
|
586
|
+
group.rows.forEach((row, i) => {
|
|
587
|
+
if (list.groupOf.get(row) === group && list.data.has(row))
|
|
588
|
+
was.set(row, i);
|
|
589
|
+
});
|
|
590
|
+
const keep = inPlace(rows.map((row) => was.get(row) ?? -1), rows.map((row) => changed.has(row)));
|
|
591
|
+
let ref = group.slot;
|
|
592
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
593
|
+
const row = rows[i];
|
|
594
|
+
if (!keep.has(i))
|
|
595
|
+
moveRow(row, ref, moved);
|
|
596
|
+
ref = row;
|
|
597
|
+
}
|
|
598
|
+
group.rows = rows;
|
|
599
|
+
for (const row of rows)
|
|
600
|
+
list.groupOf.set(row, group);
|
|
601
|
+
};
|
|
602
|
+
const visit = (parent, level, rows) => {
|
|
603
|
+
if (level === list.levels.length) {
|
|
604
|
+
placeRows(parent, rows);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const term = terms[level];
|
|
608
|
+
const runs = [];
|
|
609
|
+
for (const row of rows) {
|
|
610
|
+
const value = term ? list.data.get(row)[term.column] : null;
|
|
611
|
+
const id = groupId(term?.column ?? "", value);
|
|
612
|
+
const last = runs.at(-1);
|
|
613
|
+
if (last?.id === id)
|
|
614
|
+
last.rows.push(row);
|
|
615
|
+
else
|
|
616
|
+
runs.push({ id, value, rows: [row] });
|
|
617
|
+
}
|
|
618
|
+
// Groups a level keeps must still be in the order the runs are in.
|
|
619
|
+
// When they are not, the order changed, and every one is made again.
|
|
620
|
+
const at = new Map(runs.map((run, i) => [run.id, i]));
|
|
621
|
+
let last = -1;
|
|
622
|
+
let kept = true;
|
|
623
|
+
for (const child of parent.children) {
|
|
624
|
+
const i = at.get(child.id);
|
|
625
|
+
if (i === undefined)
|
|
626
|
+
continue;
|
|
627
|
+
if (i < last)
|
|
628
|
+
kept = false;
|
|
629
|
+
last = i;
|
|
630
|
+
}
|
|
631
|
+
const existing = new Map(kept ? parent.children.map((child) => [child.id, child]) : []);
|
|
632
|
+
const next = runs.map((run) => existing.get(run.id) ??
|
|
633
|
+
makeGroup(doc, list.levels[level], run.id, term, run.value));
|
|
634
|
+
for (const child of parent.children) {
|
|
635
|
+
if (!next.includes(child))
|
|
636
|
+
retired.push(child);
|
|
637
|
+
}
|
|
638
|
+
let ref = parent.slot;
|
|
639
|
+
for (let i = next.length - 1; i >= 0; i--) {
|
|
640
|
+
const group = next[i];
|
|
641
|
+
if (!group.placed) {
|
|
642
|
+
for (const node of group.nodes)
|
|
643
|
+
ref.parentNode.insertBefore(node, ref);
|
|
644
|
+
group.placed = true;
|
|
645
|
+
}
|
|
646
|
+
ref = group.nodes[0] ?? group.slot;
|
|
647
|
+
}
|
|
648
|
+
parent.children = next;
|
|
649
|
+
runs.forEach((run, i) => visit(next[i], level + 1, run.rows));
|
|
650
|
+
};
|
|
651
|
+
visit(list.root, 0, desired);
|
|
652
|
+
// Only now, when every row that was in them has gone to its new place.
|
|
653
|
+
for (const group of retired)
|
|
654
|
+
leaveGroup(group);
|
|
655
|
+
return moved;
|
|
656
|
+
}
|
|
657
|
+
/** A plain decimal number, as JSON or Postgres writes one. */
|
|
658
|
+
const DECIMAL = /^[+-]?\d+(\.\d+)?$/;
|
|
659
|
+
function decimalOf(value) {
|
|
660
|
+
const text = typeof value === "number" ? String(value) : value;
|
|
661
|
+
if (typeof text !== "string" || !DECIMAL.test(text.trim()))
|
|
662
|
+
return null;
|
|
663
|
+
const [whole, fraction = ""] = text.trim().split(".");
|
|
664
|
+
return { digits: BigInt(whole + fraction), scale: fraction.length };
|
|
665
|
+
}
|
|
666
|
+
function rescale(d, scale) {
|
|
667
|
+
return d.digits * 10n ** BigInt(scale - d.scale);
|
|
668
|
+
}
|
|
669
|
+
function decimalText(digits, scale) {
|
|
670
|
+
const negative = digits < 0n;
|
|
671
|
+
const text = (negative ? -digits : digits)
|
|
672
|
+
.toString()
|
|
673
|
+
.padStart(scale + 1, "0");
|
|
674
|
+
const whole = text.slice(0, text.length - scale);
|
|
675
|
+
const fraction = scale > 0 ? `.${text.slice(text.length - scale)}` : "";
|
|
676
|
+
return `${negative ? "-" : ""}${whole}${fraction}`;
|
|
677
|
+
}
|
|
678
|
+
/** Aggregates already warned about, so a bad value is reported once. */
|
|
679
|
+
const reported = new Set();
|
|
680
|
+
/**
|
|
681
|
+
* One aggregate over rows. A sum and an average are exact, carried as
|
|
682
|
+
* decimals with as many places as the most precise value, and an average
|
|
683
|
+
* keeps up to two places more. Null and empty values are left out, as SQL
|
|
684
|
+
* leaves them out; a value that is not a number is left out and reported.
|
|
685
|
+
*/
|
|
686
|
+
function aggregateOf(name, column, rows) {
|
|
687
|
+
if (name === ATTR_COUNT)
|
|
688
|
+
return rows.length;
|
|
689
|
+
const values = rows
|
|
690
|
+
.map((row) => row[column])
|
|
691
|
+
.filter((v) => v !== null && v !== undefined && v !== "");
|
|
692
|
+
if (values.length === 0)
|
|
693
|
+
return null;
|
|
694
|
+
if (name === ATTR_MIN || name === ATTR_MAX) {
|
|
695
|
+
const sign = name === ATTR_MIN ? 1 : -1;
|
|
696
|
+
return values.reduce((a, b) => (sign * compareValues(b, a) < 0 ? b : a));
|
|
697
|
+
}
|
|
698
|
+
const decimals = [];
|
|
699
|
+
for (const value of values) {
|
|
700
|
+
const d = decimalOf(value);
|
|
701
|
+
if (d)
|
|
702
|
+
decimals.push(d);
|
|
703
|
+
else if (!reported.has(`${name}:${column}`)) {
|
|
704
|
+
reported.add(`${name}:${column}`);
|
|
705
|
+
console.warn(`lb-hub: ${name}="${column}" left out ${JSON.stringify(value)}, ` +
|
|
706
|
+
`which is not a number`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (decimals.length === 0)
|
|
710
|
+
return null;
|
|
711
|
+
const scale = Math.max(...decimals.map((d) => d.scale));
|
|
712
|
+
const sum = decimals.reduce((total, d) => total + rescale(d, scale), 0n);
|
|
713
|
+
if (name === ATTR_SUM)
|
|
714
|
+
return decimalText(sum, scale);
|
|
715
|
+
// An average, rounded half away from zero at two places past the values',
|
|
716
|
+
// which are then dropped while they are zeros.
|
|
717
|
+
const scaled = sum * 100n;
|
|
718
|
+
const n = BigInt(decimals.length);
|
|
719
|
+
const sign = scaled < 0n ? -1n : 1n;
|
|
720
|
+
let text = decimalText((2n * scaled + sign * n) / (2n * n), scale + 2);
|
|
721
|
+
for (let i = 0; i < 2 && text.endsWith("0"); i++)
|
|
722
|
+
text = text.slice(0, -1);
|
|
723
|
+
return text.endsWith(".") ? text.slice(0, -1) : text;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Set every aggregate in `root` from rows: the root itself when `self`, and
|
|
727
|
+
* every element below it that is not inside a row or a group of its own.
|
|
728
|
+
*/
|
|
729
|
+
function aggregate(root, rows, self) {
|
|
730
|
+
const found = below(root, AGGREGATE, SCAFFOLD_STOP);
|
|
731
|
+
if (self && root.matches(AGGREGATE))
|
|
732
|
+
found.unshift(root);
|
|
733
|
+
for (const el of found) {
|
|
734
|
+
const name = AGGREGATES.find((a) => el.hasAttribute(a));
|
|
735
|
+
land(el, aggregateOf(name, el.getAttribute(name) ?? "", rows));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Fill each group's heading from its first row, and set each group's
|
|
740
|
+
* aggregates from its rows, all the way down.
|
|
741
|
+
*/
|
|
742
|
+
function finish(list, group) {
|
|
743
|
+
for (const child of group.children)
|
|
744
|
+
finish(list, child);
|
|
745
|
+
const rows = rowsUnder(group);
|
|
746
|
+
const data = rows.map((row) => list.data.get(row));
|
|
747
|
+
for (const node of group.nodes) {
|
|
748
|
+
if (node.nodeType !== ELEMENT_NODE || node === group.slot)
|
|
749
|
+
continue;
|
|
750
|
+
const el = node;
|
|
751
|
+
// A heading's own column is filled, as a live row's is, unless it holds
|
|
752
|
+
// the group's contents, which filling it would replace.
|
|
753
|
+
if (data[0]) {
|
|
754
|
+
applyRow(el, data[0], !el.contains(group.slot), SCAFFOLD_STOP);
|
|
755
|
+
}
|
|
756
|
+
aggregate(el, data, true);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** The order to place rows by, or none when it cannot be read. */
|
|
760
|
+
function termsOf(el, order) {
|
|
761
|
+
try {
|
|
762
|
+
return parseOrder(order ?? "");
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
console.error(`lb-hub: <${el.localName}> ${err.message}`);
|
|
766
|
+
return [];
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Whether a row's columns as landed differ from what they were. */
|
|
770
|
+
function differs(before, after) {
|
|
771
|
+
if (!before)
|
|
772
|
+
return false;
|
|
773
|
+
return Object.entries(after).some(([column, value]) => JSON.stringify(before[column]) !== JSON.stringify(value));
|
|
774
|
+
}
|
|
261
775
|
/**
|
|
262
776
|
* Land rows through a row template.
|
|
263
777
|
*
|
|
264
|
-
* All rows decide membership
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* position.
|
|
778
|
+
* All rows decide membership: a row whose key did not arrive leaves. A patch
|
|
779
|
+
* disturbs only what it names — a row it did not mention keeps its contents,
|
|
780
|
+
* and keeps its place unless a row the patch named now sorts around it.
|
|
268
781
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
782
|
+
* Every live row is placed by the order. With none, all rows show in the
|
|
783
|
+
* order they arrived, and a patch's new rows go last. A row moves only when
|
|
784
|
+
* the order put it somewhere else, so landing the same rows again moves
|
|
785
|
+
* nothing.
|
|
273
786
|
*/
|
|
274
|
-
export function applyRows(el, template, keyColumn, result) {
|
|
787
|
+
export function applyRows(el, template, keyColumn, result, options = {}) {
|
|
788
|
+
const list = listFor(el, template);
|
|
789
|
+
if (!list)
|
|
790
|
+
return;
|
|
275
791
|
const shown = showing(el);
|
|
276
792
|
const host = el;
|
|
277
793
|
const whole = !isPatch(result);
|
|
278
|
-
const landed = new
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
template.parentElement.insertBefore(row, template);
|
|
284
|
-
};
|
|
794
|
+
const landed = new Map();
|
|
795
|
+
const touched = new Map();
|
|
796
|
+
// What a request did is said by this landing alone.
|
|
797
|
+
for (const row of shown.values())
|
|
798
|
+
row.removeAttribute(ATTR_ROW_REQUESTED);
|
|
285
799
|
const upsert = (data) => {
|
|
286
800
|
if (data[keyColumn] === undefined) {
|
|
287
801
|
console.error(`lb-hub: a row for <${el.localName}> has no '${keyColumn}' column`);
|
|
@@ -293,8 +807,8 @@ export function applyRows(el, template, keyColumn, result) {
|
|
|
293
807
|
if (landed.has(key)) {
|
|
294
808
|
console.error(`lb-hub: two rows for <${el.localName}> have the key '${key}'; ` +
|
|
295
809
|
`the last one shows`);
|
|
810
|
+
landed.delete(key);
|
|
296
811
|
}
|
|
297
|
-
landed.add(key);
|
|
298
812
|
let row = shown.get(key);
|
|
299
813
|
const fresh = row === undefined;
|
|
300
814
|
if (!row) {
|
|
@@ -302,47 +816,109 @@ export function applyRows(el, template, keyColumn, result) {
|
|
|
302
816
|
// with no custom element definitions, so a clone would not upgrade
|
|
303
817
|
// until inserted, and a form-associated control would be filled as if
|
|
304
818
|
// it were not one.
|
|
305
|
-
row = el.ownerDocument.importNode(
|
|
819
|
+
row = el.ownerDocument.importNode(list.rowTemplate.content.firstElementChild, true);
|
|
306
820
|
row.setAttribute(ATTR_ROW_LIVE, "");
|
|
307
821
|
row.setAttribute(ATTR_KEY_VALUE, key);
|
|
308
822
|
shown.set(key, row);
|
|
309
823
|
}
|
|
824
|
+
landed.set(key, row);
|
|
825
|
+
const before = list.data.get(row);
|
|
826
|
+
const prior = touched.get(row);
|
|
827
|
+
touched.set(row, {
|
|
828
|
+
fresh: prior?.fresh ?? fresh,
|
|
829
|
+
changed: (prior?.changed ?? false) || differs(before, data),
|
|
830
|
+
});
|
|
831
|
+
list.data.set(row, { ...before, ...data });
|
|
310
832
|
// Fill before insertion. The attributes are already there when the row
|
|
311
833
|
// upgrades, which is the same thing that makes a first landing and a
|
|
312
834
|
// refresh one operation everywhere else.
|
|
313
835
|
applyRow(row, data, true);
|
|
314
|
-
if (fresh || whole)
|
|
315
|
-
place(row, data);
|
|
316
836
|
};
|
|
317
837
|
if (Array.isArray(result)) {
|
|
318
838
|
for (const data of result)
|
|
319
839
|
upsert(data);
|
|
320
|
-
for (const [key, row] of shown)
|
|
840
|
+
for (const [key, row] of shown) {
|
|
321
841
|
if (!landed.has(key))
|
|
322
|
-
row
|
|
842
|
+
leaveRow(list, row);
|
|
843
|
+
}
|
|
323
844
|
}
|
|
324
845
|
else {
|
|
325
846
|
for (const data of result.rows ?? [])
|
|
326
847
|
upsert(data);
|
|
327
|
-
for (const key of result.drop ?? [])
|
|
328
|
-
shown.get(String(key))
|
|
848
|
+
for (const key of result.drop ?? []) {
|
|
849
|
+
const row = shown.get(String(key));
|
|
850
|
+
if (row)
|
|
851
|
+
leaveRow(list, row);
|
|
852
|
+
}
|
|
329
853
|
}
|
|
854
|
+
const terms = termsOf(el, options.order);
|
|
855
|
+
if (list.levels.length > terms.length && !warned.has(el)) {
|
|
856
|
+
warned.add(el);
|
|
857
|
+
console.warn(`lb-hub: <${el.localName}> has ${list.levels.length} group ` +
|
|
858
|
+
`template(s) and an order of ${terms.length} term(s); a level with ` +
|
|
859
|
+
`no term shows its rows as one group`);
|
|
860
|
+
}
|
|
861
|
+
// Before sorting: the order the rows arrived in for all rows, and for a
|
|
862
|
+
// patch the order already showing, with what is new after it. A sort keeps
|
|
863
|
+
// rows that compare equal in this order.
|
|
864
|
+
const live = new Set(list.data.keys());
|
|
865
|
+
let base;
|
|
866
|
+
if (whole) {
|
|
867
|
+
base = [...landed.values()].filter((row) => live.has(row));
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
base = rowsUnder(list.root).filter((row) => live.has(row));
|
|
871
|
+
const seen = new Set(base);
|
|
872
|
+
for (const row of landed.values()) {
|
|
873
|
+
if (!seen.has(row) && live.has(row))
|
|
874
|
+
base.push(row);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const desired = terms.length === 0
|
|
878
|
+
? base
|
|
879
|
+
: base
|
|
880
|
+
.map((row, i) => ({ row, i, data: list.data.get(row) }))
|
|
881
|
+
.sort((a, b) => compareRows(a.data, b.data, terms) || a.i - b.i)
|
|
882
|
+
.map(({ row }) => row);
|
|
883
|
+
const moved = arrange(list, el.ownerDocument, desired, terms, new Set([...touched].filter(([, t]) => t.changed).map(([row]) => row)));
|
|
884
|
+
for (const [row, { fresh, changed }] of touched) {
|
|
885
|
+
if (!list.data.has(row))
|
|
886
|
+
continue;
|
|
887
|
+
const went = moved.has(row);
|
|
888
|
+
restamp(row, ATTR_ROW_CREATED, fresh);
|
|
889
|
+
restamp(row, ATTR_ROW_CHANGED, changed);
|
|
890
|
+
restamp(row, ATTR_ROW_MOVED, went);
|
|
891
|
+
const requested = went
|
|
892
|
+
? REQUESTED_MOVED
|
|
893
|
+
: fresh
|
|
894
|
+
? REQUESTED_CREATED
|
|
895
|
+
: changed
|
|
896
|
+
? REQUESTED_CHANGED
|
|
897
|
+
: null;
|
|
898
|
+
if (options.requested && requested) {
|
|
899
|
+
row.setAttribute(ATTR_ROW_REQUESTED, requested);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
for (const row of moved) {
|
|
903
|
+
if (!touched.has(row))
|
|
904
|
+
restamp(row, ATTR_ROW_MOVED, true);
|
|
905
|
+
}
|
|
906
|
+
finish(list, list.root);
|
|
907
|
+
aggregate(el, rowsUnder(list.root).map((row) => list.data.get(row)), false);
|
|
330
908
|
// Counted from the DOM rather than from either branch above, so all rows
|
|
331
909
|
// and a patch report the same fact the same way. Only here is the count
|
|
332
910
|
// known: the server answers with rows and says nothing about how many
|
|
333
911
|
// survived reconciliation.
|
|
334
912
|
el.setAttribute(ATTR_QUERY_ROW_COUNT, String(liveRows(el).length));
|
|
335
|
-
// Derived scaffolding — a section heading, an <optgroup> — goes when its
|
|
336
|
-
// last row does, and only the custom element knows it exists.
|
|
337
913
|
host.lbRowsLanded?.();
|
|
338
914
|
}
|
|
339
915
|
/** Land one response item on one element that names its query. */
|
|
340
|
-
function landItem(el, item) {
|
|
341
|
-
const template = templateIn(el);
|
|
916
|
+
function landItem(el, item, options = {}) {
|
|
917
|
+
const template = lists.get(el)?.top ?? templateIn(el);
|
|
342
918
|
if (item.kind === KIND_ROW) {
|
|
343
919
|
const row = item.row ?? {};
|
|
344
920
|
if (template) {
|
|
345
|
-
applyRows(el, template, item.key, [row]);
|
|
921
|
+
applyRows(el, template, item.key, [row], options);
|
|
346
922
|
return;
|
|
347
923
|
}
|
|
348
924
|
applyRow(el, row);
|
|
@@ -357,7 +933,7 @@ function landItem(el, item) {
|
|
|
357
933
|
if (!template)
|
|
358
934
|
return;
|
|
359
935
|
const result = item.patch ?? item.rows ?? [];
|
|
360
|
-
applyRows(el, template, item.key, result);
|
|
936
|
+
applyRows(el, template, item.key, result, options);
|
|
361
937
|
}
|
|
362
938
|
const kept = new WeakMap();
|
|
363
939
|
function keptFor(root) {
|
|
@@ -368,6 +944,14 @@ function keptFor(root) {
|
|
|
368
944
|
}
|
|
369
945
|
return found;
|
|
370
946
|
}
|
|
947
|
+
/** The answer kept for a query, as all of its rows. */
|
|
948
|
+
export function keptAnswer(root, query) {
|
|
949
|
+
return kept.get(root)?.answers.get(query);
|
|
950
|
+
}
|
|
951
|
+
/** The order a query's rows are placed by: the user's, else the server's. */
|
|
952
|
+
function orderOf(state, item) {
|
|
953
|
+
return state.orderFor?.(item.query) ?? item.order;
|
|
954
|
+
}
|
|
371
955
|
/**
|
|
372
956
|
* Drop what landing kept for a root. The hub calls it when it replaces the
|
|
373
957
|
* page, since another page's query of the same name is another query.
|
|
@@ -430,15 +1014,16 @@ function waiting(el) {
|
|
|
430
1014
|
* Filling one can build more — a live row holding a picker of its own — so
|
|
431
1015
|
* it goes round until a pass finds nothing new.
|
|
432
1016
|
*/
|
|
433
|
-
function fillArrivals(root,
|
|
1017
|
+
function fillArrivals(root, state) {
|
|
1018
|
+
const { answers, landed } = state;
|
|
434
1019
|
for (let filled = true; filled;) {
|
|
435
1020
|
filled = false;
|
|
436
1021
|
for (const [query, item] of answers) {
|
|
437
1022
|
for (const el of everywhere(root, `[${ATTR_QUERY}="${query}"]`)) {
|
|
438
|
-
if (landed.has(el) || waiting(el))
|
|
1023
|
+
if (landed.has(el) || waiting(el) || el.closest(LEAVING))
|
|
439
1024
|
continue;
|
|
440
1025
|
landed.add(el);
|
|
441
|
-
landItem(el, item);
|
|
1026
|
+
landItem(el, item, { order: orderOf(state, item) });
|
|
442
1027
|
filled = true;
|
|
443
1028
|
}
|
|
444
1029
|
}
|
|
@@ -447,28 +1032,41 @@ function fillArrivals(root, { answers, landed }) {
|
|
|
447
1032
|
/**
|
|
448
1033
|
* Land response items. Every answer arrives through here, and is kept for
|
|
449
1034
|
* `root`, so that what arrives after it is filled from it — see forget().
|
|
450
|
-
*
|
|
451
|
-
* `quiet` names queries that may have nowhere to land without a warning: the
|
|
452
|
-
* hub's own `lb-url` lands wherever a chrome or page names it, and nowhere
|
|
453
|
-
* otherwise.
|
|
454
1035
|
*/
|
|
455
|
-
export function applyResponse(root, items,
|
|
456
|
-
const
|
|
1036
|
+
export function applyResponse(root, items, options = {}) {
|
|
1037
|
+
const state = keptFor(root);
|
|
1038
|
+
if (options.orderFor)
|
|
1039
|
+
state.orderFor = options.orderFor;
|
|
457
1040
|
for (const item of items) {
|
|
458
|
-
keep(
|
|
459
|
-
const targets = everywhere(root, `[${ATTR_QUERY}="${item.query}"]`);
|
|
1041
|
+
keep(state.answers, item);
|
|
1042
|
+
const targets = everywhere(root, `[${ATTR_QUERY}="${item.query}"]`).filter((el) => !el.closest(LEAVING));
|
|
460
1043
|
if (targets.length === 0) {
|
|
461
|
-
if (!quiet
|
|
1044
|
+
if (!options.quiet?.includes(item.query)) {
|
|
462
1045
|
console.warn(`lb-hub: nothing names '${item.query}', skipping`);
|
|
463
1046
|
}
|
|
464
1047
|
continue;
|
|
465
1048
|
}
|
|
466
1049
|
for (const el of targets) {
|
|
467
1050
|
if (!waiting(el))
|
|
468
|
-
|
|
469
|
-
landItem(el, item
|
|
1051
|
+
state.landed.add(el);
|
|
1052
|
+
landItem(el, item, {
|
|
1053
|
+
order: orderOf(state, item),
|
|
1054
|
+
requested: options.requested,
|
|
1055
|
+
});
|
|
470
1056
|
}
|
|
471
1057
|
}
|
|
472
|
-
fillArrivals(root,
|
|
1058
|
+
fillArrivals(root, state);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Land a query's kept answer again, as all of its rows: what the hub does
|
|
1062
|
+
* when the user's order for it changes and the rows themselves have not.
|
|
1063
|
+
* False when no answer is kept.
|
|
1064
|
+
*/
|
|
1065
|
+
export function reland(root, query) {
|
|
1066
|
+
const item = keptAnswer(root, query);
|
|
1067
|
+
if (!item)
|
|
1068
|
+
return false;
|
|
1069
|
+
applyResponse(root, [item]);
|
|
1070
|
+
return true;
|
|
473
1071
|
}
|
|
474
1072
|
//# sourceMappingURL=lb-apply.js.map
|