@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.
- package/README.md +3 -3
- package/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +107 -90
- package/dist/build/assemble.js.map +1 -1
- package/dist/build/expand.d.ts +6 -1
- package/dist/build/expand.d.ts.map +1 -1
- package/dist/build/expand.js +112 -26
- 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 +27 -24
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +103 -168
- package/dist/core/lb-constants.js.map +1 -1
- package/dist/core/lb-types.d.ts +64 -77
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/core/lb-types.js +40 -7
- 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 +195 -199
- 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 +410 -449
- package/dist/hub/lb-hub.browser.js.map +1 -1
- package/dist/server/lb-express.d.ts +5 -5
- package/dist/server/lb-express.d.ts.map +1 -1
- package/dist/server/lb-express.js +35 -66
- package/dist/server/lb-express.js.map +1 -1
- package/dist/server/lb-server.d.ts +77 -135
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/server/lb-server.js +132 -79
- package/dist/server/lb-server.js.map +1 -1
- package/docs/TECHREF-1.0.md +908 -585
- package/docs/comparison.md +243 -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 +291 -173
- package/docs/reference/data-binding.md +381 -374
- package/docs/reference/overview.md +12 -10
- package/docs/reference/page-files.md +164 -99
- package/docs/reference/server.md +2 -2
- package/docs/reference/widgets.md +104 -110
- package/docs/roadmap.md +32 -39
- 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/docs/what-does-loadbare-extend.md +124 -0
- package/package.json +1 -1
- package/skills/loadbare-app/SKILL.md +201 -123
- package/skills/loadbare-app/references/TECHREF-1.0.md +908 -585
- 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 +291 -173
- package/skills/loadbare-app/references/data-binding.md +381 -374
- package/skills/loadbare-app/references/overview.md +12 -10
- package/skills/loadbare-app/references/page-files.md +164 -99
- package/skills/loadbare-app/references/server.md +2 -2
- 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,247 +1,214 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
export {
|
|
6
|
-
/** What each
|
|
7
|
-
const
|
|
8
|
-
[
|
|
9
|
-
[
|
|
10
|
-
[
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
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
|
-
*
|
|
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
|
|
28
|
-
if (
|
|
29
|
-
|
|
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 {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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 = {
|
|
162
|
+
const detail = { name: el.getAttribute(ATTR_REQUEST) };
|
|
155
163
|
el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
156
164
|
}
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
214
|
-
for (
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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}
|
|
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
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
|
269
|
-
* browser is showing rides after it verbatim, so every round trip
|
|
270
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
303
|
-
el.setAttribute(
|
|
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(
|
|
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
|
|
319
|
-
*
|
|
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
|
-
//
|
|
326
|
-
// sees the request on its way back up sees it whole. A request that
|
|
327
|
-
// cannot be
|
|
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 (!
|
|
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
|
|
337
|
-
//
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
389
|
-
dispatchAction(el);
|
|
321
|
+
void this.send(request, origin);
|
|
390
322
|
});
|
|
391
|
-
//
|
|
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
|
|
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
|
-
|
|
338
|
+
commit(el);
|
|
398
339
|
});
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
418
|
-
if (!el || !
|
|
352
|
+
const el = e.target;
|
|
353
|
+
if (!el || !el.hasAttribute(ATTR_REQUEST) || !isControl(el))
|
|
419
354
|
return;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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
|
|
462
|
-
const page =
|
|
463
|
-
this.
|
|
464
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
422
|
+
return await fetchItems(page, request);
|
|
482
423
|
}
|
|
483
424
|
catch (err) {
|
|
484
|
-
console.error(`lb-hub: failed
|
|
485
|
-
origin?.setAttribute(
|
|
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
|
-
*
|
|
494
|
-
*
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
471
|
+
async follow(page, item, load, origin) {
|
|
519
472
|
if (pageNameFor(location.pathname) !== page)
|
|
520
473
|
return;
|
|
521
|
-
|
|
522
|
-
|
|
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.
|
|
527
|
-
|
|
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
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
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
|
-
*
|
|
557
|
-
*
|
|
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
|
-
|
|
561
|
-
|
|
493
|
+
async navigate(origin) {
|
|
494
|
+
const template = this.arrive();
|
|
495
|
+
if (!template)
|
|
562
496
|
return;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
*
|
|
579
|
-
*
|
|
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
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
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);
|