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