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