@loadbare/app 0.5.5 → 0.6.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 (81) hide show
  1. package/README.md +3 -4
  2. package/dist/build/assemble.d.ts +1 -1
  3. package/dist/build/assemble.d.ts.map +1 -1
  4. package/dist/build/assemble.js +80 -7
  5. package/dist/build/cli.d.ts +2 -2
  6. package/dist/build/cli.js +2 -2
  7. package/dist/build/expand.d.ts.map +1 -1
  8. package/dist/build/expand.js +30 -34
  9. package/dist/build/locations.d.ts +3 -3
  10. package/dist/build/locations.d.ts.map +1 -1
  11. package/dist/build/locations.js +15 -3
  12. package/dist/build/origins.d.ts +0 -13
  13. package/dist/build/origins.d.ts.map +1 -1
  14. package/dist/build/origins.js +32 -8
  15. package/dist/build/pages.d.ts +3 -3
  16. package/dist/build/pages.d.ts.map +1 -1
  17. package/dist/build/pages.js +9 -7
  18. package/dist/core/lb-constants.d.ts +17 -12
  19. package/dist/core/lb-constants.d.ts.map +1 -1
  20. package/dist/core/lb-constants.js +106 -43
  21. package/dist/core/lb-types.d.ts +103 -62
  22. package/dist/core/lb-types.d.ts.map +1 -1
  23. package/dist/core/lb-types.js +11 -3
  24. package/dist/hub/lb-apply.d.ts +18 -4
  25. package/dist/hub/lb-apply.d.ts.map +1 -1
  26. package/dist/hub/lb-apply.js +184 -34
  27. package/dist/hub/lb-hub.browser.d.ts +1 -1
  28. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  29. package/dist/hub/lb-hub.browser.js +165 -83
  30. package/dist/server/lb-express.d.ts +7 -4
  31. package/dist/server/lb-express.d.ts.map +1 -1
  32. package/dist/server/lb-express.js +45 -33
  33. package/dist/server/lb-server.d.ts +67 -45
  34. package/dist/server/lb-server.d.ts.map +1 -1
  35. package/dist/server/lb-server.js +55 -18
  36. package/dist/tests/assemble.test.js +154 -2
  37. package/dist/tests/expand.test.js +30 -3
  38. package/dist/tests/helpers/hub.d.ts +73 -0
  39. package/dist/tests/helpers/hub.d.ts.map +1 -0
  40. package/dist/tests/helpers/hub.js +151 -0
  41. package/dist/tests/lb-apply.test.js +86 -62
  42. package/dist/tests/lb-express.test.d.ts +1 -1
  43. package/dist/tests/lb-express.test.js +43 -38
  44. package/dist/tests/lb-hub.test.d.ts +14 -0
  45. package/dist/tests/lb-hub.test.d.ts.map +1 -0
  46. package/dist/tests/lb-hub.test.js +319 -0
  47. package/dist/tests/{lb-rows.test.d.ts → lb-list.test.d.ts} +1 -1
  48. package/dist/tests/lb-list.test.d.ts.map +1 -0
  49. package/dist/tests/{lb-rows.test.js → lb-list.test.js} +109 -106
  50. package/dist/tests/lb-server.test.js +151 -100
  51. package/dist/tests/origins.test.js +19 -1
  52. package/dist/tests/pages.test.d.ts +1 -1
  53. package/dist/tests/pages.test.js +64 -14
  54. package/docs/TECHREF-1.0.md +1000 -0
  55. package/docs/reference/builder.md +4 -4
  56. package/docs/reference/chrome.md +56 -5
  57. package/docs/reference/custom-elements.md +59 -36
  58. package/docs/reference/data-binding.md +142 -84
  59. package/docs/reference/overview.md +1 -1
  60. package/docs/reference/page-files.md +64 -49
  61. package/docs/reference/server.md +7 -6
  62. package/docs/reference/widgets.md +43 -32
  63. package/docs/roadmap.md +68 -22
  64. package/docs/testing.md +47 -17
  65. package/docs/theory.md +2 -2
  66. package/docs/tutorials/010-pages-and-navigation.md +14 -8
  67. package/docs/tutorials/040-displaying-data.md +9 -9
  68. package/docs/tutorials/050-actions.md +5 -5
  69. package/docs/tutorials/060-custom-element-code.md +1 -1
  70. package/docs/tutorials/065-conditional-rendering.md +4 -4
  71. package/docs/tutorials/070-displaying-a-list.md +24 -47
  72. package/docs/tutorials/072-inserting-into-a-list.md +17 -17
  73. package/docs/tutorials/074-deleting-from-a-list.md +15 -17
  74. package/docs/tutorials/076-updating-a-list-item.md +20 -22
  75. package/docs/tutorials/080-widget-requests.md +27 -23
  76. package/docs/tutorials/090-using-widget-libraries.md +23 -1
  77. package/package.json +1 -2
  78. package/dist/hub/lb-rows.d.ts +0 -18
  79. package/dist/hub/lb-rows.d.ts.map +0 -1
  80. package/dist/hub/lb-rows.js +0 -106
  81. package/dist/tests/lb-rows.test.d.ts.map +0 -1
