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