@loadbare/app 0.12.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.
Files changed (56) hide show
  1. package/dist/build/assemble.d.ts.map +1 -1
  2. package/dist/build/assemble.js +87 -1
  3. package/dist/build/assemble.js.map +1 -1
  4. package/dist/build/cli.js +2 -1
  5. package/dist/build/cli.js.map +1 -1
  6. package/dist/build/elements.d.ts +9 -1
  7. package/dist/build/elements.d.ts.map +1 -1
  8. package/dist/build/elements.js +50 -0
  9. package/dist/build/elements.js.map +1 -1
  10. package/dist/build/origins.d.ts +2 -0
  11. package/dist/build/origins.d.ts.map +1 -1
  12. package/dist/build/origins.js +1 -1
  13. package/dist/build/origins.js.map +1 -1
  14. package/dist/core/lb-constants.d.ts +34 -1
  15. package/dist/core/lb-constants.d.ts.map +1 -1
  16. package/dist/core/lb-constants.js +105 -0
  17. package/dist/core/lb-constants.js.map +1 -1
  18. package/dist/core/lb-types.d.ts +74 -18
  19. package/dist/core/lb-types.d.ts.map +1 -1
  20. package/dist/core/lb-types.js +74 -11
  21. package/dist/core/lb-types.js.map +1 -1
  22. package/dist/hub/lb-apply.d.ts +57 -18
  23. package/dist/hub/lb-apply.d.ts.map +1 -1
  24. package/dist/hub/lb-apply.js +661 -63
  25. package/dist/hub/lb-apply.js.map +1 -1
  26. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  27. package/dist/hub/lb-hub.browser.js +400 -33
  28. package/dist/hub/lb-hub.browser.js.map +1 -1
  29. package/dist/server/lb-express.d.ts +19 -7
  30. package/dist/server/lb-express.d.ts.map +1 -1
  31. package/dist/server/lb-express.js +62 -26
  32. package/dist/server/lb-express.js.map +1 -1
  33. package/dist/server/lb-server.d.ts +80 -11
  34. package/dist/server/lb-server.d.ts.map +1 -1
  35. package/dist/server/lb-server.js +185 -33
  36. package/dist/server/lb-server.js.map +1 -1
  37. package/docs/TECHREF-1.0.md +584 -111
  38. package/docs/comparison.md +17 -13
  39. package/docs/possible-ideas.md +308 -0
  40. package/docs/reference/chrome.md +7 -1
  41. package/docs/reference/custom-elements.md +134 -39
  42. package/docs/reference/data-binding.md +405 -17
  43. package/docs/reference/page-files.md +101 -19
  44. package/docs/reference/server.md +24 -0
  45. package/docs/reference/widgets.md +118 -25
  46. package/docs/roadmap.md +17 -6
  47. package/docs/testing.md +21 -10
  48. package/package.json +2 -3
  49. package/skills/loadbare-app/SKILL.md +92 -30
  50. package/skills/loadbare-app/references/TECHREF-1.0.md +584 -111
  51. package/skills/loadbare-app/references/chrome.md +7 -1
  52. package/skills/loadbare-app/references/custom-elements.md +134 -39
  53. package/skills/loadbare-app/references/data-binding.md +405 -17
  54. package/skills/loadbare-app/references/page-files.md +101 -19
  55. package/skills/loadbare-app/references/server.md +24 -0
  56. package/skills/loadbare-app/references/widgets.md +118 -25
@@ -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, 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, forget, isControl, isFormAssociated, within, } from "./lb-apply.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
+ import { isRowRequest, nextLocation, orderParm, pageNameFor, } from "../core/lb-types.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
- /** The gathered controls, with their values under their columns. */
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.value;
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
- 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)
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 === "values" && Object.keys(request.values).length === 0));
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 Error
278
- ? { request, items: [], error: landed }
279
- : { request, items: landed };
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
- for (const el of main.querySelectorAll(FOCUSABLE)) {
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.
@@ -321,6 +519,12 @@ function isPlainClick(e) {
321
519
  !e.shiftKey &&
322
520
  !e.altKey);
323
521
  }
522
+ /** The user's order for a query, from the parm the URL carries for it. */
523
+ function orderFor(query) {
524
+ return new URLSearchParams(location.search).get(orderParm(query)) ?? undefined;
525
+ }
526
+ /** How everything the hub lands is landed: placed by the user's order. */
527
+ const LANDING = { orderFor };
324
528
  /** The URL a row for `lb-url` leads to, by the rule the server follows. */
325
529
  function nextUrl(row) {
326
530
  const url = new URL(location.href);
@@ -389,12 +593,30 @@ class LbHub extends HTMLElement {
389
593
  });
390
594
  // A reset returns a form's controls to their defaults, so none of them
391
595
  // shows what landed any longer. The browser resets the controls; the
392
- // 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.
393
598
  this.addEventListener("reset", (e) => {
394
599
  if (e.defaultPrevented)
395
600
  return;
396
601
  for (const el of controlsOf(e.target)) {
397
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);
398
620
  }
399
621
  });
400
622
  // A control commits on change.
@@ -440,17 +662,83 @@ class LbHub extends HTMLElement {
440
662
  this.moveTo(row, true);
441
663
  void this.navigate();
442
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 });
443
689
  addEventListener("popstate", () => void this.navigate());
444
690
  void this.navigate();
