@loadbare/app 0.15.0 → 0.17.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 (46) hide show
  1. package/README.md +10 -14
  2. package/dist/build/assemble.d.ts.map +1 -1
  3. package/dist/build/assemble.js +15 -1
  4. package/dist/build/assemble.js.map +1 -1
  5. package/dist/core/lb-constants.d.ts +6 -2
  6. package/dist/core/lb-constants.d.ts.map +1 -1
  7. package/dist/core/lb-constants.js +38 -8
  8. package/dist/core/lb-constants.js.map +1 -1
  9. package/dist/core/lb-types.d.ts +57 -20
  10. package/dist/core/lb-types.d.ts.map +1 -1
  11. package/dist/core/lb-types.js +122 -11
  12. package/dist/core/lb-types.js.map +1 -1
  13. package/dist/hub/lb-apply.d.ts +44 -25
  14. package/dist/hub/lb-apply.d.ts.map +1 -1
  15. package/dist/hub/lb-apply.js +702 -424
  16. package/dist/hub/lb-apply.js.map +1 -1
  17. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  18. package/dist/hub/lb-hub.browser.js +77 -59
  19. package/dist/hub/lb-hub.browser.js.map +1 -1
  20. package/dist/hub/lb-lifecycle.d.ts +1 -2
  21. package/dist/hub/lb-lifecycle.d.ts.map +1 -1
  22. package/dist/hub/lb-lifecycle.js +9 -3
  23. package/dist/hub/lb-lifecycle.js.map +1 -1
  24. package/dist/server/lb-server.d.ts +29 -12
  25. package/dist/server/lb-server.d.ts.map +1 -1
  26. package/dist/server/lb-server.js +72 -11
  27. package/dist/server/lb-server.js.map +1 -1
  28. package/docs/TECHREF-1.0.md +162 -48
  29. package/docs/comparison.md +9 -8
  30. package/docs/reference/chrome.md +8 -5
  31. package/docs/reference/custom-elements.md +29 -9
  32. package/docs/reference/data-binding.md +130 -22
  33. package/docs/reference/page-files.md +51 -7
  34. package/docs/reference/widgets.md +5 -1
  35. package/docs/roadmap.md +8 -0
  36. package/docs/testing.md +6 -2
  37. package/docs/theory-old.md +839 -0
  38. package/docs/theory.md +113 -839
  39. package/package.json +1 -1
  40. package/skills/loadbare-app/SKILL.md +28 -15
  41. package/skills/loadbare-app/references/TECHREF-1.0.md +162 -48
  42. package/skills/loadbare-app/references/chrome.md +8 -5
  43. package/skills/loadbare-app/references/custom-elements.md +29 -9
  44. package/skills/loadbare-app/references/data-binding.md +130 -22
  45. package/skills/loadbare-app/references/page-files.md +51 -7
  46. package/skills/loadbare-app/references/widgets.md +5 -1
@@ -25,31 +25,28 @@
25
25
  * Every custom element landing places is told so once the landing is done,
26
26
  * and every one it removes is told so once it is gone — see lb-lifecycle.
27
27
  *
28
- * A `rows` query is placed by its order: the one the URL carries for it, or
29
- * the one the server declared, or, with neither, the order its rows arrived
30
- * in. Groups are built from the markup's group templates as the order breaks,
31
- * and every change to a list — a row created, changed, moved or leaving, a
32
- * group appearing or leaving — is one a stylesheet can see.
28
+ * A `rows` query is placed by its order: the user's, from the choice its
29
+ * element's `lb-order` names, or the one the server declared, or, with
30
+ * neither, the order its rows arrived in. Groups are built from the markup's
31
+ * group templates as the order breaks, and every change to a list — a row
32
+ * created, changed, moved or leaving, a group appearing or leaving — is one
33
+ * a stylesheet can see. A row a filter the user chose does not pass is
34
+ * hidden.
33
35
  *
34
36
  * A condition is the one other thing landing moves. An element whose
35
37
  * `lb-show` column is off stands in the document inside a template, and
36
38
  * landing reaches in there as it reaches everywhere else, so a branch that
37
39
  * returns is already current. Nothing but landing reaches in.
38
40
  */
