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