@loadbare/app 0.24.0 → 0.26.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 +1 -2
  2. package/dist/build/assemble.d.ts.map +1 -1
  3. package/dist/build/assemble.js +61 -53
  4. package/dist/build/assemble.js.map +1 -1
  5. package/dist/build/cli.d.ts +4 -10
  6. package/dist/build/cli.d.ts.map +1 -1
  7. package/dist/build/cli.js +11 -33
  8. package/dist/build/cli.js.map +1 -1
  9. package/dist/build/declared.js +1 -1
  10. package/dist/build/declared.js.map +1 -1
  11. package/dist/core/lb-constants.d.ts +8 -18
  12. package/dist/core/lb-constants.d.ts.map +1 -1
  13. package/dist/core/lb-constants.js +32 -72
  14. package/dist/core/lb-constants.js.map +1 -1
  15. package/dist/core/lb-types.d.ts +12 -11
  16. package/dist/core/lb-types.d.ts.map +1 -1
  17. package/dist/core/lb-types.js +7 -0
  18. package/dist/core/lb-types.js.map +1 -1
  19. package/dist/hub/lb-apply.d.ts +23 -37
  20. package/dist/hub/lb-apply.d.ts.map +1 -1
  21. package/dist/hub/lb-apply.js +205 -518
  22. package/dist/hub/lb-apply.js.map +1 -1
  23. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  24. package/dist/hub/lb-hub.browser.js +159 -132
  25. package/dist/hub/lb-hub.browser.js.map +1 -1
  26. package/dist/hub/lb-lifecycle.d.ts +2 -2
  27. package/dist/hub/lb-lifecycle.d.ts.map +1 -1
  28. package/dist/hub/lb-lifecycle.js +9 -17
  29. package/dist/hub/lb-lifecycle.js.map +1 -1
  30. package/dist/server/lb-express.d.ts.map +1 -1
  31. package/dist/server/lb-express.js +3 -3
  32. package/dist/server/lb-express.js.map +1 -1
  33. package/dist/server/lb-server.d.ts +35 -19
  34. package/dist/server/lb-server.d.ts.map +1 -1
  35. package/dist/server/lb-server.js +141 -88
  36. package/dist/server/lb-server.js.map +1 -1
  37. package/docs/binding-cases.md +150 -0
  38. package/docs/comparison.md +20 -29
  39. package/docs/miswarts.md +2 -2
  40. package/docs/reference/css.md +2 -15
  41. package/docs/reference/custom-elements.md +6 -8
  42. package/docs/reference/data-binding.md +100 -185
  43. package/docs/reference/names.md +11 -27
  44. package/docs/reference/page-files.md +4 -14
  45. package/docs/reference/server.md +0 -1
  46. package/docs/testing.md +2 -5
  47. package/package.json +1 -1
  48. package/skills/loadbare-app/SKILL.md +66 -56
  49. package/skills/loadbare-app/references/css.md +2 -15
  50. package/skills/loadbare-app/references/custom-elements.md +6 -8
  51. package/skills/loadbare-app/references/data-binding.md +100 -185
  52. package/skills/loadbare-app/references/miswarts.md +2 -2
  53. package/skills/loadbare-app/references/names.md +11 -27
  54. package/skills/loadbare-app/references/page-files.md +4 -14
  55. package/skills/loadbare-app/references/server.md +0 -1
  56. package/docs/temp-proposals.md +0 -251
