@loadbare/app 0.13.0 → 0.14.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/core/lb-constants.d.ts +11 -1
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +34 -0
- package/dist/core/lb-constants.js.map +1 -1
- package/dist/core/lb-types.d.ts +27 -0
- 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 +20 -3
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +64 -8
- 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 +341 -21
- package/dist/hub/lb-hub.browser.js.map +1 -1
- 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 +294 -21
- package/docs/comparison.md +6 -4
- package/docs/possible-ideas.md +146 -26
- package/docs/reference/custom-elements.md +97 -1
- package/docs/reference/data-binding.md +303 -4
- package/docs/reference/page-files.md +76 -3
- package/docs/reference/server.md +20 -0
- package/docs/reference/widgets.md +33 -10
- package/docs/roadmap.md +9 -2
- package/package.json +1 -1
- package/skills/loadbare-app/SKILL.md +50 -7
- package/skills/loadbare-app/references/TECHREF-1.0.md +294 -21
- package/skills/loadbare-app/references/custom-elements.md +97 -1
- package/skills/loadbare-app/references/data-binding.md +303 -4
- 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 +33 -10
|
@@ -1,7 +1,7 @@
|
|
|
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
5
|
export { applyResponse, applyRow } from "./lb-apply.js";
|
|
6
6
|
/** What each request Loadbare provides cannot be issued without. */
|
|
7
7
|
const NEEDS = {
|
|
@@ -80,31 +80,123 @@ function groupFor(el) {
|
|
|
80
80
|
return { controls: controlsOf(form), form, root: form };
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* The gathered controls, with their values under their columns.
|
|
85
|
+
*
|
|
86
|
+
* A form also gathers the live rows of a query nested in its own, each with
|
|
87
|
+
* the values of its own controls, under the nested query's name: a master's
|
|
88
|
+
* details, entered beside it. One level only, since a query nested deeper
|
|
89
|
+
* shows the same rows in every row around it, and its keys would repeat. A
|
|
90
|
+
* live row gathers nothing nested.
|
|
91
|
+
*/
|
|
84
92
|
function gather(group) {
|
|
85
93
|
const controls = [];
|
|
86
94
|
const values = {};
|
|
95
|
+
const nestedControls = [];
|
|
96
|
+
const byQuery = new Map();
|
|
97
|
+
const form = group?.form;
|
|
98
|
+
const own = form
|
|
99
|
+
? form.hasAttribute(ATTR_QUERY)
|
|
100
|
+
? form
|
|
101
|
+
: queryOf(form)
|
|
102
|
+
: null;
|
|
87
103
|
for (const el of group?.controls ?? []) {
|
|
88
104
|
const column = el.getAttribute(ATTR_COLUMN);
|
|
89
105
|
if (column === null || !isControl(el))
|
|
90
106
|
continue;
|
|
91
107
|
const nested = queryOf(el);
|
|
92
108
|
if (nested && nested !== group.root && group.root.contains(nested)) {
|
|
109
|
+
const live = liveRowOf(el);
|
|
110
|
+
if (own &&
|
|
111
|
+
queryOf(nested) === own &&
|
|
112
|
+
live &&
|
|
113
|
+
queryOf(live) === nested) {
|
|
114
|
+
const name = nested.getAttribute(ATTR_QUERY);
|
|
115
|
+
const key = live.getAttribute(ATTR_KEY_VALUE);
|
|
116
|
+
const set = byQuery.get(name) ?? new Map();
|
|
117
|
+
byQuery.set(name, set);
|
|
118
|
+
const row = set.get(key) ?? {};
|
|
119
|
+
set.set(key, row);
|
|
120
|
+
row[column] = controlValue(el);
|
|
121
|
+
nestedControls.push(el);
|
|
122
|
+
}
|
|
93
123
|
continue;
|
|
94
124
|
}
|
|
95
125
|
controls.push(el);
|
|
96
|
-
values[column] = el
|
|
126
|
+
values[column] = controlValue(el);
|
|
127
|
+
}
|
|
128
|
+
const rows = {};
|
|
129
|
+
for (const [name, set] of byQuery) {
|
|
130
|
+
rows[name] = [...set].map(([key, row]) => ({ key, values: row }));
|
|
131
|
+
}
|
|
132
|
+
return { controls, values, rows, nestedControls, own };
|
|
133
|
+
}
|
|
134
|
+
/** The controls each request gathered, so a refusal can find them. */
|
|
135
|
+
const gatheredFrom = new WeakMap();
|
|
136
|
+
/** A request's round trip came back refused, with the handler's words. */
|
|
137
|
+
class RefusedError extends Error {
|
|
138
|
+
refusal;
|
|
139
|
+
constructor(refusal) {
|
|
140
|
+
super(refusal.message ?? "refused");
|
|
141
|
+
this.refusal = refusal;
|
|
142
|
+
this.name = "RefusedError";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const shown = [];
|
|
146
|
+
function validates(el) {
|
|
147
|
+
const v = el;
|
|
148
|
+
return (typeof v.setCustomValidity === "function" &&
|
|
149
|
+
typeof v.reportValidity === "function");
|
|
150
|
+
}
|
|
151
|
+
/** Take a refusal off show: its messages, and the stamp on its origin. */
|
|
152
|
+
function withdraw(refused) {
|
|
153
|
+
for (const el of refused.marked)
|
|
154
|
+
el.setCustomValidity("");
|
|
155
|
+
refused.origin?.removeAttribute(ATTR_REQUEST_REFUSED);
|
|
156
|
+
shown.splice(shown.indexOf(refused), 1);
|
|
157
|
+
}
|
|
158
|
+
/** Withdraw every refusal whose request gathered `el`, or what holds it. */
|
|
159
|
+
function edited(target) {
|
|
160
|
+
for (const refused of [...shown]) {
|
|
161
|
+
if (refused.gathered.some((c) => c === target || c.contains(target))) {
|
|
162
|
+
withdraw(refused);
|
|
163
|
+
}
|
|
97
164
|
}
|
|
98
|
-
return { controls, values };
|
|
99
165
|
}
|
|
100
166
|
/** The form each insert gathered from, until its round trip succeeds. */
|
|
101
167
|
const insertedFrom = new WeakMap();
|
|
168
|
+
/**
|
|
169
|
+
* Every form a button has pointed, with the live row it is pointed at: null
|
|
170
|
+
* when it was started empty, for an insert.
|
|
171
|
+
*/
|
|
172
|
+
const pointed = new WeakMap();
|
|
173
|
+
/** The pointed form each request gathered from, until it succeeds. */
|
|
174
|
+
const savedFrom = new WeakMap();
|
|
175
|
+
/**
|
|
176
|
+
* The `lb-select` checkboxes showing for a query, under the hub an element
|
|
177
|
+
* is in. One parked by `lb-show` is in template content, out of reach, so
|
|
178
|
+
* only a selection the user can see counts.
|
|
179
|
+
*/
|
|
180
|
+
function selectorsFor(el, query) {
|
|
181
|
+
const root = el.closest(HUB_TAG_NAME) ?? el.ownerDocument;
|
|
182
|
+
return [...root.querySelectorAll(`[${ATTR_SELECT}]`)].filter((box) => {
|
|
183
|
+
const live = liveRowOf(box);
|
|
184
|
+
return live !== null && queryOf(live)?.getAttribute(ATTR_QUERY) === query;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/** The keys of the rows a query's showing checkboxes select. */
|
|
188
|
+
function selectedKeys(boxes) {
|
|
189
|
+
return boxes
|
|
190
|
+
.filter((box) => box.checked)
|
|
191
|
+
.map((box) => liveRowOf(box).getAttribute(ATTR_KEY_VALUE));
|
|
192
|
+
}
|
|
102
193
|
/**
|
|
103
194
|
* Complete a request from where its element sits. Every request passes
|
|
104
195
|
* through here, whoever issued it; a field it already carries is its
|
|
105
196
|
* issuer's to decide and is left as it is.
|
|
106
197
|
*
|
|
107
|
-
* False when the request cannot be issued, having said why
|
|
198
|
+
* False when the request cannot be issued, having said why, or when it is
|
|
199
|
+
* an update or delete for a row not yet entered, which is not sent.
|
|
108
200
|
*/
|
|
109
201
|
function complete(request, el) {
|
|
110
202
|
if (!request?.name) {
|
|
@@ -118,10 +210,19 @@ function complete(request, el) {
|
|
|
118
210
|
return false;
|
|
119
211
|
}
|
|
120
212
|
const group = groupFor(el);
|
|
121
|
-
const { controls, values } = gather(group);
|
|
213
|
+
const { controls, values, rows, nestedControls, own } = gather(group);
|
|
214
|
+
gatheredFrom.set(request, [...controls, ...nestedControls]);
|
|
122
215
|
if (request.values === undefined && controls.length > 0) {
|
|
123
216
|
request.values = values;
|
|
124
217
|
}
|
|
218
|
+
// A form's nested rows go with what it writes, and not with a delete. A
|
|
219
|
+
// form of nested rows alone still writes, with no values of its own.
|
|
220
|
+
if (request.rows === undefined &&
|
|
221
|
+
Object.keys(rows).length > 0 &&
|
|
222
|
+
name !== REQUEST_ROW_DELETE) {
|
|
223
|
+
request.rows = rows;
|
|
224
|
+
request.values ??= {};
|
|
225
|
+
}
|
|
125
226
|
// The request is for the nearest `lb-query` of what it gathered, or of the
|
|
126
227
|
// element itself when it gathered nothing.
|
|
127
228
|
const queries = new Set(controls.map(queryOf));
|
|
@@ -129,21 +230,66 @@ function complete(request, el) {
|
|
|
129
230
|
console.error(`lb-hub: '${name}' gathered controls of more than one query, not sent`, el);
|
|
130
231
|
return false;
|
|
131
232
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
request.query ??=
|
|
136
|
-
const key =
|
|
137
|
-
if (key !==
|
|
233
|
+
// A form of nested rows alone is for its own query, and keyed by the row
|
|
234
|
+
// landed on it or the row it is pointed at, if any.
|
|
235
|
+
if (controls.length === 0 && nestedControls.length > 0 && own) {
|
|
236
|
+
request.query ??= own.getAttribute(ATTR_QUERY);
|
|
237
|
+
const key = own.getAttribute(ATTR_KEY_VALUE);
|
|
238
|
+
if (key !== null)
|
|
138
239
|
request.key ??= key;
|
|
139
240
|
}
|
|
241
|
+
else {
|
|
242
|
+
const from = controls[0] ?? el;
|
|
243
|
+
const query = controls.length > 0 ? [...queries][0] : queryOf(el);
|
|
244
|
+
if (query) {
|
|
245
|
+
request.query ??= query.getAttribute(ATTR_QUERY);
|
|
246
|
+
const key = keyFrom(from, query);
|
|
247
|
+
if (key !== undefined)
|
|
248
|
+
request.key ??= key;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
140
251
|
if (name === REQUEST_ROW_INSERT && group?.form) {
|
|
141
252
|
insertedFrom.set(request, group.form);
|
|
142
253
|
}
|
|
254
|
+
if (group?.form && pointed.has(group.form)) {
|
|
255
|
+
savedFrom.set(request, group.form);
|
|
256
|
+
}
|
|
257
|
+
// A row not yet entered: its key is empty, from a `row` the server
|
|
258
|
+
// answered with no key yet, or from a form New pointed. It has no key to
|
|
259
|
+
// send, and nothing on record to update or delete, so those two are not
|
|
260
|
+
// sent. That is the row's state, not a mistake, and nothing is reported.
|
|
261
|
+
const unentered = request.key === "";
|
|
262
|
+
if (unentered) {
|
|
263
|
+
delete request.key;
|
|
264
|
+
if (name === REQUEST_ROW_UPDATE || name === REQUEST_ROW_DELETE) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// A request that names no one row acts on the rows the user selected,
|
|
269
|
+
// when its query shows checkboxes to select them by. An insert adds a row
|
|
270
|
+
// and selects nothing. With none ticked, an update or a delete has
|
|
271
|
+
// nothing to act on, and is not sent, as for a row not yet entered.
|
|
272
|
+
if (request.key === undefined &&
|
|
273
|
+
request.keys === undefined &&
|
|
274
|
+
request.query !== undefined &&
|
|
275
|
+
!unentered &&
|
|
276
|
+
name !== REQUEST_ROW_INSERT) {
|
|
277
|
+
const boxes = selectorsFor(el, request.query);
|
|
278
|
+
if (boxes.length > 0) {
|
|
279
|
+
request.keys = selectedKeys(boxes);
|
|
280
|
+
if (request.keys.length === 0 &&
|
|
281
|
+
(name === REQUEST_ROW_UPDATE || name === REQUEST_ROW_DELETE)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
143
286
|
const needs = NEEDS[name];
|
|
144
287
|
if (needs) {
|
|
145
|
-
const missing = needs.filter((field) => request[field] === undefined
|
|
146
|
-
(field === "
|
|
288
|
+
const missing = needs.filter((field) => (request[field] === undefined &&
|
|
289
|
+
!(field === "key" && request.keys !== undefined)) ||
|
|
290
|
+
(field === "values" &&
|
|
291
|
+
Object.keys(request.values).length === 0 &&
|
|
292
|
+
request.rows === undefined));
|
|
147
293
|
if (missing.length > 0) {
|
|
148
294
|
console.error(`lb-hub: '${name}' from <${el.localName}> has no ` +
|
|
149
295
|
`${missing.join(", ")}, not sent`, el);
|
|
@@ -245,6 +391,10 @@ async function fetchItems(page, request) {
|
|
|
245
391
|
body: JSON.stringify(request ?? {}),
|
|
246
392
|
signal: controller.signal,
|
|
247
393
|
});
|
|
394
|
+
if (res.status === LB_REFUSAL_STATUS) {
|
|
395
|
+
const body = (await res.json().catch(() => ({})));
|
|
396
|
+
throw new RefusedError(body.refusal ?? {});
|
|
397
|
+
}
|
|
248
398
|
if (!res.ok) {
|
|
249
399
|
throw new Error(`lb-hub: server responded ${res.status} for ${url}`);
|
|
250
400
|
}
|
|
@@ -260,6 +410,7 @@ function pending(el, on) {
|
|
|
260
410
|
return;
|
|
261
411
|
if (on) {
|
|
262
412
|
el.removeAttribute(ATTR_REQUEST_ERROR);
|
|
413
|
+
el.removeAttribute(ATTR_REQUEST_REFUSED);
|
|
263
414
|
el.setAttribute(ATTR_REQUEST_PENDING, "");
|
|
264
415
|
el.setAttribute("aria-busy", "true");
|
|
265
416
|
}
|
|
@@ -274,9 +425,11 @@ function pending(el, on) {
|
|
|
274
425
|
* page as the answer left it, and `lb-request-pending` is already gone.
|
|
275
426
|
*/
|
|
276
427
|
function done(origin, request, landed) {
|
|
277
|
-
const detail = landed instanceof
|
|
278
|
-
? { request, items: [], error: landed }
|
|
279
|
-
:
|
|
428
|
+
const detail = landed instanceof RefusedError
|
|
429
|
+
? { request, items: [], error: landed, refusal: landed.refusal }
|
|
430
|
+
: landed instanceof Error
|
|
431
|
+
? { request, items: [], error: landed }
|
|
432
|
+
: { request, items: landed };
|
|
280
433
|
origin?.dispatchEvent(new CustomEvent(LB_DONE_EVENT_NAME, { bubbles: true, detail }));
|
|
281
434
|
}
|
|
282
435
|
/** What can take focus, before asking whether it can be operated. */
|
|
@@ -293,7 +446,11 @@ const FOCUSABLE = `a[href], button, input:not([type="hidden"]), select, textarea
|
|
|
293
446
|
function focusFirst(main) {
|
|
294
447
|
if (main.contains(document.activeElement))
|
|
295
448
|
return;
|
|
296
|
-
|
|
449
|
+
focusIn(main);
|
|
450
|
+
}
|
|
451
|
+
/** Focus the first element in `root` the user can operate. */
|
|
452
|
+
function focusIn(root) {
|
|
453
|
+
for (const el of root.querySelectorAll(FOCUSABLE)) {
|
|
297
454
|
if (el.matches(":disabled"))
|
|
298
455
|
continue;
|
|
299
456
|
if (el.closest(`dialog:not([open]), [inert], [hidden]`))
|
|
@@ -305,6 +462,47 @@ function focusFirst(main) {
|
|
|
305
462
|
return;
|
|
306
463
|
}
|
|
307
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Empty a form of whatever row it showed: every column it names lands as
|
|
467
|
+
* null, so text and `lb-show` read as no row, and then the form resets, so
|
|
468
|
+
* each control returns to its default.
|
|
469
|
+
*/
|
|
470
|
+
function clear(form) {
|
|
471
|
+
pointed.get(form)?.removeAttribute(ATTR_ROW_EDITING);
|
|
472
|
+
applyRow(form, blankRow(form));
|
|
473
|
+
form.reset();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Point a form at a row: fill it from the row and key it by the row's key,
|
|
477
|
+
* so gathering sends that key. With no row, the form starts empty and its
|
|
478
|
+
* key is empty, for an insert. A form inside a `<dialog>` opens it.
|
|
479
|
+
*/
|
|
480
|
+
function point(form, row, key, live) {
|
|
481
|
+
clear(form);
|
|
482
|
+
if (row)
|
|
483
|
+
applyRow(form, row);
|
|
484
|
+
form.setAttribute(ATTR_KEY_VALUE, key);
|
|
485
|
+
live?.setAttribute(ATTR_ROW_EDITING, "");
|
|
486
|
+
pointed.set(form, live);
|
|
487
|
+
const dialog = form.closest("dialog");
|
|
488
|
+
if (dialog && !dialog.open)
|
|
489
|
+
dialog.showModal();
|
|
490
|
+
focusIn(form);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Let a pointed form go: empty it and drop its key. A form inside a
|
|
494
|
+
* `<dialog>` closes it, and the dialog's `close` finds nothing left to do.
|
|
495
|
+
*/
|
|
496
|
+
function release(form) {
|
|
497
|
+
if (!pointed.has(form))
|
|
498
|
+
return;
|
|
499
|
+
clear(form);
|
|
500
|
+
form.removeAttribute(ATTR_KEY_VALUE);
|
|
501
|
+
pointed.delete(form);
|
|
502
|
+
const dialog = form.closest("dialog");
|
|
503
|
+
if (dialog?.open)
|
|
504
|
+
dialog.close();
|
|
505
|
+
}
|
|
308
506
|
/**
|
|
309
507
|
* The URL a round trip answers for: its path and its query string. An answer
|
|
310
508
|
* that returns to another is not landed.
|
|
@@ -395,12 +593,30 @@ class LbHub extends HTMLElement {
|
|
|
395
593
|
});
|
|
396
594
|
// A reset returns a form's controls to their defaults, so none of them
|
|
397
595
|
// shows what landed any longer. The browser resets the controls; the
|
|
398
|
-
// hub drops its own stamp from the same controls a submit gathers
|
|
596
|
+
// hub drops its own stamp from the same controls a submit gathers, and
|
|
597
|
+
// any refusal they were answering.
|
|
399
598
|
this.addEventListener("reset", (e) => {
|
|
400
599
|
if (e.defaultPrevented)
|
|
401
600
|
return;
|
|
402
601
|
for (const el of controlsOf(e.target)) {
|
|
403
602
|
el.removeAttribute(ATTR_COLUMN_VALUE);
|
|
603
|
+
edited(el);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
// An edit to anything a refused request gathered withdraws the refusal.
|
|
607
|
+
// Heard on the way down, so a control's own request on change goes out
|
|
608
|
+
// with its message already gone.
|
|
609
|
+
for (const type of ["input", "change"]) {
|
|
610
|
+
this.addEventListener(type, (e) => {
|
|
611
|
+
if (shown.length > 0)
|
|
612
|
+
edited(e.target);
|
|
613
|
+
}, { capture: true });
|
|
614
|
+
}
|
|
615
|
+
// A selector selects its row, and is no column.
|
|
616
|
+
this.addEventListener("change", (e) => {
|
|
617
|
+
const box = e.target;
|
|
618
|
+
if (box?.hasAttribute(ATTR_SELECT)) {
|
|
619
|
+
select(box, box.checked);
|
|
404
620
|
}
|
|
405
621
|
});
|
|
406
622
|
// A control commits on change.
|
|
@@ -446,17 +662,83 @@ class LbHub extends HTMLElement {
|
|
|
446
662
|
this.moveTo(row, true);
|
|
447
663
|
void this.navigate();
|
|
448
664
|
});
|
|
665
|
+
// A button points a form with HTML's own commandfor and command. The
|
|
666
|
+
// command event goes to the form and does not bubble, so it is heard on
|
|
667
|
+
// the way down.
|
|
668
|
+
this.addEventListener("command", (e) => {
|
|
669
|
+
const { command, source } = e;
|
|
670
|
+
if (!command?.startsWith(LB_COMMAND_PREFIX))
|
|
671
|
+
return;
|
|
672
|
+
const form = e.target;
|
|
673
|
+
if (form.localName !== "form" || !form.hasAttribute(ATTR_QUERY)) {
|
|
674
|
+
console.error(`lb-hub: '${command}' is for a <form> carrying ${ATTR_QUERY}, ` +
|
|
675
|
+
`not <${form.localName}>, ignoring`, form);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
this.command(command, form, source ?? null);
|
|
679
|
+
}, { capture: true });
|
|
680
|
+
// A dialog closes however HTML closes it — Escape, a close command,
|
|
681
|
+
// `method="dialog"` — and a form pointed inside it lets go.
|
|
682
|
+
this.addEventListener("close", (e) => {
|
|
683
|
+
const dialog = e.target;
|
|
684
|
+
if (dialog.localName !== "dialog")
|
|
685
|
+
return;
|
|
686
|
+
for (const form of dialog.querySelectorAll("form"))
|
|
687
|
+
release(form);
|
|
688
|
+
}, { capture: true });
|
|
449
689
|
addEventListener("popstate", () => void this.navigate());
|
|
450
690
|
void this.navigate();
|
|
451
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* A command for a form. Edit fills the form from the row the button sits
|
|
694
|
+
* in, as the hub holds it: no round trip, since the user can only edit a
|
|
695
|
+
* row they can see.
|
|
696
|
+
*/
|
|
697
|
+
command(command, form, source) {
|
|
698
|
+
const query = form.getAttribute(ATTR_QUERY);
|
|
699
|
+
switch (command) {
|
|
700
|
+
case COMMAND_EDIT: {
|
|
701
|
+
const live = source ? liveRowOf(source) : null;
|
|
702
|
+
if (!live || queryOf(live)?.getAttribute(ATTR_QUERY) !== query) {
|
|
703
|
+
console.error(`lb-hub: '${command}' comes from no row of '${query}', ignoring`, source);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const key = live.getAttribute(ATTR_KEY_VALUE);
|
|
707
|
+
const item = keptAnswer(this, query);
|
|
708
|
+
const row = item?.rows?.find((r) => String(r[item.key]) === key);
|
|
709
|
+
if (!row) {
|
|
710
|
+
console.error(`lb-hub: the hub holds no row of '${query}' keyed '${key}', ` +
|
|
711
|
+
`ignoring`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
point(form, row, key, live);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
case COMMAND_NEW:
|
|
718
|
+
point(form, null, "", null);
|
|
719
|
+
return;
|
|
720
|
+
case COMMAND_RELEASE:
|
|
721
|
+
release(form);
|
|
722
|
+
return;
|
|
723
|
+
default:
|
|
724
|
+
console.error(`lb-hub: '${command}' begins with '${LB_COMMAND_PREFIX}' but ` +
|
|
725
|
+
`names no command Loadbare provides, ignoring`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
452
728
|
/**
|
|
453
729
|
* Send a request to the server and land its answer. An answer naming
|
|
454
730
|
* `lb-url` moves the URL first; the server has already loaded the page
|
|
455
731
|
* there, in the same round trip.
|
|
456
732
|
*/
|
|
457
733
|
async send(request, origin) {
|
|
734
|
+
// Sending again answers the last refusal from here, whatever it said.
|
|
735
|
+
for (const refused of shown.filter((r) => r.origin === origin)) {
|
|
736
|
+
withdraw(refused);
|
|
737
|
+
}
|
|
458
738
|
const at = here();
|
|
459
739
|
const items = await this.roundTrip(this.page, request, origin);
|
|
740
|
+
if (items instanceof RefusedError)
|
|
741
|
+
this.refused(request, items, origin);
|
|
460
742
|
if (items instanceof Error)
|
|
461
743
|
return done(origin, request, items);
|
|
462
744
|
let landed = [];
|
|
@@ -474,9 +756,43 @@ class LbHub extends HTMLElement {
|
|
|
474
756
|
else
|
|
475
757
|
landed = await this.follow(url, load, origin);
|
|
476
758
|
}
|
|
477
|
-
|
|
759
|
+
// The selected rows have been acted on, and the selection is done.
|
|
760
|
+
if (request.keys !== undefined && request.query !== undefined) {
|
|
761
|
+
for (const box of selectorsFor(this, request.query))
|
|
762
|
+
select(box, false);
|
|
763
|
+
}
|
|
764
|
+
// A pointed form has done what it was pointed for, and lets go.
|
|
765
|
+
const saved = savedFrom.get(request);
|
|
766
|
+
if (saved)
|
|
767
|
+
release(saved);
|
|
768
|
+
else
|
|
769
|
+
insertedFrom.get(request)?.reset();
|
|
478
770
|
done(origin, request, landed);
|
|
479
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Show a refusal. The message for the request as a whole is stamped on the
|
|
774
|
+
* element that committed, for a stylesheet to show. Each column's message
|
|
775
|
+
* goes on the control the request gathered it from, as HTML's own
|
|
776
|
+
* validity, and the first is reported: the browser shows its message
|
|
777
|
+
* there and moves focus to it.
|
|
778
|
+
*/
|
|
779
|
+
refused(request, { refusal }, origin) {
|
|
780
|
+
const gathered = gatheredFrom.get(request) ?? [];
|
|
781
|
+
const marked = [];
|
|
782
|
+
for (const [column, message] of Object.entries(refusal.columns ?? {})) {
|
|
783
|
+
const control = gathered.find((c) => c.getAttribute(ATTR_COLUMN) === column);
|
|
784
|
+
if (!control || !validates(control)) {
|
|
785
|
+
console.warn(`lb-hub: '${request.name}' was refused for '${column}', but ` +
|
|
786
|
+
`gathered no control there that takes a message`);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
control.setCustomValidity(message);
|
|
790
|
+
marked.push(control);
|
|
791
|
+
}
|
|
792
|
+
origin?.setAttribute(ATTR_REQUEST_REFUSED, refusal.message ?? "");
|
|
793
|
+
shown.push({ gathered, marked, origin });
|
|
794
|
+
marked[0]?.reportValidity();
|
|
795
|
+
}
|
|
480
796
|
/**
|
|
481
797
|
* One round trip for a page: a request, or with none a page load. The
|
|
482
798
|
* element that issued it carries `lb-request-pending` while it is in
|
|
@@ -488,6 +804,10 @@ class LbHub extends HTMLElement {
|
|
|
488
804
|
return await fetchItems(page, request);
|
|
489
805
|
}
|
|
490
806
|
catch (err) {
|
|
807
|
+
// A refusal is the handler's answer, not a failure: the request's
|
|
808
|
+
// sender shows it, and nothing is reported.
|
|
809
|
+
if (err instanceof RefusedError)
|
|
810
|
+
return err;
|
|
491
811
|
console.error(`lb-hub: request failed for '${page}'`, err);
|
|
492
812
|
origin?.setAttribute(ATTR_REQUEST_ERROR, "");
|
|
493
813
|
return err instanceof Error ? err : new Error(String(err));
|