@loadbare/app 0.6.0 → 0.7.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 (122) hide show
  1. package/dist/build/assemble.js +1 -0
  2. package/dist/build/assemble.js.map +1 -0
  3. package/dist/build/cli.js +1 -0
  4. package/dist/build/cli.js.map +1 -0
  5. package/dist/build/elements.js +1 -0
  6. package/dist/build/elements.js.map +1 -0
  7. package/dist/build/expand.js +1 -0
  8. package/dist/build/expand.js.map +1 -0
  9. package/dist/build/format.js +1 -0
  10. package/dist/build/format.js.map +1 -0
  11. package/dist/build/locations.d.ts +2 -2
  12. package/dist/build/locations.d.ts.map +1 -1
  13. package/dist/build/locations.js +3 -4
  14. package/dist/build/locations.js.map +1 -0
  15. package/dist/build/origins.js +1 -0
  16. package/dist/build/origins.js.map +1 -0
  17. package/dist/build/package-root.js +1 -0
  18. package/dist/build/package-root.js.map +1 -0
  19. package/dist/build/pages.d.ts +4 -0
  20. package/dist/build/pages.d.ts.map +1 -1
  21. package/dist/build/pages.js +5 -0
  22. package/dist/build/pages.js.map +1 -0
  23. package/dist/build/styles.js +1 -0
  24. package/dist/build/styles.js.map +1 -0
  25. package/dist/core/lb-constants.js +1 -0
  26. package/dist/core/lb-constants.js.map +1 -0
  27. package/dist/core/lb-types.d.ts +1 -1
  28. package/dist/core/lb-types.d.ts.map +1 -1
  29. package/dist/core/lb-types.js +2 -1
  30. package/dist/core/lb-types.js.map +1 -0
  31. package/dist/hub/lb-apply.d.ts +11 -1
  32. package/dist/hub/lb-apply.d.ts.map +1 -1
  33. package/dist/hub/lb-apply.js +50 -13
  34. package/dist/hub/lb-apply.js.map +1 -0
  35. package/dist/hub/lb-hub.browser.d.ts +1 -1
  36. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  37. package/dist/hub/lb-hub.browser.js +117 -109
  38. package/dist/hub/lb-hub.browser.js.map +1 -0
  39. package/dist/server/lb-express.d.ts +1 -1
  40. package/dist/server/lb-express.d.ts.map +1 -1
  41. package/dist/server/lb-express.js +1 -0
  42. package/dist/server/lb-express.js.map +1 -0
  43. package/dist/server/lb-server.d.ts +1 -1
  44. package/dist/server/lb-server.d.ts.map +1 -1
  45. package/dist/server/lb-server.js +2 -1
  46. package/dist/server/lb-server.js.map +1 -0
  47. package/docs/TECHREF-1.0.md +149 -42
  48. package/docs/analysis-accidental-complexity.md +149 -0
  49. package/docs/reference/custom-elements.md +10 -17
  50. package/docs/reference/data-binding.md +56 -27
  51. package/docs/reference/server.md +18 -0
  52. package/docs/reference/widgets.md +3 -3
  53. package/docs/theory.md +114 -1
  54. package/docs/tutorials/072-inserting-into-a-list.md +14 -11
  55. package/docs/tutorials/080-widget-requests.md +9 -26
  56. package/package.json +2 -2
  57. package/dist/tests/assemble.test.d.ts +0 -8
  58. package/dist/tests/assemble.test.d.ts.map +0 -1
  59. package/dist/tests/assemble.test.js +0 -210
  60. package/dist/tests/elements.test.d.ts +0 -8
  61. package/dist/tests/elements.test.d.ts.map +0 -1
  62. package/dist/tests/elements.test.js +0 -118
  63. package/dist/tests/expand.test.d.ts +0 -10
  64. package/dist/tests/expand.test.d.ts.map +0 -1
  65. package/dist/tests/expand.test.js +0 -253
  66. package/dist/tests/fixtures/elements/collision/imports.d.ts +0 -3
  67. package/dist/tests/fixtures/elements/collision/imports.d.ts.map +0 -1
  68. package/dist/tests/fixtures/elements/collision/imports.js +0 -1
  69. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.browser.d.ts +0 -2
  70. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.browser.d.ts.map +0 -1
  71. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.browser.js +0 -1
  72. package/dist/tests/fixtures/elements/local/widgets/app-box.browser.d.ts +0 -2
  73. package/dist/tests/fixtures/elements/local/widgets/app-box.browser.d.ts.map +0 -1
  74. package/dist/tests/fixtures/elements/local/widgets/app-box.browser.js +0 -1
  75. package/dist/tests/fixtures/elements/manifest/imports.d.ts +0 -3
  76. package/dist/tests/fixtures/elements/manifest/imports.d.ts.map +0 -1
  77. package/dist/tests/fixtures/elements/manifest/imports.js +0 -1
  78. package/dist/tests/fixtures/elements/manifest-bad-entry/imports.d.ts +0 -3
  79. package/dist/tests/fixtures/elements/manifest-bad-entry/imports.d.ts.map +0 -1
  80. package/dist/tests/fixtures/elements/manifest-bad-entry/imports.js +0 -1
  81. package/dist/tests/fixtures/elements/manifest-not-array/imports.d.ts +0 -5
  82. package/dist/tests/fixtures/elements/manifest-not-array/imports.d.ts.map +0 -1
  83. package/dist/tests/fixtures/elements/manifest-not-array/imports.js +0 -1
  84. package/dist/tests/fixtures/elements/pkg/acme-widget.browser.d.ts +0 -2
  85. package/dist/tests/fixtures/elements/pkg/acme-widget.browser.d.ts.map +0 -1
  86. package/dist/tests/fixtures/elements/pkg/acme-widget.browser.js +0 -1
  87. package/dist/tests/fixtures/elements/unmarked/widgets/app-box.d.ts +0 -6
  88. package/dist/tests/fixtures/elements/unmarked/widgets/app-box.d.ts.map +0 -1
  89. package/dist/tests/fixtures/elements/unmarked/widgets/app-box.js +0 -1
  90. package/dist/tests/helpers/console.d.ts +0 -20
  91. package/dist/tests/helpers/console.d.ts.map +0 -1
  92. package/dist/tests/helpers/console.js +0 -28
  93. package/dist/tests/helpers/dom.d.ts +0 -18
  94. package/dist/tests/helpers/dom.d.ts.map +0 -1
  95. package/dist/tests/helpers/dom.js +0 -22
  96. package/dist/tests/helpers/hub.d.ts +0 -73
  97. package/dist/tests/helpers/hub.d.ts.map +0 -1
  98. package/dist/tests/helpers/hub.js +0 -151
  99. package/dist/tests/lb-apply.test.d.ts +0 -8
  100. package/dist/tests/lb-apply.test.d.ts.map +0 -1
  101. package/dist/tests/lb-apply.test.js +0 -177
  102. package/dist/tests/lb-express.test.d.ts +0 -14
  103. package/dist/tests/lb-express.test.d.ts.map +0 -1
  104. package/dist/tests/lb-express.test.js +0 -243
  105. package/dist/tests/lb-hub.test.d.ts +0 -14
  106. package/dist/tests/lb-hub.test.d.ts.map +0 -1
  107. package/dist/tests/lb-hub.test.js +0 -319
  108. package/dist/tests/lb-list.test.d.ts +0 -12
  109. package/dist/tests/lb-list.test.d.ts.map +0 -1
  110. package/dist/tests/lb-list.test.js +0 -339
  111. package/dist/tests/lb-server.test.d.ts +0 -9
  112. package/dist/tests/lb-server.test.d.ts.map +0 -1
  113. package/dist/tests/lb-server.test.js +0 -546
  114. package/dist/tests/origins.test.d.ts +0 -10
  115. package/dist/tests/origins.test.d.ts.map +0 -1
  116. package/dist/tests/origins.test.js +0 -387
  117. package/dist/tests/pages.test.d.ts +0 -6
  118. package/dist/tests/pages.test.d.ts.map +0 -1
  119. package/dist/tests/pages.test.js +0 -148
  120. package/dist/tests/styles.test.d.ts +0 -7
  121. package/dist/tests/styles.test.d.ts.map +0 -1
  122. package/dist/tests/styles.test.js +0 -76