@@ -2,15 +2,21 @@
2
2
  /**
3
3
  * Landing data on a live host — see docs/reference/data-binding.md.
4
4
  *
5
- * This is one file rather than part of the hub because a list widget needs
6
- * the same operation. It clones a row and fills it with a tuple, and filling
7
- * a row is filling a scope: the only difference is how much of the page the
8
- * root covers. A widget imports `applyTuple` the way it imports `ATTR_VALUE`,
9
- * so there is one implementation of what a cell means and nobody reaches into
10
- * anybody's children.
5
+ * One containment ladder, and one function per rung. `applyData` lands a
6
+ * whole response, `applyList` lands one list result, `applyRow` fills one
7
+ * scope from one row. Filling a live row and filling a single-row scope are
8
+ * the same operation, and the only difference is how much of the page the
9
+ * root covers.
10
+ *
11
+ * The row machinery used to live in its own file because every list widget
12
+ * imported and called it. The hub reconciles a list scope itself now, so a
13
+ * widget supplies placement and scaffolding through two optional hooks and
14
+ * never sees a whole result. Cloning the template, matching a row to the
15
+ * element already showing it, and filling that element are the same in every
16
+ * list, so no widget can get them wrong.
11
17
  */
12
- import { ATTR_CELL, ATTR_QUERY, ATTR_VALUE } from "../core/lb-constants";
13
- import { isProjection, } from "../core/lb-types";
18
+ import { ATTR_CELL, ATTR_KEY, ATTR_KEY_VALUE, ATTR_LIST, ATTR_ROW, ATTR_ROW_COUNT, ATTR_VALUE, } from "../core/lb-constants";
19
+ import { isPatch, } from "../core/lb-types";
14
20
  /**
15
21
  * A cell lands one of two ways. A custom element owns whatever control it
16
22
  * wraps, so it receives the value as an attribute and renders it itself.
@@ -23,55 +29,199 @@ import { isProjection, } from "../core/lb-types";
23
29
  * knowledge of any particular element.
24
30
  */
25
31
  function land(el, value) {
32
+ // Untouched, whatever the server sent: the browser decides what a
33
+ // non-string looks like. See Row in core/lb-types.ts.
26
34
  if (el.localName.includes("-"))
27
35
  el.setAttribute(ATTR_VALUE, value);
28
36
  else
29
37
  el.textContent = value;
30
38
  }
31
39
  /**
32
- * Fill one scope from one tuple.
40
+ * Whether an element under `root` belongs to root's scope, rather than to a
41
+ * scope nested inside it.
42
+ *
43
+ * Both scope attributes scope their DOM children, and a nested one of either
44
+ * kind begins a new scope (docs/reference/data-binding.md). So a descendant
45
+ * is in root's scope unless something between it and root — the element
46
+ * itself included — carries one. Root's own attribute does not count: root is
47
+ * the scope being filled, whatever it carries.
48
+ */
49
+ function inScope(root, el) {
50
+ for (let node = el; node && node !== root;) {
51
+ if (node.hasAttribute(ATTR_LIST) || node.hasAttribute(ATTR_ROW)) {
52
+ return false;
53
+ }
54
+ node = node.parentElement;
55
+ }
56
+ return true;
57
+ }
58
+ /** Root if it matches, then every descendant in root's scope that does. */
59
+ function within(root, selector) {
60
+ const found = root.matches(selector) ? [root] : [];
61
+ for (const el of root.querySelectorAll(selector)) {
62
+ if (inScope(root, el))
63
+ found.push(el);
64
+ }
65
+ return found;
66
+ }
67
+ /**
68
+ * Fill one scope from one row.
33
69
  *
34
70
  * The root counts as a cell if it carries one. A `<tr>` holds its cells in
35
71
  * `<td>` children, but `<option>`'s content model is text, so an option row
36
72
  * has to be the cell it displays. Requiring a wrapper there would require an
37
73
  * element HTML does not allow.
38
74
  */
