@loadbare/app 0.9.0 → 0.11.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 (82) hide show
  1. package/README.md +3 -3
  2. package/dist/build/assemble.d.ts.map +1 -1
  3. package/dist/build/assemble.js +107 -90
  4. package/dist/build/assemble.js.map +1 -1
  5. package/dist/build/expand.d.ts +6 -1
  6. package/dist/build/expand.d.ts.map +1 -1
  7. package/dist/build/expand.js +112 -26
  8. package/dist/build/expand.js.map +1 -1
  9. package/dist/build/locations.d.ts +2 -3
  10. package/dist/build/locations.d.ts.map +1 -1
  11. package/dist/build/locations.js +2 -3
  12. package/dist/build/locations.js.map +1 -1
  13. package/dist/build/pages.d.ts +3 -4
  14. package/dist/build/pages.d.ts.map +1 -1
  15. package/dist/build/pages.js +3 -4
  16. package/dist/build/pages.js.map +1 -1
  17. package/dist/core/lb-constants.d.ts +27 -24
  18. package/dist/core/lb-constants.d.ts.map +1 -1
  19. package/dist/core/lb-constants.js +103 -168
  20. package/dist/core/lb-constants.js.map +1 -1
  21. package/dist/core/lb-types.d.ts +64 -77
  22. package/dist/core/lb-types.d.ts.map +1 -1
  23. package/dist/core/lb-types.js +40 -7
  24. package/dist/core/lb-types.js.map +1 -1
  25. package/dist/hub/lb-apply.d.ts +47 -37
  26. package/dist/hub/lb-apply.d.ts.map +1 -1
  27. package/dist/hub/lb-apply.js +195 -199
  28. package/dist/hub/lb-apply.js.map +1 -1
  29. package/dist/hub/lb-hub.browser.d.ts +1 -1
  30. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  31. package/dist/hub/lb-hub.browser.js +410 -449
  32. package/dist/hub/lb-hub.browser.js.map +1 -1
  33. package/dist/server/lb-express.d.ts +5 -5
  34. package/dist/server/lb-express.d.ts.map +1 -1
  35. package/dist/server/lb-express.js +35 -66
  36. package/dist/server/lb-express.js.map +1 -1
  37. package/dist/server/lb-server.d.ts +77 -135
  38. package/dist/server/lb-server.d.ts.map +1 -1
  39. package/dist/server/lb-server.js +132 -79
  40. package/dist/server/lb-server.js.map +1 -1
  41. package/docs/TECHREF-1.0.md +908 -585
  42. package/docs/comparison.md +243 -185
  43. package/docs/prior-art.md +15 -14
  44. package/docs/reference/builder.md +9 -3
  45. package/docs/reference/chrome.md +107 -56
  46. package/docs/reference/custom-elements.md +291 -173
  47. package/docs/reference/data-binding.md +381 -374
  48. package/docs/reference/overview.md +12 -10
  49. package/docs/reference/page-files.md +164 -99
  50. package/docs/reference/server.md +2 -2
  51. package/docs/reference/widgets.md +104 -110
  52. package/docs/roadmap.md +32 -39
  53. package/docs/terms-of-art.md +57 -0
  54. package/docs/testing.md +97 -68
  55. package/docs/theory.md +92 -58
  56. package/docs/tutorials/010-pages-and-navigation.md +20 -12
  57. package/docs/tutorials/020-css.md +6 -3
  58. package/docs/tutorials/030-html-decomposition.md +9 -7
  59. package/docs/tutorials/040-displaying-data.md +30 -13
  60. package/docs/tutorials/{050-actions.md → 050-requests.md} +25 -15
  61. package/docs/tutorials/060-custom-element-code.md +17 -16
  62. package/docs/tutorials/065-conditional-rendering.md +34 -23
  63. package/docs/tutorials/070-displaying-a-list.md +29 -21
  64. package/docs/tutorials/072-inserting-into-a-list.md +24 -16
  65. package/docs/tutorials/074-deleting-from-a-list.md +9 -7
  66. package/docs/tutorials/076-updating-a-list-item.md +11 -10
  67. package/docs/tutorials/080-widget-requests.md +71 -43
  68. package/docs/tutorials/090-using-widget-libraries.md +22 -22
  69. package/docs/what-does-loadbare-extend.md +124 -0
  70. package/package.json +1 -1
  71. package/skills/loadbare-app/SKILL.md +201 -123
  72. package/skills/loadbare-app/references/TECHREF-1.0.md +908 -585
  73. package/skills/loadbare-app/references/builder.md +9 -3
  74. package/skills/loadbare-app/references/chrome.md +107 -56
  75. package/skills/loadbare-app/references/custom-elements.md +291 -173
  76. package/skills/loadbare-app/references/data-binding.md +381 -374
  77. package/skills/loadbare-app/references/overview.md +12 -10
  78. package/skills/loadbare-app/references/page-files.md +164 -99
  79. package/skills/loadbare-app/references/server.md +2 -2
  80. package/skills/loadbare-app/references/widgets.md +104 -110
  81. package/docs/analysis-accidental-complexity.md +0 -149
  82. package/docs/analysis-closed-set.md +0 -210
