@loadbare/app 0.16.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 +38 -26
  14. package/dist/hub/lb-apply.d.ts.map +1 -1
  15. package/dist/hub/lb-apply.js +655 -471
  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 +25 -7
  32. package/docs/reference/data-binding.md +125 -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 +25 -7
  44. package/skills/loadbare-app/references/data-binding.md +125 -22
  45. package/skills/loadbare-app/references/page-files.md +51 -7
  46. package/skills/loadbare-app/references/widgets.md +5 -1
@@ -25,19 +25,21 @@
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;
@@ -45,13 +47,6 @@ const FRAGMENT_NODE = 11;
45
47
  const ELEMENT_NODE = 1;
46
48
  /** A Text node's nodeType, for the same reason. */
47
49
  const TEXT_NODE = 3;
48
- /**
49
- * What a group's heading and aggregates stop at: the rows and groups inside
50
- * it, live or leaving, which are filled from rows of their own.
51
- */
52
- const SCAFFOLD_STOP = `${LIVE_ROW}, ${LIVE_GROUP}, ${LEAVING}`;
53
- /** An element carrying any aggregate. */
54
- const AGGREGATE = AGGREGATES.map((name) => `[${name}]`).join(", ");
55
50
  /**
56
51
  * The input types whose state is not their `value`, or cannot be set. A
57
52
  * checkbox's state is `checked`, and it is a control by its own rule.
@@ -287,90 +282,145 @@ function presentPlaced(el, on) {
287
282
  present(parked, on);
288
283
  present(el, on);
289
284
  }
290
- const placesOf = new WeakMap();
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
+ }
291
292
  /**
292
- * A live row's places, found the first time it is filled. Every live row is
293
- * a copy of the row template, and landing moves what it shows and hides
294
- * rather than rebuilding it, so what the first search finds stays true of
295
- * the row, and filling it again searches nothing.
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
296
  */
297
- function places(row) {
298
- let found = placesOf.get(row);
299
- if (found)
300
- return found;
301
- found = { columns: new Map(), shows: new Map() };
302
- const add = (map, name, el) => {
303
- const list = map.get(name);
304
- if (list)
305
- list.push(el);
306
- else
307
- map.set(name, [el]);
308
- };
309
- if (row.hasAttribute(ATTR_COLUMN)) {
310
- add(found.columns, row.getAttribute(ATTR_COLUMN), row);
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;
311
309
  }
312
- for (const el of below(row, `[${ATTR_COLUMN}]`)) {
313
- add(found.columns, el.getAttribute(ATTR_COLUMN), el);
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);
314
324
  }
315
- for (const el of below(row, `[${ATTR_SHOW}]`)) {
316
- if (el.localName === "template") {
317
- const branch = el.content.firstElementChild;
318
- if (branch)
319
- parkedIn.set(branch, el);
320
- continue;
321
- }
322
- add(found.shows, el.getAttribute(ATTR_SHOW), el);
325
+ for (const el of below(root, `[${ATTR_COLUMN}], [${ATTR_SHOW}]`, stop)) {
326
+ note(places, el);
323
327
  }
324
- placesOf.set(row, found);
325
- return found;
328
+ return places;
329
+ }
330
+ function kids(el) {
331
+ return el.localName === "template"
332
+ ? el.content.children
333
+ : el.children;
326
334
  }
327
335
  /**
328
- * Fill from one row: every `lb-column` and `lb-show` in root's query.
329
- *
330
- * A live row is its own row, so its root counts when it carries
331
- * `lb-column`: `<option>`'s content model is text, so an option row has to
332
- * be the column it displays. An element a `row` lands on is not its own
333
- * ancestor, so its own `lb-column` reads from the row around it and is left
334
- * alone here.
335
- *
336
- * A live row is searched once, the first time it is filled; see places().
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.
337
340
  */