39
- export function applyTuple(root, cells) {
40
- for (const [cell, value] of Object.entries(cells)) {
41
- const selector = `[${ATTR_CELL}="${cell}"]`;
42
- if (root.matches(selector))
43
- land(root, value);
44
- for (const el of root.querySelectorAll(selector))
75
+ export function applyRow(root, row) {
76
+ for (const [column, value] of Object.entries(row)) {
77
+ for (const el of within(root, `[${ATTR_CELL}="${column}"]`)) {
45
78
  land(el, value);
79
+ }
46
80
  }
47
81
  }
48
82
  /**
49
- * A projection has to reach a widget. A native element has one destination
50
- * for a value and no way to acquire children, so a list is not something it
51
- * can be asked to show.
83
+ * The row template: the one the developer wrote inside this list scope, or
84
+ * null when this scope shows nothing.
85
+ *
86
+ * A scope with no template is bound to the list without displaying it — an
87
+ * insert form names the list it adds a row to, and has no rows of its own.
88
+ * That is not a mistake, so it is silent. A template that does not name its
89
+ * key column is a mistake, and says so.
52
90
  */
53
- function landRows(scope, query, result) {
54
- const host = scope;
55
- if (typeof host.acceptRows !== "function") {
56
- console.error(`lb-hub: query '${query}' returned rows, but <${scope.localName}> ` +
57
- `is not a list widget`);
91
+ function templateIn(scope) {
92
+ const template = scope.querySelector("template");
93
+ if (!template)
94
+ return null;
95
+ if (!template.getAttribute(ATTR_KEY)) {
96
+ console.error(`lb-hub: the <template> in <${scope.localName}> has no ${ATTR_KEY} ` +
97
+ `naming the column that identifies a row`);
98
+ return null;
99
+ }
100
+ return template;
101
+ }
102
+ /**
103
+ * The rows already showing, by the key value each carries. Only a live row
104
+ * carries one: the template names the key column under a different name.
105
+ */
106
+ function showing(scope) {
107
+ const rows = new Map();
108
+ for (const el of scope.querySelectorAll(`[${ATTR_KEY_VALUE}]`)) {
109
+ rows.set(el.getAttribute(ATTR_KEY_VALUE), el);
110
+ }
111
+ return rows;
112
+ }
113
+ /**
114
+ * Land a list result in a list scope.
115
+ *
116
+ * An array is the whole set, so it decides membership and order: every row is
117
+ * placed in the order given, and a row whose key did not arrive is gone. A
118
+ * patch disturbs only what it names — a row it did not mention keeps its
119
+ * contents and its position.
120
+ *
121
+ * A widget that carries `lbPlaceRow` decides where a row goes, because only
122
+ * it knows whether it sorts or groups. Without it a row lands immediately
123
+ * before the template, so rows accumulate in the order they arrive and the
124
+ * template stays put as the insertion marker.
125
+ */
126
+ export function applyList(scope, result) {
127
+ const template = templateIn(scope);
128
+ if (!template)
58
129
  return;
130
+ const keyColumn = template.getAttribute(ATTR_KEY);
131
+ const shown = showing(scope);
132
+ const host = scope;
133
+ const whole = !isPatch(result);
134
+ const place = (el, row) => {
135
+ if (host.lbPlaceRow)
136
+ host.lbPlaceRow(el, row, template);
137
+ else
138
+ template.parentElement.insertBefore(el, template);
139
+ };
140
+ const upsert = (row) => {
141
+ if (row[keyColumn] === undefined) {
142
+ console.error(`lb-hub: a row for <${scope.localName}> has no '${keyColumn}' column`);
143
+ return null;
144
+ }
145
+ // The key is stored on the row as an attribute and read back from
146
+ // there, so it is compared as the string the attribute holds.
147
+ const key = String(row[keyColumn]);
148
+ let el = shown.get(key);
149
+ const fresh = el === undefined;
150
+ if (!el) {
151
+ el = template.content.firstElementChild.cloneNode(true);
152
+ el.setAttribute(ATTR_KEY_VALUE, key);
153
+ shown.set(key, el);
154
+ }
155
+ // Fill before insertion. The attributes are already there when the row
156
+ // upgrades, which is the same thing that makes hydration and refresh one
157
+ // operation everywhere else.
158
+ applyRow(el, row);
159
+ if (fresh || whole)
160
+ place(el, row);
161
+ return key;
162
+ };
163
+ if (Array.isArray(result)) {
164
+ const arrived = new Set();
165
+ for (const row of result) {
166
+ const key = upsert(row);
167
+ if (key !== null)
168
+ arrived.add(key);
169
+ }
170
+ for (const [key, el] of shown)
171
+ if (!arrived.has(key))
172
+ el.remove();
59
173
  }
60
- host.acceptRows(result);
174
+ else {
175
+ for (const row of result.rows ?? [])
176
+ upsert(row);
177
+ for (const key of result.drop ?? [])
178
+ shown.get(String(key))?.remove();
179
+ }
180
+ // How many rows are showing, counted from the DOM rather than from either
181
+ // branch above, so a whole set and a patch report the same fact the same
182
+ // way.
183
+ //
184
+ // It is stamped here because only this function knows the count: it is the
185
+ // one conditional a page cannot be sent, since the server answers with rows
186
+ // and says nothing about how many survived reconciliation. A page says what
187
+ // an empty list looks like in a stylesheet, and no list widget carries code
188
+ // for it. See docs/reference/data-binding.md.
189
+ scope.setAttribute(ATTR_ROW_COUNT, String(showing(scope).size));
190
+ // Derived scaffolding — a section heading, an <optgroup> — goes when its
191
+ // last row does, and only the widget knows it exists.
192
+ host.lbRowsLanded?.();
61
193
  }
62
- /** Land a whole response. Every query result arrives through here. */
194
+ /** Land a whole response. Every result arrives through here. */
63
195
  export function applyData(root, data) {
64
- for (const [query, result] of Object.entries(data)) {
65
- const scopes = root.querySelectorAll(`[${ATTR_QUERY}="${query}"]`);
66
- if (scopes.length === 0) {
67
- console.warn(`lb-hub: no scope for query '${query}', skipping`);
196
+ for (const [name, result] of Object.entries(data)) {
197
+ const lists = root.querySelectorAll(`[${ATTR_LIST}="${name}"]`);
198
+ const rows = root.querySelectorAll(`[${ATTR_ROW}="${name}"]`);
199
+ if (lists.length === 0 && rows.length === 0) {
200
+ console.warn(`lb-hub: no scope for '${name}', skipping`);
201
+ continue;
202
+ }
203
+ // Cardinality is a property of the name, so one name is a list or a row
204
+ // and never both. Two spellings of it on one page is a mistake in the
205
+ // markup rather than a case to reconcile.
206
+ if (lists.length > 0 && rows.length > 0) {
207
+ console.error(`lb-hub: '${name}' is bound as a list in one place and a row in ` +
208
+ `another; a name answers with one shape`);
209
+ continue;
210
+ }
211
+ if (lists.length > 0) {
212
+ for (const scope of lists)
213
+ applyList(scope, result);
68
214
  continue;
69
215
  }
70
- for (const scope of scopes) {
71
- if (isProjection(result))
72
- landRows(scope, query, result);
73
- else
74
- applyTuple(scope, result);
216
+ // The only disagreement visible from here: a patch and a row are both
217
+ // objects, so an array arriving at a row scope is the one case the
218
+ // browser can name. createHub holds the stronger check, because it knows
219
+ // what the query declared.
220
+ if (Array.isArray(result)) {
221
+ console.error(`lb-hub: '${name}' is bound with ${ATTR_ROW} but answered with rows`);
222
+ continue;
75
223
  }
224
+ for (const scope of rows)
225
+ applyRow(scope, result);
76
226
  }
77
227
  }
@@ -1,2 +1,2 @@
1
- export { applyData, applyTuple } from "./lb-apply";
1
+ export { applyData, applyRow } from "./lb-apply";
2
2
  //# sourceMappingURL=lb-hub.browser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lb-hub.browser.d.ts","sourceRoot":"","sources":["../../hub/lb-hub.browser.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"lb-hub.browser.d.ts","sourceRoot":"","sources":["../../hub/lb-hub.browser.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
@@ -1,18 +1,25 @@
1
1
  /// <reference lib="dom" />
2
- import { ATTR_ACTION, ATTR_CELL, ATTR_DELETE, ATTR_ERROR, ATTR_INSERT, ATTR_KEY, ATTR_NAV_LINK, ATTR_PENDING, ATTR_QUERY, ATTR_UNKNOWN_PAGE, ATTR_UPDATE, HUB_TAG_NAME, PAGE_TEMPLATE_PREFIX, LB_DATA_ENDPOINT, LB_EVENT_NAME, LB_REQUEST_ENDPOINT, LB_REQUEST_TIMEOUT_MS, } from "../core/lb-constants";
3
- import { applyData, applyTuple } from "./lb-apply";
4
- export { applyData, applyTuple } from "./lb-apply";
2
+ import { ACTION_ROW_DELETE, ACTION_ROW_INSERT, ACTION_ROW_UPDATE, ATTR_ACTION, ATTR_CELL, ATTR_ERROR, ATTR_KEY_VALUE, ATTR_LIST, ATTR_NAV_LINK, ATTR_PAGE, ATTR_PENDING, ATTR_ROW, ATTR_UNKNOWN_PAGE, LB_ACTIONS, LB_RESERVED_PREFIX, HUB_TAG_NAME, NAV_CELL_LABEL, NAV_CELL_URI, NAV_ROW, LB_EVENT_NAME, LB_ENDPOINT, LB_REQUEST_TIMEOUT_MS, } from "../core/lb-constants";
3
+ import { applyData } from "./lb-apply";
4
+ export { applyData, applyRow } from "./lb-apply";
5
5
  /**
6
6
  * Where a click happened, in the binding vocabulary. An action carries
7
7
  * these and nothing else, so there is no channel for a browser-supplied
8
8
  * argument.
9
+ *
10
+ * The scope field is `list` or `row`, whichever attribute scoped the nearest
11
+ * ancestor, so an action fired inside a single-row scope says so and the
12
+ * wire never needs a noun for the addressable thing.
9
13
  */
10
14
  function binding(el) {
11
- const query = el.closest(`[${ATTR_QUERY}]`)?.getAttribute(ATTR_QUERY);
12
- const key = el.closest(`[${ATTR_KEY}]`)?.getAttribute(ATTR_KEY);
15
+ const scope = el.closest(`[${ATTR_LIST}], [${ATTR_ROW}]`);
16
+ const list = scope?.getAttribute(ATTR_LIST) ?? null;
17
+ const row = list === null ? (scope?.getAttribute(ATTR_ROW) ?? null) : null;
18
+ const key = el.closest(`[${ATTR_KEY_VALUE}]`)?.getAttribute(ATTR_KEY_VALUE);
13
19
  const cell = el.getAttribute(ATTR_CELL);
14
20
  return {
15
- ...(query === null || query === undefined ? {} : { query }),
21
+ ...(list === null ? {} : { list }),
22
+ ...(row === null ? {} : { row }),
16
23
  ...(key === null || key === undefined ? {} : { key }),
17
24
  ...(cell === null ? {} : { cell }),
18
25
  };
@@ -61,6 +68,29 @@ function pageNameFor(path) {
61
68
  const name = path.replace(/^\/+|\/+$/g, "");
62
69
  return name === "" ? "index" : name;
63
70
  }
71
+ /**
72
+ * The `<template>` a page's markup ships in. The builder guarantees one per
73
+ * page name (build/locations.ts refuses two page files sharing a stub), so
74
+ * a second match here means the document was assembled by something else,
75
+ * and is worth saying out loud rather than resolving by document order.
76
+ */
77
+ function pageHostFor(page) {
78
+ const hosts = [];
79
+ for (const el of document.querySelectorAll(`template[${ATTR_PAGE}]`)) {
80
+ if (el.getAttribute(ATTR_PAGE) === page) {
81
+ hosts.push(el);
82
+ }
83
+ }
84
+ if (hosts.length === 0) {
85
+ console.error(`lb-hub: no page host for '${page}'`);
86
+ return null;
87
+ }
88
+ if (hosts.length > 1) {
89
+ console.error(`lb-hub: ${hosts.length} page hosts carry ${ATTR_PAGE}="${page}", ` +
90
+ `using the first`);
91
+ }
92
+ return hosts[0];
93
+ }
64
94
  /**
65
95
  * Fetch one round trip against the client-side deadline. Throws — rather
66
96
  * than swallowing — on a non-ok response, a network failure, or a timeout,
@@ -68,11 +98,18 @@ function pageNameFor(path) {
68
98
  * only ever wants the freshest data, like navigate()) can tell the two
69
99
  * apart from a genuinely empty result.
70
100
  */
71
- async function fetchData(url, init) {
101
+ async function fetchData(page, request) {
102
+ const url = `${LB_ENDPOINT}?page=${encodeURIComponent(page)}`;
72
103
  const controller = new AbortController();
73
104
  const timeout = setTimeout(() => controller.abort(), LB_REQUEST_TIMEOUT_MS);
74
105
  try {
75
- const res = await fetch(url, { ...init, signal: controller.signal });
106
+ // An empty body is how the hub asks for the page's whole query set.
107
+ const res = await fetch(url, {
108
+ method: "POST",
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify(request ?? {}),
111
+ signal: controller.signal,
112
+ });
76
113
  if (!res.ok) {
77
114
  throw new Error(`lb-hub: server responded ${res.status} for ${url}`);
78
115
  }
@@ -89,7 +126,7 @@ async function fetchData(url, init) {
89
126
  class LbHub extends HTMLElement {
90
127
  main;
91
128
  /**
92
- * The page currently in <main>. Hooks are declared per page, so a request
129
+ * The page currently in <main>. A page declares its own requests, so a request
93
130
  * has to say which page it came from. The page rides on the URL rather
94
131
  * than in the request, leaving the operation set closed.
95
132
  */
@@ -98,8 +135,18 @@ class LbHub extends HTMLElement {
98
135
  this.main = this.querySelector("main");
99
136
  this.addEventListener(LB_EVENT_NAME, (e) => {
100
137
  const request = e.detail;
101
- if (!request?.op) {
102
- console.warn(`lb-hub: event without an op, ignoring`, request);
138
+ if (!request?.action) {
139
+ console.warn(`lb-hub: event without an action, ignoring`, request);
140
+ return;
141
+ }
142
+ // The reserved prefix is the whole of the discriminant, so a reserved
143
+ // name that is not one of the four operations is a typo rather than
144
+ // something to forward. Refused here, where the widget can see why: a
145
+ // server would only report that it declares no such action.
146
+ if (request.action.startsWith(LB_RESERVED_PREFIX) &&
147
+ !LB_ACTIONS.includes(request.action)) {
148
+ console.error(`lb-hub: '${request.action}' begins with '${LB_RESERVED_PREFIX}' ` +
149
+ `but names no operation, ignoring`);
103
150
  return;
104
151
  }
105
152
  // The element that dispatched the request, not e.currentTarget (the
@@ -108,12 +155,7 @@ class LbHub extends HTMLElement {
108
155
  const origin = e.target;
109
156
  origin?.removeAttribute(ATTR_ERROR);
110
157
  origin?.setAttribute(ATTR_PENDING, "");
111
- const url = `${LB_REQUEST_ENDPOINT}?page=${encodeURIComponent(this.page)}`;
112
- void fetchData(url, {
113
- method: "POST",
114
- headers: { "content-type": "application/json" },
115
- body: JSON.stringify(request),
116
- })
158
+ void fetchData(this.page, request)
117
159
  .then((data) => applyData(this, data))
118
160
  .catch((err) => {
119
161
  console.error(`lb-hub: request failed`, err);
@@ -137,73 +179,29 @@ class LbHub extends HTMLElement {
137
179
  const el = e.target?.closest(`[${ATTR_ACTION}]`);
138
180
  if (!el || el.localName.includes("-"))
139
181
  return;
140
- e.preventDefault();
141
- const detail = {
142
- op: "action",
143
- name: el.getAttribute(ATTR_ACTION),
144
- ...binding(el),
145
- };
146
- el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
147
- });
148
- // The CRUD counterpart to the lb-action delegation above: a native
149
- // element needs no declared name to delete the row it is in, only its
150
- // own query/key binding.
151
- this.addEventListener("click", (e) => {
152
- const el = e.target?.closest(`[${ATTR_DELETE}]`);
153
- if (!el || el.localName.includes("-"))
182
+ // A form performs its action on submit, whatever was clicked in it.
183
+ if (el instanceof HTMLFormElement)
154
184
  return;
155
185
  e.preventDefault();
156
- const { query, key } = binding(el);
157
- if (!query || !key) {
158
- console.warn(`lb-hub: lb-delete with no query/key binding, ignoring`, el);
159
- return;
186
+ const name = el.getAttribute(ATTR_ACTION);
187
+ const detail = this.requestFor(name, el);
188
+ if (detail) {
189
+ el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
160
190
  }
161
- const detail = { op: "tuple-delete", query, key };
162
- el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
163
191
  });
164
- // The CRUD op that gathers rather than binds a single cell: every
165
- // lb-cell inside the form becomes one entry of the values map. There is
166
- // no key, because there is no row yet — only the query, found the same
167
- // way binding() finds one for a click, says what it is inserting
168
- // into.
192
+ // The same delegation for the event a form owns. A reserved name here
193
+ // gathers the form's cells; a declared one carries the binding, as a
194
+ // click does.
169
195
  this.addEventListener("submit", (e) => {
170
- const form = e.target?.closest(`[${ATTR_INSERT}]`);
171
- if (!form)
196
+ const form = e.target;
197
+ if (!form || !form.hasAttribute(ATTR_ACTION))
172
198
  return;
173
199
  e.preventDefault();
174
- const { query } = binding(form);
175
- if (!query) {
176
- console.warn(`lb-hub: lb-insert with no lb-query, ignoring`, form);
177
- return;
200
+ const name = form.getAttribute(ATTR_ACTION);
201
+ const detail = this.requestFor(name, form);
202
+ if (detail) {
203
+ form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
178
204
  }
179
- const detail = {
180
- op: "tuple-insert",
181
- query,
182
- values: gatherValues(form),
183
- };
184
- form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
185
- });
186
- // The batch counterpart to cell-change: same gathering as lb-insert, but
187
- // with the key of the row it is a form for — a list's row template root,
188
- // or a form inside one, has it from the same ancestor a delete button
189
- // reads.
190
- this.addEventListener("submit", (e) => {
191
- const form = e.target?.closest(`[${ATTR_UPDATE}]`);
192
- if (!form)
193
- return;
194
- e.preventDefault();
195
- const { query, key } = binding(form);
196
- if (!query || !key) {
197
- console.warn(`lb-hub: lb-update with no query/key binding, ignoring`, form);
198
- return;
199
- }
200
- const detail = {
201
- op: "tuple-update",
202
- query,
203
- key,
204
- values: gatherValues(form),
205
- };
206
- form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
207
205
  });
208
206
  // Only anchors that opted in. Everything else is a real link.
209
207
  this.addEventListener("click", (e) => {
@@ -223,12 +221,62 @@ class LbHub extends HTMLElement {
223
221
  * already in the document, so selecting a page is getElementById() and
224
222
  * there is no fetching mechanism on the host channel.
225
223
  */
224
+ /**
225
+ * What a native element carrying `lb-action` asks for. A reserved name is
226
+ * one of the CRUD operations and takes the binding that operation needs;
227
+ * any other name is an action the page declared, carrying whatever
228
+ * binding is in scope. A reserved name the hub does not turn into an
229
+ * operation here — a cell change, which needs a widget to say what a
230
+ * change is, or a name nobody reserved — is refused rather than sent.
231
+ */
232
+ requestFor(name, el) {
233
+ const { list, row, key, cell } = binding(el);
234
+ const form = el instanceof HTMLFormElement;
235
+ switch (name) {
236
+ case ACTION_ROW_DELETE:
237
+ if (list && key)
238
+ return { action: ACTION_ROW_DELETE, list, key };
239
+ break;
240
+ case ACTION_ROW_INSERT:
241
+ if (form && list) {
242
+ return {
243
+ action: ACTION_ROW_INSERT,
244
+ list,
245
+ values: gatherValues(el),
246
+ };
247
+ }
248
+ break;
249
+ case ACTION_ROW_UPDATE:
250
+ if (form && list && key) {
251
+ return {
252
+ action: ACTION_ROW_UPDATE,
253
+ list,
254
+ key,
255
+ values: gatherValues(el),
256
+ };
257
+ }
258
+ break;
259
+ default:
260
+ if (!name.startsWith(LB_RESERVED_PREFIX)) {
261
+ return {
262
+ action: name,
263
+ ...(list === undefined ? {} : { list }),
264
+ ...(row === undefined ? {} : { row }),
265
+ ...(key === undefined ? {} : { key }),
266
+ ...(cell === undefined ? {} : { cell }),
267
+ };
268
+ }
269
+ }
270
+ console.warn(`lb-hub: ${ATTR_ACTION}="${name}" on <${el.localName}> cannot be ` +
271
+ `sent from here, ignoring`, el);
272
+ return null;
273
+ }
226
274
  async navigate(path) {
227
275
  const page = pageNameFor(path);
228
- const template = document.getElementById(PAGE_TEMPLATE_PREFIX + page);
229
- if (!(template instanceof HTMLTemplateElement)) {
230
- console.error(`lb-hub: no page host for '${page}'`);
231
- this.reportUnknownPage(page);
276
+ this.landNavigation(path, page);
277
+ const template = pageHostFor(page);
278
+ if (!template) {
279
+ this.reportUnknownPage();
232
280
  this.reveal();
233
281
  return;
234
282
  }
@@ -240,7 +288,7 @@ class LbHub extends HTMLElement {
240
288
  this.reveal();
241
289
  let data = {};
242
290
  try {
243
- data = await fetchData(`${LB_DATA_ENDPOINT}?page=${encodeURIComponent(page)}`);
291
+ data = await fetchData(page);
244
292
  }
245
293
  catch (err) {
246
294
  console.error(`lb-hub: failed to load data for '${page}'`, err);
@@ -256,6 +304,38 @@ class LbHub extends HTMLElement {
256
304
  reveal() {
257
305
  document.body.hidden = false;
258
306
  }
307
+ /**
308
+ * The hub's own row: where the page is, in the vocabulary a server
309
+ * answer arrives in, so a chrome displays it the way it displays anything
310
+ * — `lb-row="lb-navigation"` on a subtree, `lb-cell` on what shows a
311
+ * value. Landed before the page host is looked up, so a miss has it too;
312
+ * the unknown-page dialog is one consumer among whatever others the
313
+ * chrome writes.
314
+ *
315
+ * The label is the nav's: the text of the first lb-nav-link anchor whose
316
+ * href names this page. Taken from the nav rather than from a click, so a
317
+ * click, a reload and the back button all land the same label — and a
318
+ * path no anchor names lands an empty one, explicitly, so a consumer is
319
+ * never left showing the previous page's.
320
+ *
321
+ * Landed only where a subtree names it. applyData() warns about a query
322
+ * with no scope, and a chrome that displays no navigation has nothing to
323
+ * be warned about.
324
+ */
325
+ landNavigation(path, page) {
326
+ if (!this.querySelector(`[${ATTR_ROW}="${NAV_ROW}"]`))
327
+ return;
328
+ let label = "";
329
+ for (const a of this.querySelectorAll(`a[${ATTR_NAV_LINK}]`)) {
330
+ if (pageNameFor(new URL(a.href).pathname) !== page)
331
+ continue;
332
+ label = a.textContent?.trim() ?? "";
333
+ break;
334
+ }
335
+ applyData(this, {
336
+ [NAV_ROW]: { [NAV_CELL_LABEL]: label, [NAV_CELL_URI]: path },
337
+ });
338
+ }
259
339
  /**
260
340
  * A path that resolves to no page host is discovered client-side, after a
261
341
  * successful 200 — every route gets the same document, so there is no
@@ -265,14 +345,16 @@ class LbHub extends HTMLElement {
265
345
  * element it's on is a <dialog> (build/assemble.ts), so this can call
266
346
  * showModal() without checking the tag here.
267
347
  *
348
+ * Only opens it. What the dialog says it gets from landNavigation(),
349
+ * already run, by naming the hub's row in lb-row like any subtree.
350
+ *
268
351
  * Scoped to the hub, like every other lookup here, so that one rule holds
269
352
  * without exception: what the hub acts on is inside the hub.
270
353
  */
271
- reportUnknownPage(page) {
354
+ reportUnknownPage() {
272
355
  const dialog = this.querySelector(`[${ATTR_UNKNOWN_PAGE}]`);
273
356
  if (!dialog)
274
357
  return;
275
- applyTuple(dialog, { page });
276
358
  const modal = dialog;
277
359
  if (!modal.open)
278
360
  modal.showModal();
@@ -6,13 +6,16 @@
6
6
  * Express itself is a peer dependency: nothing outside this file imports it,
7
7
  * so an application that hosts Hub some other way never pays for it.
8
8
  *
9
- * Two endpoints, and no third:
9
+ * One endpoint, and no second:
10
10
  *
11
- * GET /hub/data?page=<name> the page's whole query set
12
- * POST /hub?page=<name> one operation, then its refresh set
11
+ * POST /lb?page=<name> an empty body asks for the page's whole query
12
+ * set; a body carrying an action runs that action
13
+ * and answers with its refresh set
13
14
  *
14
15
  * The page rides on the query string rather than in the body, which is what
15
- * lets the operation set stay closed.
16
+ * lets the operation set stay closed. Every hub call is a POST, so an
17
+ * application's own GET routes never collide with this one and no page name
18
+ * is reserved.
16
19
  */
17
20
  import { type Request, type Router } from "express";
18
21
  import type { Hub, HubContext } from "./lb-server";
@@ -1 +1 @@
1
- {"version":3,"file":"lb-express.d.ts","sourceRoot":"","sources":["../../server/lb-express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAgB,EAAE,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,KAAK,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;AAEtD,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CAkElE"}
1
+ {"version":3,"file":"lb-express.d.ts","sourceRoot":"","sources":["../../server/lb-express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAgB,EAAE,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,SAAS,CAAC;AAS7D,OAAO,KAAK,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;AAEtD,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CAkFlE"}