@@ -1,247 +1,214 @@
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, LIVE_ROW, 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
+ }
16
+ /** The live row an element is, or is in. */
17
+ function liveRowOf(el) {
18
+ return el.closest(LIVE_ROW);
19
+ }
20
+ /**
21
+ * The key of the nearest ancestor row, when that row is of `query`: a live
22
+ * row whose query it is, or the element a `row` of it landed on.
23
+ */
24
+ function keyFrom(el, query) {
25
+ // A live row is its own row; any other element's row is an ancestor.
26
+ const row = el.matches(LIVE_ROW)
27
+ ? el
28
+ : el.parentElement?.closest(`[${ATTR_KEY_VALUE}]`);
29
+ if (!row)
30
+ return undefined;
31
+ // A live row's key is its outer query's, even when the row names a query
32
+ // of its own. Any other element's key is its own query's.
33
+ const ofQuery = row.matches(LIVE_ROW)
34
+ ? queryOf(row) === query
35
+ : row === query;
36
+ return ofQuery ? (row.getAttribute(ATTR_KEY_VALUE) ?? undefined) : undefined;
37
+ }
38
+ /**
39
+ * Every control whose form owner is `form`: HTML's own list, which holds a
40
+ * control nested in the form or joined to it by the `form` attribute, and a
41
+ * form-associated custom element as well as a native control.
42
+ */
43
+ function controlsOf(form) {
44
+ return [...form.elements];
45
+ }
15
46
  /**
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.
47
+ * The form owner of an element that has one, as HTML reports it on the
48
+ * element's `form` property.
49
+ */
50
+ function formOwnerOf(el) {
51
+ const owner = el.form;
52
+ return owner && owner.localName === "form"
53
+ ? owner
54
+ : null;
55
+ }
56
+ /**
57
+ * The controls a request gathers — see docs/reference/data-binding.md,
58
+ * "Gathering":
19
59
  *
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.
60
+ * 1. the element carrying `lb-request` has `lb-column`: it alone;
61
+ * 2. the element is a `<form>`: the controls whose form owner it is;
62
+ * 3. the element is in a live row: the controls in that live row;
63
+ * 4. otherwise: the controls whose form owner is the element's.
24
64
  *
25
- * False when the request cannot be sent, having said why.
65
+ * A control whose nearest `lb-query` is inside the group belongs to a query
66
+ * nested in it, and is skipped.
26
67
  */