@@ -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_REQUEST_REFUSED, ATTR_ROW_EDITING, ATTR_SELECT, ATTR_URL_LINK, ATTR_URL_PUSH, ATTR_URL_UNKNOWN, CHOICES_QUERY, COMMAND_EDIT, COMMAND_NEW, COMMAND_RELEASE, COMMAND_PLACE, HUB_COLUMN_AS_OF, HUB_COLUMN_ERROR, HUB_COLUMN_LOAD_STATE, HUB_COLUMN_PENDING, HUB_COLUMN_SERVER_STATE, HUB_ERROR_NETWORK, HUB_ERROR_TIMEOUT, HUB_LOAD_FAILED, HUB_LOAD_LOADED, HUB_LOAD_LOADING, HUB_QUERY, HUB_SERVER_FAILING, HUB_SERVER_REACHABLE, HUB_SERVER_UNREACHABLE, 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, 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, parmsRow, } from "../core/lb-types.js";
4
- import { applyResponse, applyRow, blankRow, focusIn, gatherInto, select, forget, isControl, isFormAssociated, keptAnswer, placeAgain, 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_URL_LINK, ATTR_URL_PUSH, ATTR_URL_UNKNOWN, COMMAND_EDIT, COMMAND_NEW, COMMAND_RELEASE, COMMAND_PLACE, FILTERS_COLUMN_QUERY, FILTERS_QUERY, HUB_COLUMN_AS_OF, HUB_COLUMN_ERROR, HUB_COLUMN_LOAD_STATE, HUB_COLUMN_PENDING, HUB_COLUMN_SERVER_STATE, HUB_ERROR_NETWORK, HUB_ERROR_TIMEOUT, HUB_LOAD_FAILED, HUB_LOAD_LOADED, HUB_LOAD_LOADING, HUB_QUERY, HUB_SERVER_FAILING, HUB_SERVER_REACHABLE, HUB_SERVER_UNREACHABLE, HUB_TAG_NAME, KIND_ROW, KIND_ROWS, LB_DONE_EVENT_NAME, LB_ENDPOINT, LB_EVENT_NAME, LB_REFUSAL_STATUS, LB_REQUEST_TIMEOUT_MS, LB_COMMAND_PREFIX, LB_RESERVED_PREFIX, LIVE_ROW, REQUEST_READ, REQUEST_ROW_DELETE, REQUEST_ROW_INSERT, REQUEST_ROW_UPDATE, SORT_COLUMN_ORDER, SORT_COLUMN_QUERY, SORT_QUERY, URL_COLUMN_PAGE_LABEL, URL_COLUMN_PAGE_UNKNOWN, URL_COLUMN_PATH, URL_QUERY, } from "../core/lb-constants.js";
3
+ import { isRowRequest, nextLocation, pageNameFor, parmsRow, oneRow, } from "../core/lb-types.js";
4
+ import { addUnentered, applyResponse, discardUnentered, applyRow, blankRow, focusIn, gatherInto, forget, isControl, isFormAssociated, keptAnswer, placeAgain, templateIn, within, } from "./lb-apply.js";
5
5
  import { attachPlaced, detachRemoved, placed, requestDone, upgradeAbsent, } from "./lb-lifecycle.js";
6
6
  export { applyResponse, applyRow } from "./lb-apply.js";
7
7
  /** What each request Loadbare provides cannot be issued without. */
@@ -55,8 +55,7 @@ function formOwnerOf(el) {
55
55
  : null;
56
56
  }
57
57
  /**
58
- * The controls a request gathers — see docs/reference/data-binding.md,
59
- * "Gathering":
58
+ * The controls a request gathers:
60
59
  *
61
60
  * 1. the element carrying `lb-request` has `lb-column`: it alone;
62
61
  * 2. the element is a `<form>`: the controls whose form owner it is;
@@ -94,6 +93,8 @@ function gather(group) {
94
93
  const controls = [];
95
94
  const values = {};
96
95
  const nestedControls = [];
96
+ // By row element rather than by key: rows not yet entered share the empty
97
+ // key, and each is a row of its own.
97
98
  const byQuery = new Map();
98
99
  const form = group?.form;
99
100
  const own = form
@@ -113,12 +114,14 @@ function gather(group) {
113
114
  live &&
114
115
  queryOf(live) === nested) {
115
116
  const name = nested.getAttribute(ATTR_QUERY);
116
- const key = live.getAttribute(ATTR_KEY_VALUE);
117
117
  const set = byQuery.get(name) ?? new Map();
118
118
  byQuery.set(name, set);
119
- const row = set.get(key) ?? {};
120
- set.set(key, row);
121
- gatherInto(row, column, el);
119
+ const row = set.get(live) ?? {
120
+ key: live.getAttribute(ATTR_KEY_VALUE),
121
+ values: {},
122
+ };
123
+ set.set(live, row);
124
+ gatherInto(row.values, column, el);
122
125
  nestedControls.push(el);
123
126
  }
124
127
  continue;
@@ -128,12 +131,17 @@ function gather(group) {
128
131
  }
129
132
  const rows = {};
130
133
  for (const [name, set] of byQuery) {
131
- rows[name] = [...set].map(([key, row]) => ({ key, values: row }));
134
+ rows[name] = [...set.values()];
132
135
  }
133
136
  return { controls, values, rows, nestedControls, own };
134
137
  }
135
138
  /** The controls each request gathered, so a refusal can find them. */