445
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
+ }
446
728
  /**
447
729
  * Send a request to the server and land its answer. An answer naming
448
730
  * `lb-url` moves the URL first; the server has already loaded the page
449
731
  * there, in the same round trip.
450
732
  */
451
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
+ }
452
738
  const at = here();
453
739
  const items = await this.roundTrip(this.page, request, origin);
740
+ if (items instanceof RefusedError)
741
+ this.refused(request, items, origin);
454
742
  if (items instanceof Error)
455
743
  return done(origin, request, items);
456
744
  let landed = [];
@@ -462,14 +750,49 @@ class LbHub extends HTMLElement {
462
750
  const url = items.find((item) => item.query === URL_QUERY);
463
751
  const load = items.filter((item) => item !== url);
464
752
  landed = load;
465
- if (url === undefined)
466
- applyResponse(this, load);
753
+ if (url === undefined) {
754
+ applyResponse(this, load, { ...LANDING, requested: true });
755
+ }
467
756
  else
468
757
  landed = await this.follow(url, load, origin);
469
758
  }
470
- insertedFrom.get(request)?.reset();
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();
471
770
  done(origin, request, landed);
472
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
+ }
473
796
  /**
474
797
  * One round trip for a page: a request, or with none a page load. The
475
798
  * element that issued it carries `lb-request-pending` while it is in
@@ -481,6 +804,10 @@ class LbHub extends HTMLElement {
481
804
  return await fetchItems(page, request);
482
805
  }
483
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;
484
811
  console.error(`lb-hub: request failed for '${page}'`, err);
485
812
  origin?.setAttribute(ATTR_REQUEST_ERROR, "");
486
813
  return err instanceof Error ? err : new Error(String(err));
@@ -501,8 +828,41 @@ class LbHub extends HTMLElement {
501
828
  return done(origin, request, new Error(message));
502
829
  }
503
830
  const pushed = origin?.hasAttribute(ATTR_URL_PUSH) ?? false;
504
- const moved = this.moveTo(request.values ?? {}, pushed);
505
- done(origin, request, moved ? await this.navigate(origin) : []);
831
+ const was = new URL(location.href);
832
+ if (!this.moveTo(request.values ?? {}, pushed)) {
833
+ return done(origin, request, []);
834
+ }
835
+ if (this.sortHere(was))
836
+ return done(origin, request, []);
837
+ done(origin, request, await this.navigate(origin));
838
+ }
839
+ /**
840
+ * A change of URL that only changed the user's order for queries the hub
841
+ * sorts itself: the rows are already here, so they are placed again with
842
+ * no round trip. False when anything else changed, or a query whose order
843
+ * changed reads the parm itself, and the page must load.
844
+ */
845
+ sortHere(was) {
846
+ if (was.pathname !== location.pathname)
847
+ return false;
848
+ const now = new URLSearchParams(location.search);
849
+ const names = new Set([...was.searchParams.keys(), ...now.keys()]);
850
+ const queries = [];
851
+ for (const name of names) {
852
+ if (was.searchParams.get(name) === now.get(name))
853
+ continue;
854
+ if (!name.startsWith(ORDER_PARM_PREFIX))
855
+ return false;
856
+ const query = name.slice(ORDER_PARM_PREFIX.length);
857
+ const item = keptAnswer(this, query);
858
+ if (!item || item.serverSortedByUrl)
859
+ return false;
860
+ queries.push(query);
861
+ }
862
+ this.arrive();
863
+ for (const query of queries)
864
+ reland(this, query);
865
+ return true;
506
866
  }
507
867
  /**
508
868
  * Write a row for `lb-url` into the URL. A new page pushes a history
@@ -536,7 +896,7 @@ class LbHub extends HTMLElement {
536
896
  }
537
897
  const template = this.arrive();
538
898
  if (template)
539
- applyResponse(this, load);
899
+ applyResponse(this, load, LANDING);
540
900
  this.entered();
541
901
  return template ? [item, ...load] : [item];
542
902
  }
@@ -564,10 +924,19 @@ class LbHub extends HTMLElement {
564
924
  const items = await this.roundTrip(this.page, undefined, origin);
565
925
  if (here() !== at)
566
926
  return items instanceof Error ? items : [];
567
- const landed = items instanceof Error ? [] : items;
568
- applyResponse(this, landed);
927
+ if (items instanceof Error) {
928
+ this.entered();
929
+ return items;
930
+ }
931
+ // Entering the page moved the URL, as an application restoring what the
932
+ // user last had on screen does: the server loaded the page there.
933
+ const url = items.find((item) => item.query === URL_QUERY);
934
+ if (url) {
935
+ return this.follow(url, items.filter((item) => item !== url), origin);
936
+ }
937
+ applyResponse(this, items, LANDING);
569
938
  this.entered();
570
- return items instanceof Error ? items : landed;
939
+ return items;
571
940
  }
572
941
  /**
573
942
  * A page that was entered has landed its queries, so its first control
@@ -639,9 +1008,7 @@ class LbHub extends HTMLElement {
639
1008
  row[URL_COLUMN_PATH] = location.pathname;
640
1009
  row[URL_COLUMN_PAGE_LABEL] = label;
641
1010
  row[URL_COLUMN_PAGE_UNKNOWN] = label === null;
642
- applyResponse(this, [
643
- { query: URL_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row },
644
- ]);
1011
+ applyResponse(this, [{ query: URL_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row }], LANDING);
645
1012
  }
646
1013
  }
647
1014
  customElements.define(HUB_TAG_NAME, LbHub);