338
- export function applyRow(root, row, liveRow = false, stop) {
339
- if (liveRow && !stop) {
340
- const { columns, shows } = places(root);
341
- for (const [column, value] of Object.entries(row)) {
342
- for (const el of columns.get(column) ?? [])
343
- land(el, value);
344
- const on = value !== null && value !== false;
345
- for (const el of shows.get(column) ?? [])
346
- presentPlaced(el, on);
347
- for (const el of shows.get(`${SHOW_NOT}${column}`) ?? []) {
348
- presentPlaced(el, !on);
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;
349
355
  }
356
+ walk(child, at);
350
357
  }
351
- return;
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);
352
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.
394
+ *
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.
397
+ */
398
+ function fill(places, row) {
353
399
  for (const [column, value] of Object.entries(row)) {
354
- const selector = `[${ATTR_COLUMN}="${column}"]`;
355
- const columns = below(root, selector, stop);
356
- if (liveRow && root.matches(selector))
357
- columns.unshift(root);
358
- for (const el of columns)
400
+ for (const el of places.columns.get(column) ?? [])
359
401
  land(el, value);
360
- // Only null and false are off. A string is never read, so "false" is on,
361
- // and the query spells a condition as a boolean or a null.
362
- //
363
- // Never the root's own: a row template's root carries none, and the
364
- // element a row lands on reads its condition from the row around it.
365
402
  const on = value !== null && value !== false;
366
- for (const el of below(root, `[${ATTR_SHOW}="${column}"]`, stop)) {
367
- present(el, on);
368
- }
369
- for (const el of below(root, `[${ATTR_SHOW}="${SHOW_NOT}${column}"]`, stop)) {
370
- 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);
371
407
  }
372
408
  }
373
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
+ }
374
424
  /**
375
425
  * A row naming every column root's query shows, each null: what landing it
376
426
  * leaves is root as it stands with no row. Absent branches are counted, so a
@@ -397,34 +447,40 @@ export function templateIn(el) {
397
447
  const template = within(el, `template:not([${ATTR_SHOW}])`).find((t) => t !== el);
398
448
  return template ?? null;
399
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"])`;
400
454
  /**
401
- * The live rows of the element's query, in document order. A row of a nested
402
- * query is that query's. An element a `row` query landed on carries a key and
403
- * is not a live row, so a `row` shown in a table's head or foot is not one of
404
- * 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.
405
459
  */
406
- function liveRows(el) {
407
- return [...el.querySelectorAll(LIVE_ROW)].filter((row) => inScope(el, row));
408
- }
409
- /**
410
- * The live rows already showing, by the key each carries. Two live rows with
411
- * one key are a mistake nothing downstream can recover from: only one of them
412
- * would ever be matched, and the other would stay until the page goes.
413
- */
414
- function showing(el) {
415
- const rows = new Map();
416
- for (const row of liveRows(el)) {
417
- const key = row.getAttribute(ATTR_KEY_VALUE);
418
- if (rows.has(key)) {
419
- console.error(`lb-hub: <${el.localName}> shows two rows with the key '${key}'`);
420
- }
421
- 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;
422
472
  }
423
- return rows;
424
473
  }
425
474
  const lists = new WeakMap();
426
475
  /** Elements already told their group templates outrun their order. */
427
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(", ");
428
484
  /**
429
485
  * The template a group's contents go in front of: the first `<template>` in
430
486
  * the content outside any nested `lb-query`, and not one holding an absent
@@ -437,6 +493,16 @@ function slotIn(root) {
437
493
  }
438
494
  return null;
439
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
+ }
440
506
  /**
441
507
  * The list an element holds, found from its first template the first time
442
508
  * rows land on it, and kept after that: once groups exist, their slots are
@@ -462,24 +528,37 @@ function listFor(el, template) {
462
528
  top: template,
463
529
  levels,
464
530
  rowTemplate: at,
465
- data: new Map(),
466
- groupOf: new WeakMap(),
531
+ byKey: new Map(),
467
532
  root: {
468
533
  id: "",
469
534
  nodes: [],
470
535
  slot: template,
471
536
  children: [],
472
537
  rows: [],
473
- placed: true,
538
+ heads: [],
539
+ aggregates: aggregatesIn(el, false),
540
+ hidden: false,
474
541
  },
475
542
  };
476
543
  // Live rows already in the markup, as a page rendered ahead of its data
477
544
  // ships them, are the list's from the start, in the order they stand.
478
545
  if (levels.length === 0) {
479
- for (const row of liveRows(el)) {
480
- list.data.set(row, {});
481
- list.groupOf.set(row, list.root);
482
- 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);
483
562
  }
484
563
  }
485
564
  lists.set(el, list);
@@ -491,74 +570,36 @@ function rowsUnder(group) {
491
570
  ? group.children.flatMap(rowsUnder)
492
571
  : group.rows;
493
572
  }
494
- /** A group's identity among its siblings: its column and its value there. */
495
- function groupId(column, value) {
496
- return `${column}\u0000${JSON.stringify(value ?? null)}`;
497
- }
498
573
  /**
499
- * Make an attribute's change one a stylesheet sees, even when it is removed
500
- * and added again in one landing: the style is computed between the two.
501
- */
502
- function restamp(el, name, on, value = "") {
503
- const had = el.hasAttribute(name);
504
- if (had)
505
- el.removeAttribute(name);
506
- if (!on)
507
- return;
508
- if (had)
509
- void el.ownerDocument.defaultView?.getComputedStyle(el).animationName;
510
- el.setAttribute(name, value);
511
- }
512
- /**
513
- * Take nodes out of the document once whatever their stylesheet started on
514
- * them has finished: at once when nothing is running. Asking for the running
515
- * animations computes the style, so an animation keyed to the leaving stamp
516
- * just set is among them.
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.
517
577
  */