136
139
  const gatheredFrom = new WeakMap();
140
+ /**
141
+ * The rows not yet entered each request carries, which leave their list
142
+ * once it succeeds: the rows its answer lands take their place.
143
+ */
144
+ const enteredWith = new WeakMap();
137
145
  /** A request's round trip came back refused, with the handler's words. */
138
146
  class RefusedError extends Error {
139
147
  refusal;
@@ -185,24 +193,6 @@ const insertedFrom = new WeakMap();
185
193
  const pointed = new WeakMap();
186
194
  /** The pointed form each request gathered from, until it succeeds. */
187
195
  const savedFrom = new WeakMap();
188
- /**
189
- * The `lb-select` checkboxes showing for a query, under the hub an element
190
- * is in. One parked by `lb-show` is in template content, out of reach, so
191
- * only a selection the user can see counts.
192
- */
193
- function selectorsFor(el, query) {
194
- const root = el.closest(HUB_TAG_NAME) ?? el.ownerDocument;
195
- return [...root.querySelectorAll(`[${ATTR_SELECT}]`)].filter((box) => {
196
- const live = liveRowOf(box);
197
- return live !== null && queryOf(live)?.getAttribute(ATTR_QUERY) === query;
198
- });
199
- }
200
- /** The keys of the rows a query's showing checkboxes select. */
201
- function selectedKeys(boxes) {
202
- return boxes
203
- .filter((box) => box.checked)
204
- .map((box) => liveRowOf(box).getAttribute(ATTR_KEY_VALUE));
205
- }
206
196
  /**
207
197
  * Complete a request from where its element sits. Every request passes
208
198
  * through here, whoever issued it; a field it already carries is its
@@ -225,6 +215,15 @@ function complete(request, el) {
225
215
  const group = groupFor(el);
226
216
  const { controls, values, rows, nestedControls, own } = gather(group);
227
217
  gatheredFrom.set(request, [...controls, ...nestedControls]);
218
+ const fresh = new Set();
219
+ for (const control of nestedControls) {
220
+ const live = liveRowOf(control);
221
+ if (live?.getAttribute(ATTR_KEY_VALUE) === "")
222
+ fresh.add(live);
223
+ }
224
+ if (fresh.size > 0 && name !== REQUEST_ROW_DELETE) {
225
+ enteredWith.set(request, [...fresh]);
226
+ }
228
227
  if (request.values === undefined && controls.length > 0) {
229
228
  request.values = values;
230
229
  }
@@ -263,7 +262,7 @@ function complete(request, el) {
263
262
  }
264
263
  // A query the hub serves has one row, keyed by the path, so a request for
265
264
  // one from anywhere on the page is for that row.
266
- if (request.query === URL_QUERY || request.query === CHOICES_QUERY) {
265
+ if (request.query === URL_QUERY) {
267
266
  request.key ??= location.pathname;
268
267
  }
269
268
  if (name === REQUEST_ROW_INSERT && group?.form) {
@@ -279,32 +278,19 @@ function complete(request, el) {
279
278
  const unentered = request.key === "";
280
279
  if (unentered) {
281
280
  delete request.key;
281
+ // A row `--lb-new` put in a list is deleted by taking it out.
282
+ if (name === REQUEST_ROW_DELETE) {
283
+ const live = liveRowOf(el);
284
+ if (live)
285
+ discardUnentered(live);
286
+ }
282
287
  if (name === REQUEST_ROW_UPDATE || name === REQUEST_ROW_DELETE) {
283
288
  return false;
284
289
  }
285
290
  }
286
- // A request that names no one row acts on the rows the user selected,
287
- // when its query shows checkboxes to select them by. An insert adds a row
288
- // and selects nothing. With none ticked, an update or a delete has
289
- // nothing to act on, and is not sent, as for a row not yet entered.
290
- if (request.key === undefined &&
291
- request.keys === undefined &&
292
- request.query !== undefined &&
293
- !unentered &&
294
- name !== REQUEST_ROW_INSERT) {
295
- const boxes = selectorsFor(el, request.query);
296
- if (boxes.length > 0) {
297
- request.keys = selectedKeys(boxes);
298
- if (request.keys.length === 0 &&
299
- (name === REQUEST_ROW_UPDATE || name === REQUEST_ROW_DELETE)) {
300
- return false;
301
- }
302
- }
303
- }
304
291
  const needs = NEEDS[name];
305
292
  if (needs) {
306
- const missing = needs.filter((field) => (request[field] === undefined &&
307
- !(field === "key" && request.keys !== undefined)) ||
293
+ const missing = needs.filter((field) => request[field] === undefined ||
308
294
  (field === "values" &&
309
295
  Object.keys(request.values).length === 0 &&
310
296
  request.rows === undefined));
@@ -396,9 +382,18 @@ function pageHostFor(page) {
396
382
  * browser is showing rides after it verbatim, so every round trip reaches
397
383
  * the server with the query parms in hand.
398
384
  */
399
- async function fetchItems(page, request) {
385
+ async function fetchItems(page, request, parms) {
400
386
  const path = page.split("/").map(encodeURIComponent).join("/");
401
- const url = `${LB_ENDPOINT}/${path}${location.search}`;
387
+ // Parms set for this request alone, as a bound row's key is for the lists
388
+ // inside it. An empty one is still sent: it names no row.
389
+ let query = location.search;
390
+ if (parms) {
391
+ const search = new URLSearchParams(location.search);
392
+ for (const [name, value] of Object.entries(parms))
393
+ search.set(name, value);
394
+ query = `?${search.toString()}`;
395
+ }
396
+ const url = `${LB_ENDPOINT}/${path}${query}`;
402
397
  const controller = new AbortController();
403
398
  const timeout = setTimeout(() => controller.abort(), LB_REQUEST_TIMEOUT_MS);
404
399
  try {
@@ -492,7 +487,7 @@ function clear(form) {
492
487
  */
493
488
  function point(form, row, key, live) {
494
489
  clear(form);
495
- if (row)
490
+ if (row !== null)
496
491
  applyRow(form, row);
497
492
  form.setAttribute(ATTR_KEY_VALUE, key);
498
493
  live?.setAttribute(ATTR_ROW_EDITING, "");
@@ -575,7 +570,7 @@ class LbHub extends HTMLElement {
575
570
  * lands, so each custom element in it is filled before it is attached.
576
571
  */
577
572
  unplaced = [];
578
- /** The hub's own state, as the query `lb-hub` serves it. */
573
+ /** The hub's own state, as the query `lb-hub-status` serves it. */
579
574
  trips = 0;
580
575
  loadState = HUB_LOAD_LOADING;
581
576
  serverState = "";
@@ -597,10 +592,18 @@ class LbHub extends HTMLElement {
597
592
  // The element that issued the request, not e.currentTarget (the hub
598
593
  // itself, since the event bubbles).
599
594
  const origin = e.target ?? undefined;
600
- if (request.query?.startsWith(LB_RESERVED_PREFIX)) {
595
+ if (request.query === URL_QUERY) {
601
596
  void this.answer(request, origin);
602
597
  return;
603
598
  }
599
+ if (request.query === SORT_QUERY) {
600
+ void this.chooseSort(request, origin);
601
+ return;
602
+ }
603
+ if (request.query === FILTERS_QUERY) {
604
+ void this.chooseFilters(request, origin);
605
+ return;
606
+ }
604
607
  void this.send(request, origin);
605
608
  });
606
609
  // A form commits on submit. The submitter's own lb-request replaces the
@@ -641,14 +644,6 @@ class LbHub extends HTMLElement {
641
644
  edited(e.target);
642
645
  }, { capture: true });
643
646
  }
644
- // A selector selects its row, and is no column.
645
- this.addEventListener("change", (e) => {
646
- const box = e.target;
647
- if (box?.hasAttribute(ATTR_SELECT)) {
648
- select(box, box.checked);
649
- }
650
- });
651
- // A control commits on change.
652
647
  this.addEventListener("change", (e) => {
653
648
  const el = e.target;
654
649
  if (!el || !el.hasAttribute(ATTR_REQUEST) || !isControl(el))
@@ -702,8 +697,19 @@ class LbHub extends HTMLElement {
702
697
  return;
703
698
  }
704
699
  const form = e.target;
700
+ // New aimed at a list, not a form: a row not yet entered joins it.
701
+ if (command === COMMAND_NEW &&
702
+ form.localName !== "form" &&
703
+ form.hasAttribute(ATTR_QUERY)) {
704
+ if (!addUnentered(form)) {
705
+ console.error(`lb-hub: '${command}' is for a list holding a row template, ` +
706
+ `and <${form.localName}> holds none, ignoring`, form);
707
+ }
708
+ return;
709
+ }
705
710
  if (form.localName !== "form" || !form.hasAttribute(ATTR_QUERY)) {
706
- console.error(`lb-hub: '${command}' is for a <form> carrying ${ATTR_QUERY}, ` +
711
+ const what = command === COMMAND_NEW ? `a <form> or a list` : `a <form>`;
712
+ console.error(`lb-hub: '${command}' is for ${what} carrying ${ATTR_QUERY}, ` +
707
713
  `not <${form.localName}>, ignoring`, form);
708
714
  return;
709
715
  }
@@ -746,10 +752,12 @@ class LbHub extends HTMLElement {
746
752
  return;
747
753
  }
748
754
  point(form, row, key, live);
755
+ void this.readNested(form, key);
749
756
  return;
750
757
  }
751
758
  case COMMAND_NEW:
752
759
  point(form, null, "", null);
760
+ void this.readNested(form, "");
753
761
  return;
754
762
  case COMMAND_RELEASE:
755
763
  release(form);
@@ -759,6 +767,36 @@ class LbHub extends HTMLElement {
759
767
  `names no command Loadbare provides, ignoring`);
760
768
  }
761
769
  }
770
+ /**
771
+ * Make the lists nested in a form follow the row it is now bound to: drop
772
+ * the rows not yet entered there, and ask the server for each list's
773
+ * query again with the row's key as the parm its key column names. A new
774
+ * row's key is empty, so its lists answer with none. A form bound to
775
+ * another row, or a page moved on, before the answer returns lands
776
+ * nothing.
777
+ */
778
+ async readNested(form, key) {
779
+ const lists = [...form.querySelectorAll(`[${ATTR_QUERY}]`)].filter((el) => queryOf(el) === form && templateIn(el) !== null);
780
+ if (lists.length === 0)
781
+ return;
782
+ const column = keptAnswer(this, form.getAttribute(ATTR_QUERY))?.key;
783
+ if (column === undefined)
784
+ return;
785
+ for (const list of lists) {
786
+ for (const row of list.querySelectorAll(`[${ATTR_KEY_VALUE}=""]`)) {
787
+ if (queryOf(row) === list)
788
+ discardUnentered(row);
789
+ }
790
+ }
791
+ const queries = [...new Set(lists.map((el) => el.getAttribute(ATTR_QUERY)))];
792
+ const at = here();
793
+ const items = await this.roundTrip(this.page, { name: REQUEST_READ, queries }, undefined, { [column]: key });
794
+ if (items instanceof Error || here() !== at)
795
+ return;
796
+ if (form.getAttribute(ATTR_KEY_VALUE) !== key)
797
+ return;
798
+ applyResponse(this, items);
799
+ }
762
800
  /**
763
801
  * Place a list's rows by the order and the filter again, from the rows
764
802
  * the hub holds: no round trip, since the user asks only to see what they
@@ -792,6 +830,9 @@ class LbHub extends HTMLElement {
792
830
  this.refused(request, items, origin);
793
831
  if (items instanceof Error)
794
832
  return done(origin, request, items);
833
+ // The rows not yet entered are on record now, and the answer lands them.
834
+ for (const row of enteredWith.get(request) ?? [])
835
+ discardUnentered(row);
795
836
  let landed = [];
796
837
  // The server answered for the URL the request was sent from. A user who
797
838
  // moved while it was in flight, to another page or to other parms, is
@@ -807,11 +848,6 @@ class LbHub extends HTMLElement {
807
848
  else
808
849
  landed = await this.follow(url, load, origin);
809
850
  }
810
- // The selected rows have been acted on, and the selection is done.
811
- if (request.keys !== undefined && request.query !== undefined) {
812
- for (const box of selectorsFor(this, request.query))
813
- select(box, false);
814
- }
815
851
  // A pointed form has done what it was pointed for, and lets go. A form
816
852
  // an insert was gathered from starts over, unless the answer landed the
817
853
  // new row on it.
@@ -851,7 +887,7 @@ class LbHub extends HTMLElement {
851
887
  * element that issued it carries `lb-request-pending` while it is in
852
888
  * flight, and `lb-request-error` when it fails. The error on failure.
853
889
  */
854
- async roundTrip(page, request, origin) {
890
+ async roundTrip(page, request, origin, parms) {
855
891
  // A page load says how the page showing got its data, unless the URL
856
892
  // has moved on by the time it returns, and a later load says instead.
857
893
  const load = request === undefined;
@@ -862,7 +898,7 @@ class LbHub extends HTMLElement {
862
898
  this.loadState = HUB_LOAD_LOADING;
863
899
  this.landHub();
864
900
  try {
865
- const items = await fetchItems(page, request);
901
+ const items = await fetchItems(page, request, parms);
866
902
  this.serverState = HUB_SERVER_REACHABLE;
867
903
  this.error = "";
868
904
  this.asOf = new Date().toISOString();
@@ -923,19 +959,15 @@ class LbHub extends HTMLElement {
923
959
  if (!this.querySelector(`[${ATTR_QUERY}="${HUB_QUERY}"]`))
924
960
  return;
925
961
  applyResponse(this, [
926
- { query: HUB_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row },
962
+ { query: HUB_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, rows: [row] },
927
963
  ]);
928
964
  }
929
965
  /**
930
- * Answer a request for a query the hub serves, with no round trip. The
931
- * hub serves `lb-url` and `lb-choices`, and updates them. It serves
932
- * `lb-hub` too, which nothing but the hub writes.
966
+ * Answer a request for `lb-url`, the one query the hub serves, with no
967
+ * round trip: write the URL and load the page it names.
933
968
  */
934
969
  async answer(request, origin) {
935
- if (request.query === CHOICES_QUERY && request.name === REQUEST_ROW_UPDATE) {
936
- return done(origin, request, [this.choose(request.values ?? {})]);
937
- }
938
- if (request.query !== URL_QUERY || request.name !== REQUEST_ROW_UPDATE) {
970
+ if (request.name !== REQUEST_ROW_UPDATE) {
939
971
  const message = `lb-hub: '${request.name}' for '${request.query}' is not a request ` +
940
972
  `the hub answers, ignoring`;
941
973
  console.error(message);
@@ -948,50 +980,64 @@ class LbHub extends HTMLElement {
948
980
  done(origin, request, await this.navigate(origin));
949
981
  }
950
982
  /**
951
- * The user made choices: they join the ones already made, and the rows
952
- * that read them are placed again from what the hub holds. The server is
953
- * told, for a page that keeps them, and its answer lands like any other.
954
- * What landed: the row of `lb-choices`.
983
+ * Answer an order the user chose: land its row of `lb-sort`, which places
984
+ * the rows it holds again by the new order, and send the order to the
985
+ * server to keep for the next page. Re-sorting is the browser's, from the
986
+ * rows it holds, so the round trip only persists the change.
955
987
  */
956
- choose(values) {
957
- const row = {
958
- ...keptAnswer(this, CHOICES_QUERY)?.row,
959
- ...values,
960
- [URL_COLUMN_PATH]: location.pathname,
961
- };
988
+ async chooseSort(request, origin) {
989
+ const query = request.key ?? "";
990
+ const order = request.values?.[SORT_COLUMN_ORDER] ?? "";
962
991
  const item = {
963
- query: CHOICES_QUERY,
964
- kind: KIND_ROW,
965
- key: URL_COLUMN_PATH,
966
- row,
992
+ query: SORT_QUERY,
993
+ kind: KIND_ROWS,
994
+ key: SORT_COLUMN_QUERY,
995
+ rows: [{ [SORT_COLUMN_QUERY]: query, [SORT_COLUMN_ORDER]: order }],
967
996
  };
968
997
  applyResponse(this, [item]);
969
- void this.tell(row);
970
- return item;
998
+ const items = await this.roundTrip(this.page, {
999
+ name: REQUEST_ROW_UPDATE,
1000
+ query: SORT_QUERY,
1001
+ key: query,
1002
+ values: { [SORT_COLUMN_ORDER]: order },
1003
+ }, undefined);
1004
+ if (!(items instanceof Error))
1005
+ applyResponse(this, items, { requested: true });
1006
+ done(origin, request, [item]);
971
1007
  }
972
1008
  /**
973
- * Send the server the user's choices, by `lb-row-update` for
974
- * `lb-choices`, which runs the page's `onChoices`. No element waits on
975
- * it, so none is pending, and a choice made while it is in flight is sent
976
- * in turn.
1009
+ * Answer a filter the user chose: merge it into the query's row of
1010
+ * `lb-filters`, land it, and send the whole row to the server to keep for
1011
+ * the next page. Filtering is the browser's, so no round trip answers the
1012
+ * change; the round trip only persists it.
977
1013
  */
978
- async tell(row) {
979
- const { [URL_COLUMN_PATH]: _, ...choices } = row;
980
- const values = {};
981
- for (const [column, value] of Object.entries(choices)) {
982
- values[column] = value === null || value === undefined ? "" : String(value);
1014
+ async chooseFilters(request, origin) {
1015
+ const query = request.key ?? "";
1016
+ const values = request.values ?? {};
1017
+ const held = keptAnswer(this, FILTERS_QUERY)?.rows?.find((row) => row[FILTERS_COLUMN_QUERY] === query);
1018
+ const row = { ...held, ...values, [FILTERS_COLUMN_QUERY]: query };
1019
+ const item = {
1020
+ query: FILTERS_QUERY,
1021
+ kind: KIND_ROWS,
1022
+ key: FILTERS_COLUMN_QUERY,
1023
+ rows: [row],
1024
+ };
1025
+ applyResponse(this, [item]);
1026
+ const columns = {};
1027
+ for (const [column, value] of Object.entries(row)) {
1028
+ if (column !== FILTERS_COLUMN_QUERY && typeof value === "string") {
1029
+ columns[column] = value;
1030
+ }
983
1031
  }
984
- const at = here();
985
1032
  const items = await this.roundTrip(this.page, {
986
1033
  name: REQUEST_ROW_UPDATE,
987
- query: CHOICES_QUERY,
988
- key: location.pathname,
989
- values,
1034
+ query: FILTERS_QUERY,
1035
+ key: query,
1036
+ values: columns,
990
1037
  }, undefined);
991
- if (items instanceof Error || here() !== at)
992
- return;
993
- if (items.length > 0)
1038
+ if (!(items instanceof Error))
994
1039
  applyResponse(this, items, { requested: true });
1040
+ done(origin, request, [item]);
995
1041
  }
996
1042
  /**
997
1043
  * Write a row for `lb-url` into the URL. A new page pushes a history
@@ -1018,7 +1064,7 @@ class LbHub extends HTMLElement {
1018
1064
  * asked for again that fails is not the request's failure.
1019
1065
  */
1020
1066
  async follow(item, load, origin) {
1021
- this.moveTo(item.row ?? {}, origin?.hasAttribute(ATTR_URL_PUSH) ?? false);
1067
+ this.moveTo(oneRow(item) ?? {}, origin?.hasAttribute(ATTR_URL_PUSH) ?? false);
1022
1068
  if (load.length === 0) {
1023
1069
  const again = await this.navigate();
1024
1070
  return again instanceof Error ? [item] : [item, ...again];
@@ -1121,10 +1167,9 @@ class LbHub extends HTMLElement {
1121
1167
  this.unplaced.push(this.main);
1122
1168
  forget(this);
1123
1169
  this.entering = true;
1124
- this.landChoices();
1125
1170
  }
1126
1171
  this.landUrl(template ? titleOf(template, page) : null);
1127
- // An app that ships `<body hidden>` (docs/reference/chrome.md) stays
1172
+ // An app that ships `<body hidden>` stays
1128
1173
  // invisible until there is something coherent to show.
1129
1174
  document.body.hidden = false;
1130
1175
  if (!template) {
@@ -1136,24 +1181,6 @@ class LbHub extends HTMLElement {
1136
1181
  }
1137
1182
  return template;
1138
1183
  }
1139
- /**
1140
- * The user's choices as a page begins: none made, so every column an
1141
- * element under `lb-query="lb-choices"` shows lands empty. Choices hold
1142
- * until the page changes, unless the server restores them.
1143
- */
1144
- landChoices() {
1145
- const row = {};
1146
- for (const scope of this.querySelectorAll(`[${ATTR_QUERY}="${CHOICES_QUERY}"]`)) {
1147
- for (const el of within(scope, `[${ATTR_COLUMN}]`)) {
1148
- if (el !== scope)
1149
- row[el.getAttribute(ATTR_COLUMN)] = "";
1150
- }
1151
- }
1152
- row[URL_COLUMN_PATH] = location.pathname;
1153
- applyResponse(this, [
1154
- { query: CHOICES_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row },
1155
- ]);
1156
- }
1157
1184
  /**
1158
1185
  * The hub's own query: one row, keyed by the path, whose other columns are
1159
1186
  * the query parms.
@@ -1183,7 +1210,7 @@ class LbHub extends HTMLElement {
1183
1210
  row[URL_COLUMN_PATH] = location.pathname;
1184
1211
  row[URL_COLUMN_PAGE_LABEL] = label;
1185
1212
  row[URL_COLUMN_PAGE_UNKNOWN] = label === null;
1186
- applyResponse(this, [{ query: URL_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, row }]);
1213
+ applyResponse(this, [{ query: URL_QUERY, kind: KIND_ROW, key: URL_COLUMN_PATH, rows: [row] }]);
1187
1214
  }
1188
1215
  }
1189
1216
  customElements.define(HUB_TAG_NAME, LbHub);