@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.
- package/README.md +10 -14
- package/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +15 -1
- package/dist/build/assemble.js.map +1 -1
- package/dist/core/lb-constants.d.ts +6 -2
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +38 -8
- package/dist/core/lb-constants.js.map +1 -1
- package/dist/core/lb-types.d.ts +57 -20
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/core/lb-types.js +122 -11
- package/dist/core/lb-types.js.map +1 -1
- package/dist/hub/lb-apply.d.ts +38 -26
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +655 -471
- package/dist/hub/lb-apply.js.map +1 -1
- package/dist/hub/lb-hub.browser.d.ts.map +1 -1
- package/dist/hub/lb-hub.browser.js +77 -59
- package/dist/hub/lb-hub.browser.js.map +1 -1
- package/dist/hub/lb-lifecycle.d.ts +1 -2
- package/dist/hub/lb-lifecycle.d.ts.map +1 -1
- package/dist/hub/lb-lifecycle.js +9 -3
- package/dist/hub/lb-lifecycle.js.map +1 -1
- package/dist/server/lb-server.d.ts +29 -12
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/server/lb-server.js +72 -11
- package/dist/server/lb-server.js.map +1 -1
- package/docs/TECHREF-1.0.md +162 -48
- package/docs/comparison.md +9 -8
- package/docs/reference/chrome.md +8 -5
- package/docs/reference/custom-elements.md +25 -7
- package/docs/reference/data-binding.md +125 -22
- package/docs/reference/page-files.md +51 -7
- package/docs/reference/widgets.md +5 -1
- package/docs/roadmap.md +8 -0
- package/docs/testing.md +6 -2
- package/docs/theory-old.md +839 -0
- package/docs/theory.md +113 -839
- package/package.json +1 -1
- package/skills/loadbare-app/SKILL.md +28 -15
- package/skills/loadbare-app/references/TECHREF-1.0.md +162 -48
- package/skills/loadbare-app/references/chrome.md +8 -5
- package/skills/loadbare-app/references/custom-elements.md +25 -7
- package/skills/loadbare-app/references/data-binding.md +125 -22
- package/skills/loadbare-app/references/page-files.md +51 -7
- package/skills/loadbare-app/references/widgets.md +5 -1
package/dist/hub/lb-apply.js
CHANGED
|
@@ -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
|
|
29
|
-
* the one the server declared, or, with
|
|
30
|
-
* in. Groups are built from the markup's
|
|
31
|
-
* and every change to a list — a row
|
|
32
|
-
* group appearing or leaving — is one
|
|
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
|
-
|
|
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
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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(
|
|
316
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
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
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
*
|
|
500
|
-
*
|
|
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
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
/**
|
|
537
|
-
function
|
|
538
|
-
|
|
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 {
|
|
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
|
-
*
|
|
589
|
-
*
|
|
590
|
-
* and
|
|
591
|
-
*
|
|
592
|
-
*
|
|
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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
627
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
|
666
|
-
* breaks
|
|
667
|
-
*
|
|
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,
|
|
672
|
-
const moved = new Set();
|
|
674
|
+
function arrange(list, doc, order, terms) {
|
|
673
675
|
const retired = [];
|
|
674
|
-
const
|
|
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
|
-
|
|
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
|
|
700
|
-
const value = term ?
|
|
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.
|
|
689
|
+
last.entries.push(entry);
|
|
705
690
|
else
|
|
706
|
-
runs.push({ id, value,
|
|
691
|
+
runs.push({ id, value, entries: [entry] });
|
|
707
692
|
}
|
|
708
|
-
//
|
|
709
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
729
|
-
for (
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
739
|
-
runs.forEach((run, i) => visit(next[i], level + 1, run.rows));
|
|
738
|
+
return around;
|
|
740
739
|
};
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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) =>
|
|
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
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
830
|
-
*
|
|
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(
|
|
833
|
-
|
|
834
|
-
finish(
|
|
835
|
-
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
873
|
-
*
|
|
874
|
-
* the
|
|
875
|
-
*
|
|
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
|
|
1012
|
+
const doc = el.ownerDocument;
|
|
882
1013
|
const host = el;
|
|
883
1014
|
const whole = !isPatch(result);
|
|
884
|
-
const
|
|
885
|
-
const
|
|
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
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
1111
|
+
entry.el.setAttribute(ATTR_ROW_REQUESTED, requested);
|
|
997
1112
|
}
|
|
998
1113
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
/**
|
|
1049
|
-
|
|
1050
|
-
|
|
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, {
|
|
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
|
-
*
|
|
1117
|
-
*
|
|
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
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
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
|