518
- function depart(nodes) {
519
- const running = [];
520
- for (const node of nodes) {
521
- if (node.nodeType !== ELEMENT_NODE)
522
- continue;
523
- const el = node;
524
- running.push(...(el.getAnimations?.({ subtree: true }) ?? []));
525
- }
526
- const go = () => {
527
- for (const node of nodes)
528
- node.remove();
529
- 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,
530
596
  };
531
- if (running.length === 0)
532
- go();
533
- else
534
- void Promise.allSettled(running.map((a) => a.finished)).then(go);
597
+ list.byKey.set(key, entry);
598
+ return entry;
535
599
  }
536
- /** Mark an element as leaving: no longer live, and taking no input. */
537
- function leaving(el, live, stamp) {
538
- el.removeAttribute(live);
539
- el.removeAttribute(ATTR_ROW_REQUESTED);
540
- el.setAttribute(stamp, "");
541
- el.setAttribute("inert", "");
542
- }
543
- /** A live row the answer took away: it leaves, and the list forgets it. */
544
- function leaveRow(list, row) {
545
- list.data.delete(row);
546
- const group = list.groupOf.get(row);
547
- if (group)
548
- group.rows = group.rows.filter((r) => r !== row);
549
- leaving(row, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
550
- depart([row]);
551
- }
552
- /** A group with no rows left, and every group inside it, leave. */
553
- function leaveGroup(group) {
554
- for (const child of group.children)
555
- leaveGroup(child);
556
- for (const node of group.nodes) {
557
- if (node.nodeType === ELEMENT_NODE) {
558
- leaving(node, ATTR_GROUP_LIVE, ATTR_GROUP_LEAVING);
559
- }
560
- }
561
- 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)}`;
562
603
  }
563
604
  /** A new group, cloned from the group template for its level and stamped. */
564
605
  function makeGroup(doc, template, id, term, value) {
@@ -567,6 +608,8 @@ function makeGroup(doc, template, id, term, value) {
567
608
  upgradeAbsent(fragment, doc);
568
609
  const slot = slotIn(fragment);
569
610
  const nodes = [...fragment.childNodes];
611
+ const heads = [];
612
+ const aggregates = [];
570
613
  for (const node of nodes) {
571
614
  if (node.nodeType !== ELEMENT_NODE || node === slot)
572
615
  continue;
@@ -581,169 +624,195 @@ function makeGroup(doc, template, id, term, value) {
581
624
  else {
582
625
  el.setAttribute(ATTR_GROUP_VALUE, String(value));
583
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));
584
631
  }
585
- 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
+ };
586
642
  }
587
643
  /**
588
- * Which entries of a sequence of old positions keep their place: the run
589
- * already in order that keeps the most rows whose values did not change,
590
- * and then the most rows. Everything else moves, so the row that moves is the
591
- * one whose values put it somewhere else, never a neighbour it passed, and a
592
- * list landed again unchanged moves nothing. -1 is a row with no old
593
- * 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.
594
649
  */
595
- function inPlace(positions, changed) {
596
- // Heaviest increasing run, by a running maximum over old positions. An
597
- // unchanged row outweighs every changed row together.
598
- const size = positions.length;
599
- const heavy = size + 1;
600
- // Indexed by old position, which may run past the rows placed now: rows
601
- // that left the group in this landing kept theirs.
602
- const span = Math.max(0, ...positions) + 1;
603
- const tree = Array.from({ length: span + 1 }, () => ({ weight: 0, at: -1 }));
604
- const best = new Array(size).fill(0);
605
- const before = new Array(size).fill(-1);
606
- let top = { weight: 0, at: -1 };
607
- for (let i = 0; i < size; i++) {
608
- const p = positions[i];
609
- if (p < 0)
610
- continue;
611
- let prior = { weight: 0, at: -1 };
612
- for (let k = p; k > 0; k -= k & -k) {
613
- if (tree[k].weight > prior.weight)
614
- prior = tree[k];
615
- }
616
- best[i] = prior.weight + (changed[i] ? 1 : heavy);
617
- before[i] = prior.at;
618
- const here = { weight: best[i], at: i };
619
- for (let k = p + 1; k <= span; k += k & -k) {
620
- if (here.weight > tree[k].weight)
621
- tree[k] = here;
622
- }
623
- if (here.weight > top.weight)
624
- 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++;
625
659
  }
626
- const keep = new Set();
627
- for (let i = top.at; i >= 0; i = before[i])
628
- keep.add(i);
629
- return keep;
630
- }
631
- /**
632
- * Put a row in front of `ref`. A row already in the document leaves a copy
633
- * of itself behind, leaving, and is moved rather than taken out and put
634
- * back, where the browser can, so the control the user is in keeps focus.
635
- */
636
- function moveRow(row, ref, moved) {
637
- const parent = ref.parentNode;
638
- if (!parent)
660
+ const middle = next.slice(start, next.length - tail);
661
+ if (middle.length === 0)
639
662
  return;
640
- if (row.parentNode) {
641
- const copy = row.cloneNode(true);
642
- copy.removeAttribute(ATTR_ROW_CREATED);
643
- copy.removeAttribute(ATTR_ROW_CHANGED);
644
- copy.removeAttribute(ATTR_ROW_MOVED);
645
- leaving(copy, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
646
- row.parentNode.insertBefore(copy, row);
647
- moved.add(row);
648
- if (parent.moveBefore && row.isConnected && ref.isConnected) {
649
- try {
650
- parent.moveBefore(row, ref);
651
- depart([copy]);
652
- return;
653
- }
654
- catch {
655
- // Fall back to inserting, which the browser always allows.
656
- }
657
- }
658
- parent.insertBefore(row, ref);
659
- depart([copy]);
660
- return;
661
- }
662
- 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);
663
668
  }
664
669
  /**
665
- * Place a list's live rows in the order given: groups made where the order
666
- * breaks and gone where it no longer does, and each row moved only when it
667
- * is out of place. Groups never move. Their order follows the leading terms,
668
- * so it changes only when the order does, and then every group at that level
669
- * 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.
670
673
  */
671
- function arrange(list, doc, desired, terms, changed) {
672
- const moved = new Set();
674
+ function arrange(list, doc, order, terms) {
673
675
  const retired = [];
674
- const placeRows = (group, rows) => {
675
- const was = new Map();
676
- group.rows.forEach((row, i) => {
677
- if (list.groupOf.get(row) === group && list.data.has(row))
678
- was.set(row, i);
679
- });
680
- const keep = inPlace(rows.map((row) => was.get(row) ?? -1), rows.map((row) => changed.has(row)));
681
- let ref = group.slot;
682
- for (let i = rows.length - 1; i >= 0; i--) {
683
- const row = rows[i];
684
- if (!keep.has(i))
685
- moveRow(row, ref, moved);
686
- ref = row;
687
- }
688
- group.rows = rows;
689
- for (const row of rows)
690
- list.groupOf.set(row, group);
691
- };
692
- const visit = (parent, level, rows) => {
676
+ const visit = (parent, level, entries) => {
693
677
  if (level === list.levels.length) {
694
- placeRows(parent, rows);
678
+ placeRun(parent.rows, entries, parent.slot, (entry) => [entry.el]);
679
+ parent.rows = entries;
695
680
  return;
696
681
  }
697
682
  const term = terms[level];
698
683
  const runs = [];
699
- for (const row of rows) {
700
- 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;
701
686
  const id = groupId(term?.column ?? "", value);
702
687
  const last = runs.at(-1);
703
688
  if (last?.id === id)
704
- last.rows.push(row);
689
+ last.entries.push(entry);
705
690
  else
706
- runs.push({ id, value, rows: [row] });
691
+ runs.push({ id, value, entries: [entry] });
707
692
  }
708
- // Groups a level keeps must still be in the order the runs are in.
709
- // When they are not, the order changed, and every one is made again.
710
- const at = new Map(runs.map((run, i) => [run.id, i]));
711
- let last = -1;
712
- let kept = true;
693
+ // Each group already here goes on with the first run that has its id.
694
+ const pool = new Map();
713
695
  for (const child of parent.children) {
714
- const i = at.get(child.id);
715
- if (i === undefined)
716
- continue;
717
- if (i < last)
718
- kept = false;
719
- last = i;
696
+ pool.set(child.id, [...(pool.get(child.id) ?? []), child]);
720
697
  }
721
- const existing = new Map(kept ? parent.children.map((child) => [child.id, child]) : []);
722
- const next = runs.map((run) => existing.get(run.id) ??
698
+ const next = runs.map((run) => pool.get(run.id)?.shift() ??
723
699
  makeGroup(doc, list.levels[level], run.id, term, run.value));
724
- for (const child of parent.children) {
725
- if (!next.includes(child))
726
- 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;
727
730
  }
728
- let ref = parent.slot;
729
- for (let i = next.length - 1; i >= 0; i--) {
730
- const group = next[i];
731
- if (!group.placed) {
732
- for (const node of group.nodes)
733
- ref.parentNode.insertBefore(node, ref);
734
- group.placed = true;
735
- }
736
- 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;
737
737
  }
738
- parent.children = next;
739
- runs.forEach((run, i) => visit(next[i], level + 1, run.rows));
738
+ return around;
740
739
  };
741
- visit(list.root, 0, desired);
742
- // Only now, when every row that was in them has gone to its new place.
743
- for (const group of retired)
744
- 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
+ }
745
746
  return moved;
746
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
+ }
747
816
  /** A plain decimal number, as JSON or Postgres writes one. */
748
817
  const DECIMAL = /^[+-]?\d+(\.\d+)?$/;
749
818
  function decimalOf(value) {
@@ -773,7 +842,7 @@ const reported = new Set();
773
842
  * keeps up to two places more. Null and empty values are left out, as SQL
774
843
  * leaves them out; a value that is not a number is left out and reported.
775
844
  */
776
- function aggregateOf(name, column, rows) {
845
+ function aggregateOf(name, column, rows, type) {
777
846
  if (name === ATTR_COUNT)
778
847
  return rows.length;
779
848
  const values = rows
@@ -783,7 +852,7 @@ function aggregateOf(name, column, rows) {
783
852
  return null;
784
853
  if (name === ATTR_MIN || name === ATTR_MAX) {
785
854
  const sign = name === ATTR_MIN ? 1 : -1;
786
- 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);
787
856
  }
788
857
  const decimals = [];
789
858
  for (const value of values) {
@@ -812,39 +881,43 @@ function aggregateOf(name, column, rows) {
812
881
  text = text.slice(0, -1);
813
882
  return text.endsWith(".") ? text.slice(0, -1) : text;
814
883
  }
815
- /**
816
- * Set every aggregate in `root` from rows: the root itself when `self`, and
817
- * every element below it that is not inside a row or a group of its own.
818
- */
819
- function aggregate(root, rows, self) {
820
- const found = below(root, AGGREGATE, SCAFFOLD_STOP);
821
- if (self && root.matches(AGGREGATE))
822
- found.unshift(root);
823
- 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) {
824
887
  const name = AGGREGATES.find((a) => el.hasAttribute(a));
825
- land(el, aggregateOf(name, el.getAttribute(name) ?? "", rows));
888
+ const column = el.getAttribute(name) ?? "";
889
+ land(el, aggregateOf(name, column, rows, types[column]));
826
890
  }
827
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
+ }
828
897
  /**
829
- * Fill each group's heading from its first row, and set each group's
830
- * 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.
831
902
  */
832
- function finish(list, group) {
833
- for (const child of group.children)
834
- finish(list, child);
835
- const rows = rowsUnder(group);
836
- const data = rows.map((row) => list.data.get(row));
837
- for (const node of group.nodes) {
838
- if (node.nodeType !== ELEMENT_NODE || node === group.slot)
839
- continue;
840
- const el = node;
841
- // A heading's own column is filled, as a live row's is, unless it holds
842
- // the group's contents, which filling it would replace.
843
- if (data[0]) {
844
- 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
+ }
845
914
  }
846
- aggregate(el, data, true);
847
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;
848
921
  }
849
922
  /** The order to place rows by, or none when it cannot be read. */
850
923
  function termsOf(el, order) {
@@ -856,159 +929,228 @@ function termsOf(el, order) {
856
929
  return [];
857
930
  }
858
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
+ }
859
986
  /** Whether a row's columns as landed differ from what they were. */
860
987
  function differs(before, after) {
861
- if (!before)
862
- return false;
863
- 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));
864
989
  }
865
990
  /**
866
991
  * Land rows through a row template.
867
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
+ *
868
999
  * All rows decide membership: a row whose key did not arrive leaves. A patch
869
1000
  * disturbs only what it names — a row it did not mention keeps its contents,
870
1001
  * and keeps its place unless a row the patch named now sorts around it.
871
1002
  *
872
- * Every live row is placed by the order. With none, all rows show in the
873
- * order they arrived, and a patch's new rows go last. A row moves only when
874
- * the order put it somewhere else, so landing the same rows again moves
875
- * 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`.
876
1007
  */
