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