@loadbare/app 0.23.0 → 0.25.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 (39) hide show
  1. package/dist/build/assemble.d.ts.map +1 -1
  2. package/dist/build/assemble.js +78 -1
  3. package/dist/build/assemble.js.map +1 -1
  4. package/dist/core/lb-constants.d.ts +8 -5
  5. package/dist/core/lb-constants.d.ts.map +1 -1
  6. package/dist/core/lb-constants.js +20 -11
  7. package/dist/core/lb-constants.js.map +1 -1
  8. package/dist/core/lb-types.d.ts +19 -3
  9. package/dist/core/lb-types.d.ts.map +1 -1
  10. package/dist/core/lb-types.js +35 -5
  11. package/dist/core/lb-types.js.map +1 -1
  12. package/dist/hub/lb-apply.d.ts +31 -22
  13. package/dist/hub/lb-apply.d.ts.map +1 -1
  14. package/dist/hub/lb-apply.js +186 -52
  15. package/dist/hub/lb-apply.js.map +1 -1
  16. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  17. package/dist/hub/lb-hub.browser.js +59 -28
  18. package/dist/hub/lb-hub.browser.js.map +1 -1
  19. package/dist/server/lb-server.d.ts +2 -2
  20. package/dist/server/lb-server.d.ts.map +1 -1
  21. package/dist/server/lb-server.js +29 -9
  22. package/dist/server/lb-server.js.map +1 -1
  23. package/docs/comparison.md +13 -11
  24. package/docs/miswarts.md +4 -3
  25. package/docs/reference/chrome.md +42 -0
  26. package/docs/reference/custom-elements.md +9 -4
  27. package/docs/reference/data-binding.md +143 -67
  28. package/docs/reference/names.md +11 -8
  29. package/docs/reference/page-files.md +11 -6
  30. package/docs/testing.md +3 -3
  31. package/package.json +1 -1
  32. package/skills/loadbare-app/SKILL.md +38 -15
  33. package/skills/loadbare-app/references/chrome.md +42 -0
  34. package/skills/loadbare-app/references/custom-elements.md +9 -4
  35. package/skills/loadbare-app/references/data-binding.md +143 -67
  36. package/skills/loadbare-app/references/miswarts.md +4 -3
  37. package/skills/loadbare-app/references/names.md +11 -8
  38. package/skills/loadbare-app/references/page-files.md +11 -6
  39. package/docs/temp-proposals.md +0 -251
@@ -38,7 +38,7 @@
38
38
  * landing reaches in there as it reaches everywhere else, so a branch that
39
39
  * returns is already current. Nothing but landing reaches in.
40
40
  */
41
- import { AGGREGATES, ATTR_COLUMN, ATTR_FILTER, ATTR_ORDER, CHOICES_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_COUNT, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_MAX, ATTR_MIN, ATTR_QUERY, ATTR_QUERY_ORDER, ATTR_QUERY_ROW_COUNT, ATTR_QUERY_UNSORTED_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_ROW_UNSORTED, ATTR_SHOW, ATTR_SUM, ATTR_URL_PARM, KIND_ROW, LIVE_GROUP, LIVE_ROW, ORDER_DESCENDING, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, ATTR_SELECT, ATTR_ROW_SELECTED, } from "../core/lb-constants.js";
41
+ import { AGGREGATES, ATTR_COLUMN, ATTR_FILTER, ATTR_ORDER, CHOICES_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_COUNT, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_MAX, ATTR_MIN, ATTR_QUERY, ATTR_QUERY_ORDER, ATTR_QUERY_ROW_COUNT, ATTR_MANUAL, MANUAL_FILTER, MANUAL_ORDER, ATTR_QUERY_UNPLACED_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_ROW_UNPLACED, ATTR_SHOW, ATTR_SUM, ATTR_URL_PARM, KIND_ROW, LIVE_GROUP, LIVE_ROW, ORDER_DESCENDING, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, ATTR_SELECT, ATTR_ROW_SELECTED, } from "../core/lb-constants.js";
42
42
  import { compareRows, compareValues, isPatch, parseFilter, parseOrder, passes, readAs, } from "../core/lb-types.js";
43
43
  import { attachPlaced, detachRemoved, LEAVING, PARKED, placed, upgradeAbsent, } from "./lb-lifecycle.js";