877
1008
  export function applyRows(el, template, keyColumn, result, options = {}) {
878
1009
  const list = listFor(el, template);
879
1010
  if (!list)
880
1011
  return;
881
- const shown = showing(el);
1012
+ const doc = el.ownerDocument;
882
1013
  const host = el;
883
1014
  const whole = !isPatch(result);
884
- const landed = new Map();
885
- const touched = new Map();
1015
+ const shown = rowsUnder(list.root);
1016
+ const focused = focusedRow(list, doc);
886
1017
  // What a request did is said by this landing alone.
887
- for (const row of shown.values())
888
- row.removeAttribute(ATTR_ROW_REQUESTED);
889
- 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 ?? [])) {
890
1023
  if (data[keyColumn] === undefined) {
891
1024
  console.error(`lb-hub: a row for <${el.localName}> has no '${keyColumn}' column`);
892
- return;
1025
+ continue;
893
1026
  }
894
1027
  // The key is stored on the row as an attribute and read back from
895
1028
  // there, so it is compared as the string the attribute holds.
896
1029
  const key = String(data[keyColumn]);
897
- if (landed.has(key)) {
1030
+ if (arriving.has(key)) {
898
1031
  console.error(`lb-hub: two rows for <${el.localName}> have the key '${key}'; ` +
899
1032
  `the last one shows`);
900
- landed.delete(key);
901
- }
902
- let row = shown.get(key);
903
- const fresh = row === undefined;
904
- if (!row) {
905
- // Imported rather than cloned: template content belongs to a document
906
- // with no custom element definitions, so a clone would not upgrade
907
- // until inserted, and a form-associated control would be filled as if
908
- // it were not one.
909
- row = el.ownerDocument.importNode(list.rowTemplate.content.firstElementChild, true);
910
- upgradeAbsent(row, el.ownerDocument);
911
- placed(row);
912
- row.setAttribute(ATTR_ROW_LIVE, "");
913
- row.setAttribute(ATTR_KEY_VALUE, key);
914
- shown.set(key, row);
915
- }
916
- landed.set(key, row);
917
- const before = list.data.get(row);
918
- const prior = touched.get(row);
919
- touched.set(row, {
920
- fresh: prior?.fresh ?? fresh,
921
- changed: (prior?.changed ?? false) || differs(before, data),
922
- });
923
- list.data.set(row, { ...before, ...data });
924
- // Fill before insertion, on a row already upgraded, which is the same
925
- // thing that makes a first landing and a refresh one operation
926
- // everywhere else.
927
- //
928
- // A row that already shows these values is left as it is, so a control
929
- // the user has typed into and not committed keeps what was typed.
930
- if (!before || options.refill || differs(before, data)) {
931
- applyRow(row, data, true);
932
- }
933
- };
934
- if (Array.isArray(result)) {
935
- for (const data of result)
936
- upsert(data);
937
- for (const [key, row] of shown) {
938
- if (!landed.has(key))
939
- leaveRow(list, row);
1033
+ arriving.delete(key);
940
1034
  }
1035
+ arriving.set(key, data);
941
1036
  }
942
- else {
943
- for (const data of result.rows ?? [])
944
- upsert(data);
945
- for (const key of result.drop ?? []) {
946
- const row = shown.get(String(key));
947
- if (row)
948
- leaveRow(list, row);
949
- }
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);
950
1067
  }
