@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.
Files changed (51) hide show
  1. package/dist/build/assemble.d.ts.map +1 -1
  2. package/dist/build/assemble.js +31 -1
  3. package/dist/build/assemble.js.map +1 -1
  4. package/dist/build/elements.d.ts +12 -2
  5. package/dist/build/elements.d.ts.map +1 -1
  6. package/dist/build/elements.js +44 -10
  7. package/dist/build/elements.js.map +1 -1
  8. package/dist/core/lb-constants.d.ts +13 -2
  9. package/dist/core/lb-constants.d.ts.map +1 -1
  10. package/dist/core/lb-constants.js +50 -3
  11. package/dist/core/lb-constants.js.map +1 -1
  12. package/dist/core/lb-types.d.ts +52 -8
  13. package/dist/core/lb-types.d.ts.map +1 -1
  14. package/dist/core/lb-types.js.map +1 -1
  15. package/dist/hub/lb-apply.d.ts +22 -3
  16. package/dist/hub/lb-apply.d.ts.map +1 -1
  17. package/dist/hub/lb-apply.js +82 -37
  18. package/dist/hub/lb-apply.js.map +1 -1
  19. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  20. package/dist/hub/lb-hub.browser.js +382 -26
  21. package/dist/hub/lb-hub.browser.js.map +1 -1
  22. package/dist/hub/lb-lifecycle.d.ts +40 -0
  23. package/dist/hub/lb-lifecycle.d.ts.map +1 -0
  24. package/dist/hub/lb-lifecycle.js +140 -0
  25. package/dist/hub/lb-lifecycle.js.map +1 -0
  26. package/dist/server/lb-express.d.ts +13 -3
  27. package/dist/server/lb-express.d.ts.map +1 -1
  28. package/dist/server/lb-express.js +30 -12
  29. package/dist/server/lb-express.js.map +1 -1
  30. package/dist/server/lb-server.d.ts +31 -1
  31. package/dist/server/lb-server.d.ts.map +1 -1
  32. package/dist/server/lb-server.js +112 -2
  33. package/dist/server/lb-server.js.map +1 -1
  34. package/docs/TECHREF-1.0.md +343 -68
  35. package/docs/comparison.md +13 -9
  36. package/docs/possible-ideas.md +152 -26
  37. package/docs/reference/custom-elements.md +203 -88
  38. package/docs/reference/data-binding.md +310 -11
  39. package/docs/reference/page-files.md +76 -3
  40. package/docs/reference/server.md +20 -0
  41. package/docs/reference/widgets.md +35 -12
  42. package/docs/roadmap.md +9 -2
  43. package/docs/testing.md +2 -2
  44. package/package.json +1 -1
  45. package/skills/loadbare-app/SKILL.md +70 -23
  46. package/skills/loadbare-app/references/TECHREF-1.0.md +343 -68
  47. package/skills/loadbare-app/references/custom-elements.md +203 -88
  48. package/skills/loadbare-app/references/data-binding.md +310 -11
  49. package/skills/loadbare-app/references/page-files.md +76 -3
  50. package/skills/loadbare-app/references/server.md +20 -0
  51. 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
- /** The gathered controls, with their values under their columns. */
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.value;
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
- const from = controls[0] ?? el;
133
- const query = controls.length > 0 ? [...queries][0] : queryOf(el);
134
- if (query) {
135
- request.query ??= query.getAttribute(ATTR_QUERY);
136
- const key = keyFrom(from, query);
137
- if (key !== undefined)
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 === "values" && Object.keys(request.values).length === 0));
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. Raised after
273
- * everything the request brought back has landed, so a listener sees the
274
- * page as the answer left it, and `lb-request-pending` is already gone.
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 Error
278
- ? { request, items: [], error: landed }
279
- : { request, items: landed };
280
- origin?.dispatchEvent(new CustomEvent(LB_DONE_EVENT_NAME, { bubbles: true, detail }));
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
- for (const el of main.querySelectorAll(FOCUSABLE)) {
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
- insertedFrom.get(request)?.reset();
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
  }