44
44
  /** A DocumentFragment's nodeType. Tier 3 has no `Node` global to read it from. */
@@ -100,23 +100,58 @@ export function isFormAssociated(el) {
100
100
  export function isControl(el) {
101
101
  return (isNativeControl(el) || isCheckbox(el) || isRadio(el) || isFormAssociated(el));
102
102
  }
103
+ /**
104
+ * A control that holds several values: a `<select multiple>`, or a checkbox
105
+ * carrying a `value`, one of a set naming one column. A checkbox with no
106
+ * `value` is a flag.
107
+ */
108
+ function holdsSeveral(el) {
109
+ if (el.localName === "select")
110
+ return el.multiple;
111
+ return isCheckbox(el) && el.hasAttribute("value");
112
+ }
113
+ /** A landed value as the list of strings a control holding several shows. */
114
+ function listOf(value) {
115
+ if (Array.isArray(value))
116
+ return value.map((v) => String(v));
117
+ if (value === null || value === undefined || value === "")
118
+ return [];
119
+ return [String(value)];
120
+ }
103
121
  /**
104
122
  * What a control gathers: a checkbox `"true"` or `"false"`, so unchecked
105
- * still says so, and any other control its `value`.
123
+ * still says so, a `<select multiple>` the values of its selected options,
124
+ * and any other control its `value`, which a custom element may give as
125
+ * several.
106
126
  */
107
127
  function controlValue(el) {
108
- return isCheckbox(el) ? String(el.checked) : el.value;
128
+ if (isCheckbox(el))
129
+ return String(el.checked);
130
+ if (el.localName === "select" && el.multiple) {
131
+ return [...el.selectedOptions].map((o) => o.value);
132
+ }
133
+ const value = el.value;
134
+ return Array.isArray(value) ? value.map((v) => String(v)) : el.value;
109
135
  }
110
136
  /**
111
137
  * Gather a control into `values` under its column. Of the radio buttons
112
138
  * naming one column, the one checked gives the value, and `""` stands when
113
- * none is.
139
+ * none is. Of the checkboxes in a set naming one column, the checked ones
140
+ * give their values, and none checked gives none.
114
141
  */
115
142
  export function gatherInto(values, column, el) {
116
143
  if (isRadio(el) && !el.checked) {
117
144
  values[column] ??= "";
118
145
  return;
119
146
  }
147
+ if (isCheckbox(el) && holdsSeveral(el)) {
148
+ const had = values[column];
149
+ const set = Array.isArray(had) ? had : [];
150
+ values[column] = set;
151
+ if (el.checked)
152
+ set.push(el.value);
153
+ return;
154
+ }
120
155
  values[column] = controlValue(el);
121
156
  }
122
157
  /**
@@ -126,6 +161,12 @@ export function gatherInto(values, column, el) {
126
161
  * text content is set. Every one carries the value as `lb-column-value`.
127
162
  */
128
163
  function land(el, value) {
164
+ if (holdsSeveral(el))
165
+ return landSeveral(el, value);
166
+ // A custom element control takes several values as they are. Anything
167
+ // else holds one, and shows the first, as a URL's parm reads.
168
+ if (Array.isArray(value) && !isFormAssociated(el))
169
+ value = value[0];
129
170
  // Untouched, whatever the server sent: the browser decides what a
130
171
  // non-string looks like. See Row in core/lb-types.ts.
131
172
  if (value === null || value === undefined) {
@@ -163,6 +204,26 @@ function land(el, value) {
163
204
  }
164
205
  setStamp(el, ATTR_COLUMN_VALUE, String(value));
165
206
  }
207
+ /**
208
+ * Set a control that holds several values: a checkbox in a set is checked
209
+ * when its `value` is among them, and a `<select multiple>` selects each
210
+ * option whose `value` is.
211
+ */
212
+ function landSeveral(el, value) {
213
+ const list = listOf(value);
214
+ if (isCheckbox(el)) {
215
+ el.checked = list.includes(el.value);
216
+ }
217
+ else {
218
+ for (const option of el.options) {
219
+ option.selected = list.includes(option.value);
220
+ }
221
+ }
222
+ if (list.length === 0)
223
+ el.removeAttribute(ATTR_COLUMN_VALUE);
224
+ else
225
+ setStamp(el, ATTR_COLUMN_VALUE, JSON.stringify(list));
226
+ }
166
227
  /**
167
228
  * Set an element's text, unless it already holds exactly that text. Writing
168
229
  * the same text again replaces the text node, which is two mutations and a
@@ -632,6 +693,7 @@ function listFor(el, template) {
632
693
  levels,
633
694
  rowTemplate: at,
634
695
  byKey: new Map(),
696
+ unentered: [],
635
697
  root: {
636
698
  id: "",
637
699
  nodes: [],
@@ -660,7 +722,7 @@ function listFor(el, template) {
660
722
  data: {},
661
723
  placed: {},
662
724
  hidden: false,
663
- unsorted: false,
725
+ unplaced: false,
664
726
  };
665
727
  list.byKey.set(key, entry);
666
728
  list.root.rows.push(entry);
@@ -700,11 +762,79 @@ function create(list, doc, key) {
700
762
  data: {},
701
763
  placed: {},
702
764
  hidden: false,
703
- unsorted: false,
765
+ unplaced: false,
704
766
  };
705
767
  list.byKey.set(key, entry);
706
768
  return entry;
707
769
  }
770
+ /**
771
+ * Put the rows not yet entered after every row: immediately after the list's
772
+ * first template, which the rows and groups land in front of, so placing
773
+ * them never moves these.
774
+ */
775
+ function standLast(list) {
776
+ let at = list.root.slot;
777
+ for (const row of list.unentered) {
778
+ if (at.nextElementSibling !== row)
779
+ at.after(row);
780
+ at = row;
781
+ }
782
+ }
783
+ /** Stamp a list's count: the rows it shows, and its rows not yet entered. */
784
+ function recount(el, list) {
785
+ const showing = rowsUnder(list.root).filter((entry) => !entry.hidden);
786
+ setStamp(el, ATTR_QUERY_ROW_COUNT, String(showing.length + list.unentered.length));
787
+ }
788
+ /**
789
+ * Put a row not yet entered into a list: a live row copied from its row
790
+ * template, keyed by the empty key, its controls at their HTML defaults and
791
+ * its `lb-show` branches as for a row whose columns are all null. It sends
792
+ * nothing; the submit of the form around the list carries it. False when the
793
+ * element holds no row template.
794
+ */
795
+ export function addUnentered(el) {
796
+ const template = lists.get(el)?.top ?? templateIn(el);
797
+ if (!template)
798
+ return false;
799
+ const list = listFor(el, template);
800
+ if (!list)
801
+ return false;
802
+ const doc = el.ownerDocument;
803
+ const source = list.rowTemplate.content.firstElementChild;
804
+ list.paths ??= compile(source);
805
+ // Imported rather than cloned, for the reason a live row is.
806
+ const row = doc.importNode(source, true);
807
+ upgradeAbsent(row, doc);
808
+ placed(row);
809
+ row.setAttribute(ATTR_ROW_LIVE, "");
810
+ row.setAttribute(ATTR_KEY_VALUE, "");
811
+ nameRadios(row, "");
812
+ for (const [show, els] of placesBy(row, list.paths).shows) {
813
+ for (const shown of els)
814
+ presentPlaced(shown, show.startsWith(SHOW_NOT));
815
+ }
816
+ list.unentered.push(row);
817
+ standLast(list);
818
+ recount(el, list);
819
+ attachPlaced();
820
+ return true;
821
+ }
822
+ /**
823
+ * Take a row not yet entered out of its list, as a row leaves. False when
824
+ * the row is not one `--lb-new` put in a list.
825
+ */
826
+ export function discardUnentered(row) {
827
+ const el = row.parentElement?.closest(`[${ATTR_QUERY}]`);
828
+ const list = el ? lists.get(el) : undefined;
829
+ const at = list?.unentered.indexOf(row) ?? -1;
830
+ if (!el || !list || at < 0)
831
+ return false;
832
+ list.unentered.splice(at, 1);
833
+ leaving(row, ATTR_ROW_LIVE, ATTR_ROW_LEAVING);
834
+ depart([row]);
835
+ recount(el, list);
836
+ return true;
837
+ }
708
838
  /**
709
839
  * Give each radio button of a new live row a group of its own. The browser
710
840
  * groups radio buttons by `name`, and a row template repeats one `name` in
@@ -1136,10 +1266,12 @@ function differs(before, after) {
1136
1266
  * the same rows again moves nothing. A row a filter the user chose does not
1137
1267
  * pass is `hidden`.
1138
1268
  *
1139
- * A landing `held` for the user working in the list moves nothing already
1140
- * showing, whole or a patch. A row it changed or created that the order
1141
- * would put elsewhere, or that the filter would hide, stays and is stamped
1142
- * unsorted until the rows are next placed by the order.
1269
+ * A landing that answers a request leaves alone each side `lb-manual` names
1270
+ * on the element, whole or a patch. Under `order`, nothing already showing
1271
+ * moves, and a new row goes last in its group. Under `filter`, a row the
1272
+ * answer changed or created stays showing. A row left where the order or
1273
+ * the filter would not leave it is stamped unplaced until the rows are next
1274
+ * placed.
1143
1275
  */
1144
1276
  export function applyRows(el, template, keyColumn, result, options = {}) {
1145
1277
  const list = listFor(el, template);
@@ -1224,39 +1356,39 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1224
1356
  a.i - b.i)
1225
1357
  .map(({ entry }) => entry);
1226
1358
  const passing = filterFor(el, options.choices, options.types ?? {});
1359
+ const manual = options.requested ? manualOf(el) : NO_MANUAL;
1360
+ const touched = new Set([...changed, ...created]);
1361
+ // A row left out of place stays touched until the rows are placed again.
1362
+ for (const entry of shown)
1363
+ if (entry.unplaced)
1364
+ touched.add(entry);
1227
1365
  let order;
1228
- if (options.held) {
1229
- // The user is working in this list: what already shows stays where it
1230
- // stands, and what is new goes last in the group it belongs to.
1366
+ let out = new Set();
1367
+ if (manual.order) {
1368
+ // What already shows stays where it stands, and what is new goes last
1369
+ // in the group it belongs to.
1231
1370
  for (const entry of created)
1232
1371
  entry.placed = entry.data;
1233
1372
  order = heldOrder(stayed, created, groupTerms(list, terms));
1234
- const touched = new Set([...changed, ...created]);
1235
- for (const entry of order)
1236
- if (entry.unsorted)
1237
- touched.add(entry);
1238
- const out = movedRows(order, sort(order), touched);
1239
- for (const entry of order) {
1240
- const fails = !passing(entry.data);
1241
- // A row the user just changed does not vanish from under them.
1242
- const kept = touched.has(entry) && !entry.hidden;
1243
- filterRow(entry, fails && !kept);
1244
- // A row stands in the group it was placed in until it is placed again.
1245
- const moves = regrouped(entry, list, terms);
1246
- if (!moves)
1247
- entry.placed = entry.data;
1248
- entry.unsorted = out.has(entry) || (kept && fails) || moves;
1249
- }
1373
+ out = movedRows(order, sort(order), touched);
1250
1374
  }
1251
1375
  else {
1252
1376
  order = sort(whole
1253
1377
  ? [...arriving.keys()].map((key) => list.byKey.get(key))
1254
1378
  : [...stayed, ...created]);
1255
- for (const entry of order) {
1379
+ }
1380
+ for (const entry of order) {
1381
+ const fails = !passing(entry.data);
1382
+ // A row the answer touched does not vanish from under the user.
1383
+ const kept = manual.filter && touched.has(entry) && !entry.hidden && fails;
1384
+ const hides = fails && !kept;
1385
+ filterRow(entry, hides);
1386
+ // A row stands in the group it was placed in until it is placed again.
1387
+ const moves = manual.order && regrouped(entry, list, terms);
1388
+ if (!moves)
1256
1389
  entry.placed = entry.data;
1257
- entry.unsorted = false;
1258
- filterRow(entry, !passing(entry.data));
1259
- }
1390
+ // A row nobody sees stands nowhere the user would notice.
1391
+ entry.unplaced = !hides && (out.has(entry) || kept || moves);
1260
1392
  }
1261
1393
  const moved = movedRows(stayed, order, changed);
1262
1394
  const retired = arrange(list, doc, order, terms);
@@ -1281,18 +1413,19 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1281
1413
  }
1282
1414
  }
1283
1415
  stamps.done(doc);
1284
- let unsorted = 0;
1416
+ let unplaced = 0;
1285
1417
  for (const entry of order) {
1286
- if (entry.el.hasAttribute(ATTR_ROW_UNSORTED) !== entry.unsorted) {
1287
- entry.el.toggleAttribute(ATTR_ROW_UNSORTED, entry.unsorted);
1418
+ if (entry.el.hasAttribute(ATTR_ROW_UNPLACED) !== entry.unplaced) {
1419
+ entry.el.toggleAttribute(ATTR_ROW_UNPLACED, entry.unplaced);
1288
1420
  }
1289
- if (entry.unsorted)
1290
- unsorted++;
1421
+ if (entry.unplaced)
1422
+ unplaced++;
1291
1423
  }
1292
1424
  const rows = finish(list.root, options.types ?? {});
1293
1425
  // Counted from what the hub holds, which is what the rows show.
1294
- setStamp(el, ATTR_QUERY_ROW_COUNT, String(rows.length));
1295
- setStamp(el, ATTR_QUERY_UNSORTED_COUNT, String(unsorted));
1426
+ setStamp(el, ATTR_QUERY_ROW_COUNT, String(rows.length + list.unentered.length));
1427
+ standLast(list);
1428
+ setStamp(el, ATTR_QUERY_UNPLACED_COUNT, String(unplaced));
1296
1429
  if (terms.length === 0)
1297
1430
  el.removeAttribute(ATTR_QUERY_ORDER);
1298
1431
  else
@@ -1304,6 +1437,14 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1304
1437
  depart(departing);
1305
1438
  host.lbRowsLanded?.();
1306
1439
  }
1440
+ const NO_MANUAL = { order: false, filter: false };
1441
+ function manualOf(el) {
1442
+ const sides = (el.getAttribute(ATTR_MANUAL) ?? "").split(/\s+/);
1443
+ return {
1444
+ order: sides.includes(MANUAL_ORDER),
1445
+ filter: sides.includes(MANUAL_FILTER),
1446
+ };
1447
+ }
1307
1448
  /** The order's terms a list's groups break on, outermost first. */
1308
1449
  function groupTerms(list, terms) {
1309
1450
  return terms.slice(0, list.levels.length);
@@ -1533,13 +1674,6 @@ function fillArrivals(root, state) {
1533
1674
  }
1534
1675
  }
1535
1676
  }