951
- 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));
952
1076
  if (list.levels.length > terms.length && !warned.has(el)) {
953
1077
  warned.add(el);
954
1078
  console.warn(`lb-hub: <${el.localName}> has ${list.levels.length} group ` +
955
1079
  `template(s) and an order of ${terms.length} term(s); a level with ` +
956
1080
  `no term shows its rows as one group`);
957
1081
  }
958
- // Before sorting: the order the rows arrived in for all rows, and for a
959
- // patch the order already showing, with what is new after it. A sort keeps
960
- // rows that compare equal in this order.
961
- const live = new Set(list.data.keys());
962
- let base;
963
- if (whole) {
964
- 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);
965
1088
  }
966
- else {
967
- base = rowsUnder(list.root).filter((row) => live.has(row));
968
- const seen = new Set(base);
969
- for (const row of landed.values()) {
970
- if (!seen.has(row) && live.has(row))
971
- base.push(row);
972
- }
973
- }
974
- const desired = terms.length === 0
975
- ? base
976
- : base
977
- .map((row, i) => ({ row, i, data: list.data.get(row) }))
978
- .sort((a, b) => compareRows(a.data, b.data, terms) || a.i - b.i)
979
- .map(({ row }) => row);
980
- const moved = arrange(list, el.ownerDocument, desired, terms, new Set([...touched].filter(([, t]) => t.changed).map(([row]) => row)));
981
- for (const [row, { fresh, changed }] of touched) {
982
- if (!list.data.has(row))
983
- continue;
984
- const went = moved.has(row);
985
- restamp(row, ATTR_ROW_CREATED, fresh);
986
- restamp(row, ATTR_ROW_CHANGED, changed);
987
- 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);
988
1103
  const requested = went
