@loadbare/app 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +3 -3
  2. package/dist/build/assemble.d.ts.map +1 -1
  3. package/dist/build/assemble.js +74 -66
  4. package/dist/build/assemble.js.map +1 -1
  5. package/dist/build/expand.d.ts.map +1 -1
  6. package/dist/build/expand.js +20 -19
  7. package/dist/build/expand.js.map +1 -1
  8. package/dist/build/locations.d.ts +2 -3
  9. package/dist/build/locations.d.ts.map +1 -1
  10. package/dist/build/locations.js +2 -3
  11. package/dist/build/locations.js.map +1 -1
  12. package/dist/build/pages.d.ts +3 -4
  13. package/dist/build/pages.d.ts.map +1 -1
  14. package/dist/build/pages.js +3 -4
  15. package/dist/build/pages.js.map +1 -1
  16. package/dist/core/lb-constants.d.ts +25 -23
  17. package/dist/core/lb-constants.d.ts.map +1 -1
  18. package/dist/core/lb-constants.js +95 -158
  19. package/dist/core/lb-constants.js.map +1 -1
  20. package/dist/core/lb-types.d.ts +73 -75
  21. package/dist/core/lb-types.d.ts.map +1 -1
  22. package/dist/core/lb-types.js +58 -5
  23. package/dist/core/lb-types.js.map +1 -1
  24. package/dist/hub/lb-apply.d.ts +47 -37
  25. package/dist/hub/lb-apply.d.ts.map +1 -1
  26. package/dist/hub/lb-apply.js +174 -193
  27. package/dist/hub/lb-apply.js.map +1 -1
  28. package/dist/hub/lb-hub.browser.d.ts +1 -1
  29. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  30. package/dist/hub/lb-hub.browser.js +419 -415
  31. package/dist/hub/lb-hub.browser.js.map +1 -1
  32. package/dist/server/lb-express.d.ts +20 -13
  33. package/dist/server/lb-express.d.ts.map +1 -1
  34. package/dist/server/lb-express.js +50 -52
  35. package/dist/server/lb-express.js.map +1 -1
  36. package/dist/server/lb-server.d.ts +81 -116
  37. package/dist/server/lb-server.d.ts.map +1 -1
  38. package/dist/server/lb-server.js +151 -48
  39. package/dist/server/lb-server.js.map +1 -1
  40. package/docs/TECHREF-1.0.md +893 -558
  41. package/docs/comparison.md +222 -185
  42. package/docs/prior-art.md +15 -14
  43. package/docs/reference/builder.md +9 -3
  44. package/docs/reference/chrome.md +107 -56
  45. package/docs/reference/custom-elements.md +199 -173
  46. package/docs/reference/data-binding.md +375 -370
  47. package/docs/reference/overview.md +12 -10
  48. package/docs/reference/page-files.md +161 -86
  49. package/docs/reference/server.md +13 -7
  50. package/docs/reference/widgets.md +104 -110
  51. package/docs/roadmap.md +36 -31
  52. package/docs/terms-of-art.md +57 -0
  53. package/docs/testing.md +97 -68
  54. package/docs/theory.md +92 -58
  55. package/docs/tutorials/010-pages-and-navigation.md +20 -12
  56. package/docs/tutorials/020-css.md +6 -3
  57. package/docs/tutorials/030-html-decomposition.md +9 -7
  58. package/docs/tutorials/040-displaying-data.md +30 -13
  59. package/docs/tutorials/{050-actions.md → 050-requests.md} +25 -15
  60. package/docs/tutorials/060-custom-element-code.md +17 -16
  61. package/docs/tutorials/065-conditional-rendering.md +34 -23
  62. package/docs/tutorials/070-displaying-a-list.md +29 -21
  63. package/docs/tutorials/072-inserting-into-a-list.md +24 -16
  64. package/docs/tutorials/074-deleting-from-a-list.md +9 -7
  65. package/docs/tutorials/076-updating-a-list-item.md +11 -10
  66. package/docs/tutorials/080-widget-requests.md +71 -43
  67. package/docs/tutorials/090-using-widget-libraries.md +22 -22
  68. package/package.json +1 -1
  69. package/skills/loadbare-app/SKILL.md +178 -111
  70. package/skills/loadbare-app/references/TECHREF-1.0.md +893 -558
  71. package/skills/loadbare-app/references/builder.md +9 -3
  72. package/skills/loadbare-app/references/chrome.md +107 -56
  73. package/skills/loadbare-app/references/custom-elements.md +199 -173
  74. package/skills/loadbare-app/references/data-binding.md +375 -370
  75. package/skills/loadbare-app/references/overview.md +12 -10
  76. package/skills/loadbare-app/references/page-files.md +161 -86
  77. package/skills/loadbare-app/references/server.md +13 -7
  78. package/skills/loadbare-app/references/widgets.md +104 -110
  79. package/docs/analysis-accidental-complexity.md +0 -149
  80. package/docs/analysis-closed-set.md +0 -210
@@ -1,246 +1,215 @@
1
1
  /// <reference lib="dom" />