@@ -1,319 +0,0 @@
1
- /**
2
- * The hub element — see hub/lb-hub.browser.ts and docs/testing.md.
3
- *
4
- * Tier 4. jsdom is evidence for what the hub does to the document it is
5
- * already holding: which element an event came from, what request that
6
- * produces, which attributes land where, and which page host a path selects.
7
- * A real network and a real frame belong to the browser tier, which is not
8
- * built.
9
- *
10
- * The hub registers itself as its module loads, so the window goes in first
11
- * and the module is imported after it.
12
- */
13
- import assert from "node:assert/strict";
14
- import { before, describe, it } from "node:test";
15
- import { captured } from "./helpers/console";
16
- import { back, click, installWindow, mountHub, send, server, settle, submit, } from "./helpers/hub";
17
- installWindow();
18
- before(async () => {
19
- await import("../hub/lb-hub.browser");
20
- });
21
- /** A live row, as the hub stamps one. */
22
- const ROSTER = `
23
- <ul lb-list="roster">
24
- <li lb-key-value="42">
25
- <span lb-cell="name">Ann</span>
26
- <button lb-action="lb-row-delete">x</button>
27
- </li>
28
- <template lb-key="id"><li><span lb-cell="name"></span></li></template>
29
- </ul>`;
30
- describe("what a native element asks for", () => {
31
- it("builds a delete from the list and the key around the button", async () => {
32
- const hub = await mountHub(ROSTER);
33
- server.sent.length = 0;
34
- await click(hub.querySelector("button"));
35
- assert.deepEqual(server.sent[0]?.request, {
36
- action: "lb-row-delete",
37
- list: "roster",
38
- key: "42",
39
- });
40
- });
41
- it("builds an insert from a form in a list scope, gathering its cells", async () => {
42
- const hub = await mountHub(`
43
- <form lb-list="roster" lb-action="lb-row-insert">
44
- <input lb-cell="name" value="Bo" />
45
- </form>`);
46
- server.sent.length = 0;
47
- await submit(hub.querySelector("form"));
48
- assert.deepEqual(server.sent[0]?.request, {
49
- action: "lb-row-insert",
50
- list: "roster",
51
- values: { name: "Bo" },
52
- });
53
- });
54
- it("builds an update from a form inside a live row", async () => {
55
- const hub = await mountHub(`
56
- <ul lb-list="roster">
57
- <li lb-key-value="42">
58
- <form lb-action="lb-row-update">
59
- <input lb-cell="name" value="Ann B" />
60
- </form>
61
- </li>
62
- </ul>`);
63
- server.sent.length = 0;
64
- await submit(hub.querySelector("form"));
65
- assert.deepEqual(server.sent[0]?.request, {
66
- action: "lb-row-update",
67
- list: "roster",
68
- key: "42",
69
- values: { name: "Ann B" },
70
- });
71
- });
72
- it("carries whatever binding is in scope for a declared action, and no value", async () => {
73
- const hub = await mountHub(`
74
- <section lb-row="prefs">
75
- <button lb-action="reset" lb-cell="active_tab">Reset</button>
76
- </section>`);
77
- server.sent.length = 0;
78
- await click(hub.querySelector("button"));
79
- assert.deepEqual(server.sent[0]?.request, {
80
- action: "reset",
81
- row: "prefs",
82
- cell: "active_tab",
83
- });
84
- });
85
- it("reports a cell with no control to read, and sends the rest", async () => {
86
- const hub = await mountHub(`
87
- <form lb-list="roster" lb-action="lb-row-insert">
88
- <span lb-cell="label">not a control</span>
89
- <input lb-cell="name" value="Bo" />
90
- </form>`);
91
- server.sent.length = 0;
92
- const { warn } = await captured(() => submit(hub.querySelector("form")));
93
- assert.match(warn.join("\n"), /lb-cell 'label' has no control to read/);
94
- assert.deepEqual(server.sent[0]?.request, {
95
- action: "lb-row-insert",
96
- list: "roster",
97
- values: { name: "Bo" },
98
- });
99
- });
100
- it("refuses a reserved name it cannot turn into an operation", async () => {
101
- // A cell change needs a widget to say what a change is, so a bare button
102
- // naming one is a mistake the hub can see.
103
- const hub = await mountHub(`
104
- <ul lb-list="roster">
105
- <li lb-key-value="42">
106
- <button lb-action="lb-cell-change" lb-cell="name">go</button>
107
- </li>
108
- </ul>`);
109
- server.sent.length = 0;
110
- const { warn } = await captured(() => click(hub.querySelector("button")));
111
- assert.match(warn.join("\n"), /cannot be sent from here/);
112
- assert.equal(server.sent.length, 0);
113
- });
114
- it("refuses a delete with no key, since nothing says which row", async () => {
115
- const hub = await mountHub(`
116
- <ul lb-list="roster">
117
- <button lb-action="lb-row-delete">x</button>
118
- </ul>`);
119
- server.sent.length = 0;
120
- const { warn } = await captured(() => click(hub.querySelector("button")));
121
- assert.match(warn.join("\n"), /cannot be sent from here/);
122
- assert.equal(server.sent.length, 0);
123
- });
124
- });
125
- describe("which interactions the hub takes", () => {
126
- it("finds the nearest lb-action above the element clicked", async () => {
127
- const hub = await mountHub(`
128
- <section lb-row="prefs">
129
- <button lb-action="reset"><span>Reset</span></button>
130
- </section>`);
131
- server.sent.length = 0;
132
- await click(hub.querySelector("span"));
133
- assert.equal(server.sent[0]?.request.action, "reset");
134
- });
135
- it("leaves a widget's own click alone, since the widget owns it", async () => {
136
- const hub = await mountHub(`<section lb-row="prefs"><lb-thing lb-action="reset"></lb-thing></section>`);
137
- server.sent.length = 0;
138
- await click(hub.querySelector("lb-thing"));
139
- assert.equal(server.sent.length, 0);
140
- });
141
- it("sends a form's action once, on its submit rather than on the click", async () => {
142
- // The button submits the form, so both listeners see the interaction.
143
- // Only the submit turns into a request.
144
- const hub = await mountHub(`
145
- <form lb-list="roster" lb-action="lb-row-insert">
146
- <input lb-cell="name" value="Bo" />
147
- <button>Add</button>
148
- </form>`);
149
- server.sent.length = 0;
150
- await click(hub.querySelector("button"));
151
- assert.equal(server.sent.length, 1);
152
- assert.deepEqual(server.sent[0]?.request, {
153
- action: "lb-row-insert",
154
- list: "roster",
155
- values: { name: "Bo" },
156
- });
157
- });
158
- it("ignores an element carrying no action at all", async () => {
159
- const hub = await mountHub(`<button>Plain</button>`);
160
- server.sent.length = 0;
161
- await click(hub.querySelector("button"));
162
- assert.equal(server.sent.length, 0);
163
- });
164
- });
165
- describe("what the hub refuses to forward", () => {
166
- it("refuses an event with no action", async () => {
167
- const hub = await mountHub(`<button>go</button>`);
168
- server.sent.length = 0;
169
- const { warn } = await captured(() => send(hub.querySelector("button"), undefined));
170
- assert.match(warn.join("\n"), /event without an action/);
171
- assert.equal(server.sent.length, 0);
172
- });
173
- it("refuses a reserved name that is not one of the four", async () => {
174
- const hub = await mountHub(`<button>go</button>`);
175
- server.sent.length = 0;
176
- const { error } = await captured(() => send(hub.querySelector("button"), { action: "lb-row-rename" }));
177
- assert.match(error.join("\n"), /names no operation/);
178
- assert.equal(server.sent.length, 0);
179
- });
180
- it("forwards an operation a widget built, which the hub would not build itself", async () => {
181
- const hub = await mountHub(`<lb-thing></lb-thing>`);
182
- server.sent.length = 0;
183
- await send(hub.querySelector("lb-thing"), {
184
- action: "lb-cell-change",
185
- list: "roster",
186
- key: "42",
187
- cell: "name",
188
- value: "Ann",
189
- });
190
- assert.equal(server.sent[0]?.request.action, "lb-cell-change");
191
- });
192
- });
193
- describe("request state", () => {
194
- it("stamps the element that dispatched, not the hub", async () => {
195
- const hub = await mountHub(`<section lb-row="prefs"><button lb-action="reset">go</button></section>`);
196
- const button = hub.querySelector("button");
197
- let pendingWhileInFlight = false;
198
- server.answer = {};
199
- const watching = new Promise((resolve) => {
200
- globalThis.queueMicrotask(() => {
201
- pendingWhileInFlight = button.hasAttribute("lb-pending");
202
- resolve();
203
- });
204
- });
205
- button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
206
- await watching;
207
- await settle();
208
- assert.equal(pendingWhileInFlight, true);
209
- assert.equal(button.hasAttribute("lb-pending"), false);
210
- assert.equal(hub.hasAttribute("lb-pending"), false);
211
- });
212
- it("sets lb-error on a failed round trip and clears it on the next", async () => {
213
- const hub = await mountHub(`<section lb-row="prefs"><button lb-action="reset">go</button></section>`);
214
- const button = hub.querySelector("button");
215
- server.fail = "network down";
216
- const { error } = await captured(() => click(button));
217
- assert.match(error.join("\n"), /request failed/);
218
- assert.equal(button.hasAttribute("lb-error"), true);
219
- assert.equal(button.hasAttribute("lb-pending"), false);
220
- delete server.fail;
221
- await click(button);
222
- assert.equal(button.hasAttribute("lb-error"), false);
223
- });
224
- });
225
- describe("finding a page host", () => {
226
- it("lands the page the path names", async () => {
227
- const hub = await mountHub(``, {
228
- index: `<h1>Home</h1>`,
229
- members: `<h1>Members</h1>`,
230
- });
231
- assert.match(hub.querySelector("main").innerHTML, /Home/);
232
- });
233
- it("reports a path with no host and opens the unknown-page dialog", async () => {
234
- const { error } = await captured(() => mountHub(`<dialog lb-unknown-page></dialog>`, { index: `<h1>Home</h1>` }, "/nowhere"));
235
- assert.match(error.join("\n"), /no page host for 'nowhere'/);
236
- assert.equal(document.querySelector("dialog").hasAttribute("open"), true);
237
- });
238
- it("reports and carries on when a chrome declares no dialog", async () => {
239
- const { error } = await captured(() => mountHub(``, { index: `<h1>Home</h1>` }, "/nowhere"));
240
- assert.match(error.join("\n"), /no page host for 'nowhere'/);
241
- });
242
- it("reports two hosts for one name and takes the first", async () => {
243
- // The builder refuses this — build/locations.ts — so reaching it means
244
- // the document was assembled by something else.
245
- const { error } = await captured(async () => {
246
- history.replaceState(null, "", "/");
247
- document.body.innerHTML =
248
- `<lb-hub><main></main></lb-hub>` +
249
- `<template lb-page="index"><h1>First</h1></template>` +
250
- `<template lb-page="index"><h1>Second</h1></template>`;
251
- await settle();
252
- });
253
- assert.match(error.join("\n"), /2 page hosts carry lb-page="index"/);
254
- assert.match(document.querySelector("main").innerHTML, /First/);
255
- });
256
- });
257
- describe("navigation", () => {
258
- it("swaps the host on an lb-nav-link and reverses it on popstate", async () => {
259
- const hub = await mountHub(`<nav><a href="/members" lb-nav-link>Members</a></nav>`, { index: `<h1>Home</h1>`, members: `<h1>Members</h1>` });
260
- const main = hub.querySelector("main");
261
- assert.match(main.innerHTML, /Home/);
262
- await click(hub.querySelector("a"));
263
- assert.equal(location.pathname, "/members");
264
- assert.match(main.innerHTML, /Members/);
265
- await back();
266
- assert.match(main.innerHTML, /Home/);
267
- });
268
- it("leaves an anchor without lb-nav-link to the browser", async () => {
269
- const hub = await mountHub(`<nav><a href="/members">Members</a></nav>`, {
270
- index: `<h1>Home</h1>`,
271
- members: `<h1>Members</h1>`,
272
- });
273
- await click(hub.querySelector("a"));
274
- assert.equal(location.pathname, "/");
275
- assert.match(hub.querySelector("main").innerHTML, /Home/);
276
- });
277
- it("asks the server for the page the path names", async () => {
278
- const hub = await mountHub(`<nav><a href="/members" lb-nav-link>Members</a></nav>`, { index: ``, members: `` });
279
- server.sent.length = 0;
280
- await click(hub.querySelector("a"));
281
- assert.equal(server.sent[0]?.page, "members");
282
- });
283
- it("lands the navigation row with the path and the link that names it", async () => {
284
- const hub = await mountHub(`<nav><a href="/members" lb-nav-link>Members</a></nav>
285
- <p lb-row="lb-navigation">
286
- <span lb-cell="page-label"></span> at <span lb-cell="page-uri"></span>
287
- </p>`, { index: ``, members: `` });
288
- await click(hub.querySelector("a"));
289
- const row = hub.querySelector(`[lb-row="lb-navigation"]`);
290
- assert.equal(row.querySelector(`[lb-cell="page-label"]`).textContent, "Members");
291
- assert.equal(row.querySelector(`[lb-cell="page-uri"]`).textContent, "/members");
292
- });
293
- it("leaves the label empty when no link names the page", async () => {
294
- const hub = await mountHub(`<p lb-row="lb-navigation"><span lb-cell="page-label"></span></p>`);
295
- const label = hub.querySelector(`[lb-cell="page-label"]`);
296
- assert.equal(label.textContent, "");
297
- });
298
- });
299
- describe("revealing the body", () => {
300
- it("takes hidden off once a page has landed", async () => {
301
- document.body.setAttribute("hidden", "");
302
- await mountHub(``, { index: `<h1>Home</h1>` });
303
- assert.equal(document.body.hasAttribute("hidden"), false);
304
- });
305
- it("takes hidden off even when the path names no page", async () => {
306
- document.body.setAttribute("hidden", "");
307
- await captured(() => mountHub(``, { index: `<h1>Home</h1>` }, "/nowhere"));
308
- assert.equal(document.body.hasAttribute("hidden"), false);
309
- });
310
- it("takes hidden off even when the data fails to load", async () => {
311
- document.body.setAttribute("hidden", "");
312
- server.fail = "network down";
313
- await captured(async () => {
314
- await mountHub(``, { index: `<h1>Home</h1>` });
315
- });
316
- delete server.fail;
317
- assert.equal(document.body.hasAttribute("hidden"), false);
318
- });
319
- });
@@ -1,12 +0,0 @@
1
- /**
2
- * The row machinery — see docs/testing.md, tier 3.
3
- *
4
- * This is the most intricate code in the framework and the most likely to
5
- * break in ways nobody notices, because what it gets wrong is a row in the
6
- * wrong place rather than an exception.
7
- *
8
- * The last describe holds the two properties. Reconciliation's contract is
9
- * convergence, and two properties state it better than twenty examples.
10
- */
11
- export {};
12
- //# sourceMappingURL=lb-list.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"lb-list.test.d.ts","sourceRoot":"","sources":["../../tests/lb-list.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
@@ -1,339 +0,0 @@
1
- /**
2
- * The row machinery — see docs/testing.md, tier 3.
3
- *
4
- * This is the most intricate code in the framework and the most likely to
5
- * break in ways nobody notices, because what it gets wrong is a row in the
6
- * wrong place rather than an exception.
7
- *
8
- * The last describe holds the two properties. Reconciliation's contract is
9
- * convergence, and two properties state it better than twenty examples.
10
- */
11
- import assert from "node:assert/strict";
12
- import { describe, it } from "node:test";
13
- import { applyList } from "../hub/lb-apply";
14
- import { body } from "./helpers/dom";
15
- import { captured } from "./helpers/console";
16
- /**
17
- * A list scope: the template a developer wrote, and nothing else. No widget,
18
- * because the hub reconciles a list itself and the default placement puts
19
- * rows in front of the template — so the template staying put is part of
20
- * what is under test.
21
- */
22
- function list(template = `<template lb-key="id"><p><span lb-cell="name"></span></p></template>`) {
23
- return body(`<div lb-list="members">${template}</div>`).firstElementChild;
24
- }
25
- /** The same scope, with a widget's placement hook on it. */
26
- function placing(lbPlaceRow, template) {
27
- const scope = list(template);
28
- scope.lbPlaceRow = lbPlaceRow;
29
- return scope;
30
- }
31
- /** The keys of the rows showing, in document order. */
32
- function keys(scope) {
33
- return [...scope.querySelectorAll(`[lb-key-value]`)].map((el) => el.getAttribute("lb-key-value"));
34
- }
35
- /** What each row is showing, in document order. */
36
- function shown(scope) {
37
- return [...scope.querySelectorAll(`[lb-key-value]`)].map((el) => el.textContent.trim());
38
- }
39
- /** A whole set is the array itself; `Array.isArray` is the discriminant. */
40
- const rows = (set) => set;
41
- const patch = (change) => change;
42
- // --- The whole set -------------------------------------------------------
43
- describe("a rows result", () => {
44
- it("clones the template once per row and fills it", () => {
45
- const scope = list();
46
- applyList(scope, rows([
47
- { id: "1", name: "Ada" },
48
- { id: "2", name: "Bab" },
49
- ]));
50
- assert.deepEqual(keys(scope), ["1", "2"]);
51
- assert.deepEqual(shown(scope), ["Ada", "Bab"]);
52
- });
53
- it("carries the key value on the live row", () => {
54
- // lb-key names the key column on the template; lb-key-value is
55
- // stamped on the row with that row's value of it.
56
- const scope = list();
57
- applyList(scope, rows([{ id: "7", name: "Ada" }]));
58
- const row = scope.querySelector("p");
59
- assert.equal(row.getAttribute("lb-key-value"), "7");
60
- });
61
- it("leaves the template where it was, after the rows", () => {
62
- const scope = list();
63
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
64
- assert.equal(scope.lastElementChild.localName, "template");
65
- });
66
- it("decides membership: a key that did not arrive is gone", () => {
67
- const scope = list();
68
- applyList(scope, rows([
69
- { id: "1", name: "Ada" },
70
- { id: "2", name: "Bab" },
71
- ]));
72
- applyList(scope, rows([{ id: "2", name: "Bab" }]));
73
- assert.deepEqual(keys(scope), ["2"]);
74
- });
75
- it("decides order: a row that already exists is placed again", () => {
76
- const scope = list();
77
- applyList(scope, rows([
78
- { id: "1", name: "Ada" },
79
- { id: "2", name: "Bab" },
80
- ]));
81
- applyList(scope, rows([
82
- { id: "2", name: "Bab" },
83
- { id: "1", name: "Ada" },
84
- ]));
85
- assert.deepEqual(keys(scope), ["2", "1"]);
86
- });
87
- it("updates a row it keeps rather than replacing it", () => {
88
- // The row is the same element, so anything a widget put on it survives.
89
- const scope = list();
90
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
91
- const before = scope.querySelector("p");
92
- applyList(scope, rows([{ id: "1", name: "Grace" }]));
93
- assert.equal(scope.querySelector("p"), before, "same element");
94
- assert.deepEqual(shown(scope), ["Grace"]);
95
- });
96
- it("empties the list when the set is empty", () => {
97
- const scope = list();
98
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
99
- applyList(scope, rows([]));
100
- assert.deepEqual(keys(scope), []);
101
- assert.equal(scope.querySelector("template"), scope.firstElementChild);
102
- });
103
- });
104
- // --- Only what changed ---------------------------------------------------
105
- describe("a patch result", () => {
106
- it("adds a row it names", () => {
107
- const scope = list();
108
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
109
- applyList(scope, patch({ rows: [{ id: "2", name: "Bab" }] }));
110
- assert.deepEqual(keys(scope), ["1", "2"]);
111
- });
112
- it("updates a row it names", () => {
113
- const scope = list();
114
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
115
- applyList(scope, patch({ rows: [{ id: "1", name: "Grace" }] }));
116
- assert.deepEqual(shown(scope), ["Grace"]);
117
- });
118
- it("drops a row it names", () => {
119
- const scope = list();
120
- applyList(scope, rows([
121
- { id: "1", name: "Ada" },
122
- { id: "2", name: "Bab" },
123
- ]));
124
- applyList(scope, patch({ drop: ["1"] }));
125
- assert.deepEqual(keys(scope), ["2"]);
126
- });
127
- it("leaves a row it did not name, in contents and in place", () => {
128
- const scope = list();
129
- applyList(scope, rows([
130
- { id: "1", name: "Ada" },
131
- { id: "2", name: "Bab" },
132
- { id: "3", name: "Cai" },
133
- ]));
134
- applyList(scope, patch({ rows: [{ id: "2", name: "Grace" }] }));
135
- assert.deepEqual(keys(scope), ["1", "2", "3"]);
136
- assert.deepEqual(shown(scope), ["Ada", "Grace", "Cai"]);
137
- });
138
- it("does not re-place a row that already exists", () => {
139
- // Today's behavior, and exactly the gap NEEDED-FOR-1.0 names: a row whose
140
- // sort column changed stays where it was. This test states what is true
141
- // now, and is the one that flips when that item closes.
142
- const placed = [];
143
- const scope = placing((el, _row, template) => {
144
- placed.push(el.getAttribute("lb-key-value"));
145
- template.parentElement.insertBefore(el, template);
146
- });
147
- applyList(scope, rows([
148
- { id: "1", name: "Ada" },
149
- { id: "2", name: "Bab" },
150
- ]));
151
- placed.length = 0;
152
- applyList(scope, patch({ rows: [{ id: "1", name: "Grace" }] }));
153
- assert.deepEqual(placed, [], "an existing row is filled, never placed");
154
- });
155
- it("places a row it has not seen before", () => {
156
- const placed = [];
157
- const scope = placing((el, _row, template) => {
158
- placed.push(el.getAttribute("lb-key-value"));
159
- template.parentElement.insertBefore(el, template);
160
- });
161
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
162
- placed.length = 0;
163
- applyList(scope, patch({ rows: [{ id: "2", name: "Bab" }] }));
164
- assert.deepEqual(placed, ["2"]);
165
- });
166
- it("ignores a drop for a row that is not showing", () => {
167
- const scope = list();
168
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
169
- assert.doesNotThrow(() => applyList(scope, patch({ drop: ["9"] })));
170
- assert.deepEqual(keys(scope), ["1"]);
171
- });
172
- it("naming nothing changes nothing", () => {
173
- const scope = list();
174
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
175
- const before = scope.innerHTML;
176
- applyList(scope, patch({}));
177
- assert.equal(scope.innerHTML, before);
178
- });
179
- });
180
- // --- Placement -----------------------------------------------------------
181
- describe("placement", () => {
182
- it("hands the widget the element, the row and the template", () => {
183
- const seen = [];
184
- const scope = placing((el, row, template) => {
185
- seen.push([el.localName, row, template.localName]);
186
- template.parentElement.insertBefore(el, template);
187
- });
188
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
189
- assert.deepEqual(seen, [["p", { id: "1", name: "Ada" }, "template"]]);
190
- });
191
- it("fills the row before the widget places it", () => {
192
- // The attributes are already there when the row upgrades, which is the
193
- // same thing that makes hydration and refresh one operation everywhere
194
- // else. A widget that reads a cell to decide where the row goes — every
195
- // sorting or grouping widget — depends on this ordering.
196
- let atPlacement = "";
197
- const scope = placing((el, _row, template) => {
198
- atPlacement = el.textContent.trim();
199
- template.parentElement.insertBefore(el, template);
200
- });
201
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
202
- assert.equal(atPlacement, "Ada");
203
- });
204
- it("hands the widget a row that is not yet in the document", () => {
205
- let connected = true;
206
- const scope = placing((el, _row, template) => {
207
- connected = el.parentElement !== null;
208
- template.parentElement.insertBefore(el, template);
209
- });
210
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
211
- assert.equal(connected, false, "the row arrives detached");
212
- });
213
- });
214
- // --- How many are showing ------------------------------------------------
215
- describe("the row count", () => {
216
- it("is stamped on the widget", () => {
217
- // The one conditional a page cannot be sent: the server answers with rows
218
- // and says nothing about how many survived reconciliation. It is here so
219
- // that "no members yet" is a stylesheet rule rather than code in every
220
- // list widget.
221
- const scope = list();
222
- applyList(scope, rows([
223
- { id: "1", name: "Ada" },
224
- { id: "2", name: "Bab" },
225
- ]));
226
- assert.equal(scope.getAttribute("lb-row-count"), "2");
227
- });
228
- it("is zero for an empty set", () => {
229
- const scope = list();
230
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
231
- applyList(scope, rows([]));
232
- assert.equal(scope.getAttribute("lb-row-count"), "0");
233
- });
234
- it("reports the same fact the same way after a set and after a patch", () => {
235
- const viaRows = list();
236
- applyList(viaRows, rows([
237
- { id: "1", name: "Ada" },
238
- { id: "2", name: "Bab" },
239
- ]));
240
- const viaPatch = list();
241
- applyList(viaPatch, rows([
242
- { id: "1", name: "Ada" },
243
- { id: "3", name: "Cai" },
244
- ]));
245
- applyList(viaPatch, patch({ rows: [{ id: "2", name: "Bab" }], drop: ["3"] }));
246
- assert.equal(viaPatch.getAttribute("lb-row-count"), viaRows.getAttribute("lb-row-count"));
247
- });
248
- });
249
- // --- What it refuses to guess at ----------------------------------------
250
- describe("applyList reports and stops", () => {
251
- it("says nothing about a scope with no row template", async () => {
252
- // Bound to the list without displaying it: an insert form names the list
253
- // it adds a row to, and has no rows of its own. Not a mistake, so not a
254
- // message.
255
- const scope = body(`<div></div>`).firstElementChild;
256
- const { error } = await captured(() => applyList(scope, rows([{ id: "1" }])));
257
- assert.deepEqual(error, []);
258
- assert.equal(scope.children.length, 0, "nothing was invented");
259
- });
260
- it("when the template does not name the key column", async () => {
261
- const scope = list(`<template><p></p></template>`);
262
- const { error } = await captured(() => applyList(scope, rows([{ id: "1" }])));
263
- assert.match(error.join("\n"), /has no lb-key naming the column that identifies a row/);
264
- assert.deepEqual(keys(scope), []);
265
- });
266
- it("does not stamp a count it could not compute", async () => {
267
- const scope = body(`<div></div>`).firstElementChild;
268
- await captured(() => applyList(scope, rows([{ id: "1" }])));
269
- assert.equal(scope.hasAttribute("lb-row-count"), false);
270
- });
271
- it("when a row has no key cell, and lands its siblings anyway", async () => {
272
- const scope = list();
273
- const { error } = await captured(() => applyList(scope, rows([
274
- { id: "1", name: "Ada" },
275
- { name: "nameless" },
276
- { id: "2", name: "Bab" },
277
- ])));
278
- assert.match(error.join("\n"), /a row for <div> has no 'id' column/);
279
- assert.deepEqual(keys(scope), ["1", "2"]);
280
- });
281
- it("and a keyless row does not take a surviving row down with it", async () => {
282
- // A row with no key contributes no key to the arrived set, so the
283
- // reconciliation that follows must not read its absence as a removal.
284
- const scope = list();
285
- applyList(scope, rows([{ id: "1", name: "Ada" }]));
286
- await captured(() => applyList(scope, rows([{ id: "1", name: "Ada" }, { name: "nameless" }])));
287
- assert.deepEqual(keys(scope), ["1"]);
288
- });
289
- });
290
- // --- The properties ------------------------------------------------------
291
- describe("reconciliation converges", () => {
292
- const SET = [
293
- { id: "1", name: "Ada" },
294
- { id: "2", name: "Bab" },
295
- { id: "3", name: "Cai" },
296
- ];
297
- it("so applying the same set twice is applying it once", () => {
298
- const once = list();
299
- applyList(once, rows(SET));
300
- const twice = list();
301
- applyList(twice, rows(SET));
302
- applyList(twice, rows(SET));
303
- assert.equal(twice.innerHTML, once.innerHTML);
304
- assert.equal(twice.getAttribute("lb-row-count"), once.getAttribute("lb-row-count"));
305
- });
306
- it("so a set reached through any patches is the set reached from empty", () => {
307
- // Every history below ends at SET. A reconciler that leaks state — a row
308
- // it forgot to remove, an order it kept from before, a count it stamped
309
- // from the wrong branch — fails here and nowhere else.
310
- const histories = [
311
- [],
312
- [rows([])],
313
- [rows([{ id: "9", name: "Zed" }])],
314
- [rows(SET.slice().reverse())],
315
- [
316
- rows([{ id: "1", name: "Wrong" }]),
317
- patch({ rows: [{ id: "5", name: "Eve" }] }),
318
- ],
319
- [rows(SET), patch({ drop: ["2"] })],
320
- [
321
- rows(SET),
322
- patch({ rows: [{ id: "3", name: "Changed" }] }),
323
- patch({ drop: ["1"] }),
324
- ],
325
- [patch({ rows: [{ id: "4", name: "Dot" }] }), patch({ drop: ["4"] })],
326
- ];
327
- const fresh = list();
328
- applyList(fresh, rows(SET));
329
- const expected = fresh.innerHTML;
330
- for (const [index, history] of histories.entries()) {
331
- const scope = list();
332
- for (const step of history)
333
- applyList(scope, step);
334
- applyList(scope, rows(SET));
335
- assert.equal(scope.innerHTML, expected, `history ${index} did not converge`);
336
- assert.equal(scope.getAttribute("lb-row-count"), String(SET.length));
337
- }
338
- });
339
- });