989
1104
  ? REQUESTED_MOVED
990
1105
  : fresh
991
1106
  ? REQUESTED_CREATED
992
- : changed
1107
+ : changed.has(entry)
993
1108
  ? REQUESTED_CHANGED
994
1109
  : null;
995
1110
  if (options.requested && requested) {
996
- row.setAttribute(ATTR_ROW_REQUESTED, requested);
1111
+ entry.el.setAttribute(ATTR_ROW_REQUESTED, requested);
997
1112
  }
998
1113
  }
999
- for (const row of moved) {
1000
- if (!touched.has(row))
1001
- restamp(row, ATTR_ROW_MOVED, true);
1002
- }
1003
- finish(list, list.root);
1004
- aggregate(el, rowsUnder(list.root).map((row) => list.data.get(row)), false);
1005
- // Counted from the DOM rather than from either branch above, so all rows
1006
- // and a patch report the same fact the same way. Only here is the count
1007
- // known: the server answers with rows and says nothing about how many
1008
- // survived reconciliation.
1009
- setStamp(el, 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);
1010
1123
  host.lbRowsLanded?.();
1011
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
+ }
1012
1154
  /** Land one response item on one element that names its query. */
1013
1155
  function landItem(el, item, options = {}) {
1014
1156
  const template = lists.get(el)?.top ?? templateIn(el);
@@ -1045,13 +1187,23 @@ function keptFor(root) {
1045
1187
  export function keptAnswer(root, query) {
1046
1188
  return kept.get(root)?.answers.get(query);
1047
1189
  }
1048
- /** The order a query's rows are placed by: the user's, else the server's. */
1049
- function orderOf(state, item) {
1050
- 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
+ };
1051
1202
  }
1052
1203
  /**
1053
1204
  * Drop what landing kept for a root. The hub calls it when it replaces the
1054
- * 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.
1055
1207
  */
1056
1208
  export function forget(root) {
1057
1209
  kept.delete(root);
@@ -1075,21 +1227,64 @@ function patched(rows, key, patch) {
1075
1227
  * Keep an answer as all of the query's rows. A patch is applied to the
1076
1228
  * answer already kept; with none, it is not the whole of anything, and
1077
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.
1078
1233
  */
1079
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
+ };
1080
1240
  if (!item.patch) {
1081
- answers.set(item.query, item);
1241
+ answers.set(item.query, { ...declared, ...item });
1082
1242
  return;
1083
1243
  }
1084
- const before = answers.get(item.query);
1085
1244
  if (!before)
1086
1245
  return;
1087
1246
  const { patch: _, ...whole } = item;
1088
1247
  answers.set(item.query, {
1248
+ ...declared,
1089
1249
  ...whole,
1090
1250
  rows: patched(before.rows ?? [], item.key, item.patch),
1091
1251
  });
1092
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
+ }
1093
1288
  /**
1094
1289
  * Fill every element that names a kept query and has had nothing land on it.
1095
1290
  * Filling one can build more — a live row holding a picker of its own — so
@@ -1104,7 +1299,7 @@ function fillArrivals(root, state) {
1104
1299
  if (landed.has(el) || el.closest(LEAVING))
1105
1300
  continue;
1106
1301
  landed.add(el);
1107
- landItem(el, item, { order: orderOf(state, item), refill: true });
1302
+ landItem(el, item, { ...rowsOptions(state, item), refill: true });
1108
1303
  filled = true;
1109
1304
  }
1110
1305
  }
@@ -1113,43 +1308,32 @@ function fillArrivals(root, state) {
1113
1308
  /**
1114
1309
  * Land response items. Every answer arrives through here, and is kept for
1115
1310
  * `root`, so that what arrives after it is filled from it — see forget().
1116
- * Once everything has landed, each custom element placed since the last
1117
- * 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.
1118
1314
  */
1119
1315
  export function applyResponse(root, items, options = {}) {
1120
1316
  const state = keptFor(root);
1121
- if (options.orderFor)
1122
- state.orderFor = options.orderFor;
1123
1317
  for (const item of items) {
1318
+ const choices = item.query === CHOICES_QUERY ? chosen(state, item) : new Set();
1124
1319
  keep(state.answers, item);
1125
1320
  const targets = everywhere(root, `[${ATTR_QUERY}="${item.query}"]`).filter((el) => !el.closest(LEAVING));
1126
- if (targets.length === 0) {
1127
- if (!options.quiet?.includes(item.query)) {
1128
- console.warn(`lb-hub: nothing names '${item.query}', skipping`);
1129
- }
1130
- 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`);
1131
1325
  }
1132
1326
  for (const el of targets) {
1133
1327
  state.landed.add(el);
1134
1328
  landItem(el, item, {
1135
- order: orderOf(state, item),
1329
+ ...rowsOptions(state, item),
1136
1330
  requested: options.requested,
1137
1331
  });
1138
1332
  }
1333
+ if (choices.size > 0)
1334
+ rechoose(root, state, choices);
1139
1335
  }
1140
1336
  fillArrivals(root, state);
1141
1337
  attachPlaced();
1142
1338
  }
1143
- /**
1144
- * Land a query's kept answer again, as all of its rows: what the hub does
1145
- * when the user's order for it changes and the rows themselves have not.
1146
- * False when no answer is kept.
1147
- */
1148
- export function reland(root, query) {
1149
- const item = keptAnswer(root, query);
1150
- if (!item)
1151
- return false;
1152
- applyResponse(root, [item]);
1153
- return true;
1154
- }
1155
1339
  //# sourceMappingURL=lb-apply.js.map