1536
- /**
1537
- * Whether a request from `origin` was sent from inside `el`'s list: `el` is
1538
- * the nearest element carrying `lb-query` around it.
1539
- */
1540
- function sentFrom(el, origin) {
1541
- return origin?.parentElement?.closest(`[${ATTR_QUERY}]`) === el;
1542
- }
1543
1677
  /**
1544
1678
  * Land response items. Every answer arrives through here, and is kept for
1545
1679
  * `root`, so that what arrives after it is filled from it — see forget().
@@ -1563,7 +1697,6 @@ export function applyResponse(root, items, options = {}) {
1563
1697
  landItem(el, item, {
1564
1698
  ...rowsOptions(state, item),
1565
1699
  requested: options.requested,
1566
- held: sentFrom(el, options.origin),
1567
1700
  });
1568
1701
  }
1569
1702
  if (choices.size > 0)
@@ -1573,11 +1706,12 @@ export function applyResponse(root, items, options = {}) {
1573
1706
  attachPlaced();
1574
1707
  }
1575
1708
  /**
1576
- * Place an element's rows by the order again, from the answer kept for its
1577
- * query, with no round trip: what the user asks for once an edit has left
1578
- * rows out of order. False when the element shows no rows the hub holds.
1709
+ * Place an element's rows by the order and the filter again, from the answer
1710
+ * kept for its query, with no round trip: what the user asks for once an
1711
+ * answer has left rows out of place. False when the element shows no rows
1712
+ * the hub holds.
1579
1713
  */
1580
- export function resort(root, el) {
1714
+ export function placeAgain(root, el) {
1581
1715
  const state = kept.get(root);
1582
1716
  const item = state?.answers.get(el.getAttribute(ATTR_QUERY) ?? "");
1583
1717
  if (!state || !item || item.kind === KIND_ROW || !state.landed.has(el)) {