@loadbare/app 0.13.0 → 0.15.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/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +31 -1
- package/dist/build/assemble.js.map +1 -1
- package/dist/build/elements.d.ts +12 -2
- package/dist/build/elements.d.ts.map +1 -1
- package/dist/build/elements.js +44 -10
- package/dist/build/elements.js.map +1 -1
- package/dist/core/lb-constants.d.ts +13 -2
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +50 -3
- package/dist/core/lb-constants.js.map +1 -1
- package/dist/core/lb-types.d.ts +52 -8
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/core/lb-types.js.map +1 -1
- package/dist/hub/lb-apply.d.ts +22 -3
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +82 -37
- package/dist/hub/lb-apply.js.map +1 -1
- package/dist/hub/lb-hub.browser.d.ts.map +1 -1
- package/dist/hub/lb-hub.browser.js +382 -26
- package/dist/hub/lb-hub.browser.js.map +1 -1
- package/dist/hub/lb-lifecycle.d.ts +40 -0
- package/dist/hub/lb-lifecycle.d.ts.map +1 -0
- package/dist/hub/lb-lifecycle.js +140 -0
- package/dist/hub/lb-lifecycle.js.map +1 -0
- package/dist/server/lb-express.d.ts +13 -3
- package/dist/server/lb-express.d.ts.map +1 -1
- package/dist/server/lb-express.js +30 -12
- package/dist/server/lb-express.js.map +1 -1
- package/dist/server/lb-server.d.ts +31 -1
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/server/lb-server.js +112 -2
- package/dist/server/lb-server.js.map +1 -1
- package/docs/TECHREF-1.0.md +343 -68
- package/docs/comparison.md +13 -9
- package/docs/possible-ideas.md +152 -26
- package/docs/reference/custom-elements.md +203 -88
- package/docs/reference/data-binding.md +310 -11
- package/docs/reference/page-files.md +76 -3
- package/docs/reference/server.md +20 -0
- package/docs/reference/widgets.md +35 -12
- package/docs/roadmap.md +9 -2
- package/docs/testing.md +2 -2
- package/package.json +1 -1
- package/skills/loadbare-app/SKILL.md +70 -23
- package/skills/loadbare-app/references/TECHREF-1.0.md +343 -68
- package/skills/loadbare-app/references/custom-elements.md +203 -88
- package/skills/loadbare-app/references/data-binding.md +310 -11
- package/skills/loadbare-app/references/page-files.md +76 -3
- package/skills/loadbare-app/references/server.md +20 -0
- package/skills/loadbare-app/references/widgets.md +35 -12
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
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_DONE_EVENT_NAME, LB_ENDPOINT, LB_EVENT_NAME, LB_REQUEST_TIMEOUT_MS, LB_RESERVED_PREFIX, LIVE_ROW, ORDER_PARM_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";
|
|
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_REQUEST_REFUSED, ATTR_ROW_EDITING, ATTR_SELECT, ATTR_URL_LINK, ATTR_URL_PUSH, ATTR_URL_UNKNOWN, COMMAND_EDIT, COMMAND_NEW, COMMAND_RELEASE, HUB_TAG_NAME, KIND_ROW, LB_DONE_EVENT_NAME, LB_ENDPOINT, LB_EVENT_NAME, LB_REFUSAL_STATUS, LB_REQUEST_TIMEOUT_MS, LB_COMMAND_PREFIX, LB_RESERVED_PREFIX, LIVE_ROW, ORDER_PARM_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
3
|
import { isRowRequest, nextLocation, orderParm, pageNameFor, } from "../core/lb-types.js";
|
|
4
|
-
import { applyResponse, forget, isControl, isFormAssociated, keptAnswer, reland, within, } from "./lb-apply.js";
|
|
4
|
+
import { applyResponse, applyRow, blankRow, controlValue, select, forget, isControl, isFormAssociated, keptAnswer, reland, within, } from "./lb-apply.js";
|
|
5
|
+
import { attachPlaced, detachRemoved, placed, requestDone, upgradeAbsent, } from "./lb-lifecycle.js";
|
|
5
6
|
export { applyResponse, applyRow } from "./lb-apply.js";
|
|
6
7
|
/** What each request Loadbare provides cannot be issued without. */
|
|
7
8
|
const NEEDS = {
|
|
@@ -80,31 +81,123 @@ function groupFor(el) {
|
|
|
80
81
|
return { controls: controlsOf(form), form, root: form };
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* The gathered controls, with their values under their columns.
|
|
86
|
+
*
|
|
87
|
+
* A form also gathers the live rows of a query nested in its own, each with
|
|
88
|
+
* the values of its own controls, under the nested query's name: a master's
|
|
89
|
+
* details, entered beside it. One level only, since a query nested deeper
|
|
90
|
+
* shows the same rows in every row around it, and its keys would repeat. A
|
|
91
|
+
* live row gathers nothing nested.
|
|
92
|
+
*/
|
|
84
93
|
function gather(group) {
|
|
85
94
|
const controls = [];
|
|
86
95
|
const values = {};
|
|
96
|
+
const nestedControls = [];
|
|
97
|
+
const byQuery = new Map();
|
|
98
|
+
const form = group?.form;
|
|
99
|
+
const own = form
|
|
100
|
+
? form.hasAttribute(ATTR_QUERY)
|
|
101
|
+
? form
|
|
102
|
+
: queryOf(form)
|
|
103
|
+
: null;
|
|
87
104
|
for (const el of group?.controls ?? []) {
|
|
88
105
|
const column = el.getAttribute(ATTR_COLUMN);
|
|
89
106
|
if (column === null || !isControl(el))
|
|
90
107
|
continue;
|
|
91
108
|
const nested = queryOf(el);
|
|
92
109
|
if (nested && nested !== group.root && group.root.contains(nested)) {
|
|
110
|
+
const live = liveRowOf(el);
|
|
111
|
+
if (own &&
|
|
112
|
+
queryOf(nested) === own &&
|
|
113
|
+
live &&
|
|
114
|
+
queryOf(live) === nested) {
|
|
115
|
+
const name = nested.getAttribute(ATTR_QUERY);
|
|
116
|
+
const key = live.getAttribute(ATTR_KEY_VALUE);
|
|
117
|
+
const set = byQuery.get(name) ?? new Map();
|
|
118
|
+
byQuery.set(name, set);
|
|
119
|
+
const row = set.get(key) ?? {};
|
|
120
|
+
set.set(key, row);
|
|
121
|
+
row[column] = controlValue(el);
|
|
122
|
+
nestedControls.push(el);
|
|
123
|
+
}
|
|
93
124
|
continue;
|
|
94
125
|
}
|
|
95
126
|
controls.push(el);
|
|
96
|
-
values[column] = el
|
|
127
|
+
values[column] = controlValue(el);
|
|
128
|
+
}
|
|
129
|
+
const rows = {};
|
|
130
|
+
for (const [name, set] of byQuery) {
|
|
131
|
+
rows[name] = [...set].map(([key, row]) => ({ key, values: row }));
|
|
132
|
+
}
|
|
133
|
+
return { controls, values, rows, nestedControls, own };
|
|
134
|
+
}
|
|
135
|
+
/** The controls each request gathered, so a refusal can find them. */
|
|
136
|
+
const gatheredFrom = new WeakMap();
|
|
137
|
+
/** A request's round trip came back refused, with the handler's words. */
|
|
138
|
+
class RefusedError extends Error {
|
|
139
|
+
refusal;
|
|
140
|
+
constructor(refusal) {
|
|
141
|
+
super(refusal.message ?? "refused");
|
|
142
|
+
this.refusal = refusal;
|
|
143
|
+
this.name = "RefusedError";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const shown = [];
|
|
147
|
+
function validates(el) {
|
|
148
|
+
const v = el;
|
|
149
|
+
return (typeof v.setCustomValidity === "function" &&
|
|
150
|
+
typeof v.reportValidity === "function");
|
|
151
|
+
}
|
|
152
|
+
/** Take a refusal off show: its messages, and the stamp on its origin. */
|
|
153
|
+
function withdraw(refused) {
|
|
154
|
+
for (const el of refused.marked)
|
|
155
|
+
el.setCustomValidity("");
|
|
156
|
+
refused.origin?.removeAttribute(ATTR_REQUEST_REFUSED);
|
|
157
|
+
shown.splice(shown.indexOf(refused), 1);
|
|
158
|
+
}
|
|
159
|
+
/** Withdraw every refusal whose request gathered `el`, or what holds it. */
|
|
160
|
+
function edited(target) {
|
|
161
|
+
for (const refused of [...shown]) {
|
|
162
|
+
if (refused.gathered.some((c) => c === target || c.contains(target))) {
|
|
163
|
+
withdraw(refused);
|
|
164
|
+
}
|
|
97
165
|
}
|
|
98
|
-
return { controls, values };
|
|
99
166
|
}
|
|
100
167
|
/** The form each insert gathered from, until its round trip succeeds. */
|
|
101
168
|
const insertedFrom = new WeakMap();
|
|
169
|
+
/**
|
|
170
|
+
* Every form a button has pointed, with the live row it is pointed at: null
|
|
171
|
+
* when it was started empty, for an insert.
|
|
172
|
+
*/
|
|
173
|
+
const pointed = new WeakMap();
|
|
174
|
+
/** The pointed form each request gathered from, until it succeeds. */
|
|
175
|
+
const savedFrom = new WeakMap();
|
|
176
|
+
/**
|
|
177
|
+
* The `lb-select` checkboxes showing for a query, under the hub an element
|
|
178
|
+
* is in. One parked by `lb-show` is in template content, out of reach, so
|
|
179
|
+
* only a selection the user can see counts.
|
|
180
|
+
*/
|
|
181
|
+
function selectorsFor(el, query) {
|
|
182
|
+
const root = el.closest(HUB_TAG_NAME) ?? el.ownerDocument;
|
|
183
|
+
return [...root.querySelectorAll(`[${ATTR_SELECT}]`)].filter((box) => {
|
|
184
|
+
const live = liveRowOf(box);
|
|
185
|
+
return live !== null && queryOf(live)?.getAttribute(ATTR_QUERY) === query;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/** The keys of the rows a query's showing checkboxes select. */
|
|
189
|
+
function selectedKeys(boxes) {
|
|
190
|
+
return boxes
|
|
191
|
+
.filter((box) => box.checked)
|
|
192
|
+
.map((box) => liveRowOf(box).getAttribute(ATTR_KEY_VALUE));
|
|
193
|
+
}
|
|
102
194
|
/**
|
|
103
195
|
* Complete a request from where its element sits. Every request passes
|
|
104
196
|
* through here, whoever issued it; a field it already carries is its
|
|
105
197
|
* issuer's to decide and is left as it is.
|
|
106
198
|
*
|
|
107
|
-
* False when the request cannot be issued, having said why
|
|
199
|
+
* False when the request cannot be issued, having said why, or when it is
|
|
200
|
+
* an update or delete for a row not yet entered, which is not sent.
|
|
108
201
|
*/
|
|
109
202
|
function complete(request, el) {
|
|
110
203
|
if (!request?.name) {
|
|
@@ -118,10 +211,19 @@ function complete(request, el) {
|
|
|
118
211
|
return false;
|
|
119
212
|
}
|
|
120
213
|
const group = groupFor(el);
|
|
121
|
-
const { controls, values } = gather(group);
|
|
214
|
+
const { controls, values, rows, nestedControls, own } = gather(group);
|
|
215
|
+
gatheredFrom.set(request, [...controls, ...nestedControls]);
|
|
122
216
|
if (request.values === undefined && controls.length > 0) {
|
|
123
217
|
request.values = values;
|
|
124
218
|
}
|
|
219
|
+
// A form's nested rows go with what it writes, and not with a delete. A
|
|
220
|
+
// form of nested rows alone still writes, with no values of its own.
|
|
221
|
+
if (request.rows === undefined &&
|
|
222
|
+
Object.keys(rows).length > 0 &&
|
|
223
|
+
name !== REQUEST_ROW_DELETE) {
|
|
224
|
+
request.rows = rows;
|
|
225
|
+
request.values ??= {};
|
|
226
|
+
}
|
|
125
227
|
// The request is for the nearest `lb-query` of what it gathered, or of the
|
|
126
228
|
// element itself when it gathered nothing.
|
|
127
229
|
const queries = new Set(controls.map(queryOf));
|
|
@@ -129,21 +231,66 @@ function complete(request, el) {
|
|
|
129
231
|
console.error(`lb-hub: '${name}' gathered controls of more than one query, not sent`, el);
|
|
130
232
|
return false;
|
|
131
233
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
request.query ??=
|
|
136
|
-
const key =
|
|
137
|
-
if (key !==
|
|
234
|
+
// A form of nested rows alone is for its own query, and keyed by the row
|
|
235
|
+
// landed on it or the row it is pointed at, if any.
|
|
236
|
+
if (controls.length === 0 && nestedControls.length > 0 && own) {
|
|
237
|
+
request.query ??= own.getAttribute(ATTR_QUERY);
|
|
238
|
+
const key = own.getAttribute(ATTR_KEY_VALUE);
|
|
239
|
+
if (key !== null)
|
|
138
240
|
request.key ??= key;
|
|
139
241
|
}
|
|
242
|
+
else {
|
|
243
|
+
const from = controls[0] ?? el;
|
|
244
|
+
const query = controls.length > 0 ? [...queries][0] : queryOf(el);
|
|
245
|
+
if (query) {
|
|
246
|
+
request.query ??= query.getAttribute(ATTR_QUERY);
|
|
247
|
+
const key = keyFrom(from, query);
|
|
248
|
+
if (key !== undefined)
|
|
249
|
+
request.key ??= key;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
140
252
|
if (name === REQUEST_ROW_INSERT && group?.form) {
|
|
141
253
|
insertedFrom.set(request, group.form);
|
|
142
254
|
}
|
|
255
|
+
if (group?.form && pointed.has(group.form)) {
|
|
256
|
+
savedFrom.set(request, group.form);
|
|
257
|
+
}
|
|
258
|
+
// A row not yet entered: its key is empty, from a `row` the server
|
|
259
|
+
// answered with no key yet, or from a form New pointed. It has no key to
|
|
260
|
+
// send, and nothing on record to update or delete, so those two are not
|
|
261
|
+
// sent. That is the row's state, not a mistake, and nothing is reported.
|
|
262
|
+
const unentered = request.key === "";
|
|
263
|
+
if (unentered) {
|
|
264
|
+
delete request.key;
|
|
265
|
+
if (name === REQUEST_ROW_UPDATE || name === REQUEST_ROW_DELETE) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// A request that names no one row acts on the rows the user selected,
|
|
270
|
+
// when its query shows checkboxes to select them by. An insert adds a row
|
|
271
|
+
// and selects nothing. With none ticked, an update or a delete has
|
|
272
|
+
// nothing to act on, and is not sent, as for a row not yet entered.
|
|
273
|
+
if (request.key === undefined &&
|
|
274
|
+
request.keys === undefined &&
|
|
275
|
+
request.query !== undefined &&
|
|
276
|
+
!unentered &&
|
|
277
|
+
name !== REQUEST_ROW_INSERT) {
|
|
278
|
+
const boxes = selectorsFor(el, request.query);
|
|
279
|
+
if (boxes.length > 0) {
|
|
280
|
+
request.keys = selectedKeys(boxes);
|
|
281
|
+
if (request.keys.length === 0 &&
|
|
282
|
+
(name === REQUEST_ROW_UPDATE || name === REQUEST_ROW_DELETE)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
143
287
|
const needs = NEEDS[name];
|
|
144
288
|
if (needs) {
|
|
145
|
-
const missing = needs.filter((field) => request[field] === undefined
|
|
146
|
-
(field === "
|
|
289
|
+
const missing = needs.filter((field) => (request[field] === undefined &&
|
|
290
|
+
!(field === "key" && request.keys !== undefined)) ||
|
|
291
|
+
(field === "values" &&
|
|
292
|
+
Object.keys(request.values).length === 0 &&
|
|
293
|
+
request.rows === undefined));
|
|
147
294
|
if (missing.length > 0) {
|
|
148
295
|
console.error(`lb-hub: '${name}' from <${el.localName}> has no ` +
|
|
149
296
|
`${missing.join(", ")}, not sent`, el);
|
|
@@ -245,6 +392,10 @@ async function fetchItems(page, request) {
|
|
|
245
392
|
body: JSON.stringify(request ?? {}),
|
|
246
393
|
signal: controller.signal,
|
|
247
394
|
});
|
|
395
|
+
if (res.status === LB_REFUSAL_STATUS) {
|
|
396
|
+
const body = (await res.json().catch(() => ({})));
|
|
397
|
+
throw new RefusedError(body.refusal ?? {});
|
|
398
|
+
}
|
|
248
399
|
if (!res.ok) {
|
|
249
400
|
throw new Error(`lb-hub: server responded ${res.status} for ${url}`);
|
|
250
401
|
}
|
|
@@ -260,6 +411,7 @@ function pending(el, on) {
|
|
|
260
411
|
return;
|
|
261
412
|
if (on) {
|
|
262
413
|
el.removeAttribute(ATTR_REQUEST_ERROR);
|
|
414
|
+
el.removeAttribute(ATTR_REQUEST_REFUSED);
|
|
263
415
|
el.setAttribute(ATTR_REQUEST_PENDING, "");
|
|
264
416
|
el.setAttribute("aria-busy", "true");
|
|
265
417
|
}
|
|
@@ -269,15 +421,21 @@ function pending(el, on) {
|
|
|
269
421
|
}
|
|
270
422
|
}
|
|
271
423
|
/**
|
|
272
|
-
* Tell the element that committed how its request turned out
|
|
273
|
-
*
|
|
274
|
-
*
|
|
424
|
+
* Tell the element that committed how its request turned out: the custom
|
|
425
|
+
* elements from it outward first, then any listener. Told after everything
|
|
426
|
+
* the request brought back has landed, so each sees the page as the answer
|
|
427
|
+
* left it, and `lb-request-pending` is already gone.
|
|
275
428
|
*/
|
|
276
429
|
function done(origin, request, landed) {
|
|
277
|
-
const detail = landed instanceof
|
|
278
|
-
? { request, items: [], error: landed }
|
|
279
|
-
:
|
|
280
|
-
|
|
430
|
+
const detail = landed instanceof RefusedError
|
|
431
|
+
? { request, items: [], error: landed, refusal: landed.refusal }
|
|
432
|
+
: landed instanceof Error
|
|
433
|
+
? { request, items: [], error: landed }
|
|
434
|
+
: { request, items: landed };
|
|
435
|
+
if (!origin)
|
|
436
|
+
return;
|
|
437
|
+
requestDone(origin, detail);
|
|
438
|
+
origin.dispatchEvent(new CustomEvent(LB_DONE_EVENT_NAME, { bubbles: true, detail }));
|
|
281
439
|
}
|
|
282
440
|
/** What can take focus, before asking whether it can be operated. */
|
|
283
441
|
const FOCUSABLE = `a[href], button, input:not([type="hidden"]), select, textarea, ` +
|
|
@@ -293,7 +451,11 @@ const FOCUSABLE = `a[href], button, input:not([type="hidden"]), select, textarea
|
|
|
293
451
|
function focusFirst(main) {
|
|
294
452
|
if (main.contains(document.activeElement))
|
|
295
453
|
return;
|
|
296
|
-
|
|
454
|
+
focusIn(main);
|
|
455
|
+
}
|
|
456
|
+
/** Focus the first element in `root` the user can operate. */
|
|
457
|
+
function focusIn(root) {
|
|
458
|
+
for (const el of root.querySelectorAll(FOCUSABLE)) {
|
|
297
459
|
if (el.matches(":disabled"))
|
|
298
460
|
continue;
|
|
299
461
|
if (el.closest(`dialog:not([open]), [inert], [hidden]`))
|
|
@@ -305,6 +467,47 @@ function focusFirst(main) {
|
|
|
305
467
|
return;
|
|
306
468
|
}
|
|
307
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Empty a form of whatever row it showed: every column it names lands as
|
|
472
|
+
* null, so text and `lb-show` read as no row, and then the form resets, so
|
|
473
|
+
* each control returns to its default.
|
|
474
|
+
*/
|
|
475
|
+
function clear(form) {
|
|
476
|
+
pointed.get(form)?.removeAttribute(ATTR_ROW_EDITING);
|
|
477
|
+
applyRow(form, blankRow(form));
|
|
478
|
+
form.reset();
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Point a form at a row: fill it from the row and key it by the row's key,
|
|
482
|
+
* so gathering sends that key. With no row, the form starts empty and its
|
|
483
|
+
* key is empty, for an insert. A form inside a `<dialog>` opens it.
|
|
484
|
+
*/
|
|
485
|
+
function point(form, row, key, live) {
|
|
486
|
+
clear(form);
|
|
487
|
+
if (row)
|
|
488
|
+
applyRow(form, row);
|
|
489
|
+
form.setAttribute(ATTR_KEY_VALUE, key);
|
|
490
|
+
live?.setAttribute(ATTR_ROW_EDITING, "");
|
|
491
|
+
pointed.set(form, live);
|
|
492
|
+
const dialog = form.closest("dialog");
|
|
493
|
+
if (dialog && !dialog.open)
|
|
494
|
+
dialog.showModal();
|
|
495
|
+
focusIn(form);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Let a pointed form go: empty it and drop its key. A form inside a
|
|
499
|
+
* `<dialog>` closes it, and the dialog's `close` finds nothing left to do.
|
|
500
|
+
*/
|
|
501
|
+
function release(form) {
|
|
502
|
+
if (!pointed.has(form))
|
|
503
|
+
return;
|
|
504
|
+
clear(form);
|
|
505
|
+
form.removeAttribute(ATTR_KEY_VALUE);
|
|
506
|
+
pointed.delete(form);
|
|
507
|
+
const dialog = form.closest("dialog");
|
|
508
|
+
if (dialog?.open)
|
|
509
|
+
dialog.close();
|
|
510
|
+
}
|
|
308
511
|
/**
|
|
309
512
|
* The URL a round trip answers for: its path and its query string. An answer
|
|
310
513
|
* that returns to another is not landed.
|
|
@@ -354,6 +557,12 @@ class LbHub extends HTMLElement {
|
|
|
354
557
|
* same page does not set it: the user is where they chose to be.
|
|
355
558
|
*/
|
|
356
559
|
entering = false;
|
|
560
|
+
/**
|
|
561
|
+
* What the hub has shown and not yet placed: the chrome when it starts,
|
|
562
|
+
* and <main> each time it shows a page. Placed as the page's first answer
|
|
563
|
+
* lands, so each custom element in it is filled before it is attached.
|
|
564
|
+
*/
|
|
565
|
+
unplaced = [];
|
|
357
566
|
connectedCallback() {
|
|
358
567
|
this.main = this.querySelector("main");
|
|
359
568
|
// Completed in the capture phase, on the way down, so every ancestor
|
|
@@ -395,12 +604,30 @@ class LbHub extends HTMLElement {
|
|
|
395
604
|
});
|
|
396
605
|
// A reset returns a form's controls to their defaults, so none of them
|
|
397
606
|
// shows what landed any longer. The browser resets the controls; the
|
|
398
|
-
// hub drops its own stamp from the same controls a submit gathers
|
|
607
|
+
// hub drops its own stamp from the same controls a submit gathers, and
|
|
608
|
+
// any refusal they were answering.
|
|
399
609
|
this.addEventListener("reset", (e) => {
|
|
400
610
|
if (e.defaultPrevented)
|
|
401
611
|
return;
|
|
402
612
|
for (const el of controlsOf(e.target)) {
|
|
403
613
|
el.removeAttribute(ATTR_COLUMN_VALUE);
|
|
614
|
+
edited(el);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
// An edit to anything a refused request gathered withdraws the refusal.
|
|
618
|
+
// Heard on the way down, so a control's own request on change goes out
|
|
619
|
+
// with its message already gone.
|
|
620
|
+
for (const type of ["input", "change"]) {
|
|
621
|
+
this.addEventListener(type, (e) => {
|
|
622
|
+
if (shown.length > 0)
|
|
623
|
+
edited(e.target);
|
|
624
|
+
}, { capture: true });
|
|
625
|
+
}
|
|
626
|
+
// A selector selects its row, and is no column.
|
|
627
|
+
this.addEventListener("change", (e) => {
|
|
628
|
+
const box = e.target;
|
|
629
|
+
if (box?.hasAttribute(ATTR_SELECT)) {
|
|
630
|
+
select(box, box.checked);
|
|
404
631
|
}
|
|
405
632
|
});
|
|
406
633
|
// A control commits on change.
|
|
@@ -446,17 +673,85 @@ class LbHub extends HTMLElement {
|
|
|
446
673
|
this.moveTo(row, true);
|
|
447
674
|
void this.navigate();
|
|
448
675
|
});
|
|
676
|
+
// A button points a form with HTML's own commandfor and command. The
|
|
677
|
+
// command event goes to the form and does not bubble, so it is heard on
|
|
678
|
+
// the way down.
|
|
679
|
+
this.addEventListener("command", (e) => {
|
|
680
|
+
const { command, source } = e;
|
|
681
|
+
if (!command?.startsWith(LB_COMMAND_PREFIX))
|
|
682
|
+
return;
|
|
683
|
+
const form = e.target;
|
|
684
|
+
if (form.localName !== "form" || !form.hasAttribute(ATTR_QUERY)) {
|
|
685
|
+
console.error(`lb-hub: '${command}' is for a <form> carrying ${ATTR_QUERY}, ` +
|
|
686
|
+
`not <${form.localName}>, ignoring`, form);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.command(command, form, source ?? null);
|
|
690
|
+
}, { capture: true });
|
|
691
|
+
// A dialog closes however HTML closes it — Escape, a close command,
|
|
692
|
+
// `method="dialog"` — and a form pointed inside it lets go.
|
|
693
|
+
this.addEventListener("close", (e) => {
|
|
694
|
+
const dialog = e.target;
|
|
695
|
+
if (dialog.localName !== "dialog")
|
|
696
|
+
return;
|
|
697
|
+
for (const form of dialog.querySelectorAll("form"))
|
|
698
|
+
release(form);
|
|
699
|
+
}, { capture: true });
|
|
449
700
|
addEventListener("popstate", () => void this.navigate());
|
|
701
|
+
upgradeAbsent(this, document);
|
|
702
|
+
this.unplaced = [...this.children];
|
|
450
703
|
void this.navigate();
|
|
451
704
|
}
|
|
705
|
+
/**
|
|
706
|
+
* A command for a form. Edit fills the form from the row the button sits
|
|
707
|
+
* in, as the hub holds it: no round trip, since the user can only edit a
|
|
708
|
+
* row they can see.
|
|
709
|
+
*/
|
|
710
|
+
command(command, form, source) {
|
|
711
|
+
const query = form.getAttribute(ATTR_QUERY);
|
|
712
|
+
switch (command) {
|
|
713
|
+
case COMMAND_EDIT: {
|
|
714
|
+
const live = source ? liveRowOf(source) : null;
|
|
715
|
+
if (!live || queryOf(live)?.getAttribute(ATTR_QUERY) !== query) {
|
|
716
|
+
console.error(`lb-hub: '${command}' comes from no row of '${query}', ignoring`, source);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const key = live.getAttribute(ATTR_KEY_VALUE);
|
|
720
|
+
const item = keptAnswer(this, query);
|
|
721
|
+
const row = item?.rows?.find((r) => String(r[item.key]) === key);
|
|
722
|
+
if (!row) {
|
|
723
|
+
console.error(`lb-hub: the hub holds no row of '${query}' keyed '${key}', ` +
|
|
724
|
+
`ignoring`);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
point(form, row, key, live);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
case COMMAND_NEW:
|
|
731
|
+
point(form, null, "", null);
|
|
732
|
+
return;
|
|
733
|
+
case COMMAND_RELEASE:
|
|
734
|
+
release(form);
|
|
735
|
+
return;
|
|
736
|
+
default:
|
|
737
|
+
console.error(`lb-hub: '${command}' begins with '${LB_COMMAND_PREFIX}' but ` +
|
|
738
|
+
`names no command Loadbare provides, ignoring`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
452
741
|
/**
|
|
453
742
|
* Send a request to the server and land its answer. An answer naming
|
|
454
743
|
* `lb-url` moves the URL first; the server has already loaded the page
|
|
455
744
|
* there, in the same round trip.
|
|
456
745
|
*/
|
|
457
746
|
async send(request, origin) {
|
|
747
|
+
// Sending again answers the last refusal from here, whatever it said.
|
|
748
|
+
for (const refused of shown.filter((r) => r.origin === origin)) {
|
|
749
|
+
withdraw(refused);
|
|
750
|
+
}
|
|
458
751
|
const at = here();
|
|
459
752
|
const items = await this.roundTrip(this.page, request, origin);
|
|
753
|
+
if (items instanceof RefusedError)
|
|
754
|
+
this.refused(request, items, origin);
|
|
460
755
|
if (items instanceof Error)
|
|
461
756
|
return done(origin, request, items);
|
|
462
757
|
let landed = [];
|
|
@@ -474,9 +769,43 @@ class LbHub extends HTMLElement {
|
|
|
474
769
|
else
|
|
475
770
|
landed = await this.follow(url, load, origin);
|
|
476
771
|
}
|
|
477
|
-
|
|
772
|
+
// The selected rows have been acted on, and the selection is done.
|
|
773
|
+
if (request.keys !== undefined && request.query !== undefined) {
|
|
774
|
+
for (const box of selectorsFor(this, request.query))
|
|
775
|
+
select(box, false);
|
|
776
|
+
}
|
|
777
|
+
// A pointed form has done what it was pointed for, and lets go.
|
|
778
|
+
const saved = savedFrom.get(request);
|
|
779
|
+
if (saved)
|
|
780
|
+
release(saved);
|
|
781
|
+
else
|
|
782
|
+
insertedFrom.get(request)?.reset();
|
|
478
783
|
done(origin, request, landed);
|
|
479
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* Show a refusal. The message for the request as a whole is stamped on the
|
|
787
|
+
* element that committed, for a stylesheet to show. Each column's message
|
|
788
|
+
* goes on the control the request gathered it from, as HTML's own
|
|
789
|
+
* validity, and the first is reported: the browser shows its message
|
|
790
|
+
* there and moves focus to it.
|
|
791
|
+
*/
|
|
792
|
+
refused(request, { refusal }, origin) {
|
|
793
|
+
const gathered = gatheredFrom.get(request) ?? [];
|
|
794
|
+
const marked = [];
|
|
795
|
+
for (const [column, message] of Object.entries(refusal.columns ?? {})) {
|
|
796
|
+
const control = gathered.find((c) => c.getAttribute(ATTR_COLUMN) === column);
|
|
797
|
+
if (!control || !validates(control)) {
|
|
798
|
+
console.warn(`lb-hub: '${request.name}' was refused for '${column}', but ` +
|
|
799
|
+
`gathered no control there that takes a message`);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
control.setCustomValidity(message);
|
|
803
|
+
marked.push(control);
|
|
804
|
+
}
|
|
805
|
+
origin?.setAttribute(ATTR_REQUEST_REFUSED, refusal.message ?? "");
|
|
806
|
+
shown.push({ gathered, marked, origin });
|
|
807
|
+
marked[0]?.reportValidity();
|
|
808
|
+
}
|
|
480
809
|
/**
|
|
481
810
|
* One round trip for a page: a request, or with none a page load. The
|
|
482
811
|
* element that issued it carries `lb-request-pending` while it is in
|
|
@@ -488,6 +817,10 @@ class LbHub extends HTMLElement {
|
|
|
488
817
|
return await fetchItems(page, request);
|
|
489
818
|
}
|
|
490
819
|
catch (err) {
|
|
820
|
+
// A refusal is the handler's answer, not a failure: the request's
|
|
821
|
+
// sender shows it, and nothing is reported.
|
|
822
|
+
if (err instanceof RefusedError)
|
|
823
|
+
return err;
|
|
491
824
|
console.error(`lb-hub: request failed for '${page}'`, err);
|
|
492
825
|
origin?.setAttribute(ATTR_REQUEST_ERROR, "");
|
|
493
826
|
return err instanceof Error ? err : new Error(String(err));
|
|
@@ -575,8 +908,11 @@ class LbHub extends HTMLElement {
|
|
|
575
908
|
return again instanceof Error ? [item] : [item, ...again];
|
|
576
909
|
}
|
|
577
910
|
const template = this.arrive();
|
|
911
|
+
this.place();
|
|
578
912
|
if (template)
|
|
579
913
|
applyResponse(this, load, LANDING);
|
|
914
|
+
else
|
|
915
|
+
attachPlaced();
|
|
580
916
|
this.entered();
|
|
581
917
|
return template ? [item, ...load] : [item];
|
|
582
918
|
}
|
|
@@ -598,13 +934,18 @@ class LbHub extends HTMLElement {
|
|
|
598
934
|
*/
|
|
599
935
|
async navigate(origin) {
|
|
600
936
|
const template = this.arrive();
|
|
601
|
-
if (!template)
|
|
937
|
+
if (!template) {
|
|
938
|
+
this.place();
|
|
939
|
+
attachPlaced();
|
|
602
940
|
return [];
|
|
941
|
+
}
|
|
603
942
|
const at = here();
|
|
604
943
|
const items = await this.roundTrip(this.page, undefined, origin);
|
|
605
944
|
if (here() !== at)
|
|
606
945
|
return items instanceof Error ? items : [];
|
|
607
946
|
if (items instanceof Error) {
|
|
947
|
+
this.place();
|
|
948
|
+
attachPlaced();
|
|
608
949
|
this.entered();
|
|
609
950
|
return items;
|
|
610
951
|
}
|
|
@@ -614,10 +955,20 @@ class LbHub extends HTMLElement {
|
|
|
614
955
|
if (url) {
|
|
615
956
|
return this.follow(url, items.filter((item) => item !== url), origin);
|
|
616
957
|
}
|
|
958
|
+
this.place();
|
|
617
959
|
applyResponse(this, items, LANDING);
|
|
618
960
|
this.entered();
|
|
619
961
|
return items;
|
|
620
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* Place what the hub has shown since it last placed anything, for the
|
|
965
|
+
* landing that follows to attach.
|
|
966
|
+
*/
|
|
967
|
+
place() {
|
|
968
|
+
for (const root of this.unplaced)
|
|
969
|
+
placed(root);
|
|
970
|
+
this.unplaced = [];
|
|
971
|
+
}
|
|
621
972
|
/**
|
|
622
973
|
* A page that was entered has landed its queries, so its first control
|
|
623
974
|
* exists: a row cloned, a branch shown, a picker's options in place.
|
|
@@ -640,7 +991,12 @@ class LbHub extends HTMLElement {
|
|
|
640
991
|
// The template is already in the document, so the page's markup goes
|
|
641
992
|
// in before its data arrives rather than leaving <main> empty. What
|
|
642
993
|
// the last page's queries answered goes with it.
|
|
994
|
+
const last = [...this.main.childNodes];
|
|
643
995
|
this.main.replaceChildren(template.content.cloneNode(true));
|
|
996
|
+
detachRemoved(last);
|
|
997
|
+
upgradeAbsent(this.main, document);
|
|
998
|
+
if (!this.unplaced.includes(this.main))
|
|
999
|
+
this.unplaced.push(this.main);
|
|
644
1000
|
forget(this);
|
|
645
1001
|
this.entering = true;
|
|
646
1002
|
}
|