27
- function scope(request, el) {
28
- if (!request?.action) {
29
- console.warn(`lb-hub: event without an action, ignoring`, request);
68
+ function groupFor(el) {
69
+ if (el.hasAttribute(ATTR_COLUMN))
70
+ return { controls: [el], root: el };
71
+ if (el.localName === "form") {
72
+ const form = el;
73
+ return { controls: controlsOf(form), form, root: form };
74
+ }
75
+ const live = liveRowOf(el);
76
+ if (live)
77
+ return { controls: within(live, `[${ATTR_COLUMN}]`), root: live };
78
+ const form = formOwnerOf(el);
79
+ if (form)
80
+ return { controls: controlsOf(form), form, root: form };
81
+ return null;
82
+ }
83
+ /** The gathered controls, with their values under their columns. */
84
+ function gather(group) {
85
+ const controls = [];
86
+ const values = {};
87
+ for (const el of group?.controls ?? []) {
88
+ const column = el.getAttribute(ATTR_COLUMN);
89
+ if (column === null || !isControl(el))
90
+ continue;
91
+ const nested = queryOf(el);
92
+ if (nested && nested !== group.root && group.root.contains(nested)) {
93
+ continue;
94
+ }
95
+ controls.push(el);
96
+ values[column] = el.value;
97
+ }
98
+ return { controls, values };
99
+ }
100
+ /** The form each insert gathered from, until its round trip succeeds. */
101
+ const insertedFrom = new WeakMap();
102
+ /**
103
+ * Complete a request from where its element sits. Every request passes
104
+ * through here, whoever issued it; a field it already carries is its
105
+ * issuer's to decide and is left as it is.
106
+ *
107
+ * False when the request cannot be issued, having said why.
108
+ */
109
+ function complete(request, el) {
110
+ if (!request?.name) {
111
+ console.warn(`lb-hub: a request without a name, ignoring`, request);
30
112
  return false;
31
113
  }
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`);
114
+ const { name } = request;
115
+ if (name.startsWith(LB_RESERVED_PREFIX) && !isRowRequest(name)) {
116
+ console.error(`lb-hub: '${name}' begins with '${LB_RESERVED_PREFIX}' but names no ` +
117
+ `request Loadbare provides, ignoring`);
41
118
  return false;
42
119
  }
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);
120
+ const group = groupFor(el);
121
+ const { controls, values } = gather(group);
122
+ if (request.values === undefined && controls.length > 0) {
123
+ request.values = values;
124
+ }
125
+ // The request is for the nearest `lb-query` of what it gathered, or of the
126
+ // element itself when it gathered nothing.
127
+ const queries = new Set(controls.map(queryOf));
128
+ if (queries.size > 1) {
129
+ console.error(`lb-hub: '${name}' gathered controls of more than one query, not sent`, el);
49
130
  return false;
50
131
  }
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;
132
+ const from = controls[0] ?? el;
133
+ const query = controls.length > 0 ? [...queries][0] : queryOf(el);
134
+ if (query) {
135
+ request.query ??= query.getAttribute(ATTR_QUERY);
136
+ const key = keyFrom(from, query);
137
+ if (key !== undefined)
138
+ request.key ??= key;
83
139
  }
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);
140
+ if (name === REQUEST_ROW_INSERT && group?.form) {
141
+ insertedFrom.set(request, group.form);
142
+ }
143
+ const needs = NEEDS[name];
144
+ if (needs) {
145
+ const missing = needs.filter((field) => request[field] === undefined ||
146
+ (field === "values" && Object.keys(request.values).length === 0));
147
+ if (missing.length > 0) {
148
+ console.error(`lb-hub: '${name}' from <${el.localName}> has no ` +
149
+ `${missing.join(", ")}, not sent`, el);
114
150
  return false;
115
151
  }
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
152
  }
144
153
  return true;
145
154
  }
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))
155
+ /**
156
+ * Commit: dispatch the request an element names, for the hub to complete. A
157
+ * commit on an element whose round trip is in flight is ignored.
158
+ */
159
+ function commit(el) {
160
+ if (el.hasAttribute(ATTR_REQUEST_PENDING))
153
161
  return;
154
- const detail = { action: el.getAttribute(ATTR_ACTION) };
162
+ const detail = { name: el.getAttribute(ATTR_REQUEST) };
155
163
  el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
156
164
  }
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);
165
+ /** A submit button with a form owner commits with its form, never on click. */
166
+ function isSubmitButton(el) {
167
+ const type = el.type;
168
+ const submits = (el.localName === "button" && type === "submit") ||
169
+ (el.localName === "input" && (type === "submit" || type === "image"));
170
+ return submits && formOwnerOf(el) !== null;
175
171
  }
176
172
  /**
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.
173
+ * HTML's interactive content: elements a click already means something to.
174
+ * HTML forbids nesting them inside `<a>` or `<button>` so a click means one
175
+ * thing, and the hub draws the same line.
185
176
  */
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
- }
177
+ const INTERACTIVE = [
178
+ "a[href]",
179
+ "button",
180
+ "input:not([type=hidden])",
181
+ "select",
182
+ "textarea",
183
+ "label",
184
+ "details",
185
+ "iframe",
186
+ "embed",
187
+ "audio[controls]",
188
+ "video[controls]",
189
+ "img[usemap]",
190
+ ].join(", ");
202
191
  /**
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`.
192
+ * Whether a click on `target` is the click of `el`, the element carrying
193
+ * `lb-request` above it: no interactive content lies between them. A click on
194
+ * a control, link or button inside `el` belongs to that element.
212
195
  */
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
- }
196
+ function clicks(target, el) {
197
+ for (let node = target; node && node !== el;) {
198
+ if (node.matches(INTERACTIVE) || isFormAssociated(node))
199
+ return false;
200
+ node = node.parentElement;
228
201
  }
202
+ return true;
229
203
  }
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;
204
+ /** The page file's title, or its stub when it has none. */
205
+ function titleOf(template, page) {
206
+ return template?.getAttribute(ATTR_PAGE_TITLE) || page;
239
207
  }
240
208
  /**
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.
209
+ * The `<template>` a page ships in. The builder guarantees one per stub
210
+ * (build/locations.ts refuses two page files sharing one), so a second match
211
+ * here means the document was assembled by something else.
245
212
  */
246
213
  function pageHostFor(page) {
247
214
  const hosts = [];
@@ -250,32 +217,28 @@ function pageHostFor(page) {
250
217
  hosts.push(el);
251
218
  }
252
219
  }
253
- if (hosts.length === 0) {
254
- console.error(`lb-hub: no page host for '${page}'`);
255
- return null;
256
- }
257
220
  if (hosts.length > 1) {
258
- console.error(`lb-hub: ${hosts.length} page hosts carry ${ATTR_PAGE}="${page}", ` +
221
+ console.error(`lb-hub: ${hosts.length} pages carry ${ATTR_PAGE}="${page}", ` +
259
222
  `using the first`);
260
223
  }
261
- return hosts[0];
224
+ return hosts[0] ?? null;
262
225
  }
263
226
  /**
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.
227
+ * One round trip against the client-side deadline. Throws on a non-ok
228
+ * response, a network failure or a timeout, so a caller can tell a failed
229
+ * request from an empty answer.
267
230
  *
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.
231
+ * The stub names the path below the endpoint and the query string the
232
+ * browser is showing rides after it verbatim, so every round trip reaches
233
+ * the server with the query parms in hand.
271
234
  */
272
- async function fetchData(page, request) {
235
+ async function fetchItems(page, request) {
273
236
  const path = page.split("/").map(encodeURIComponent).join("/");
274
237
  const url = `${LB_ENDPOINT}/${path}${location.search}`;
275
238
  const controller = new AbortController();
276
239
  const timeout = setTimeout(() => controller.abort(), LB_REQUEST_TIMEOUT_MS);
277
240
  try {
278
- // An empty body is how the hub asks for the page's whole query set.
241
+ // An empty body is how the hub asks for the page's queries.
279
242
  const res = await fetch(url, {
280
243
  method: "POST",
281
244
  headers: { "content-type": "application/json" },
@@ -291,23 +254,39 @@ async function fetchData(page, request) {
291
254
  clearTimeout(timeout);
292
255
  }
293
256
  }
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
- */
257
+ /** An element's round trip is in flight, or is not. */
298
258
  function pending(el, on) {
299
259
  if (!el)
300
260
  return;
301
261
  if (on) {
302
- el.removeAttribute(ATTR_ERROR);
303
- el.setAttribute(ATTR_PENDING, "");
262
+ el.removeAttribute(ATTR_REQUEST_ERROR);
263
+ el.setAttribute(ATTR_REQUEST_PENDING, "");
304
264
  el.setAttribute("aria-busy", "true");
305
265
  }
306
266
  else {
307
- el.removeAttribute(ATTR_PENDING);
267
+ el.removeAttribute(ATTR_REQUEST_PENDING);
308
268
  el.removeAttribute("aria-busy");
309
269
  }
310
270
  }
271
+ /** A plain primary click: no other button, no modifier, not yet handled. */
272
+ function isPlainClick(e) {
273
+ return (e.button === 0 &&
274
+ !e.defaultPrevented &&
275
+ !e.metaKey &&
276
+ !e.ctrlKey &&
277
+ !e.shiftKey &&
278
+ !e.altKey);
279
+ }
280
+ /** The URL a row for `lb-url` leads to, by the rule the server follows. */
281
+ function nextUrl(row) {
282
+ const url = new URL(location.href);
283
+ const next = nextLocation(pageNameFor(url.pathname), url.searchParams, row);
284
+ const path = row[URL_COLUMN_PATH];
285
+ if (typeof path === "string")
286
+ url.pathname = path;
287
+ url.search = next.parms.toString();
288
+ return { url, moved: next.moved };
289
+ }
311
290
  /**
312
291
  * The hub is a singleton outside <main>. It survives every page change,
313
292
  * so it needs no id to disambiguate it.
@@ -315,287 +294,269 @@ function pending(el, on) {
315
294
  class LbHub extends HTMLElement {
316
295
  main;
317
296
  /**
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.
297
+ * The stub of the page in <main>. A page declares its own requests, so a
298
+ * request says which page it came from, on the path below the endpoint.
321
299
  */
322
300
  page = "";
323
301
  connectedCallback() {
324
302
  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.
303
+ // Completed in the capture phase, on the way down, so every ancestor
304
+ // that sees the request on its way back up sees it whole. A request that
305
+ // cannot be issued is stopped here, before anything sees it at all.
328
306
  this.addEventListener(LB_EVENT_NAME, (e) => {
329
307
  const origin = e.target;
330
308
  const request = e.detail;
331
- if (!scope(request, origin))
309
+ if (!complete(request, origin))
332
310
  e.stopPropagation();
333
311
  }, { capture: true });
334
312
  this.addEventListener(LB_EVENT_NAME, (e) => {
335
313
  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.
314
+ // The element that issued the request, not e.currentTarget (the hub
315
+ // itself, since the event bubbles).
339
316
  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);
317
+ if (request.query?.startsWith(LB_RESERVED_PREFIX)) {
318
+ this.answer(request, origin);
386
319
  return;
387
320
  }
388
- e.preventDefault();
389
- dispatchAction(el);
321
+ void this.send(request, origin);
390
322
  });
391
- // The same delegation for the event a form owns.
323
+ // A form commits on submit. The submitter's own lb-request replaces the
324
+ // form's, the way `formaction` replaces `action`.
392
325
  this.addEventListener("submit", (e) => {
393
326
  const form = e.target;
394
- if (!form || !form.hasAttribute(ATTR_ACTION))
327
+ if (!form)
328
+ return;
329
+ const submitter = e.submitter;
330
+ const el = submitter?.hasAttribute(ATTR_REQUEST)
331
+ ? submitter
332
+ : form.hasAttribute(ATTR_REQUEST)
333
+ ? form
334
+ : null;
335
+ if (!el)
395
336
  return;
396
337
  e.preventDefault();
397
- dispatchAction(form);
338
+ commit(el);
398
339
  });
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)
340
+ // A reset returns a form's controls to their defaults, so none of them
341
+ // shows what landed any longer. The browser resets the controls; the
342
+ // hub drops its own stamp from the same controls a submit gathers.
343
+ this.addEventListener("reset", (e) => {
344
+ if (e.defaultPrevented)
403
345
  return;
404
- e.preventDefault();
405
- const { pathname, search } = new URL(link.href);
406
- history.pushState(null, "", pathname + search);
407
- void this.navigate();
346
+ for (const el of controlsOf(e.target)) {
347
+ el.removeAttribute(ATTR_COLUMN_VALUE);
348
+ }
408
349
  });
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
- */
350
+ // A control commits on change.
416
351
  this.addEventListener("change", (e) => {
417
- const el = e.target?.closest(`[${ATTR_QUERY_PARM}]`);
418
- if (!el || !this.contains(el))
352
+ const el = e.target;
353
+ if (!el || !el.hasAttribute(ATTR_REQUEST) || !isControl(el))
419
354
  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);
355
+ commit(el);
356
+ });
357
+ // Anything else commits on click. A form commits on submit, a control on
358
+ // change, and a submit button with its form. A click on interactive
359
+ // content inside the element is that content's own.
360
+ this.addEventListener("click", (e) => {
361
+ const target = e.target;
362
+ const el = target?.closest(`[${ATTR_REQUEST}]`);
363
+ if (!target || !el || !this.contains(el))
424
364
  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)
365
+ if (el.localName === "form" || isControl(el) || isSubmitButton(el)) {
429
366
  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
367
  }
438
- else {
439
- history.replaceState(null, "", url);
368
+ if (!clicks(target, el))
369
+ return;
370
+ e.preventDefault();
371
+ commit(el);
372
+ });
373
+ // A link that opted in. Everything else is a real link, and so is a
374
+ // click that opens a new tab or window. The link's URL is a row for
375
+ // lb-url: its path, its query parms, and every other parm emptied. A
376
+ // link pushes a history entry, as a link does.
377
+ this.addEventListener("click", (e) => {
378
+ const link = e.target?.closest(`a[${ATTR_URL_LINK}]`);
379
+ if (!link || !isPlainClick(e))
380
+ return;
381
+ e.preventDefault();
382
+ const href = new URL(link.href);
383
+ const row = {};
384
+ for (const name of new URLSearchParams(location.search).keys()) {
385
+ row[name] = "";
440
386
  }
441
- void this.navigate(el);
387
+ for (const [name, value] of href.searchParams)
388
+ row[name] = value;
389
+ row[URL_COLUMN_PATH] = href.pathname;
390
+ this.moveTo(row, true);
391
+ void this.navigate();
442
392
  });
443
393
  addEventListener("popstate", () => void this.navigate());
444
394
  void this.navigate();
445
395
  }
446
396
  /**
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.
397
+ * Send a request to the server and land its answer. An answer naming
398
+ * `lb-url` moves the URL first; the server has already loaded the page
399
+ * there, in the same round trip.
460
400
  */
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();
401
+ async send(request, origin) {
402
+ const page = this.page;
403
+ const items = await this.roundTrip(page, request, origin);
404
+ if (items === null)
468
405
  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 = {};
406
+ const url = items.find((item) => item.query === URL_QUERY);
407
+ const load = items.filter((item) => item !== url);
408
+ if (url === undefined)
409
+ applyResponse(this, load);
410
+ else
411
+ await this.follow(page, url, load, origin);
412
+ insertedFrom.get(request)?.reset();
413
+ }
414
+ /**
415
+ * One round trip for a page: a request, or with none a page load. The
416
+ * element that issued it carries `lb-request-pending` while it is in
417
+ * flight, and `lb-request-error` when it fails. Null on failure.
418
+ */
419
+ async roundTrip(page, request, origin) {
479
420
  pending(origin, true);
480
421
  try {
481
- data = await fetchData(page);
422
+ return await fetchItems(page, request);
482
423
  }
483
424
  catch (err) {
484
- console.error(`lb-hub: failed to load data for '${page}'`, err);
485
- origin?.setAttribute(ATTR_ERROR, "");
425
+ console.error(`lb-hub: request failed for '${page}'`, err);
426
+ origin?.setAttribute(ATTR_REQUEST_ERROR, "");
427
+ return null;
486
428
  }
487
429
  finally {
488
430
  pending(origin, false);
489
431
  }
490
- this.land(data);
491
432
  }
492
433
  /**
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.
434
+ * Answer a request for a query the hub serves, with no round trip. The
435
+ * hub serves `lb-url`, and updates it.
496
436
  */
497
- land(data) {
498
- applyData(this, data);
499
- applyQueryParms(this.main, new URLSearchParams(location.search));
437
+ answer(request, origin) {
438
+ if (request.query !== URL_QUERY || request.name !== REQUEST_ROW_UPDATE) {
439
+ console.error(`lb-hub: '${request.name}' for '${request.query}' is not a request ` +
440
+ `the hub answers, ignoring`);
441
+ return;
442
+ }
443
+ const pushed = origin?.hasAttribute(ATTR_URL_PUSH) ?? false;
444
+ if (this.moveTo(request.values ?? {}, pushed))
445
+ void this.navigate(origin);
500
446
  }
501
447
  /**
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.
448
+ * Write a row for `lb-url` into the URL. A new page pushes a history
449
+ * entry, and so does `push`; a change of query parms replaces the entry.
450
+ * False when the URL is already the row's.
451
+ */
452
+ moveTo(row, push) {
453
+ const { url, moved } = nextUrl(row);
454
+ if (url.href === location.href)
455
+ return false;
456
+ if (moved || push)
457
+ history.pushState(null, "", url);
458
+ else
459
+ history.replaceState(null, "", url);
460
+ return true;
461
+ }
462
+ /**
463
+ * A server answer named `lb-url`: write the URL, then land the page the
464
+ * server loaded there. A load that did not come back is asked for again,
465
+ * without marking the element that issued the request: its write
466
+ * succeeded, and an error would invite sending it twice.
514
467
  *
515
468
  * `page` is where the request was sent from. A user who left it while the
516
469
  * request was in flight is on a URL that is not the server's to change.
517
470
  */
518
- async changeQueryParms(page, written, load) {
471
+ async follow(page, item, load, origin) {
519
472
  if (pageNameFor(location.pathname) !== page)
520
473
  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)
474
+ this.moveTo(item.row ?? {}, origin?.hasAttribute(ATTR_URL_PUSH) ?? false);
475
+ if (load.length === 0)
525
476
  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;
477
+ const template = this.arrive();
478
+ if (template)
479
+ applyResponse(this, load);
537
480
  }
538
481
  /**
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.
482
+ * Load the page at the URL the browser is showing. Every way of arriving
483
+ * comes through here — a cold load, a link, Back, a new query parm — so
484
+ * each is the same page load and a URL always shows the same thing.
551
485
  *
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.
486
+ * Entering a page is the one operation that replaces what <main> holds.
487
+ * Staying on it, with only the query parms changed, keeps what it has: the
488
+ * queries re-run and land by key, so rows that come back keep their place.
555
489
  *
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.
490
+ * `origin` is the element whose request changed the URL, if one did, and
491
+ * carries the round trip's pending and error state.
559
492
  */
560
- landNavigation(page) {
561
- if (!this.querySelector(`[${ATTR_ROW}="${NAV_ROW}"]`))
493
+ async navigate(origin) {
494
+ const template = this.arrive();
495
+ if (!template)
562
496
  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;
497
+ const items = await this.roundTrip(this.page, undefined, origin);
498
+ applyResponse(this, items ?? []);
499
+ }
500
+ /**
501
+ * Show the page the path names, and land `lb-url`. Null when no page's
502
+ * stub matches the path.
503
+ */
504
+ arrive() {
505
+ const page = pageNameFor(location.pathname);
506
+ const template = pageHostFor(page);
507
+ if (template && page !== this.page) {
508
+ this.page = page;
509
+ // The template is already in the document, so the page's markup goes
510
+ // in before its data arrives rather than leaving <main> empty.
511
+ this.main.replaceChildren(template.content.cloneNode(true));
569
512
  }
570
- applyData(this, {
571
- [NAV_ROW]: {
572
- [NAV_CELL_LABEL]: label,
573
- [NAV_CELL_URI]: location.pathname + location.search,
574
- },
575
- });
513
+ this.landUrl(template ? titleOf(template, page) : null);
514
+ // An app that ships `<body hidden>` (docs/reference/chrome.md) stays
515
+ // invisible until there is something coherent to show.
516
+ document.body.hidden = false;
517
+ if (!template) {
518
+ console.error(`lb-hub: no page for '${page}'`);
519
+ const dialog = this.querySelector(`dialog[${ATTR_URL_UNKNOWN}]`);
520
+ if (dialog && !dialog.open) {
521
+ dialog.showModal();
522
+ }
523
+ }
524
+ return template;
576
525
  }
577
526
  /**
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.
527
+ * The hub's own query: one row, keyed by the path, whose other columns are
528
+ * the query parms.
585
529
  *
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.
530
+ * The one exception to landing. A row sets the columns it names and
531
+ * leaves the rest as they were, and a URL names only the parms it carries.
532
+ * So the row also names, as empty, every column an element under
533
+ * `lb-query="lb-url"` shows: a control then shows what the address bar
534
+ * says after Back as well as after a reload, and never a parm the URL has
535
+ * dropped.
591
536
  */
592
- reportUnknownPage() {
593
- const dialog = this.querySelector(`[${ATTR_UNKNOWN_PAGE}]`);
594
- if (!dialog)
537
+ landUrl(label) {
538
+ if (label !== null)
539
+ document.title = label;
540
+ const scopes = this.querySelectorAll(`[${ATTR_QUERY}="${URL_QUERY}"]`);
541
+ if (scopes.length === 0)
595
542
  return;
596
- const modal = dialog;
597
- if (!modal.open)
598
- modal.showModal();
543
+ const row = {};
544
+ for (const scope of scopes) {
545
+ for (const el of within(scope, `[${ATTR_COLUMN}]`)) {
546
+ if (el === scope)
547
+ continue;
548
+ row[el.getAttribute(ATTR_COLUMN)] = "";
549
+ }
550
+ }
551
+ for (const [name, value] of new URLSearchParams(location.search)) {
552
+ row[name] = value;
553
+ }
554
+ row[URL_COLUMN_PATH] = location.pathname;
555
+ row[URL_COLUMN_PAGE_LABEL] = label;
556
+ row[URL_COLUMN_PAGE_UNKNOWN] = label === null;
557
+ applyResponse(this, [
558
+ { query: URL_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row },
559
+ ]);
599
560
  }
600
561
  }
601
562
  customElements.define(HUB_TAG_NAME, LbHub);