39
- 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";
40
- import { compareRows, compareValues, isPatch, parseOrder, } from "../core/lb-types.js";
41
+ import { AGGREGATES, ATTR_COLUMN, ATTR_FILTER, ATTR_ORDER, CHOICES_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_COUNT, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_MAX, ATTR_MIN, ATTR_QUERY, ATTR_QUERY_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";
42
+ import { compareRows, compareValues, isPatch, parseFilter, parseOrder, passes, readAs, } from "../core/lb-types.js";
41
43
  import { attachPlaced, detachRemoved, LEAVING, PARKED, placed, upgradeAbsent, } from "./lb-lifecycle.js";
42
44
  /** A DocumentFragment's nodeType. Tier 3 has no `Node` global to read it from. */
43
45
  const FRAGMENT_NODE = 11;
44
46
  /** An Element's nodeType, for the same reason. */
45
47
  const ELEMENT_NODE = 1;
46
- /**
47
- * What a group's heading and aggregates stop at: the rows and groups inside
48
- * it, live or leaving, which are filled from rows of their own.
49
- */
50
- const SCAFFOLD_STOP = `${LIVE_ROW}, ${LIVE_GROUP}, ${LEAVING}`;
51
- /** An element carrying any aggregate. */
52
- const AGGREGATE = AGGREGATES.map((name) => `[${name}]`).join(", ");
48
+ /** A Text node's nodeType, for the same reason. */
49
+ const TEXT_NODE = 3;
53
50
  /**
54
51
  * The input types whose state is not their `value`, or cannot be set. A
55
52
  * checkbox's state is `checked`, and it is a control by its own rule.
@@ -133,9 +130,29 @@ function land(el, value) {
133
130
  return;
134
131
  }
135
132
  else if (!el.localName.includes("-")) {
136
- el.textContent = value;
133
+ setText(el, String(value));
137
134
  }
138
- el.setAttribute(ATTR_COLUMN_VALUE, value);
135
+ setStamp(el, ATTR_COLUMN_VALUE, String(value));
136
+ }
137
+ /**
138
+ * Set an element's text, unless it already holds exactly that text. Writing
139
+ * the same text again replaces the text node, which is two mutations and a
140
+ * style check for nothing.
141
+ */
142
+ function setText(el, text) {
143
+ const only = el.firstChild;
144
+ const same = only
145
+ ? only === el.lastChild &&
146
+ only.nodeType === TEXT_NODE &&
147
+ only.data === text
148
+ : text === "";
149
+ if (!same)
150
+ el.textContent = text;
151
+ }
152
+ /** Set an attribute, unless it already has that value. */
153
+ function setStamp(el, name, value) {
154
+ if (el.getAttribute(name) !== value)
155
+ el.setAttribute(name, value);
139
156
  }
140
157
  /**
141
158
  * Whether an element under `root` belongs to root's query, rather than to a
@@ -250,38 +267,160 @@ function present(el, on) {
250
267
  parked.setAttribute(ATTR_SHOW, el.getAttribute(ATTR_SHOW));
251
268
  el.replaceWith(parked);
252
269
  parked.content.append(el);
270
+ parkedIn.set(el, parked);
253
271
  }
272
+ /** The template each absent element was last put in. */
273
+ const parkedIn = new WeakMap();
254
274
  /**
255
- * Fill from one row: every `lb-column` and `lb-show` in root's query.
275
+ * Put a live row's element in the document or take it out, the element
276
+ * itself being known. Its template, while it is in one, decides first, as it
277
+ * does when landing finds both.
278
+ */
279
+ function presentPlaced(el, on) {
280
+ const parked = parkedIn.get(el);
281
+ if (parked && el.parentNode === parked.content)
282
+ present(parked, on);
283
+ present(el, on);
284
+ }
285
+ function addPlace(map, name, el) {
286
+ const list = map.get(name);
287
+ if (list)
288
+ list.push(el);
289
+ else
290
+ map.set(name, [el]);
291
+ }
292
+ /**
293
+ * Note an element among the places: where its `lb-column` lands, and what
294
+ * its `lb-show` decides. A template holding an absent branch decides for the
295
+ * branch it holds, and is noted as that branch's.
296
+ */
297
+ function note(places, el) {
298
+ const column = el.getAttribute(ATTR_COLUMN);
299
+ if (column !== null)
300
+ addPlace(places.columns, column, el);
301
+ const show = el.getAttribute(ATTR_SHOW);
302
+ if (show === null)
303
+ return;
304
+ if (el.localName === "template") {
305
+ const branch = el.content.firstElementChild;
306
+ if (branch)
307
+ parkedIn.set(branch, el);
308
+ return;
309
+ }
310
+ addPlace(places.shows, show, el);
311
+ }
312
+ function noPlaces() {
313
+ return { columns: new Map(), shows: new Map() };
314
+ }
315
+ /**
316
+ * The places in root's query, found by searching: root's own `lb-column`
317
+ * when `self`, and every one below it short of `stop`, absent branches
318
+ * included.
319
+ */
320
+ function search(root, self, stop) {
321
+ const places = noPlaces();
322
+ if (self && root.hasAttribute(ATTR_COLUMN)) {
323
+ addPlace(places.columns, root.getAttribute(ATTR_COLUMN), root);
324
+ }
325
+ for (const el of below(root, `[${ATTR_COLUMN}], [${ATTR_SHOW}]`, stop)) {
326
+ note(places, el);
327
+ }
328
+ return places;
329
+ }
330
+ function kids(el) {
331
+ return el.localName === "template"
332
+ ? el.content.children
333
+ : el.children;
334
+ }
335
+ /**
336
+ * Where every place below a row template's root lies, worked out once for
337
+ * the template and followed in each copy of it. The rule is search()'s: a
338
+ * nested query's own attributes are this row's and what is inside it is not,
339
+ * and template content is entered only for an absent branch.
340
+ */
341
+ function compile(row) {
342
+ const paths = [];
343
+ const walk = (el, path) => {
344
+ const children = kids(el);
345
+ for (let i = 0; i < children.length; i++) {
346
+ const child = children[i];
347
+ const at = [...path, i];
348
+ if (child.hasAttribute(ATTR_COLUMN) || child.hasAttribute(ATTR_SHOW)) {
349
+ paths.push(at);
350
+ }
351
+ if (child.hasAttribute(ATTR_QUERY))
352
+ continue;
353
+ if (child.localName === "template" && !child.hasAttribute(ATTR_SHOW)) {
354
+ continue;
355
+ }
356
+ walk(child, at);
357
+ }
358
+ };
359
+ walk(row, []);
360
+ return paths;
361
+ }
362
+ /**
363
+ * A live row's places, by its template's paths: its root's own `lb-column`,
364
+ * and the element at the end of each path. Followed before anything lands,
365
+ * while the copy is as the template made it.
366
+ */
367
+ function placesBy(row, paths) {
368
+ const places = noPlaces();
369
+ if (row.hasAttribute(ATTR_COLUMN)) {
370
+ addPlace(places.columns, row.getAttribute(ATTR_COLUMN), row);
371
+ }
372
+ for (const path of paths) {
373
+ let el = row;
374
+ for (const i of path)
375
+ el = kids(el)[i];
376
+ note(places, el);
377
+ }
378
+ return places;
379
+ }
380
+ /** Whether two column values are the same value. */
381
+ function same(a, b) {
382
+ if (a === b)
383
+ return true;
384
+ // Loadbare does not read a non-atomic value, but one may still arrive.
385
+ return (typeof a === "object" &&
386
+ typeof b === "object" &&
387
+ a !== null &&
388
+ b !== null &&
389
+ JSON.stringify(a) === JSON.stringify(b));
390
+ }
391
+ /**
392
+ * Land a row on its places: each column's value where it lands, and the
393
+ * conditions it decides.
256
394
  *
257
- * A live row is its own row, so its root counts when it carries
258
- * `lb-column`: `<option>`'s content model is text, so an option row has to
259
- * be the column it displays. An element a `row` lands on is not its own
260
- * ancestor, so its own `lb-column` reads from the row around it and is left
261
- * alone here.
395
+ * Only null and false are off. A string is never read, so "false" is on, and
396
+ * the query spells a condition as a boolean or a null.
262
397
  */
263
- export function applyRow(root, row, liveRow = false, stop) {
398
+ function fill(places, row) {
264
399
  for (const [column, value] of Object.entries(row)) {
265
- const selector = `[${ATTR_COLUMN}="${column}"]`;
266
- const columns = below(root, selector, stop);
267
- if (liveRow && root.matches(selector))
268
- columns.unshift(root);
269
- for (const el of columns)
400
+ for (const el of places.columns.get(column) ?? [])
270
401
  land(el, value);
271
- // Only null and false are off. A string is never read, so "false" is on,
272
- // and the query spells a condition as a boolean or a null.
273
- //
274
- // Never the root's own: a row template's root carries none, and the
275
- // element a row lands on reads its condition from the row around it.
276
402
  const on = value !== null && value !== false;
277
- for (const el of below(root, `[${ATTR_SHOW}="${column}"]`, stop)) {
278
- present(el, on);
279
- }
280
- for (const el of below(root, `[${ATTR_SHOW}="${SHOW_NOT}${column}"]`, stop)) {
281
- present(el, !on);
403
+ for (const el of places.shows.get(column) ?? [])
404
+ presentPlaced(el, on);
405
+ for (const el of places.shows.get(`${SHOW_NOT}${column}`) ?? []) {
406
+ presentPlaced(el, !on);
282
407
  }
283
408
  }
284
409
  }
410
+ /**
411
+ * Fill from one row: every `lb-column` and `lb-show` in root's query.
412
+ *
413
+ * With `self`, root's own `lb-column` counts, as a live row's does:
414
+ * `<option>`'s content model is text, so an option row has to be the column
415
+ * it displays. An element a `row` lands on is not its own ancestor, so its
416
+ * own `lb-column` reads from the row around it and is left alone here. Root's
417
+ * own `lb-show` never counts.
418
+ *
419
+ * `stop` passes over everything matching it and inside it.
420
+ */
421
+ export function applyRow(root, row, self = false, stop) {
422
+ fill(search(root, self, stop), row);
423
+ }
285
424
  /**
286
425
  * A row naming every column root's query shows, each null: what landing it
287
426
  * leaves is root as it stands with no row. Absent branches are counted, so a
@@ -308,34 +447,40 @@ export function templateIn(el) {
308
447
  const template = within(el, `template:not([${ATTR_SHOW}])`).find((t) => t !== el);
309
448
  return template ?? null;
310
449
  }
450
+ /** What can take focus, before asking whether it can be operated. */
451
+ const FOCUSABLE = `a[href], button, input:not([type="hidden"]), select, textarea, ` +
452
+ `summary, [tabindex]:not([tabindex="-1"]), ` +
453
+ `[contenteditable]:not([contenteditable="false"])`;
311
454
  /**
312
- * The live rows of the element's query, in document order. A row of a nested
313
- * query is that query's. An element a `row` query landed on carries a key and
314
- * is not a live row, so a `row` shown in a table's head or foot is not one of
315
- * the table's rows.
455
+ * Focus the first element in `root` the user can operate: not disabled, not
456
+ * in a closed dialog, not inert, not hidden, and one that took the focus
457
+ * when asked. Nothing in a `<template>` is a candidate, so an absent
458
+ * `lb-show` branch is passed over.
316
459
  */
317
- function liveRows(el) {
318
- return [...el.querySelectorAll(LIVE_ROW)].filter((row) => inScope(el, row));
319
- }
320
- /**
321
- * The live rows already showing, by the key each carries. Two live rows with
322
- * one key are a mistake nothing downstream can recover from: only one of them
323
- * would ever be matched, and the other would stay until the page goes.
324
- */
325
- function showing(el) {
326
- const rows = new Map();
327
- for (const row of liveRows(el)) {
328
- const key = row.getAttribute(ATTR_KEY_VALUE);
329
- if (rows.has(key)) {
330
- console.error(`lb-hub: <${el.localName}> shows two rows with the key '${key}'`);
331
- }
332
- rows.set(key, row);
460
+ export function focusIn(root) {
461
+ const doc = root.ownerDocument;
462
+ for (const el of root.querySelectorAll(FOCUSABLE)) {
463
+ if (el.matches(":disabled"))
464
+ continue;
465
+ if (el.closest(`dialog:not([open]), [inert], [hidden]`))
466
+ continue;
467
+ if (el.checkVisibility?.() === false)
468
+ continue;
469
+ el.focus();
470
+ if (doc.activeElement === el)
471
+ return;
333
472
  }
334
- return rows;
335
473
  }
336
474
  const lists = new WeakMap();
337
475
  /** Elements already told their group templates outrun their order. */
338
476
  const warned = new WeakSet();
477
+ /**
478
+ * What a group's heading and aggregates stop at: the rows and groups inside
479
+ * it, live or leaving, which are filled from rows of their own.
480
+ */
481
+ const SCAFFOLD_STOP = `${LIVE_ROW}, ${LIVE_GROUP}, ${LEAVING}`;
482
+ /** An element carrying any aggregate. */
483
+ const AGGREGATE = AGGREGATES.map((name) => `[${name}]`).join(", ");
339
484
  /**
340
485
  * The template a group's contents go in front of: the first `<template>` in
341
486
  * the content outside any nested `lb-query`, and not one holding an absent
@@ -348,6 +493,16 @@ function slotIn(root) {
348
493
  }
349
494
  return null;
350
495
  }
496
+ /**
497
+ * The aggregates in root's scaffolding: root itself when `self`, and every
498
+ * one below it that is not inside a row or a group of its own.
499
+ */
500
+ function aggregatesIn(root, self) {
501
+ const found = below(root, AGGREGATE, SCAFFOLD_STOP);
502
+ if (self && root.matches(AGGREGATE))
503
+ found.unshift(root);
504
+ return found;
505
+ }
351
506
  /**
352
507
  * The list an element holds, found from its first template the first time
353
508
  * rows land on it, and kept after that: once groups exist, their slots are
@@ -373,24 +528,37 @@ function listFor(el, template) {
373
528
  top: template,
374
529
  levels,
375
530
  rowTemplate: at,
376
- data: new Map(),
377
- groupOf: new WeakMap(),
531
+ byKey: new Map(),
378
532
  root: {
379
533
  id: "",
380
534
  nodes: [],
381
535
  slot: template,
382
536
  children: [],
383
537
  rows: [],
384
- placed: true,
538
+ heads: [],
539
+ aggregates: aggregatesIn(el, false),
540
+ hidden: false,
385
541
  },
386
542
  };
387
543
  // Live rows already in the markup, as a page rendered ahead of its data
388
544
  // ships them, are the list's from the start, in the order they stand.
389
545
  if (levels.length === 0) {
390
- for (const row of liveRows(el)) {
391
- list.data.set(row, {});
392
- list.groupOf.set(row, list.root);
393
- list.root.rows.push(row);
546
+ for (const row of el.querySelectorAll(LIVE_ROW)) {
547
+ if (!inScope(el, row))
548
+ continue;
549
+ const key = row.getAttribute(ATTR_KEY_VALUE);
550
+ if (list.byKey.has(key)) {
551
+ console.error(`lb-hub: <${el.localName}> shows two rows with the key '${key}'`);
552
+ }
553
+ const entry = {
554
+ key,
555
+ el: row,
556
+ places: search(row, true),
557
+ data: {},
558
+ hidden: false,
559
+ };
560
+ list.byKey.set(key, entry);
561
+ list.root.rows.push(entry);
394
562
  }
395
563
  }
396
564
  lists.set(el, list);
@@ -402,74 +570,36 @@ function rowsUnder(group) {
402
570
  ? group.children.flatMap(rowsUnder)
403
571
  : group.rows;
404
572
  }
405
- /** A group's identity among its siblings: its column and its value there. */
406
- function groupId(column, value) {
407
- return `${column}\u0000${JSON.stringify(value ?? null)}`;
408
- }
409
573
  /**
410
- * Make an attribute's change one a stylesheet sees, even when it is removed
411
- * and added again in one landing: the style is computed between the two.
574
+ * A new live row for a key, copied from the row template: upgraded, stamped
575
+ * with its key, its places found by the template's paths, and no values yet.
576
+ * It is in no document until it is placed.
412
577
  */
413
- function restamp(el, name, on, value = "") {
414
- const had = el.hasAttribute(name);
415
- if (had)
416
- el.removeAttribute(name);
417
- if (!on)
418
- return;
419
- if (had)
420
- void el.ownerDocument.defaultView?.getComputedStyle(el).animationName;
421
- el.setAttribute(name, value);
422
- }
423
- /**
424
- * Take nodes out of the document once whatever their stylesheet started on
425
- * them has finished: at once when nothing is running. Asking for the running
426
- * animations computes the style, so an animation keyed to the leaving stamp
427
- * just set is among them.
428
- */
429
- function depart(nodes) {
430
- const running = [];
431
- for (const node of nodes) {
432
- if (node.nodeType !== ELEMENT_NODE)
433
- continue;
434
- const el = node;
435
- running.push(...(el.getAnimations?.({ subtree: true }) ?? []));
436
- }
437
- const go = () => {
438
- for (const node of nodes)
439
- node.remove();
440
- detachRemoved(nodes);
578
+ function create(list, doc, key) {
579
+ const template = list.rowTemplate.content.firstElementChild;
580
+ list.paths ??= compile(template);
581
+ // Imported rather than cloned: template content belongs to a document with
582
+ // no custom element definitions, so a clone would not upgrade until
583
+ // inserted, and a form-associated control would be filled as if it were
584
+ // not one.
585
+ const el = doc.importNode(template, true);
586
+ upgradeAbsent(el, doc);
587
+ placed(el);
588
+ el.setAttribute(ATTR_ROW_LIVE, "");
589
+ el.setAttribute(ATTR_KEY_VALUE, key);
590
+ const entry = {
591
+ key,
592
+ el,
593
+ places: placesBy(el, list.paths),
594
+ data: {},
595
+ hidden: false,
441
596
  };
442
- if (running.length === 0)
443
- go();
444
- else
445
- void Promise.allSettled(running.map((a) => a.finished)).then(go);
446
- }
447
- /** Mark an element as leaving: no longer live, and taking no input. */
448
- function leaving(el, live, stamp) {
449
- el.removeAttribute(live);
450
- el.removeAttribute(ATTR_ROW_REQUESTED);
451
- el.setAttribute(stamp, "");
452
- el.setAttribute("inert", "");
597
+ list.byKey.set(key, entry);
598
+ return entry;
453
599
  }
454
- /** A live row the answer took away: it leaves, and the list forgets it. */
455
- function leaveRow(list, row) {
456
- list.data.delete(row);
457
- const group = list.groupOf.get(row);
458
- if (group)
459
- group.rows = group.rows.filter((r) => r !== row);
460
- leaving(row, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
461
- depart([row]);
462
- }
463
- /** A group with no rows left, and every group inside it, leave. */
464
- function leaveGroup(group) {
465
- for (const child of group.children)
466
- leaveGroup(child);
467
- for (const node of group.nodes) {
468
- if (node.nodeType === ELEMENT_NODE) {
469
- leaving(node, ATTR_GROUP_LIVE, ATTR_GROUP_LEAVING);
470
- }
471
- }
472
- depart(group.nodes);
600
+ /** A group's identity among its siblings: its column and its value there. */
601
+ function groupId(column, value) {
602
+ return `${column}\u0000${JSON.stringify(value ?? null)}`;
473
603
  }
474
604
  /** A new group, cloned from the group template for its level and stamped. */
475
605
  function makeGroup(doc, template, id, term, value) {
@@ -478,6 +608,8 @@ function makeGroup(doc, template, id, term, value) {
478
608
  upgradeAbsent(fragment, doc);
479
609
  const slot = slotIn(fragment);
480
610
  const nodes = [...fragment.childNodes];
611
+ const heads = [];
612
+ const aggregates = [];
481
613
  for (const node of nodes) {
482
614
  if (node.nodeType !== ELEMENT_NODE || node === slot)
483
615
  continue;
@@ -492,169 +624,195 @@ function makeGroup(doc, template, id, term, value) {
492
624
  else {
493
625
  el.setAttribute(ATTR_GROUP_VALUE, String(value));
494
626
  }
627
+ // A heading's own column is filled, as a live row's is, unless it holds
628
+ // the group's contents, which filling it would replace.
629
+ heads.push(search(el, !el.contains(slot), SCAFFOLD_STOP));
630
+ aggregates.push(...aggregatesIn(el, true));
495
631
  }
496
- return { id, nodes, slot, children: [], rows: [], placed: false };
632
+ return {
633
+ id,
634
+ nodes,
635
+ slot,
636
+ children: [],
637
+ rows: [],
638
+ heads,
639
+ aggregates,
640
+ hidden: false,
641
+ };
497
642
  }
498
643
  /**
499
- * Which entries of a sequence of old positions keep their place: the run
500
- * already in order that keeps the most rows whose values did not change,
501
- * and then the most rows. Everything else moves, so the row that moves is the
502
- * one whose values put it somewhere else, never a neighbour it passed, and a
503
- * list landed again unchanged moves nothing. -1 is a row with no old
504
- * position here.
644
+ * Put a sequence of items in the document in the order given, in front of
645
+ * `end`, where `old` is the order they stood in. The run they share at the
646
+ * start and the run they share at the end stay where they are, and what lies
647
+ * between goes in as one fragment. An item not in `old` is new, and one in
648
+ * `old` that is not given has gone somewhere else, or is leaving.
505
649
  */
506
- function inPlace(positions, changed) {
507
- // Heaviest increasing run, by a running maximum over old positions. An
508
- // unchanged row outweighs every changed row together.
509
- const size = positions.length;
510
- const heavy = size + 1;
511
- // Indexed by old position, which may run past the rows placed now: rows
512
- // that left the group in this landing kept theirs.
513
- const span = Math.max(0, ...positions) + 1;
514
- const tree = Array.from({ length: span + 1 }, () => ({ weight: 0, at: -1 }));
515
- const best = new Array(size).fill(0);
516
- const before = new Array(size).fill(-1);
517
- let top = { weight: 0, at: -1 };
518
- for (let i = 0; i < size; i++) {
519
- const p = positions[i];
520
- if (p < 0)
521
- continue;
522
- let prior = { weight: 0, at: -1 };
523
- for (let k = p; k > 0; k -= k & -k) {
524
- if (tree[k].weight > prior.weight)
525
- prior = tree[k];
526
- }
527
- best[i] = prior.weight + (changed[i] ? 1 : heavy);
528
- before[i] = prior.at;
529
- const here = { weight: best[i], at: i };
530
- for (let k = p + 1; k <= span; k += k & -k) {
531
- if (here.weight > tree[k].weight)
532
- tree[k] = here;
533
- }
534
- if (here.weight > top.weight)
535
- top = here;
650
+ function placeRun(old, next, end, nodesOf) {
651
+ const most = Math.min(old.length, next.length);
652
+ let start = 0;
653
+ while (start < most && old[start] === next[start])
654
+ start++;
655
+ let tail = 0;
656
+ while (tail < most - start &&
657
+ old[old.length - 1 - tail] === next[next.length - 1 - tail]) {
658
+ tail++;
536
659
  }
537
- const keep = new Set();
538
- for (let i = top.at; i >= 0; i = before[i])
539
- keep.add(i);
540
- return keep;
541
- }
542
- /**
543
- * Put a row in front of `ref`. A row already in the document leaves a copy
544
- * of itself behind, leaving, and is moved rather than taken out and put
545
- * back, where the browser can, so the control the user is in keeps focus.
546
- */
547
- function moveRow(row, ref, moved) {
548
- const parent = ref.parentNode;
549
- if (!parent)
550
- return;
551
- if (row.parentNode) {
552
- const copy = row.cloneNode(true);
553
- copy.removeAttribute(ATTR_ROW_CREATED);
554
- copy.removeAttribute(ATTR_ROW_CHANGED);
555
- copy.removeAttribute(ATTR_ROW_MOVED);
556
- leaving(copy, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
557
- row.parentNode.insertBefore(copy, row);
558
- moved.add(row);
559
- if (parent.moveBefore && row.isConnected && ref.isConnected) {
560
- try {
561
- parent.moveBefore(row, ref);
562
- depart([copy]);
563
- return;
564
- }
565
- catch {
566
- // Fall back to inserting, which the browser always allows.
567
- }
568
- }
569
- parent.insertBefore(row, ref);
570
- depart([copy]);
660
+ const middle = next.slice(start, next.length - tail);
661
+ if (middle.length === 0)
571
662
  return;
572
- }
573
- parent.insertBefore(row, ref);
663
+ const ref = tail > 0 ? nodesOf(next[next.length - tail])[0] : end;
664
+ const fragment = ref.ownerDocument.createDocumentFragment();
665
+ for (const item of middle)
666
+ fragment.append(...nodesOf(item));
667
+ ref.parentNode.insertBefore(fragment, ref);
574
668
  }
575
669
  /**
576
- * Place a list's live rows in the order given: groups made where the order
577
- * breaks and gone where it no longer does, and each row moved only when it
578
- * is out of place. Groups never move. Their order follows the leading terms,
579
- * so it changes only when the order does, and then every group at that level
580
- * is made again.
670
+ * Place a list's live rows in the order given, with groups made where the
671
+ * order breaks, kept while their run goes on, and gone where it no longer
672
+ * does. What leaves is returned, to leave once everything is placed.
581
673
  */
582
- function arrange(list, doc, desired, terms, changed) {
583
- const moved = new Set();
674
+ function arrange(list, doc, order, terms) {
584
675
  const retired = [];
585
- const placeRows = (group, rows) => {
586
- const was = new Map();
587
- group.rows.forEach((row, i) => {
588
- if (list.groupOf.get(row) === group && list.data.has(row))
589
- was.set(row, i);
590
- });
591
- const keep = inPlace(rows.map((row) => was.get(row) ?? -1), rows.map((row) => changed.has(row)));
592
- let ref = group.slot;
593
- for (let i = rows.length - 1; i >= 0; i--) {
594
- const row = rows[i];
595
- if (!keep.has(i))
596
- moveRow(row, ref, moved);
597
- ref = row;
598
- }
599
- group.rows = rows;
600
- for (const row of rows)
601
- list.groupOf.set(row, group);
602
- };
603
- const visit = (parent, level, rows) => {
676
+ const visit = (parent, level, entries) => {
604
677
  if (level === list.levels.length) {
605
- placeRows(parent, rows);
678
+ placeRun(parent.rows, entries, parent.slot, (entry) => [entry.el]);
679
+ parent.rows = entries;
606
680
  return;
607
681
  }
608
682
  const term = terms[level];
609
683
  const runs = [];
610
- for (const row of rows) {
611
- const value = term ? list.data.get(row)[term.column] : null;
684
+ for (const entry of entries) {
685
+ const value = term ? entry.data[term.column] : null;
612
686
  const id = groupId(term?.column ?? "", value);
613
687
  const last = runs.at(-1);
614
688
  if (last?.id === id)
615
- last.rows.push(row);
689
+ last.entries.push(entry);
616
690
  else
617
- runs.push({ id, value, rows: [row] });
691
+ runs.push({ id, value, entries: [entry] });
618
692
  }
619
- // Groups a level keeps must still be in the order the runs are in.
620
- // When they are not, the order changed, and every one is made again.
621
- const at = new Map(runs.map((run, i) => [run.id, i]));
622
- let last = -1;
623
- let kept = true;
693
+ // Each group already here goes on with the first run that has its id.
694
+ const pool = new Map();
624
695
  for (const child of parent.children) {
625
- const i = at.get(child.id);
626
- if (i === undefined)
627
- continue;
628
- if (i < last)
629
- kept = false;
630
- last = i;
696
+ pool.set(child.id, [...(pool.get(child.id) ?? []), child]);
631
697
  }
632
- const existing = new Map(kept ? parent.children.map((child) => [child.id, child]) : []);
633
- const next = runs.map((run) => existing.get(run.id) ??
698
+ const next = runs.map((run) => pool.get(run.id)?.shift() ??
634
699
  makeGroup(doc, list.levels[level], run.id, term, run.value));
635
- for (const child of parent.children) {
636
- if (!next.includes(child))
637
- retired.push(child);
700
+ for (const left of pool.values())
701
+ retired.push(...left);
702
+ placeRun(parent.children, next, parent.slot, (group) => group.nodes);
703
+ parent.children = next;
704
+ runs.forEach((run, i) => visit(next[i], level + 1, run.entries));
705
+ };
706
+ visit(list.root, 0, order);
707
+ return retired;
708
+ }
709
+ /**
710
+ * The rows that moved: rows whose values changed, and which now stand
711
+ * somewhere else among the rows whose values did not. A neighbour a moved
712
+ * row passed did not move, and neither did a row whose new values left it
713
+ * where it was.
714
+ */
715
+ function movedRows(before, after, changed) {
716
+ const moved = new Set();
717
+ if (changed.size === 0)
718
+ return moved;
719
+ const stayed = new Set(before);
720
+ // For each changed row, the nearest unchanged row on either side of it.
721
+ const anchors = (rows) => {
722
+ const around = new Map();
723
+ const kept = rows.filter((row) => stayed.has(row));
724
+ let last = null;
725
+ for (const row of kept) {
726
+ if (changed.has(row))
727
+ around.set(row, [last, null]);
728
+ else
729
+ last = row;
638
730
  }
639
- let ref = parent.slot;
640
- for (let i = next.length - 1; i >= 0; i--) {
641
- const group = next[i];
642
- if (!group.placed) {
643
- for (const node of group.nodes)
644
- ref.parentNode.insertBefore(node, ref);
645
- group.placed = true;
646
- }
647
- ref = group.nodes[0] ?? group.slot;
731
+ last = null;
732
+ for (const row of kept.reverse()) {
733
+ if (changed.has(row))
734
+ around.get(row)[1] = last;
735
+ else
736
+ last = row;
648
737
  }
649
- parent.children = next;
650
- runs.forEach((run, i) => visit(next[i], level + 1, run.rows));
738
+ return around;
651
739
  };
652
- visit(list.root, 0, desired);
653
- // Only now, when every row that was in them has gone to its new place.
654
- for (const group of retired)
655
- leaveGroup(group);
740
+ const was = anchors(before);
741
+ for (const [row, [prior, following]] of anchors(after)) {
742
+ const [p, f] = was.get(row);
743
+ if (p !== prior || f !== following)
744
+ moved.add(row);
745
+ }
656
746
  return moved;
657
747
  }
748
+ /** Mark an element as leaving: no longer live, and taking no input. */
749
+ function leaving(el, live, stamp) {
750
+ el.removeAttribute(live);
751
+ el.removeAttribute(ATTR_ROW_REQUESTED);
752
+ el.setAttribute(stamp, "");
753
+ el.setAttribute("inert", "");
754
+ }
755
+ /** A group whose rows are all gone, and every group inside it, leave. */
756
+ function leaveGroup(group, nodes) {
757
+ for (const child of group.children)
758
+ leaveGroup(child, nodes);
759
+ for (const node of group.nodes) {
760
+ if (node.nodeType === ELEMENT_NODE) {
761
+ leaving(node, ATTR_GROUP_LIVE, ATTR_GROUP_LEAVING);
762
+ }
763
+ }
764
+ nodes.push(...group.nodes);
765
+ }
766
+ /**
767
+ * Take nodes out of the document once whatever their stylesheet started on
768
+ * them has finished: at once when nothing is running. Asking for the running
769
+ * animations computes the style, so it is asked once, after everything a
770
+ * landing writes, and an animation keyed to the leaving stamp is among them.
771
+ */
772
+ function depart(nodes) {
773
+ const running = [];
774
+ for (const node of nodes) {
775
+ if (node.nodeType !== ELEMENT_NODE)
776
+ continue;
777
+ const el = node;
778
+ running.push(...(el.getAnimations?.({ subtree: true }) ?? []));
779
+ }
780
+ const go = () => {
781
+ for (const node of nodes)
782
+ node.remove();
783
+ detachRemoved(nodes);
784
+ };
785
+ if (running.length === 0)
786
+ go();
787
+ else
788
+ void Promise.allSettled(running.map((a) => a.finished)).then(go);
789
+ }
790
+ /**
791
+ * The stamps of what happened to rows, set together: a stamp a row already
792
+ * carries is taken off first and put back after the style has been computed
793
+ * once for all of them, so an animation keyed to it plays again.
794
+ */
795
+ class Stamps {
796
+ again = [];
797
+ set(el, name, on) {
798
+ const had = el.hasAttribute(name);
799
+ if (had)
800
+ el.removeAttribute(name);
801
+ if (!on)
802
+ return;
803
+ if (had)
804
+ this.again.push([el, name]);
805
+ else
806
+ el.setAttribute(name, "");
807
+ }
808
+ done(doc) {
809
+ if (this.again.length === 0)
810
+ return;
811
+ void doc.defaultView?.getComputedStyle(this.again[0][0]).animationName;
812
+ for (const [el, name] of this.again)
813
+ el.setAttribute(name, "");
814
+ }
815
+ }
658
816
  /** A plain decimal number, as JSON or Postgres writes one. */
659
817
  const DECIMAL = /^[+-]?\d+(\.\d+)?$/;
660
818
  function decimalOf(value) {
@@ -684,7 +842,7 @@ const reported = new Set();
684
842
  * keeps up to two places more. Null and empty values are left out, as SQL
685
843
  * leaves them out; a value that is not a number is left out and reported.
686
844
  */
687
- function aggregateOf(name, column, rows) {
845
+ function aggregateOf(name, column, rows, type) {
688
846
  if (name === ATTR_COUNT)
689
847
  return rows.length;
690
848
  const values = rows
@@ -694,7 +852,7 @@ function aggregateOf(name, column, rows) {
694
852
  return null;
695
853
  if (name === ATTR_MIN || name === ATTR_MAX) {
696
854
  const sign = name === ATTR_MIN ? 1 : -1;
697
- return values.reduce((a, b) => (sign * compareValues(b, a) < 0 ? b : a));
855
+ return values.reduce((a, b) => sign * compareValues(readAs(b, type), readAs(a, type)) < 0 ? b : a);
698
856
  }
699
857
  const decimals = [];
700
858
  for (const value of values) {
@@ -723,39 +881,43 @@ function aggregateOf(name, column, rows) {
723
881
  text = text.slice(0, -1);
724
882
  return text.endsWith(".") ? text.slice(0, -1) : text;
725
883
  }
726
- /**
727
- * Set every aggregate in `root` from rows: the root itself when `self`, and
728
- * every element below it that is not inside a row or a group of its own.
729
- */
730
- function aggregate(root, rows, self) {
731
- const found = below(root, AGGREGATE, SCAFFOLD_STOP);
732
- if (self && root.matches(AGGREGATE))
733
- found.unshift(root);
734
- for (const el of found) {
884
+ /** Set each aggregate element from rows, each column read as its type. */
885
+ function aggregate(elements, rows, types) {
886
+ for (const el of elements) {
735
887
  const name = AGGREGATES.find((a) => el.hasAttribute(a));
736
- land(el, aggregateOf(name, el.getAttribute(name) ?? "", rows));
888
+ const column = el.getAttribute(name) ?? "";
889
+ land(el, aggregateOf(name, column, rows, types[column]));
737
890
  }
738
891
  }
892
+ /** Show or hide an element for a filter, saying so only when it changes. */
893
+ function hide(el, hidden) {
894
+ if (el.hasAttribute("hidden") !== hidden)
895
+ el.toggleAttribute("hidden", hidden);
896
+ }
739
897
  /**
740
- * Fill each group's heading from its first row, and set each group's
741
- * aggregates from its rows, all the way down.
898
+ * Finish a group and everything under it, from the rows it shows: each
899
+ * heading filled from the first, each aggregate set from all of them, and a
900
+ * group hidden when a filter hides every row in it. The rows it shows are
901
+ * returned.
742
902
  */
743
- function finish(list, group) {
744
- for (const child of group.children)
745
- finish(list, child);
746
- const rows = rowsUnder(group);
747
- const data = rows.map((row) => list.data.get(row));
748
- for (const node of group.nodes) {
749
- if (node.nodeType !== ELEMENT_NODE || node === group.slot)
750
- continue;
751
- const el = node;
752
- // A heading's own column is filled, as a live row's is, unless it holds
753
- // the group's contents, which filling it would replace.
754
- if (data[0]) {
755
- applyRow(el, data[0], !el.contains(group.slot), SCAFFOLD_STOP);
903
+ function finish(group, types) {
904
+ const rows = group.children.length > 0
905
+ ? group.children.flatMap((child) => finish(child, types))
906
+ : group.rows.filter((entry) => !entry.hidden).map((entry) => entry.data);
907
+ const hidden = rows.length === 0 && rowsUnder(group).length > 0;
908
+ if (hidden !== group.hidden) {
909
+ group.hidden = hidden;
910
+ for (const node of group.nodes) {
911
+ if (node.nodeType === ELEMENT_NODE && node !== group.slot) {
912
+ hide(node, hidden);
913
+ }
756
914
  }
757
- aggregate(el, data, true);
758
915
  }
916
+ if (rows[0])
917
+ for (const head of group.heads)
918
+ fill(head, rows[0]);
919
+ aggregate(group.aggregates, rows, types);
920
+ return rows;
759
921
  }
760
922
  /** The order to place rows by, or none when it cannot be read. */
761
923
  function termsOf(el, order) {
@@ -767,154 +929,228 @@ function termsOf(el, order) {
767
929
  return [];
768
930
  }
769
931
  }
932
+ /** The user's choice in a column of `lb-choices`, when one is made. */
933
+ function choiceIn(choices, column) {
934
+ const value = choices?.[column];
935
+ return value === null || value === undefined || value === ""
936
+ ? undefined
937
+ : String(value);
938
+ }
939
+ /** The columns an element's `lb-filter` names. */
940
+ function filtered(el) {
941
+ return (el.getAttribute(ATTR_FILTER) ?? "")
942
+ .split(",")
943
+ .map((column) => column.trim())
944
+ .filter((column) => column !== "");
945
+ }
946
+ /**
947
+ * The order an element's rows are placed by: the user's, from the column of
948
+ * `lb-choices` its `lb-order` names, else the one the server declared.
949
+ */
950
+ function orderFor(el, options) {
951
+ const column = el.getAttribute(ATTR_ORDER);
952
+ const chosen = column ? choiceIn(options.choices, column) : undefined;
953
+ return chosen ?? options.order;
954
+ }
955
+ /**
956
+ * Whether a row passes every filter the user chose for the element: for each
957
+ * column its `lb-filter` names, the filter in the column of `lb-choices` of
958
+ * the same name.
959
+ */
960
+ function filterFor(el, choices, types) {
961
+ const filters = [];
962
+ for (const column of filtered(el)) {
963
+ const text = choiceIn(choices, column);
964
+ const terms = text === undefined ? [] : parseFilter(text);
965
+ if (terms.length > 0)
966
+ filters.push([column, terms]);
967
+ }
968
+ return (row) => filters.every(([column, terms]) => passes(row[column], terms, types[column]));
969
+ }
970
+ /**
971
+ * Hide or show a row for a filter. A selection hidden is a selection
972
+ * abandoned, as it is for `lb-show`.
973
+ */
974
+ function filterRow(entry, hidden) {
975
+ if (entry.hidden === hidden)
976
+ return;
977
+ entry.hidden = hidden;
978
+ hide(entry.el, hidden);
979
+ if (!hidden)
980
+ return;
981
+ for (const box of [entry.el, ...entry.el.querySelectorAll(`[${ATTR_SELECT}]`)]) {
982
+ if (box.hasAttribute(ATTR_SELECT))
983
+ select(box, false);
984
+ }
985
+ }
770
986
  /** Whether a row's columns as landed differ from what they were. */
771
987
  function differs(before, after) {
772
- if (!before)
773
- return false;
774
- return Object.entries(after).some(([column, value]) => JSON.stringify(before[column]) !== JSON.stringify(value));
988
+ return Object.entries(after).some(([column, value]) => !same(before[column], value));
775
989
  }
776
990
  /**
777
991
  * Land rows through a row template.
778
992
  *
993
+ * Two loops do the work, on rows in no document. The creation loop makes a
994
+ * live row for each key not yet shown, with its key and no values. The
995
+ * landing loop lands each arriving row's values on its live row, as a `row`
996
+ * lands, passing over a column that already shows its value. Then every live
997
+ * row is placed by the order in one fragment, and the browser styles once.
998
+ *
779
999
  * All rows decide membership: a row whose key did not arrive leaves. A patch
780
1000
  * disturbs only what it names — a row it did not mention keeps its contents,
781
1001
  * and keeps its place unless a row the patch named now sorts around it.
782
1002
  *
783
- * Every live row is placed by the order. With none, all rows show in the
784
- * order they arrived, and a patch's new rows go last. A row moves only when
785
- * the order put it somewhere else, so landing the same rows again moves
786
- * nothing.
1003
+ * With no order, all rows show in the order they arrived, and a patch's new
1004
+ * rows go last. A row that stands where it stood is not touched, so landing
1005
+ * the same rows again moves nothing. A row a filter the user chose does not
1006
+ * pass is `hidden`.
787
1007
  */
788
1008
  export function applyRows(el, template, keyColumn, result, options = {}) {
789
1009
  const list = listFor(el, template);
790
1010
  if (!list)
791
1011
  return;
792
- const shown = showing(el);
1012
+ const doc = el.ownerDocument;
793
1013
  const host = el;
794
1014
  const whole = !isPatch(result);
795
- const landed = new Map();
796
- const touched = new Map();
1015
+ const shown = rowsUnder(list.root);
1016
+ const focused = focusedRow(list, doc);
797
1017
  // What a request did is said by this landing alone.
798
- for (const row of shown.values())
799
- row.removeAttribute(ATTR_ROW_REQUESTED);
800
- const upsert = (data) => {
1018
+ for (const entry of shown)
1019
+ entry.el.removeAttribute(ATTR_ROW_REQUESTED);
1020
+ // The rows arriving, by key.
1021
+ const arriving = new Map();
1022
+ for (const data of whole ? result : (result.rows ?? [])) {
801
1023
  if (data[keyColumn] === undefined) {
802
1024
  console.error(`lb-hub: a row for <${el.localName}> has no '${keyColumn}' column`);
803
- return;
1025
+ continue;
804
1026
  }
805
1027
  // The key is stored on the row as an attribute and read back from
806
1028
  // there, so it is compared as the string the attribute holds.
807
1029
  const key = String(data[keyColumn]);
808
- if (landed.has(key)) {
1030
+ if (arriving.has(key)) {
809
1031
  console.error(`lb-hub: two rows for <${el.localName}> have the key '${key}'; ` +
810
1032
  `the last one shows`);
811
- landed.delete(key);
812
- }
813
- let row = shown.get(key);
814
- const fresh = row === undefined;
815
- if (!row) {
816
- // Imported rather than cloned: template content belongs to a document
817
- // with no custom element definitions, so a clone would not upgrade
818
- // until inserted, and a form-associated control would be filled as if
819
- // it were not one.
820
- row = el.ownerDocument.importNode(list.rowTemplate.content.firstElementChild, true);
821
- upgradeAbsent(row, el.ownerDocument);
822
- placed(row);
823
- row.setAttribute(ATTR_ROW_LIVE, "");
824
- row.setAttribute(ATTR_KEY_VALUE, key);
825
- shown.set(key, row);
826
- }
827
- landed.set(key, row);
828
- const before = list.data.get(row);
829
- const prior = touched.get(row);
830
- touched.set(row, {
831
- fresh: prior?.fresh ?? fresh,
832
- changed: (prior?.changed ?? false) || differs(before, data),
833
- });
834
- list.data.set(row, { ...before, ...data });
835
- // Fill before insertion, on a row already upgraded, which is the same
836
- // thing that makes a first landing and a refresh one operation
837
- // everywhere else.
838
- applyRow(row, data, true);
839
- };
840
- if (Array.isArray(result)) {
841
- for (const data of result)
842
- upsert(data);
843
- for (const [key, row] of shown) {
844
- if (!landed.has(key))
845
- leaveRow(list, row);
1033
+ arriving.delete(key);
846
1034
  }
1035
+ arriving.set(key, data);
847
1036
  }
848
- else {
849
- for (const data of result.rows ?? [])
850
- upsert(data);
851
- for (const key of result.drop ?? []) {
852
- const row = shown.get(String(key));
853
- if (row)
854
- leaveRow(list, row);
855
- }
1037
+ const dropped = whole
1038
+ ? []
1039
+ : (result.drop ?? []).map((key) => String(key));
1040
+ for (const key of dropped)
1041
+ arriving.delete(key);
1042
+ const gone = whole
1043
+ ? shown.filter((entry) => !arriving.has(entry.key))
1044
+ : dropped.flatMap((key) => list.byKey.get(key) ?? []);
1045
+ // The creation loop.
1046
+ const created = new Set();
1047
+ for (const key of arriving.keys()) {
1048
+ if (!list.byKey.has(key))
1049
+ created.add(create(list, doc, key));
1050
+ }
1051
+ // The landing loop. A row whose values arrive as it already shows them is
1052
+ // left as it is, so a control the user has typed into and not committed
1053
+ // keeps what was typed. A row whose values changed is filled whole.
1054
+ const changed = new Set();
1055
+ for (const [key, data] of arriving) {
1056
+ const entry = list.byKey.get(key);
1057
+ const fresh = created.has(entry);
1058
+ if (!fresh && differs(entry.data, data))
1059
+ changed.add(entry);
1060
+ if (fresh || options.refill || changed.has(entry))
1061
+ fill(entry.places, data);
1062
+ entry.data = { ...entry.data, ...data };
1063
+ }
1064
+ for (const entry of gone) {
1065
+ list.byKey.delete(entry.key);
1066
+ leaving(entry.el, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
856
1067
  }
857
- const terms = termsOf(el, options.order);
1068
+ // The order the rows arrived in for all rows, and for a patch the order
1069
+ // already showing with what is new after it. A sort keeps rows that
1070
+ // compare equal in this order.
1071
+ const stayed = shown.filter((entry) => list.byKey.get(entry.key) === entry);
1072
+ let order = whole
1073
+ ? [...arriving.keys()].map((key) => list.byKey.get(key))
1074
+ : [...stayed, ...created];
1075
+ const terms = termsOf(el, orderFor(el, options));
858
1076
  if (list.levels.length > terms.length && !warned.has(el)) {
859
1077
  warned.add(el);
860
1078
  console.warn(`lb-hub: <${el.localName}> has ${list.levels.length} group ` +
861
1079
  `template(s) and an order of ${terms.length} term(s); a level with ` +
862
1080
  `no term shows its rows as one group`);
863
1081
  }
864
- // Before sorting: the order the rows arrived in for all rows, and for a
865
- // patch the order already showing, with what is new after it. A sort keeps
866
- // rows that compare equal in this order.
867
- const live = new Set(list.data.keys());
868
- let base;
869
- if (whole) {
870
- base = [...landed.values()].filter((row) => live.has(row));
1082
+ if (terms.length > 0) {
1083
+ order = order
1084
+ .map((entry, i) => ({ entry, i }))
1085
+ .sort((a, b) => compareRows(a.entry.data, b.entry.data, terms, options.types) ||
1086
+ a.i - b.i)
1087
+ .map(({ entry }) => entry);
871
1088
  }
872
- else {
873
- base = rowsUnder(list.root).filter((row) => live.has(row));
874
- const seen = new Set(base);
875
- for (const row of landed.values()) {
876
- if (!seen.has(row) && live.has(row))
877
- base.push(row);
878
- }
879
- }
880
- const desired = terms.length === 0
881
- ? base
882
- : base
883
- .map((row, i) => ({ row, i, data: list.data.get(row) }))
884
- .sort((a, b) => compareRows(a.data, b.data, terms) || a.i - b.i)
885
- .map(({ row }) => row);
886
- const moved = arrange(list, el.ownerDocument, desired, terms, new Set([...touched].filter(([, t]) => t.changed).map(([row]) => row)));
887
- for (const [row, { fresh, changed }] of touched) {
888
- if (!list.data.has(row))
889
- continue;
890
- const went = moved.has(row);
891
- restamp(row, ATTR_ROW_CREATED, fresh);
892
- restamp(row, ATTR_ROW_CHANGED, changed);
893
- restamp(row, ATTR_ROW_MOVED, went);
1089
+ const passing = filterFor(el, options.choices, options.types ?? {});
1090
+ for (const entry of order)
1091
+ filterRow(entry, !passing(entry.data));
1092
+ const moved = movedRows(stayed, order, changed);
1093
+ const retired = arrange(list, doc, order, terms);
1094
+ refocus(list, focused);
1095
+ const stamps = new Stamps();
1096
+ for (const key of arriving.keys()) {
1097
+ const entry = list.byKey.get(key);
1098
+ const fresh = created.has(entry);
1099
+ const went = moved.has(entry);
1100
+ stamps.set(entry.el, ATTR_ROW_CREATED, fresh);
1101
+ stamps.set(entry.el, ATTR_ROW_CHANGED, changed.has(entry));
1102
+ stamps.set(entry.el, ATTR_ROW_MOVED, went);
894
1103
  const requested = went
895
1104
  ? REQUESTED_MOVED
896
1105
  : fresh
897
1106
  ? REQUESTED_CREATED
898
- : changed
1107
+ : changed.has(entry)
899
1108
  ? REQUESTED_CHANGED
900
1109
  : null;
901
1110
  if (options.requested && requested) {
902
- row.setAttribute(ATTR_ROW_REQUESTED, requested);
1111
+ entry.el.setAttribute(ATTR_ROW_REQUESTED, requested);
903
1112
  }
904
1113
  }
905
- for (const row of moved) {
906
- if (!touched.has(row))
907
- restamp(row, ATTR_ROW_MOVED, true);
908
- }
909
- finish(list, list.root);
910
- aggregate(el, rowsUnder(list.root).map((row) => list.data.get(row)), false);
911
- // Counted from the DOM rather than from either branch above, so all rows
912
- // and a patch report the same fact the same way. Only here is the count
913
- // known: the server answers with rows and says nothing about how many
914
- // survived reconciliation.
915
- el.setAttribute(ATTR_QUERY_ROW_COUNT, String(liveRows(el).length));
1114
+ stamps.done(doc);
1115
+ const rows = finish(list.root, options.types ?? {});
1116
+ // Counted from what the hub holds, which is what the rows show.
1117
+ setStamp(el, ATTR_QUERY_ROW_COUNT, String(rows.length));
1118
+ const departing = gone.map((entry) => entry.el);
1119
+ for (const group of retired)
1120
+ leaveGroup(group, departing);
1121
+ if (departing.length > 0)
1122
+ depart(departing);
916
1123
  host.lbRowsLanded?.();
917
1124
  }
1125
+ function focusedRow(list, doc) {
1126
+ const active = doc.activeElement;
1127
+ const row = active?.closest(LIVE_ROW);
1128
+ if (!active || !row)
1129
+ return null;
1130
+ const entry = list.byKey.get(row.getAttribute(ATTR_KEY_VALUE));
1131
+ return entry?.el === row ? { entry, active } : null;
1132
+ }
1133
+ /**
1134
+ * Keep the focus where the user had it once rows are placed: on the same
1135
+ * element, scrolled into view, when its row still shows; otherwise on the
1136
+ * first element the user can operate in the first row showing.
1137
+ */
1138
+ function refocus(list, focused) {
1139
+ if (!focused)
1140
+ return;
1141
+ const { entry, active } = focused;
1142
+ const doc = active.ownerDocument;
1143
+ if (list.byKey.get(entry.key) === entry && !entry.hidden) {
1144
+ if (doc.activeElement === active || !active.isConnected)
1145
+ return;
1146
+ active.focus({ preventScroll: true });
1147
+ active.scrollIntoView?.({ block: "nearest" });
1148
+ return;
1149
+ }
1150
+ const first = rowsUnder(list.root).find((row) => !row.hidden);
1151
+ if (first)
1152
+ focusIn(first.el);
1153
+ }
918
1154
  /** Land one response item on one element that names its query. */
919
1155
  function landItem(el, item, options = {}) {
920
1156
  const template = lists.get(el)?.top ?? templateIn(el);
@@ -927,7 +1163,7 @@ function landItem(el, item, options = {}) {
927
1163
  applyRow(el, row);
928
1164
  const key = row[item.key];
929
1165
  if (key !== undefined && key !== null) {
930
- el.setAttribute(ATTR_KEY_VALUE, String(key));
1166
+ setStamp(el, ATTR_KEY_VALUE, String(key));
931
1167
  }
932
1168
  return;
933
1169
  }
@@ -951,13 +1187,23 @@ function keptFor(root) {
951
1187
  export function keptAnswer(root, query) {
952
1188
  return kept.get(root)?.answers.get(query);
953
1189
  }
954
- /** The order a query's rows are placed by: the user's, else the server's. */
955
- function orderOf(state, item) {
956
- return state.orderFor?.(item.query) ?? item.order;
1190
+ /**
1191
+ * How a query's rows land: by its order, its column types, and the user's
1192
+ * choices. The order and types are the kept answer's, since only the answer
1193
+ * that loads the page carries them.
1194
+ */
1195
+ function rowsOptions(state, item) {
1196
+ const declared = state.answers.get(item.query) ?? item;
1197
+ return {
1198
+ order: declared.order,
1199
+ types: declared.types,
1200
+ choices: state.answers.get(CHOICES_QUERY)?.row,
1201
+ };
957
1202
  }
958
1203
  /**
959
1204
  * Drop what landing kept for a root. The hub calls it when it replaces the
960
- * page, since another page's query of the same name is another query.
1205
+ * page, since another page's query of the same name is another query, and
1206
+ * the user's choices were for the page they were made on.
961
1207
  */
962
1208
  export function forget(root) {
963
1209
  kept.delete(root);
@@ -981,21 +1227,64 @@ function patched(rows, key, patch) {
981
1227
  * Keep an answer as all of the query's rows. A patch is applied to the
982
1228
  * answer already kept; with none, it is not the whole of anything, and
983
1229
  * nothing is kept.
1230
+ *
1231
+ * A query's order and column types arrive with the answer that loads the
1232
+ * page, and every answer after it, all rows or a patch, keeps them.
984
1233
  */
985
1234
  function keep(answers, item) {
1235
+ const before = answers.get(item.query);
1236
+ const declared = {
1237
+ ...(before?.order !== undefined && { order: before.order }),
1238
+ ...(before?.types !== undefined && { types: before.types }),
1239
+ };
986
1240
  if (!item.patch) {
987
- answers.set(item.query, item);
1241
+ answers.set(item.query, { ...declared, ...item });
988
1242
  return;
989
1243
  }
990
- const before = answers.get(item.query);
991
1244
  if (!before)
992
1245
  return;
993
1246
  const { patch: _, ...whole } = item;
994
1247
  answers.set(item.query, {
1248
+ ...declared,
995
1249
  ...whole,
996
1250
  rows: patched(before.rows ?? [], item.key, item.patch),
997
1251
  });
998
1252
  }
1253
+ /** The columns of `lb-choices` an answer to it changes. */
1254
+ function chosen(state, item) {
1255
+ const before = state.answers.get(CHOICES_QUERY)?.row ?? {};
1256
+ const after = item.row ?? {};
1257
+ const columns = new Set();
1258
+ for (const column of new Set([...Object.keys(before), ...Object.keys(after)])) {
1259
+ if (choiceIn(before, column) !== choiceIn(after, column))
1260
+ columns.add(column);
1261
+ }
1262
+ return columns;
1263
+ }
1264
+ /** Whether an element's order or filter reads any of the columns. */
1265
+ function reads(el, columns) {
1266
+ const order = el.getAttribute(ATTR_ORDER);
1267
+ return ((order !== null && columns.has(order)) ||
1268
+ filtered(el).some((column) => columns.has(column)));
1269
+ }
1270
+ /**
1271
+ * Place again the rows of every element whose order or filter reads a choice
1272
+ * that changed. Each lands its query's kept answer again, whole, so it shows
1273
+ * what a round trip carrying the choice would have, and the server is asked
1274
+ * for nothing.
1275
+ */
1276
+ function rechoose(root, state, columns) {
1277
+ for (const [query, item] of state.answers) {
1278
+ if (item.kind === KIND_ROW)
1279
+ continue;
1280
+ for (const el of everywhere(root, `[${ATTR_QUERY}="${query}"]`)) {
1281
+ if (!state.landed.has(el) || el.closest(LEAVING))
1282
+ continue;
1283
+ if (reads(el, columns))
1284
+ landItem(el, item, rowsOptions(state, item));
1285
+ }
1286
+ }
1287
+ }
999
1288
  /**
1000
1289
  * Fill every element that names a kept query and has had nothing land on it.
1001
1290
  * Filling one can build more — a live row holding a picker of its own — so
@@ -1010,7 +1299,7 @@ function fillArrivals(root, state) {
1010
1299
  if (landed.has(el) || el.closest(LEAVING))
1011
1300
  continue;
1012
1301
  landed.add(el);
1013
- landItem(el, item, { order: orderOf(state, item) });
1302
+ landItem(el, item, { ...rowsOptions(state, item), refill: true });
1014
1303
  filled = true;
1015
1304
  }
1016
1305
  }
@@ -1019,43 +1308,32 @@ function fillArrivals(root, state) {
1019
1308
  /**
1020
1309
  * Land response items. Every answer arrives through here, and is kept for
1021
1310
  * `root`, so that what arrives after it is filled from it — see forget().
1022
- * Once everything has landed, each custom element placed since the last
1023
- * landing is attached.
1311
+ * An answer to `lb-choices` places again the rows its choices change. Once
1312
+ * everything has landed, each custom element placed since the last landing
1313
+ * is attached.
1024
1314
  */
1025
1315
  export function applyResponse(root, items, options = {}) {
1026
1316
  const state = keptFor(root);
1027
- if (options.orderFor)
1028
- state.orderFor = options.orderFor;
1029
1317
  for (const item of items) {
1318
+ const choices = item.query === CHOICES_QUERY ? chosen(state, item) : new Set();
1030
1319
  keep(state.answers, item);
1031
1320
  const targets = everywhere(root, `[${ATTR_QUERY}="${item.query}"]`).filter((el) => !el.closest(LEAVING));
1032
- if (targets.length === 0) {
1033
- if (!options.quiet?.includes(item.query)) {
1034
- console.warn(`lb-hub: nothing names '${item.query}', skipping`);
1035
- }
1036
- continue;
1321
+ if (targets.length === 0 &&
1322
+ !item.query.startsWith(LB_RESERVED_PREFIX) &&
1323
+ !options.quiet?.includes(item.query)) {
1324
+ console.warn(`lb-hub: nothing names '${item.query}', skipping`);
1037
1325
  }
1038
1326
  for (const el of targets) {
1039
1327
  state.landed.add(el);
1040
1328
  landItem(el, item, {
1041
- order: orderOf(state, item),
1329
+ ...rowsOptions(state, item),
1042
1330
  requested: options.requested,
1043
1331
  });
1044
1332
  }
1333
+ if (choices.size > 0)
1334
+ rechoose(root, state, choices);
1045
1335
  }
1046
1336
  fillArrivals(root, state);
1047
1337
  attachPlaced();
1048
1338
  }
1049
- /**
1050
- * Land a query's kept answer again, as all of its rows: what the hub does
1051
- * when the user's order for it changes and the rows themselves have not.
1052
- * False when no answer is kept.
1053
- */
1054
- export function reland(root, query) {
1055
- const item = keptAnswer(root, query);
1056
- if (!item)
1057
- return false;
1058
- applyResponse(root, [item]);
1059
- return true;
1060
- }
1061
1339
  //# sourceMappingURL=lb-apply.js.map