2
- import { ACTION_ROW_DELETE, ACTION_ROW_INSERT, ACTION_ROW_UPDATE, ATTR_ACTION, ATTR_CELL, ATTR_ERROR, ATTR_KEY_VALUE, ATTR_LIST, ATTR_NAV_LINK, ATTR_PAGE, ATTR_PENDING, ATTR_QUERY_PARM, ATTR_QUERY_PARM_PUSH, ATTR_ROW, ATTR_UNKNOWN_PAGE, ATTR_VALUE, LB_ACTIONS, LB_RESERVED_PREFIX, HUB_TAG_NAME, NAV_CELL_LABEL, NAV_CELL_URI, NAV_ROW, LB_EVENT_NAME, LB_ENDPOINT, LB_REQUEST_TIMEOUT_MS, } from "../core/lb-constants.js";
3
- import { applyData, applyQueryParms, isValueControl, within, } from "./lb-apply.js";
4
- export { applyData, applyRow } from "./lb-apply.js";
5
- /** What each operation takes from scope, and what it cannot be sent without. */
6
- const OPERATIONS = {
7
- [ACTION_ROW_INSERT]: { fills: ["list"], requires: ["list", "values"] },
8
- [ACTION_ROW_DELETE]: { fills: ["list", "key"], requires: ["list", "key"] },
9
- [ACTION_ROW_UPDATE]: {
10
- fills: ["list", "key"],
11
- requires: ["list", "key", "values"],
12
- },
2
+ import { ATTR_COLUMN, ATTR_COLUMN_VALUE, ATTR_KEY_VALUE, ATTR_PAGE, ATTR_PAGE_TITLE, ATTR_QUERY, ATTR_REQUEST, ATTR_REQUEST_ERROR, ATTR_REQUEST_PENDING, ATTR_URL_LINK, ATTR_URL_PUSH, ATTR_URL_UNKNOWN, HUB_TAG_NAME, KIND_ROW, LB_ENDPOINT, LB_EVENT_NAME, LB_REQUEST_TIMEOUT_MS, LB_RESERVED_PREFIX, REQUEST_ROW_DELETE, REQUEST_ROW_INSERT, REQUEST_ROW_UPDATE, URL_COLUMN_PAGE_LABEL, URL_COLUMN_PAGE_UNKNOWN, URL_COLUMN_PATH, URL_QUERY, } from "../core/lb-constants.js";
3
+ import { isRowRequest, nextLocation, pageNameFor, } from "../core/lb-types.js";
4
+ import { applyResponse, isControl, isFormAssociated, within, } from "./lb-apply.js";
5
+ export { applyResponse, applyRow } from "./lb-apply.js";
6
+ /** What each request Loadbare provides cannot be issued without. */
7
+ const NEEDS = {
8
+ [REQUEST_ROW_INSERT]: ["query", "values"],
9
+ [REQUEST_ROW_UPDATE]: ["query", "key", "values"],
10
+ [REQUEST_ROW_DELETE]: ["query", "key"],
13
11
  };
12
+ /** The nearest ancestor carrying `lb-query`. An element is not its own. */
13
+ function queryOf(el) {
14
+ return el.parentElement?.closest(`[${ATTR_QUERY}]`) ?? null;
15
+ }
14
16
  /**
15
- * Complete a request from where its element sits — see docs/TECHREF-1.0.md,
16
- * "Requests". Every request passes through here, whoever built it, so a
17
- * widget states what it did and never where it is.
17
+ * A live row: an element the hub cloned from a row template. It carries a
18
+ * key and no `lb-query`; an element a `row` lands on carries both.
19
+ */
20
+ const LIVE_ROW = `[${ATTR_KEY_VALUE}]:not([${ATTR_QUERY}])`;
21
+ /** The live row an element is, or is in. */
22
+ function liveRowOf(el) {
23
+ return el.closest(LIVE_ROW);
24
+ }
25
+ /**
26
+ * The key of the nearest ancestor row, when that row is of `query`: a live
27
+ * row whose query it is, or the element a `row` of it landed on.
28
+ */
29
+ function keyFrom(el, query) {
30
+ // A live row is its own row; any other element's row is an ancestor.
31
+ const row = el.matches(LIVE_ROW)
32
+ ? el
33
+ : el.parentElement?.closest(`[${ATTR_KEY_VALUE}]`);
34
+ if (!row)
35
+ return undefined;
36
+ const ofQuery = row === query || queryOf(row) === query;
37
+ return ofQuery ? (row.getAttribute(ATTR_KEY_VALUE) ?? undefined) : undefined;
38
+ }
39
+ /**
40
+ * Every control whose form owner is `form`: HTML's own list, which holds a
41
+ * control nested in the form or joined to it by the `form` attribute, and a
42
+ * form-associated custom element as well as a native control.
43
+ */
44
+ function controlsOf(form) {
45
+ return [...form.elements];
46
+ }
47
+ /**
48
+ * The form owner of an element that has one, as HTML reports it on the
49
+ * element's `form` property.
50
+ */
51
+ function formOwnerOf(el) {
52
+ const owner = el.form;
53
+ return owner && owner.localName === "form"
54
+ ? owner
55
+ : null;
56
+ }
57
+ /**
58
+ * The controls a request gathers — see docs/reference/data-binding.md,
59
+ * "Gathering":
18
60
  *
19
- * The scope field is `list` or `row`, whichever attribute scoped the nearest
20
- * ancestor, so a declared action fired inside a single-row scope says so and
21
- * the wire never needs a noun for the addressable thing. A field the request
22
- * already carries is the dispatcher's to decide and is left as it is.
61
+ * 1. the element carrying `lb-request` has `lb-column`: it alone;
62
+ * 2. the element is a `<form>`: the controls whose form owner it is;
63
+ * 3. the element is in a live row: the controls in that live row;
64
+ * 4. otherwise: the controls whose form owner is the element's.
23
65
  *
24
- * False when the request cannot be sent, having said why.
66
+ * A control whose nearest `lb-query` is inside the group belongs to a query
67
+ * nested in it, and is skipped.
25
68
  */
26
- function scope(request, el) {
27
- if (!request?.action) {
28
- console.warn(`lb-hub: event without an action, ignoring`, request);
69
+ function groupFor(el) {
70
+ if (el.hasAttribute(ATTR_COLUMN))
71
+ return { controls: [el], root: el };
72
+ if (el.localName === "form") {
73
+ const form = el;
74
+ return { controls: controlsOf(form), form, root: form };
75
+ }
76
+ const live = liveRowOf(el);
77
+ if (live)
78
+ return { controls: within(live, `[${ATTR_COLUMN}]`), root: live };
79
+ const form = formOwnerOf(el);
80
+ if (form)
81
+ return { controls: controlsOf(form), form, root: form };
82
+ return null;
83
+ }
84
+ /** The gathered controls, with their values under their columns. */
85
+ function gather(group) {
86
+ const controls = [];
87
+ const values = {};
88
+ for (const el of group?.controls ?? []) {
89
+ const column = el.getAttribute(ATTR_COLUMN);
90
+ if (column === null || !isControl(el))
91
+ continue;
92
+ const nested = queryOf(el);
93
+ if (nested && nested !== group.root && group.root.contains(nested)) {
94
+ continue;
95
+ }
96
+ controls.push(el);
97
+ values[column] = el.value;
98
+ }
99
+ return { controls, values };
100
+ }
101
+ /** The form each insert gathered from, until its round trip succeeds. */
102
+ const insertedFrom = new WeakMap();
103
+ /**
104
+ * Complete a request from where its element sits. Every request passes
105
+ * through here, whoever issued it; a field it already carries is its
106
+ * issuer's to decide and is left as it is.
107
+ *
108
+ * False when the request cannot be issued, having said why.
109
+ */
110
+ function complete(request, el) {
111
+ if (!request?.name) {
112
+ console.warn(`lb-hub: a request without a name, ignoring`, request);
29
113
  return false;
30
114
  }
31
- const { action } = request;
32
- const reserved = action.startsWith(LB_RESERVED_PREFIX);
33
- // The reserved prefix is the whole of the discriminant, so a reserved
34
- // name that is not one of the three operations is a typo rather than
35
- // something to forward. Refused here, where the widget can see why: a
36
- // server would only report that it declares no such action.
37
- if (reserved && !LB_ACTIONS.includes(action)) {
38
- console.error(`lb-hub: '${action}' begins with '${LB_RESERVED_PREFIX}' ` +
39
- `but names no operation, ignoring`);
115
+ const { name } = request;
116
+ if (name.startsWith(LB_RESERVED_PREFIX) && !isRowRequest(name)) {
117
+ console.error(`lb-hub: '${name}' begins with '${LB_RESERVED_PREFIX}' but names no ` +
118
+ `request Loadbare provides, ignoring`);
40
119
  return false;
41
120
  }
42
- // A control that writes a query parm has said what it does with its value.
43
- // An action beside that would send the choice twice, once as a request and
44
- // once as a page load, so the request is the one refused.
45
- if (el.hasAttribute(ATTR_QUERY_PARM)) {
46
- console.error(`lb-hub: <${el.localName}> carries ${ATTR_QUERY_PARM} and sends ` +
47
- `'${action}' as well, not sent; a query parm change reloads the page`, el);
121
+ const group = groupFor(el);
122
+ const { controls, values } = gather(group);
123
+ if (request.values === undefined && controls.length > 0) {
124
+ request.values = values;
125
+ }
126
+ // The request is for the nearest `lb-query` of what it gathered, or of the
127
+ // element itself when it gathered nothing.
128
+ const queries = new Set(controls.map(queryOf));
129
+ if (queries.size > 1) {
130
+ console.error(`lb-hub: '${name}' gathered controls of more than one query, not sent`, el);
48
131
  return false;
49
132
  }
50
- const fields = request;
51
- // The element's own lb-list or lb-row is what it displays. Where it sits
52
- // is its ancestors', the way a control belongs to the form around it.
53
- const parent = el.parentElement;
54
- const enclosing = parent?.closest(`[${ATTR_LIST}], [${ATTR_ROW}]`);
55
- const row = parent?.closest(`[${ATTR_KEY_VALUE}]`);
56
- const inScope = {
57
- list: enclosing?.getAttribute(ATTR_LIST),
58
- row: enclosing?.hasAttribute(ATTR_LIST)
59
- ? null
60
- : enclosing?.getAttribute(ATTR_ROW),
61
- // Only a row of that scope: a key from outside it names a row of some
62
- // other list.
63
- key: row && enclosing?.contains(row) ? row.getAttribute(ATTR_KEY_VALUE) : null,
64
- cell: el.getAttribute(ATTR_CELL),
65
- };
66
- const fill = (name) => {
67
- const value = inScope[name];
68
- if (fields[name] === undefined && value !== null && value !== undefined) {
69
- fields[name] = value;
70
- }
71
- };
72
- const operation = OPERATIONS[action];
73
- if (!operation) {
74
- // A declared action names one scope or the other, never both.
75
- if (fields.list === undefined && fields.row === undefined) {
76
- fill("list");
77
- fill("row");
78
- }
79
- fill("key");
80
- fill("cell");
81
- return true;
133
+ const from = controls[0] ?? el;
134
+ const query = controls.length > 0 ? [...queries][0] : queryOf(el);
135
+ if (query) {
136
+ request.query ??= query.getAttribute(ATTR_QUERY);
137
+ const key = keyFrom(from, query);
138
+ if (key !== undefined)
139
+ request.key ??= key;
82
140
  }
83
- for (const name of operation.fills)
84
- fill(name);
85
- // An element carrying lb-cell is a record of one cell, the way a control
86
- // has a value and a form has values: the map holds that cell and nothing
87
- // beside it. Its value is the one the dispatcher sent, or else what its
88
- // control holds.
89
- //
90
- // Anything else gathers from the record it belongs to, the way a button
91
- // submits its form owner: the nearest form, table row or live row around
92
- // it, the element itself included. A <tr> counts because it is the one
93
- // place a form cannot go around a row's controls. What else sits beside
94
- // the element never decides it. The record is the scope's, so it is never
95
- // the scope itself nor outside it; with none, there is nothing to say which
96
- // cells are meant. Outside every scope the list is what is missing, and
97
- // that is what gets reported.
98
- if (fields.values === undefined && operation.requires.includes("values")) {
99
- const cell = el.getAttribute(ATTR_CELL);
100
- if (cell !== null) {
101
- const control = controlOf(el);
102
- const value = typeof fields.value === "string" ? fields.value : control?.value;
103
- if (value !== undefined) {
104
- fields.values = { [cell]: value };
105
- if (action === ACTION_ROW_INSERT && control?.value === value) {
106
- gathered.set(request, [{ cell: el, control, value }]);
107
- }
108
- }
109
- }
110
- else if (fields.value !== undefined) {
111
- console.error(`lb-hub: '${action}' from <${el.localName}> carries a value but no ` +
112
- `${ATTR_CELL} to say which column it is, not sent`, el);
141
+ if (name === REQUEST_ROW_INSERT && group?.form) {
142
+ insertedFrom.set(request, group.form);
143
+ }
144
+ const needs = NEEDS[name];
145
+ if (needs) {
146
+ const missing = needs.filter((field) => request[field] === undefined ||
147
+ (field === "values" && Object.keys(request.values).length === 0));
148
+ if (missing.length > 0) {
149
+ console.error(`lb-hub: '${name}' from <${el.localName}> has no ` +
150
+ `${missing.join(", ")}, not sent`, el);
113
151
  return false;
114
152
  }
115
- else {
116
- const owner = el.closest(`form, tr, [${ATTR_KEY_VALUE}]`);
117
- const ours = !enclosing || (owner !== enclosing && enclosing.contains(owner));
118
- if (owner && ours) {
119
- const { values, read } = gatherValues(owner);
120
- if (read.length > 0) {
121
- fields.values = values;
122
- if (action === ACTION_ROW_INSERT)
123
- gathered.set(request, read);
124
- }
125
- }
126
- else if (enclosing) {
127
- console.error(`lb-hub: '${action}' from <${el.localName}> is in no form, table ` +
128
- `row or live row to gather values from, not sent; put its cells ` +
129
- `in a <form>`, el);
130
- return false;
131
- }
132
- }
133
- }
134
- // An operation carries its values and nothing else: the value they were
135
- // built from is not a second copy on the wire.
136
- delete fields.value;
137
- const missing = operation.requires.filter((f) => fields[f] === undefined);
138
- if (missing.length > 0) {
139
- console.error(`lb-hub: '${action}' from <${el.localName}> has no ` +
140
- `${missing.join(", ")} in scope, not sent`, el);
141
- return false;
142
153
  }
143
154
  return true;
144
155
  }
145
- /** Dispatch the bare action an element carries, for the hub to scope. */
146
- function dispatchAction(el) {
147
- // Pressed again while its round trip is in flight. Ignoring it makes a
148
- // pending native action disabled in fact, so a stylesheet only has to
149
- // show it. A widget is not held back this way: one that sends on change
150
- // must have its latest value sent, not dropped.
151
- if (el.hasAttribute(ATTR_PENDING))
156
+ /**
157
+ * Commit: dispatch the request an element names, for the hub to complete. A
158
+ * commit on an element whose round trip is in flight is ignored.
159
+ */
160
+ function commit(el) {
161
+ if (el.hasAttribute(ATTR_REQUEST_PENDING))
152
162
  return;
153
- const detail = { action: el.getAttribute(ATTR_ACTION) };
163
+ const detail = { name: el.getAttribute(ATTR_REQUEST) };
154
164
  el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
155
165
  }
156
- /**
157
- * What each insert gathered, until its round trip succeeds. Keyed by the
158
- * request itself, so a request an ancestor stops leaves nothing behind.
159
- */
160
- const gathered = new WeakMap();
161
- /**
162
- * The control a cell reads from, for gathering rather than landing.
163
- *
164
- * A cell is always either a native form control itself (a bare
165
- * `<input lb-cell>`) or a widget that wraps one (`<lb-input>`, `<lb-select>`)
166
- * — that wrapping is structural, not incidental, which is what lets this be
167
- * one rule instead of one per widget type. The controls are the ones a value
168
- * lands on, so what is read back is what landed.
169
- */
170
- function controlOf(el) {
171
- return isValueControl(el)
172
- ? el
173
- : [...el.querySelectorAll("input, select, textarea")].find(isValueControl);
166
+ /** A submit button with a form owner commits with its form, never on click. */
167
+ function isSubmitButton(el) {
168
+ const type = el.type;
169
+ const submits = (el.localName === "button" && type === "submit") ||
170
+ (el.localName === "input" && (type === "submit" || type === "image"));
171
+ return submits && formOwnerOf(el) !== null;
174
172
  }
175
173
  /**
176
- * Every lb-cell of an element's scope, gathered into one values map. Shared
177
- * by lb-row-insert and lb-row-update — both submit a batch of cells, and
178
- * differ only in whether a key comes with them. What was read comes back
179
- * beside the map, so an insert can reset it once it succeeds.
180
- *
181
- * Found the way a row lands, so a cell inside a nested scope is that scope's
182
- * and is not read: the options of a picker in the row are the picker's rows,
183
- * while the picker's own cell is the row's.
174
+ * HTML's interactive content: elements a click already means something to.
175
+ * HTML forbids nesting them inside `<a>` or `<button>` so a click means one
176
+ * thing, and the hub draws the same line.
184
177
  */
185
- function gatherValues(el) {
186
- const values = {};
187
- const read = [];
188
- for (const cell of within(el, `[${ATTR_CELL}]`)) {
189
- const name = cell.getAttribute(ATTR_CELL);
190
- const control = controlOf(cell);
191
- if (control === undefined) {
192
- console.warn(`lb-hub: lb-cell '${name}' has no input, select or textarea to ` +
193
- `read, ignoring it`, cell);
194
- continue;
195
- }
196
- values[name] = control.value;
197
- read.push({ cell, control, value: control.value });
198
- }
199
- return { values, read };
200
- }
178
+ const INTERACTIVE = [
179
+ "a[href]",
180
+ "button",
181
+ "input:not([type=hidden])",
182
+ "select",
183
+ "textarea",
184
+ "label",
185
+ "details",
186
+ "iframe",
187
+ "embed",
188
+ "audio[controls]",
189
+ "video[controls]",
190
+ "img[usemap]",
191
+ ].join(", ");
201
192
  /**
202
- * After an insert succeeds, what it gathered goes back to its default, the
203
- * way `form.reset()` resets a form: the row those cells held now lives in the
204
- * list. A control showing something other than what was read holds an edit
205
- * made during the round trip, which was not sent, and is left alone.
206
- *
207
- * Where a value landed, `lb-value` comes off the cell first, so a widget that
208
- * renders from it sees the attribute go and returns to its default. The
209
- * control is reset after, so it ends at its default whatever the widget did.
210
- * Nothing is dispatched, as `form.reset()` fires no `change`.
193
+ * Whether a click on `target` is the click of `el`, the element carrying
194
+ * `lb-request` above it: no interactive content lies between them. A click on
195
+ * a control, link or button inside `el` belongs to that element.
211
196
  */
212
- function resetGathered(read) {
213
- for (const { cell, control, value } of read) {
214
- if (control.value !== value)
215
- continue;
216
- cell.removeAttribute(ATTR_VALUE);
217
- control.removeAttribute(ATTR_VALUE);
218
- if (control.localName === "select") {
219
- for (const option of control.options) {
220
- option.selected = option.defaultSelected;
221
- }
222
- }
223
- else {
224
- const text = control;
225
- text.value = text.defaultValue;
226
- }
197
+ function clicks(target, el) {
198
+ for (let node = target; node && node !== el;) {
199
+ if (node.matches(INTERACTIVE) || isFormAssociated(node))
200
+ return false;
201
+ node = node.parentElement;
227
202
  }
203
+ return true;
228
204
  }
229
- /**
230
- * A path names a page host, and nothing finer. The bare path resolves to
231
- * `index`, the same convention a static file server follows — an
232
- * application names its landing page that way rather than the hub singling
233
- * one out.
234
- */
235
- function pageNameFor(path) {
236
- const name = path.replace(/^\/+|\/+$/g, "");
237
- return name === "" ? "index" : name;
205
+ /** The page file's title, or its stub when it has none. */
206
+ function titleOf(template, page) {
207
+ return template?.getAttribute(ATTR_PAGE_TITLE) || page;
238
208
  }
239
209
  /**
240
- * The `<template>` a page's markup ships in. The builder guarantees one per
241
- * page name (build/locations.ts refuses two page files sharing a stub), so
242
- * a second match here means the document was assembled by something else,
243
- * and is worth saying out loud rather than resolving by document order.
210
+ * The `<template>` a page ships in. The builder guarantees one per stub
211
+ * (build/locations.ts refuses two page files sharing one), so a second match
212
+ * here means the document was assembled by something else.
244
213
  */
245
214
  function pageHostFor(page) {
246
215
  const hosts = [];
@@ -249,32 +218,28 @@ function pageHostFor(page) {
249
218
  hosts.push(el);
250
219
  }
251
220
  }
252
- if (hosts.length === 0) {
253
- console.error(`lb-hub: no page host for '${page}'`);
254
- return null;
255
- }
256
221
  if (hosts.length > 1) {
257
- console.error(`lb-hub: ${hosts.length} page hosts carry ${ATTR_PAGE}="${page}", ` +
222
+ console.error(`lb-hub: ${hosts.length} pages carry ${ATTR_PAGE}="${page}", ` +
258
223
  `using the first`);
259
224
  }
260
- return hosts[0];
225
+ return hosts[0] ?? null;
261
226
  }
262
227
  /**
263
- * Fetch one round trip against the client-side deadline. Throws — rather
264
- * than swallowing — on a non-ok response, a network failure, or a timeout,
265
- * so a caller can tell a failed request from a genuinely empty result.
228
+ * One round trip against the client-side deadline. Throws on a non-ok
229
+ * response, a network failure or a timeout, so a caller can tell a failed
230
+ * request from an empty answer.
266
231
  *
267
- * The page names the path below the endpoint and the query string the
268
- * browser is showing rides after it verbatim, so every round trip, a page
269
- * load or an action, reaches the server with the URL on screen in hand.
232
+ * The stub names the path below the endpoint and the query string the
233
+ * browser is showing rides after it verbatim, so every round trip reaches
234
+ * the server with the query parms in hand.
270
235
  */
271
- async function fetchData(page, request) {
236
+ async function fetchItems(page, request) {
272
237
  const path = page.split("/").map(encodeURIComponent).join("/");
273
238
  const url = `${LB_ENDPOINT}/${path}${location.search}`;
274
239
  const controller = new AbortController();
275
240
  const timeout = setTimeout(() => controller.abort(), LB_REQUEST_TIMEOUT_MS);
276
241
  try {
277
- // An empty body is how the hub asks for the page's whole query set.
242
+ // An empty body is how the hub asks for the page's queries.
278
243
  const res = await fetch(url, {
279
244
  method: "POST",
280
245
  headers: { "content-type": "application/json" },
@@ -290,23 +255,39 @@ async function fetchData(page, request) {
290
255
  clearTimeout(timeout);
291
256
  }
292
257
  }
293
- /**
294
- * An element's round trip is in flight, or is not. aria-busy states the same
295
- * fact to assistive technology, which no stylesheet on lb-pending reaches.
296
- */
258
+ /** An element's round trip is in flight, or is not. */
297
259
  function pending(el, on) {
298
260
  if (!el)
299
261
  return;
300
262
  if (on) {
301
- el.removeAttribute(ATTR_ERROR);
302
- el.setAttribute(ATTR_PENDING, "");
263
+ el.removeAttribute(ATTR_REQUEST_ERROR);
264
+ el.setAttribute(ATTR_REQUEST_PENDING, "");
303
265
  el.setAttribute("aria-busy", "true");
304
266
  }
305
267
  else {
306
- el.removeAttribute(ATTR_PENDING);
268
+ el.removeAttribute(ATTR_REQUEST_PENDING);
307
269
  el.removeAttribute("aria-busy");
308
270
  }
309
271
  }
272
+ /** A plain primary click: no other button, no modifier, not yet handled. */
273
+ function isPlainClick(e) {
274
+ return (e.button === 0 &&
275
+ !e.defaultPrevented &&
276
+ !e.metaKey &&
277
+ !e.ctrlKey &&
278
+ !e.shiftKey &&
279
+ !e.altKey);
280
+ }
281
+ /** The URL a row for `lb-url` leads to, by the rule the server follows. */
282
+ function nextUrl(row) {
283
+ const url = new URL(location.href);
284
+ const next = nextLocation(pageNameFor(url.pathname), url.searchParams, row);
285
+ const path = row[URL_COLUMN_PATH];
286
+ if (typeof path === "string")
287
+ url.pathname = path;
288
+ url.search = next.parms.toString();
289
+ return { url, moved: next.moved };
290
+ }
310
291
  /**
311
292
  * The hub is a singleton outside <main>. It survives every page change,
312
293
  * so it needs no id to disambiguate it.
@@ -314,246 +295,269 @@ function pending(el, on) {
314
295
  class LbHub extends HTMLElement {
315
296
  main;
316
297
  /**
317
- * The page currently in <main>. A page declares its own requests, so a request
318
- * has to say which page it came from. The page rides on the URL rather
319
- * than in the request, leaving the operation set closed.
298
+ * The stub of the page in <main>. A page declares its own requests, so a
299
+ * request says which page it came from, on the path below the endpoint.
320
300
  */
321
301
  page = "";
322
302
  connectedCallback() {
323
303
  this.main = this.querySelector("main");
324
- // Scoped in the capture phase, on the way down, so every ancestor that
325
- // sees the request on its way back up sees it whole. A request that
326
- // cannot be sent is stopped here, before anything sees it at all.
304
+ // Completed in the capture phase, on the way down, so every ancestor
305
+ // that sees the request on its way back up sees it whole. A request that
306
+ // cannot be issued is stopped here, before anything sees it at all.
327
307
  this.addEventListener(LB_EVENT_NAME, (e) => {
328
308
  const origin = e.target;
329
309
  const request = e.detail;
330
- if (!scope(request, origin))
310
+ if (!complete(request, origin))
331
311
  e.stopPropagation();
332
312
  }, { capture: true });
333
313
  this.addEventListener(LB_EVENT_NAME, (e) => {
334
314
  const request = e.detail;
335
- // The element that dispatched the request, not e.currentTarget (the
336
- // hub itself, since the event bubbles) — pending/error state belongs
337
- // at the origin so a widget can watch its own attributes.
315
+ // The element that issued the request, not e.currentTarget (the hub
316
+ // itself, since the event bubbles).
338
317
  const origin = e.target ?? undefined;
339
- pending(origin, true);
340
- void fetchData(this.page, request)
341
- .then((data) => {
342
- applyData(this, data);
343
- resetGathered(gathered.get(request) ?? []);
344
- })
345
- .catch((err) => {
346
- console.error(`lb-hub: request failed`, err);
347
- origin?.setAttribute(ATTR_ERROR, "");
348
- })
349
- .finally(() => pending(origin, false));
350
- });
351
- /**
352
- * A native element carrying an action. The hub dispatches the bare action
353
- * from that element as the ordinary bubbling event, rather than sending
354
- * it directly, so it is scoped exactly as a widget's is and an ancestor
355
- * widget can still stop it and confirm. A native action button and a
356
- * hand-written widget therefore produce identical events.
357
- *
358
- * A widget carrying an action is left alone — it owns its own interaction
359
- * and decides what counts as performing it, which for a <select> is a
360
- * change rather than a click. The test is the same lexical one applyData
361
- * uses: a hyphen in the tag name.
362
- */
363
- this.addEventListener("click", (e) => {
364
- const el = e.target?.closest(`[${ATTR_ACTION}]`);
365
- if (!el || el.localName.includes("-"))
366
- return;
367
- // A form performs its action on submit, whatever was clicked in it.
368
- if (el instanceof HTMLFormElement)
369
- return;
370
- // A button beside the cells is pressed once they are done. An element
371
- // that is a cell, or holds them, has no such moment: a click into its
372
- // own input would send the row. Deciding when a cell has changed is a
373
- // widget's.
374
- const action = el.getAttribute(ATTR_ACTION);
375
- if ((action === ACTION_ROW_INSERT || action === ACTION_ROW_UPDATE) &&
376
- (el.hasAttribute(ATTR_CELL) || el.querySelector(`[${ATTR_CELL}]`))) {
377
- console.error(`lb-hub: '${action}' on <${el.localName}> is or holds a cell, so ` +
378
- `a click on it is not sent; put it on a <form>, a button or a widget`, el);
318
+ if (request.query?.startsWith(LB_RESERVED_PREFIX)) {
319
+ this.answer(request, origin);
379
320
  return;
380
321
  }
381
- e.preventDefault();
382
- dispatchAction(el);
322
+ void this.send(request, origin);
383
323
  });
384
- // The same delegation for the event a form owns.
324
+ // A form commits on submit. The submitter's own lb-request replaces the
325
+ // form's, the way `formaction` replaces `action`.
385
326
  this.addEventListener("submit", (e) => {
386
327
  const form = e.target;
387
- if (!form || !form.hasAttribute(ATTR_ACTION))
328
+ if (!form)
329
+ return;
330
+ const submitter = e.submitter;
331
+ const el = submitter?.hasAttribute(ATTR_REQUEST)
332
+ ? submitter
333
+ : form.hasAttribute(ATTR_REQUEST)
334
+ ? form
335
+ : null;
336
+ if (!el)
388
337
  return;
389
338
  e.preventDefault();
390
- dispatchAction(form);
339
+ commit(el);
391
340
  });
392
- // Only anchors that opted in. Everything else is a real link.
393
- this.addEventListener("click", (e) => {
394
- const link = e.target?.closest(`a[${ATTR_NAV_LINK}]`);
395
- if (!link)
341
+ // A reset returns a form's controls to their defaults, so none of them
342
+ // shows what landed any longer. The browser resets the controls; the
343
+ // hub drops its own stamp from the same controls a submit gathers.
344
+ this.addEventListener("reset", (e) => {
345
+ if (e.defaultPrevented)
396
346
  return;
397
- e.preventDefault();
398
- const { pathname, search } = new URL(link.href);
399
- history.pushState(null, "", pathname + search);
400
- void this.navigate();
347
+ for (const el of controlsOf(e.target)) {
348
+ el.removeAttribute(ATTR_COLUMN_VALUE);
349
+ }
401
350
  });
402
- /**
403
- * A control that writes a query parm. Changing what the page shows is
404
- * not going anywhere, so the address updates in place and stays
405
- * copyable, and history does not accumulate unless the control asks
406
- * for it. Only the one parm is touched: the rest of the query string may
407
- * have arrived by link, with no control on screen to say it again.
408
- */
351
+ // A control commits on change.
409
352
  this.addEventListener("change", (e) => {
410
- const el = e.target?.closest(`[${ATTR_QUERY_PARM}]`);
411
- if (!el || !this.contains(el))
353
+ const el = e.target;
354
+ if (!el || !el.hasAttribute(ATTR_REQUEST) || !isControl(el))
355
+ return;
356
+ commit(el);
357
+ });
358
+ // Anything else commits on click. A form commits on submit, a control on
359
+ // change, and a submit button with its form. A click on interactive
360
+ // content inside the element is that content's own.
361
+ this.addEventListener("click", (e) => {
362
+ const target = e.target;
363
+ const el = target?.closest(`[${ATTR_REQUEST}]`);
364
+ if (!target || !el || !this.contains(el))
412
365
  return;
413
- const control = controlOf(el);
414
- if (control === undefined) {
415
- console.warn(`lb-hub: ${ATTR_QUERY_PARM} on <${el.localName}> has no input, ` +
416
- `select or textarea to read, ignoring it`, el);
366
+ if (el.localName === "form" || isControl(el) || isSubmitButton(el)) {
417
367
  return;
418
368
  }
419
- const name = el.getAttribute(ATTR_QUERY_PARM);
420
- const url = new URL(location.href);
421
- if ((url.searchParams.get(name) ?? "") === control.value)
369
+ if (!clicks(target, el))
422
370
  return;
423
- // Empty is the default, and a default is left out of the URL, so its
424
- // length tracks how far the user has narrowed the page.
425
- if (control.value === "")
426
- url.searchParams.delete(name);
427
- else
428
- url.searchParams.set(name, control.value);
429
- if (el.hasAttribute(ATTR_QUERY_PARM_PUSH)) {
430
- history.pushState(null, "", url);
431
- }
432
- else {
433
- history.replaceState(null, "", url);
371
+ e.preventDefault();
372
+ commit(el);
373
+ });
374
+ // A link that opted in. Everything else is a real link, and so is a
375
+ // click that opens a new tab or window. The link's URL is a row for
376
+ // lb-url: its path, its query parms, and every other parm emptied. A
377
+ // link pushes a history entry, as a link does.
378
+ this.addEventListener("click", (e) => {
379
+ const link = e.target?.closest(`a[${ATTR_URL_LINK}]`);
380
+ if (!link || !isPlainClick(e))
381
+ return;
382
+ e.preventDefault();
383
+ const href = new URL(link.href);
384
+ const row = {};
385
+ for (const name of new URLSearchParams(location.search).keys()) {
386
+ row[name] = "";
434
387
  }
435
- void this.navigate(el);
388
+ for (const [name, value] of href.searchParams)
389
+ row[name] = value;
390
+ row[URL_COLUMN_PATH] = href.pathname;
391
+ this.moveTo(row, true);
392
+ void this.navigate();
436
393
  });
437
394
  addEventListener("popstate", () => void this.navigate());
438
395
  void this.navigate();
439
396
  }
440
397
  /**
441
- * Load the page at the URL the browser is showing. Every way of arriving
442
- * comes through here — a cold load, a nav link, Back, a query parm write —
443
- * so each one is the same page load and a URL always shows the same thing.
444
- *
445
- * Entering a page is the one operation that replaces host DOM. The host is
446
- * already in the document, so selecting a page is a lookup and there is no
447
- * fetching mechanism on the host channel. Staying on the page, with only
448
- * the query string changed, keeps the DOM it has: the queries re-run and
449
- * land by key, so a list that got its rows back keeps them, and its scroll.
450
- *
451
- * `origin` is the control that changed the query string, if one did, and
452
- * carries the round trip's pending and error state as a request's origin
453
- * does.
398
+ * Send a request to the server and land its answer. An answer naming
399
+ * `lb-url` moves the URL first; the server has already loaded the page
400
+ * there, in the same round trip.
454
401
  */
455
- async navigate(origin) {
456
- const page = pageNameFor(location.pathname);
457
- this.landNavigation(page);
458
- const template = pageHostFor(page);
459
- if (!template) {
460
- this.reportUnknownPage();
461
- this.reveal();
402
+ async send(request, origin) {
403
+ const page = this.page;
404
+ const items = await this.roundTrip(page, request, origin);
405
+ if (items === null)
462
406
  return;
463
- }
464
- if (page !== this.page) {
465
- this.page = page;
466
- // Insert the page shell before fetching its data, not after: the
467
- // template is already in the document, so holding it back behind a
468
- // network round trip only leaves <main> empty for no reason.
469
- this.main.replaceChildren(template.content.cloneNode(true));
470
- }
471
- this.reveal();
472
- let data = {};
407
+ const url = items.find((item) => item.query === URL_QUERY);
408
+ const load = items.filter((item) => item !== url);
409
+ if (url === undefined)
410
+ applyResponse(this, load);
411
+ else
412
+ await this.follow(page, url, load, origin);
413
+ insertedFrom.get(request)?.reset();
414
+ }
415
+ /**
416
+ * One round trip for a page: a request, or with none a page load. The
417
+ * element that issued it carries `lb-request-pending` while it is in
418
+ * flight, and `lb-request-error` when it fails. Null on failure.
419
+ */
420
+ async roundTrip(page, request, origin) {
473
421
  pending(origin, true);
474
422
  try {
475
- data = await fetchData(page);
423
+ return await fetchItems(page, request);
476
424
  }
477
425
  catch (err) {
478
- console.error(`lb-hub: failed to load data for '${page}'`, err);
479
- origin?.setAttribute(ATTR_ERROR, "");
426
+ console.error(`lb-hub: request failed for '${page}'`, err);
427
+ origin?.setAttribute(ATTR_REQUEST_ERROR, "");
428
+ return null;
480
429
  }
481
430
  finally {
482
431
  pending(origin, false);
483
432
  }
484
- applyData(this, data);
485
- applyQueryParms(this.main, new URLSearchParams(location.search));
486
433
  }
487
434
  /**
488
- * An app that ships `<body hidden>` (see docs/reference/chrome.md) is
489
- * asking to stay invisible until there is something coherent to show,
490
- * rather than flash the chrome before `<main>` has real content. This is
491
- * a no-op for an app that doesn't use that convention.
435
+ * Answer a request for a query the hub serves, with no round trip. The
436
+ * hub serves `lb-url`, and updates it.
492
437
  */
493
- reveal() {
494
- document.body.hidden = false;
438
+ answer(request, origin) {
439
+ if (request.query !== URL_QUERY || request.name !== REQUEST_ROW_UPDATE) {
440
+ console.error(`lb-hub: '${request.name}' for '${request.query}' is not a request ` +
441
+ `the hub answers, ignoring`);
442
+ return;
443
+ }
444
+ const pushed = origin?.hasAttribute(ATTR_URL_PUSH) ?? false;
445
+ if (this.moveTo(request.values ?? {}, pushed))
446
+ void this.navigate(origin);
447
+ }
448
+ /**
449
+ * Write a row for `lb-url` into the URL. A new page pushes a history
450
+ * entry, and so does `push`; a change of query parms replaces the entry.
451
+ * False when the URL is already the row's.
452
+ */
453
+ moveTo(row, push) {
454
+ const { url, moved } = nextUrl(row);
455
+ if (url.href === location.href)
456
+ return false;
457
+ if (moved || push)
458
+ history.pushState(null, "", url);
459
+ else
460
+ history.replaceState(null, "", url);
461
+ return true;
495
462
  }
496
463
  /**
497
- * The hub's own row: where the page is, in the vocabulary a server
498
- * answer arrives in, so a chrome displays it the way it displays anything
499
- * — `lb-row="lb-navigation"` on a subtree, `lb-cell` on what shows a
500
- * value. Landed before the page host is looked up, so a miss has it too;
501
- * the unknown-page dialog is one consumer among whatever others the
502
- * chrome writes.
464
+ * A server answer named `lb-url`: write the URL, then land the page the
465
+ * server loaded there. A load that did not come back is asked for again,
466
+ * without marking the element that issued the request: its write
467
+ * succeeded, and an error would invite sending it twice.
503
468
  *
504
- * The label is the nav's: the text of the first lb-nav-link anchor whose
505
- * href names this page. Taken from the nav rather than from a click, so a
506
- * click, a reload and the back button all land the same label — and a
507
- * path no anchor names lands an empty one, explicitly, so a consumer is
508
- * never left showing the previous page's.
469
+ * `page` is where the request was sent from. A user who left it while the
470
+ * request was in flight is on a URL that is not the server's to change.
471
+ */
472
+ async follow(page, item, load, origin) {
473
+ if (pageNameFor(location.pathname) !== page)
474
+ return;
475
+ this.moveTo(item.row ?? {}, origin?.hasAttribute(ATTR_URL_PUSH) ?? false);
476
+ if (load.length === 0)
477
+ return this.navigate();
478
+ const template = this.arrive();
479
+ if (template)
480
+ applyResponse(this, load);
481
+ }
482
+ /**
483
+ * Load the page at the URL the browser is showing. Every way of arriving
484
+ * comes through here — a cold load, a link, Back, a new query parm — so
485
+ * each is the same page load and a URL always shows the same thing.
509
486
  *
510
- * The URI is the path and query string together, as the address bar has
511
- * them: a link that opens a page narrowed is a different place from the
512
- * bare page, though the label, which names the page, is the same.
487
+ * Entering a page is the one operation that replaces what <main> holds.
488
+ * Staying on it, with only the query parms changed, keeps what it has: the
489
+ * queries re-run and land by key, so rows that come back keep their place.
513
490
  *
514
- * Landed only where a subtree names it. applyData() warns about a query
515
- * with no scope, and a chrome that displays no navigation has nothing to
516
- * be warned about.
491
+ * `origin` is the element whose request changed the URL, if one did, and
492
+ * carries the round trip's pending and error state.
517
493
  */
518
- landNavigation(page) {
519
- if (!this.querySelector(`[${ATTR_ROW}="${NAV_ROW}"]`))
494
+ async navigate(origin) {
495
+ const template = this.arrive();
496
+ if (!template)
520
497
  return;
521
- let label = "";
522
- for (const a of this.querySelectorAll(`a[${ATTR_NAV_LINK}]`)) {
523
- if (pageNameFor(new URL(a.href).pathname) !== page)
524
- continue;
525
- label = a.textContent?.trim() ?? "";
526
- break;
498
+ const items = await this.roundTrip(this.page, undefined, origin);
499
+ applyResponse(this, items ?? []);
500
+ }
501
+ /**
502
+ * Show the page the path names, and land `lb-url`. Null when no page's
503
+ * stub matches the path.
504
+ */
505
+ arrive() {
506
+ const page = pageNameFor(location.pathname);
507
+ const template = pageHostFor(page);
508
+ if (template && page !== this.page) {
509
+ this.page = page;
510
+ // The template is already in the document, so the page's markup goes
511
+ // in before its data arrives rather than leaving <main> empty.
512
+ this.main.replaceChildren(template.content.cloneNode(true));
527
513
  }
528
- applyData(this, {
529
- [NAV_ROW]: {
530
- [NAV_CELL_LABEL]: label,
531
- [NAV_CELL_URI]: location.pathname + location.search,
532
- },
533
- });
514
+ this.landUrl(template ? titleOf(template, page) : null);
515
+ // An app that ships `<body hidden>` (docs/reference/chrome.md) stays
516
+ // invisible until there is something coherent to show.
517
+ document.body.hidden = false;
518
+ if (!template) {
519
+ console.error(`lb-hub: no page for '${page}'`);
520
+ const dialog = this.querySelector(`dialog[${ATTR_URL_UNKNOWN}]`);
521
+ if (dialog && !dialog.open) {
522
+ dialog.showModal();
523
+ }
524
+ }
525
+ return template;
534
526
  }
535
527
  /**
536
- * A path that resolves to no page host is discovered client-side, after a
537
- * successful 200 — every route gets the same document, so there is no
538
- * HTTP 404 to report (see docs/tutorials/010-pages-and-navigation.md).
539
- * lb-unknown-page is optional: an application that declares none gets
540
- * nothing beyond navigate()'s console.error. The builder guarantees the
541
- * element it's on is a <dialog> (build/assemble.ts), so this can call
542
- * showModal() without checking the tag here.
528
+ * The hub's own query: one row, keyed by the path, whose other columns are
529
+ * the query parms.
543
530
  *
544
- * Only opens it. What the dialog says it gets from landNavigation(),
545
- * already run, by naming the hub's row in lb-row like any subtree.
546
- *
547
- * Scoped to the hub, like every other lookup here, so that one rule holds
548
- * without exception: what the hub acts on is inside the hub.
531
+ * The one exception to landing. A row sets the columns it names and
532
+ * leaves the rest as they were, and a URL names only the parms it carries.
533
+ * So the row also names, as empty, every column an element under
534
+ * `lb-query="lb-url"` shows: a control then shows what the address bar
535
+ * says after Back as well as after a reload, and never a parm the URL has
536
+ * dropped.
549
537
  */
550
- reportUnknownPage() {
551
- const dialog = this.querySelector(`[${ATTR_UNKNOWN_PAGE}]`);
552
- if (!dialog)
538
+ landUrl(label) {
539
+ if (label !== null)
540
+ document.title = label;
541
+ const scopes = this.querySelectorAll(`[${ATTR_QUERY}="${URL_QUERY}"]`);
542
+ if (scopes.length === 0)
553
543
  return;
554
- const modal = dialog;
555
- if (!modal.open)
556
- modal.showModal();
544
+ const row = {};
545
+ for (const scope of scopes) {
546
+ for (const el of within(scope, `[${ATTR_COLUMN}]`)) {
547
+ if (el === scope)
548
+ continue;
549
+ row[el.getAttribute(ATTR_COLUMN)] = "";
550
+ }
551
+ }
552
+ for (const [name, value] of new URLSearchParams(location.search)) {
553
+ row[name] = value;
554
+ }
555
+ row[URL_COLUMN_PATH] = location.pathname;
556
+ row[URL_COLUMN_PAGE_LABEL] = label;
557
+ row[URL_COLUMN_PAGE_UNKNOWN] = label === null;
558
+ applyResponse(this, [
559
+ { query: URL_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row },
560
+ ]);
557
561
  }
558
562
  }
559
563
  customElements.define(HUB_TAG_NAME, LbHub);