@loadbare/app 0.4.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.
- package/LICENSE +201 -0
- package/README.md +95 -0
- package/dist/build/assemble.d.ts +31 -0
- package/dist/build/assemble.d.ts.map +1 -0
- package/dist/build/assemble.js +53 -0
- package/dist/build/cli.d.ts +30 -0
- package/dist/build/cli.d.ts.map +1 -0
- package/dist/build/cli.js +107 -0
- package/dist/build/elements.d.ts +61 -0
- package/dist/build/elements.d.ts.map +1 -0
- package/dist/build/elements.js +158 -0
- package/dist/build/expand.d.ts +45 -0
- package/dist/build/expand.d.ts.map +1 -0
- package/dist/build/expand.js +386 -0
- package/dist/build/format.d.ts +28 -0
- package/dist/build/format.d.ts.map +1 -0
- package/dist/build/format.js +42 -0
- package/dist/build/locations.d.ts +87 -0
- package/dist/build/locations.d.ts.map +1 -0
- package/dist/build/locations.js +173 -0
- package/dist/build/package-root.d.ts +9 -0
- package/dist/build/package-root.d.ts.map +1 -0
- package/dist/build/package-root.js +24 -0
- package/dist/build/pages.d.ts +25 -0
- package/dist/build/pages.d.ts.map +1 -0
- package/dist/build/pages.js +54 -0
- package/dist/build/styles.d.ts +13 -0
- package/dist/build/styles.d.ts.map +1 -0
- package/dist/build/styles.js +18 -0
- package/dist/client.js +522 -0
- package/dist/core/lb-constants.d.ts +23 -0
- package/dist/core/lb-constants.d.ts.map +1 -0
- package/dist/core/lb-constants.js +95 -0
- package/dist/core/lb-types.d.ts +88 -0
- package/dist/core/lb-types.d.ts.map +1 -0
- package/dist/core/lb-types.js +5 -0
- package/dist/demo-static/src/widgets/app-box.d.ts +15 -0
- package/dist/demo-static/src/widgets/app-box.d.ts.map +1 -0
- package/dist/demo-static/src/widgets/app-box.js +19 -0
- package/dist/hub/lb-apply.d.ts +13 -0
- package/dist/hub/lb-apply.d.ts.map +1 -0
- package/dist/hub/lb-apply.js +77 -0
- package/dist/hub/lb-hub.d.ts +2 -0
- package/dist/hub/lb-hub.d.ts.map +1 -0
- package/dist/hub/lb-hub.js +242 -0
- package/dist/hub/lb-rows.d.ts +18 -0
- package/dist/hub/lb-rows.d.ts.map +1 -0
- package/dist/hub/lb-rows.js +106 -0
- package/dist/server/lb-express.d.ts +28 -0
- package/dist/server/lb-express.d.ts.map +1 -0
- package/dist/server/lb-express.js +77 -0
- package/dist/server/lb-server.d.ts +174 -0
- package/dist/server/lb-server.d.ts.map +1 -0
- package/dist/server/lb-server.js +79 -0
- package/dist/tests/assemble.test.d.ts +8 -0
- package/dist/tests/assemble.test.d.ts.map +1 -0
- package/dist/tests/assemble.test.js +51 -0
- package/dist/tests/elements.test.d.ts +8 -0
- package/dist/tests/elements.test.d.ts.map +1 -0
- package/dist/tests/elements.test.js +111 -0
- package/dist/tests/expand.test.d.ts +10 -0
- package/dist/tests/expand.test.d.ts.map +1 -0
- package/dist/tests/expand.test.js +226 -0
- package/dist/tests/fixtures/elements/collision/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/collision/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/collision/elements.js +3 -0
- package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts +2 -0
- package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/collision/widgets/acme-widget.js +1 -0
- package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts +2 -0
- package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/local/widgets/app-box.js +1 -0
- package/dist/tests/fixtures/elements/manifest/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest/elements.js +3 -0
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +3 -0
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +3 -0
- package/dist/tests/golden.test.d.ts +19 -0
- package/dist/tests/golden.test.d.ts.map +1 -0
- package/dist/tests/golden.test.js +60 -0
- package/dist/tests/helpers/console.d.ts +20 -0
- package/dist/tests/helpers/console.d.ts.map +1 -0
- package/dist/tests/helpers/console.js +28 -0
- package/dist/tests/helpers/dom.d.ts +18 -0
- package/dist/tests/helpers/dom.d.ts.map +1 -0
- package/dist/tests/helpers/dom.js +22 -0
- package/dist/tests/helpers/window.d.ts +43 -0
- package/dist/tests/helpers/window.d.ts.map +1 -0
- package/dist/tests/helpers/window.js +78 -0
- package/dist/tests/lb-apply.test.d.ts +8 -0
- package/dist/tests/lb-apply.test.d.ts.map +1 -0
- package/dist/tests/lb-apply.test.js +153 -0
- package/dist/tests/lb-express.test.d.ts +14 -0
- package/dist/tests/lb-express.test.d.ts.map +1 -0
- package/dist/tests/lb-express.test.js +238 -0
- package/dist/tests/lb-input.test.d.ts +9 -0
- package/dist/tests/lb-input.test.d.ts.map +1 -0
- package/dist/tests/lb-input.test.js +78 -0
- package/dist/tests/lb-list.test.d.ts +12 -0
- package/dist/tests/lb-list.test.d.ts.map +1 -0
- package/dist/tests/lb-list.test.js +44 -0
- package/dist/tests/lb-options.test.d.ts +10 -0
- package/dist/tests/lb-options.test.d.ts.map +1 -0
- package/dist/tests/lb-options.test.js +121 -0
- package/dist/tests/lb-picker.test.d.ts +14 -0
- package/dist/tests/lb-picker.test.d.ts.map +1 -0
- package/dist/tests/lb-picker.test.js +59 -0
- package/dist/tests/lb-rows.test.d.ts +12 -0
- package/dist/tests/lb-rows.test.d.ts.map +1 -0
- package/dist/tests/lb-rows.test.js +336 -0
- package/dist/tests/lb-select.test.d.ts +9 -0
- package/dist/tests/lb-select.test.d.ts.map +1 -0
- package/dist/tests/lb-select.test.js +71 -0
- package/dist/tests/lb-server.test.d.ts +9 -0
- package/dist/tests/lb-server.test.d.ts.map +1 -0
- package/dist/tests/lb-server.test.js +495 -0
- package/dist/tests/lb-table.test.d.ts +15 -0
- package/dist/tests/lb-table.test.d.ts.map +1 -0
- package/dist/tests/lb-table.test.js +205 -0
- package/dist/tests/pages.test.d.ts +6 -0
- package/dist/tests/pages.test.d.ts.map +1 -0
- package/dist/tests/pages.test.js +98 -0
- package/dist/tests/styles.test.d.ts +7 -0
- package/dist/tests/styles.test.d.ts.map +1 -0
- package/dist/tests/styles.test.js +73 -0
- package/dist/widgets/index.d.ts +7 -0
- package/dist/widgets/index.d.ts.map +1 -0
- package/dist/widgets/index.js +6 -0
- package/dist/widgets/lb-input.d.ts +2 -0
- package/dist/widgets/lb-input.d.ts.map +1 -0
- package/dist/widgets/lb-input.js +48 -0
- package/dist/widgets/lb-list.d.ts +2 -0
- package/dist/widgets/lb-list.d.ts.map +1 -0
- package/dist/widgets/lb-list.js +17 -0
- package/dist/widgets/lb-options.d.ts +26 -0
- package/dist/widgets/lb-options.d.ts.map +1 -0
- package/dist/widgets/lb-options.js +72 -0
- package/dist/widgets/lb-picker.d.ts +2 -0
- package/dist/widgets/lb-picker.d.ts.map +1 -0
- package/dist/widgets/lb-picker.js +25 -0
- package/dist/widgets/lb-select.d.ts +2 -0
- package/dist/widgets/lb-select.d.ts.map +1 -0
- package/dist/widgets/lb-select.js +43 -0
- package/dist/widgets/lb-table.d.ts +2 -0
- package/dist/widgets/lb-table.d.ts.map +1 -0
- package/dist/widgets/lb-table.js +113 -0
- package/docs/application-chrome.md +36 -0
- package/docs/building-html-pages.md +130 -0
- package/docs/getting-started.md +120 -0
- package/docs/guide.md +1164 -0
- package/docs/hosting.md +218 -0
- package/docs/latent-risks.md +20 -0
- package/docs/theory.md +226 -0
- package/package.json +85 -0
- package/widgets/index.ts +6 -0
- package/widgets/lb-input.html +1 -0
- package/widgets/lb-input.ts +64 -0
- package/widgets/lb-list.html +1 -0
- package/widgets/lb-list.ts +21 -0
- package/widgets/lb-options.html +4 -0
- package/widgets/lb-options.ts +88 -0
- package/widgets/lb-picker.html +7 -0
- package/widgets/lb-picker.ts +27 -0
- package/widgets/lb-select.html +4 -0
- package/widgets/lb-select.ts +55 -0
- package/widgets/lb-table.html +8 -0
- package/widgets/lb-table.ts +126 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lb-table — see docs/testing.md, tier 4.
|
|
3
|
+
*
|
|
4
|
+
* What this widget adds at run time is placement, and only placement:
|
|
5
|
+
* `lb-sort` names the column rows are ordered by, `lb-group` names the
|
|
6
|
+
* column they are sectioned by. Both answers are local, which is the whole
|
|
7
|
+
* argument for the Place callback existing.
|
|
8
|
+
*
|
|
9
|
+
* The markup below is what widgets/lb-table.html expands to once the page
|
|
10
|
+
* has supplied its head and its row template — a real <thead> holding a real
|
|
11
|
+
* <tr>, because expansion unwrapped the templates that carried them past the
|
|
12
|
+
* parser.
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { describe, it } from "node:test";
|
|
16
|
+
import { installWindow, mount } from "./helpers/window";
|
|
17
|
+
installWindow();
|
|
18
|
+
await import("../widgets/lb-table");
|
|
19
|
+
const CELLS = `<td lb-cell="name"></td><td lb-cell="amount"></td>`;
|
|
20
|
+
/** A lb-table whose row template carries the placement attributes given. */
|
|
21
|
+
function table(placement = "") {
|
|
22
|
+
return mount(`<lb-table><table>` +
|
|
23
|
+
`<thead><tr><th>Name</th><th>Amount</th></tr></thead>` +
|
|
24
|
+
`<tbody><template lb-key="id" ${placement}><tr>${CELLS}</tr></template></tbody>` +
|
|
25
|
+
`</table></lb-table>`);
|
|
26
|
+
}
|
|
27
|
+
const rows = (list) => ({ op: "rows", rows: list });
|
|
28
|
+
const patch = (change) => ({ op: "patch", ...change });
|
|
29
|
+
/** The body, row by row: a section heading as its label, a row as its cells. */
|
|
30
|
+
function shown(widget) {
|
|
31
|
+
return [...widget.querySelectorAll("tbody > tr")].map((tr) => tr.hasAttribute("data-group")
|
|
32
|
+
? `— ${tr.textContent.trim()}`
|
|
33
|
+
: [...tr.children].map((td) => td.textContent).join("/"));
|
|
34
|
+
}
|
|
35
|
+
describe("lb-table without placement", () => {
|
|
36
|
+
it("lands rows in the body, in the order sent", () => {
|
|
37
|
+
const widget = table();
|
|
38
|
+
widget.acceptRows(rows([
|
|
39
|
+
{ id: "1", name: "Ada", amount: "10" },
|
|
40
|
+
{ id: "2", name: "Bab", amount: "20" },
|
|
41
|
+
]));
|
|
42
|
+
assert.deepEqual(shown(widget), ["Ada/10", "Bab/20"]);
|
|
43
|
+
});
|
|
44
|
+
it("leaves the head the page wrote alone", () => {
|
|
45
|
+
const widget = table();
|
|
46
|
+
widget.acceptRows(rows([{ id: "1", name: "Ada", amount: "10" }]));
|
|
47
|
+
assert.equal(widget.querySelectorAll("thead tr").length, 1);
|
|
48
|
+
assert.equal(widget.querySelector("thead th").textContent, "Name");
|
|
49
|
+
});
|
|
50
|
+
it("counts its rows for the stylesheet", () => {
|
|
51
|
+
const widget = table();
|
|
52
|
+
widget.acceptRows(rows([{ id: "1", name: "Ada", amount: "10" }]));
|
|
53
|
+
assert.equal(widget.getAttribute("data-rows"), "1");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("lb-table sorting", () => {
|
|
57
|
+
it("orders rows by the column lb-sort names", () => {
|
|
58
|
+
const widget = table(`lb-sort="name"`);
|
|
59
|
+
widget.acceptRows(rows([
|
|
60
|
+
{ id: "1", name: "Cai", amount: "30" },
|
|
61
|
+
{ id: "2", name: "Ada", amount: "10" },
|
|
62
|
+
{ id: "3", name: "Bab", amount: "20" },
|
|
63
|
+
]));
|
|
64
|
+
assert.deepEqual(shown(widget), ["Ada/10", "Bab/20", "Cai/30"]);
|
|
65
|
+
});
|
|
66
|
+
it("puts a patched-in row in its place rather than at the end", () => {
|
|
67
|
+
// A whole set and a single patched row take the same path, which is the
|
|
68
|
+
// reason placement is a callback rather than a branch.
|
|
69
|
+
const widget = table(`lb-sort="name"`);
|
|
70
|
+
widget.acceptRows(rows([
|
|
71
|
+
{ id: "1", name: "Ada", amount: "10" },
|
|
72
|
+
{ id: "3", name: "Cai", amount: "30" },
|
|
73
|
+
]));
|
|
74
|
+
widget.acceptRows(patch({ rows: [{ id: "2", name: "Bab", amount: "20" }] }));
|
|
75
|
+
assert.deepEqual(shown(widget), ["Ada/10", "Bab/20", "Cai/30"]);
|
|
76
|
+
});
|
|
77
|
+
it("leaves a row whose sort column changed where it was", () => {
|
|
78
|
+
// Today's behavior, and the gap NEEDED-FOR-1.0 names: a patch never
|
|
79
|
+
// re-places a row that already exists. The row's contents are current and
|
|
80
|
+
// its position is stale. This test flips when that item closes.
|
|
81
|
+
const widget = table(`lb-sort="name"`);
|
|
82
|
+
widget.acceptRows(rows([
|
|
83
|
+
{ id: "1", name: "Ada", amount: "10" },
|
|
84
|
+
{ id: "2", name: "Bab", amount: "20" },
|
|
85
|
+
]));
|
|
86
|
+
widget.acceptRows(patch({ rows: [{ id: "1", name: "Zed", amount: "10" }] }));
|
|
87
|
+
assert.deepEqual(shown(widget), ["Zed/10", "Bab/20"]);
|
|
88
|
+
});
|
|
89
|
+
it("sorts a whole set again, however it was reached", () => {
|
|
90
|
+
const widget = table(`lb-sort="name"`);
|
|
91
|
+
widget.acceptRows(rows([
|
|
92
|
+
{ id: "1", name: "Ada", amount: "10" },
|
|
93
|
+
{ id: "2", name: "Bab", amount: "20" },
|
|
94
|
+
]));
|
|
95
|
+
widget.acceptRows(patch({ rows: [{ id: "1", name: "Zed", amount: "10" }] }));
|
|
96
|
+
widget.acceptRows(rows([
|
|
97
|
+
{ id: "1", name: "Zed", amount: "10" },
|
|
98
|
+
{ id: "2", name: "Bab", amount: "20" },
|
|
99
|
+
]));
|
|
100
|
+
assert.deepEqual(shown(widget), ["Bab/20", "Zed/10"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("lb-table grouping", () => {
|
|
104
|
+
it("builds a section heading per distinct value", () => {
|
|
105
|
+
const widget = table(`lb-group="dept"`);
|
|
106
|
+
widget.acceptRows(rows([
|
|
107
|
+
{ id: "1", name: "Ada", amount: "10", dept: "Core" },
|
|
108
|
+
{ id: "2", name: "Bab", amount: "20", dept: "Ops" },
|
|
109
|
+
{ id: "3", name: "Cai", amount: "30", dept: "Core" },
|
|
110
|
+
]));
|
|
111
|
+
assert.deepEqual(shown(widget), [
|
|
112
|
+
"— Core",
|
|
113
|
+
"Ada/10",
|
|
114
|
+
"Cai/30",
|
|
115
|
+
"— Ops",
|
|
116
|
+
"Bab/20",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
it("spans the heading across the row the page wrote", () => {
|
|
120
|
+
// How wide the table is, from the row template. Nothing else knows, and
|
|
121
|
+
// nothing had to be told twice.
|
|
122
|
+
const widget = table(`lb-group="dept"`);
|
|
123
|
+
widget.acceptRows(rows([{ id: "1", name: "Ada", amount: "10", dept: "Core" }]));
|
|
124
|
+
const th = widget.querySelector("tr[data-group] th");
|
|
125
|
+
assert.equal(th.colSpan, 2);
|
|
126
|
+
assert.equal(th.getAttribute("scope"), "rowgroup");
|
|
127
|
+
});
|
|
128
|
+
it("removes a section when its last row goes", () => {
|
|
129
|
+
const widget = table(`lb-group="dept"`);
|
|
130
|
+
widget.acceptRows(rows([
|
|
131
|
+
{ id: "1", name: "Ada", amount: "10", dept: "Core" },
|
|
132
|
+
{ id: "2", name: "Bab", amount: "20", dept: "Ops" },
|
|
133
|
+
]));
|
|
134
|
+
widget.acceptRows(patch({ drop: ["2"] }));
|
|
135
|
+
assert.deepEqual(shown(widget), ["— Core", "Ada/10"]);
|
|
136
|
+
});
|
|
137
|
+
it("sorts within a section, not across the table", () => {
|
|
138
|
+
const widget = table(`lb-group="dept" lb-sort="name"`);
|
|
139
|
+
widget.acceptRows(rows([
|
|
140
|
+
{ id: "1", name: "Zed", amount: "10", dept: "Core" },
|
|
141
|
+
{ id: "2", name: "Ada", amount: "20", dept: "Ops" },
|
|
142
|
+
{ id: "3", name: "Bab", amount: "30", dept: "Core" },
|
|
143
|
+
]));
|
|
144
|
+
assert.deepEqual(shown(widget), [
|
|
145
|
+
"— Core",
|
|
146
|
+
"Bab/30",
|
|
147
|
+
"Zed/10",
|
|
148
|
+
"— Ops",
|
|
149
|
+
"Ada/20",
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
it("patches a row into the right place in its section", () => {
|
|
153
|
+
const widget = table(`lb-group="dept" lb-sort="name"`);
|
|
154
|
+
widget.acceptRows(rows([
|
|
155
|
+
{ id: "1", name: "Ada", amount: "10", dept: "Core" },
|
|
156
|
+
{ id: "2", name: "Cai", amount: "30", dept: "Core" },
|
|
157
|
+
{ id: "3", name: "Eve", amount: "50", dept: "Ops" },
|
|
158
|
+
]));
|
|
159
|
+
widget.acceptRows(patch({ rows: [{ id: "4", name: "Bab", amount: "20", dept: "Core" }] }));
|
|
160
|
+
assert.deepEqual(shown(widget), [
|
|
161
|
+
"— Core",
|
|
162
|
+
"Ada/10",
|
|
163
|
+
"Bab/20",
|
|
164
|
+
"Cai/30",
|
|
165
|
+
"— Ops",
|
|
166
|
+
"Eve/50",
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
it("sections a tuple with no value for the column under the empty label", () => {
|
|
170
|
+
const widget = table(`lb-group="dept"`);
|
|
171
|
+
widget.acceptRows(rows([{ id: "1", name: "Ada", amount: "10" }]));
|
|
172
|
+
assert.deepEqual(shown(widget), ["— ", "Ada/10"]);
|
|
173
|
+
});
|
|
174
|
+
it("does not count a section heading as a row", () => {
|
|
175
|
+
// A heading carries no lb-key, so it is scaffolding rather than a row —
|
|
176
|
+
// and the count a stylesheet reads has to say so.
|
|
177
|
+
const widget = table(`lb-group="dept"`);
|
|
178
|
+
widget.acceptRows(rows([
|
|
179
|
+
{ id: "1", name: "Ada", amount: "10", dept: "Core" },
|
|
180
|
+
{ id: "2", name: "Bab", amount: "20", dept: "Ops" },
|
|
181
|
+
]));
|
|
182
|
+
assert.equal(widget.getAttribute("data-rows"), "2");
|
|
183
|
+
});
|
|
184
|
+
it("empties to no rows and no sections", () => {
|
|
185
|
+
const widget = table(`lb-group="dept"`);
|
|
186
|
+
widget.acceptRows(rows([{ id: "1", name: "Ada", amount: "10", dept: "Core" }]));
|
|
187
|
+
widget.acceptRows(rows([]));
|
|
188
|
+
assert.deepEqual(shown(widget), []);
|
|
189
|
+
assert.equal(widget.getAttribute("data-rows"), "0");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe("a table's foot", () => {
|
|
193
|
+
it("is an ordinary scope, untouched by the row machinery", () => {
|
|
194
|
+
// A <tfoot> is a place on the page, not a row the hub delivers. What
|
|
195
|
+
// lands in it is a second projection of the same table, resolved by name
|
|
196
|
+
// like any other — see demo/pages/ledger.html.
|
|
197
|
+
const widget = mount(`<lb-table><table>` +
|
|
198
|
+
`<tbody><template lb-key="id"><tr>${CELLS}</tr></template></tbody>` +
|
|
199
|
+
`<tfoot><tr lb-query="total"><th lb-cell="name"></th><td lb-cell="amount"></td></tr></tfoot>` +
|
|
200
|
+
`</table></lb-table>`);
|
|
201
|
+
widget.acceptRows(rows([{ id: "1", name: "Ada", amount: "10" }]));
|
|
202
|
+
assert.equal(widget.querySelectorAll("tfoot tr").length, 1);
|
|
203
|
+
assert.equal(widget.getAttribute("data-rows"), "1", "the foot is not a row");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.test.d.ts","sourceRoot":"","sources":["../../tests/pages.test.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovering `*.hooks.ts`/`*.queries.ts` pairs (build/locations.ts) and
|
|
3
|
+
* generating the hub registry from them (build/pages.ts).
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { resolveLocations } from "../build/locations";
|
|
11
|
+
import { pagesSource } from "../build/pages";
|
|
12
|
+
const CHROME = `<!doctype html><body><lb-hub><main></main></lb-hub></body>`;
|
|
13
|
+
async function tree(files) {
|
|
14
|
+
const dir = await mkdtemp(path.join(tmpdir(), "loadbare-pages-"));
|
|
15
|
+
for (const [name, content] of Object.entries(files)) {
|
|
16
|
+
const file = path.join(dir, name);
|
|
17
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
18
|
+
await writeFile(file, content, "utf-8");
|
|
19
|
+
}
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
describe("discovering hooks and queries", () => {
|
|
23
|
+
it("pairs a *.hooks.ts and *.queries.ts with the page sharing their basename", async () => {
|
|
24
|
+
const src = await tree({
|
|
25
|
+
"chrome.chrome.html": CHROME,
|
|
26
|
+
"pages/hello.page.html": `<p>hi</p>`,
|
|
27
|
+
"pages/hello.hooks.ts": `export const hooks = {};`,
|
|
28
|
+
"pages/hello.queries.ts": `export const queries = {};`,
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
const { pageSources } = await resolveLocations({
|
|
32
|
+
src,
|
|
33
|
+
out: `${src}/dist`,
|
|
34
|
+
});
|
|
35
|
+
const hello = pageSources.get("hello");
|
|
36
|
+
assert.ok(hello?.hooks?.endsWith("hello.hooks"));
|
|
37
|
+
assert.ok(hello?.queries?.endsWith("hello.queries"));
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await rm(src, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it("allows a page to have only one of the two", async () => {
|
|
44
|
+
const src = await tree({
|
|
45
|
+
"chrome.chrome.html": CHROME,
|
|
46
|
+
"pages/counter.page.html": `<p>0</p>`,
|
|
47
|
+
"pages/counter.queries.ts": `export const queries = {};`,
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
const { pageSources } = await resolveLocations({
|
|
51
|
+
src,
|
|
52
|
+
out: `${src}/dist`,
|
|
53
|
+
});
|
|
54
|
+
const counter = pageSources.get("counter");
|
|
55
|
+
assert.equal(counter?.hooks, undefined);
|
|
56
|
+
assert.ok(counter?.queries?.endsWith("counter.queries"));
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await rm(src, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
it("throws when a hooks or queries file has no matching page", async () => {
|
|
63
|
+
const src = await tree({
|
|
64
|
+
"chrome.chrome.html": CHROME,
|
|
65
|
+
"pages/orphan.hooks.ts": `export const hooks = {};`,
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
await assert.rejects(() => resolveLocations({ src, out: `${src}/dist` }), /no 'orphan\.page\.html'/);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await rm(src, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("pagesSource", () => {
|
|
76
|
+
it("returns undefined when no page has hooks or queries", () => {
|
|
77
|
+
assert.equal(pagesSource([{ name: "hello", file: "/src/hello.page.html" }], new Map(), "/out", "@loadbare/app"), undefined);
|
|
78
|
+
});
|
|
79
|
+
it("generates one import per file found and one entry per page", () => {
|
|
80
|
+
const pages = [
|
|
81
|
+
{ name: "hello", file: "/src/hello.page.html" },
|
|
82
|
+
{ name: "counter", file: "/src/counter.page.html" },
|
|
83
|
+
];
|
|
84
|
+
const pageSources = new Map([
|
|
85
|
+
["hello", { hooks: "/src/hello.hooks", queries: "/src/hello.queries" }],
|
|
86
|
+
["counter", { queries: "/src/counter.queries" }],
|
|
87
|
+
]);
|
|
88
|
+
const out = pagesSource(pages, pageSources, "/out", "@loadbare/app");
|
|
89
|
+
assert.match(out, /import \{ createHub \} from "@loadbare\/app\/server";/);
|
|
90
|
+
assert.match(out, /import \{ hooks as page_hello_Hooks \} from "\.\.\/src\/hello\.hooks";/);
|
|
91
|
+
assert.match(out, /import \{ queries as page_hello_Queries \} from "\.\.\/src\/hello\.queries";/);
|
|
92
|
+
assert.match(out, /import \{ queries as page_counter_Queries \} from "\.\.\/src\/counter\.queries";/);
|
|
93
|
+
assert.doesNotMatch(out, /page_counter_Hooks/);
|
|
94
|
+
assert.match(out, /"hello": \{ hooks: page_hello_Hooks, queries: page_hello_Queries \}/);
|
|
95
|
+
assert.match(out, /"counter": \{ hooks: \{\}, queries: page_counter_Queries \}/);
|
|
96
|
+
assert.match(out, /export const hub = createHub\(\{/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"styles.test.d.ts","sourceRoot":"","sources":["../../tests/styles.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovering `.css` files (build/locations.ts) and concatenating them
|
|
3
|
+
* (build/styles.ts) — see that file's header for what ordering guarantee is
|
|
4
|
+
* and is not made.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { resolveLocations } from "../build/locations";
|
|
12
|
+
import { stylesSource } from "../build/styles";
|
|
13
|
+
const CHROME = `<!doctype html><body><lb-hub><main></main></lb-hub></body>`;
|
|
14
|
+
async function tree(files) {
|
|
15
|
+
const dir = await mkdtemp(path.join(tmpdir(), "loadbare-styles-"));
|
|
16
|
+
for (const [name, content] of Object.entries(files)) {
|
|
17
|
+
const file = path.join(dir, name);
|
|
18
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
19
|
+
await writeFile(file, content, "utf-8");
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
describe("discovering CSS", () => {
|
|
24
|
+
it("finds a .css file anywhere under src", async () => {
|
|
25
|
+
const src = await tree({
|
|
26
|
+
"chrome.chrome.html": CHROME,
|
|
27
|
+
"widgets/app-box.css": ".app-box {}",
|
|
28
|
+
"styles/00-reset.css": "* { margin: 0; }",
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
const { cssFiles } = await resolveLocations({ src, out: `${src}/dist` });
|
|
32
|
+
assert.equal(cssFiles.length, 2);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await rm(src, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it("orders by filename, not by directory, so a global sorts first by naming itself 00-", async () => {
|
|
39
|
+
const src = await tree({
|
|
40
|
+
"chrome.chrome.html": CHROME,
|
|
41
|
+
"widgets/app-box.css": ".app-box {}",
|
|
42
|
+
"00-reset.css": "* { margin: 0; }",
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
const { cssFiles } = await resolveLocations({ src, out: `${src}/dist` });
|
|
46
|
+
assert.deepEqual(cssFiles.map((f) => path.basename(f)), ["00-reset.css", "app-box.css"]);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await rm(src, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("stylesSource", () => {
|
|
54
|
+
it("returns undefined for no files", async () => {
|
|
55
|
+
assert.equal(await stylesSource([]), undefined);
|
|
56
|
+
});
|
|
57
|
+
it("concatenates file contents in the order given", async () => {
|
|
58
|
+
const src = await tree({
|
|
59
|
+
"a.css": "a {}",
|
|
60
|
+
"b.css": "b {}",
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
const out = await stylesSource([
|
|
64
|
+
path.join(src, "a.css"),
|
|
65
|
+
path.join(src, "b.css"),
|
|
66
|
+
]);
|
|
67
|
+
assert.equal(out, "a {}\nb {}");
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await rm(src, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../widgets/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,CAAC;AACpB,OAAO,aAAa,CAAC;AACrB,OAAO,WAAW,CAAC;AACnB,OAAO,cAAc,CAAC;AACtB,OAAO,YAAY,CAAC;AACpB,OAAO,aAAa,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lb-input.d.ts","sourceRoot":"","sources":["../../widgets/lb-input.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { ATTR_CELL, ATTR_KEY, ATTR_QUERY, ATTR_VALUE, LB_EVENT_NAME, } from "../core/lb-constants";
|
|
3
|
+
/**
|
|
4
|
+
* A leaf cell wrapping an <input>. The hub writes one attribute name for
|
|
5
|
+
* every widget type, so the value lands in `lb-value` and the widget
|
|
6
|
+
* forwards it to the control it owns.
|
|
7
|
+
*
|
|
8
|
+
* The other direction of the same cell: on change it sends `cell-change`,
|
|
9
|
+
* addressed by the same lb-query/lb-key/lb-cell coordinates the value
|
|
10
|
+
* arrived on. A `readonly` input never fires `change` from user input, so a
|
|
11
|
+
* readonly cell sends nothing on its own.
|
|
12
|
+
*/
|
|
13
|
+
class LbInput extends HTMLElement {
|
|
14
|
+
static observedAttributes = [ATTR_VALUE];
|
|
15
|
+
attributeChangedCallback(_name, _old, value) {
|
|
16
|
+
const input = this.querySelector("input");
|
|
17
|
+
if (!input) {
|
|
18
|
+
console.error(`lb-input: no <input> to receive the value`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
input.value = value;
|
|
22
|
+
}
|
|
23
|
+
connectedCallback() {
|
|
24
|
+
this.addEventListener("change", () => {
|
|
25
|
+
const input = this.querySelector("input");
|
|
26
|
+
if (!input) {
|
|
27
|
+
console.error(`lb-input: change with no <input>, ignoring`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const query = this.closest(`[${ATTR_QUERY}]`)?.getAttribute(ATTR_QUERY);
|
|
31
|
+
const key = this.closest(`[${ATTR_KEY}]`)?.getAttribute(ATTR_KEY);
|
|
32
|
+
const cell = this.getAttribute(ATTR_CELL);
|
|
33
|
+
if (!query || !key || !cell) {
|
|
34
|
+
console.error(`lb-input: change with no query/key/cell coordinates, ignoring`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const detail = {
|
|
38
|
+
op: "cell-change",
|
|
39
|
+
query,
|
|
40
|
+
key,
|
|
41
|
+
cell,
|
|
42
|
+
value: input.value,
|
|
43
|
+
};
|
|
44
|
+
this.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
customElements.define("lb-input", LbInput);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lb-list.d.ts","sourceRoot":"","sources":["../../widgets/lb-list.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { applyRows } from "../hub/lb-rows";
|
|
3
|
+
/**
|
|
4
|
+
* The plain repeater: whatever the page author wrote inside is repeated once
|
|
5
|
+
* per tuple, in the order the server sent.
|
|
6
|
+
*
|
|
7
|
+
* It exists because a projection has to land on a widget, and because a
|
|
8
|
+
* `<table>` cannot be one — a custom element written inside `<tbody>` is
|
|
9
|
+
* discarded by the parser, so the widget goes around the table and the
|
|
10
|
+
* template goes inside it, where the content model already allows it.
|
|
11
|
+
*/
|
|
12
|
+
class LbList extends HTMLElement {
|
|
13
|
+
acceptRows(result) {
|
|
14
|
+
applyRows(this, result);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
customElements.define("lb-list", LbList);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Projection, HubRowHost } from "../core/lb-types";
|
|
2
|
+
/**
|
|
3
|
+
* A `<select>` whose choices come from a query, and the case that shows why
|
|
4
|
+
* placement belongs to the widget rather than to the framework.
|
|
5
|
+
*
|
|
6
|
+
* The rows arrive flat and keyed. Nothing on the wire knows about
|
|
7
|
+
* `<optgroup>`, and nothing needs to: a group is a column like any other, and
|
|
8
|
+
* turning a repeated column value into an element is exactly the local
|
|
9
|
+
* knowledge a widget has and the hub does not. A table wanting section
|
|
10
|
+
* headings would read the same `lb-group` and build something else.
|
|
11
|
+
*
|
|
12
|
+
* The identity of a row is the value of the option, so `lb-key` supplies
|
|
13
|
+
* both and the page declares it once.
|
|
14
|
+
*
|
|
15
|
+
* Exported because `lb-picker` is this widget with its row template
|
|
16
|
+
* supplied by the definition instead of by the page. Nothing about the
|
|
17
|
+
* behavior changes, so nothing about the behavior is written twice.
|
|
18
|
+
*/
|
|
19
|
+
export declare class LbOptions extends HTMLElement implements HubRowHost {
|
|
20
|
+
acceptRows(result: Projection): void;
|
|
21
|
+
private place;
|
|
22
|
+
/** The group with this label, or a new one in front of the template. */
|
|
23
|
+
private groupFor;
|
|
24
|
+
connectedCallback(): void;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=lb-options.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lb-options.d.ts","sourceRoot":"","sources":["../../widgets/lb-options.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,UAAU,EAEV,UAAU,EACX,MAAM,kBAAkB,CAAC;AAG1B;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,SAAU,SAAQ,WAAY,YAAW,UAAU;IAC9D,UAAU,CAAC,MAAM,EAAE,UAAU;IAa7B,OAAO,CAAC,KAAK,CAQX;IAEF,wEAAwE;IACxE,OAAO,CAAC,QAAQ;IAUhB,iBAAiB;CAkBlB"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { ATTR_ACTION, ATTR_GROUP, ATTR_KEY, LB_EVENT_NAME, } from "../core/lb-constants";
|
|
3
|
+
import { applyRows } from "../hub/lb-rows";
|
|
4
|
+
/**
|
|
5
|
+
* A `<select>` whose choices come from a query, and the case that shows why
|
|
6
|
+
* placement belongs to the widget rather than to the framework.
|
|
7
|
+
*
|
|
8
|
+
* The rows arrive flat and keyed. Nothing on the wire knows about
|
|
9
|
+
* `<optgroup>`, and nothing needs to: a group is a column like any other, and
|
|
10
|
+
* turning a repeated column value into an element is exactly the local
|
|
11
|
+
* knowledge a widget has and the hub does not. A table wanting section
|
|
12
|
+
* headings would read the same `lb-group` and build something else.
|
|
13
|
+
*
|
|
14
|
+
* The identity of a row is the value of the option, so `lb-key` supplies
|
|
15
|
+
* both and the page declares it once.
|
|
16
|
+
*
|
|
17
|
+
* Exported because `lb-picker` is this widget with its row template
|
|
18
|
+
* supplied by the definition instead of by the page. Nothing about the
|
|
19
|
+
* behavior changes, so nothing about the behavior is written twice.
|
|
20
|
+
*/
|
|
21
|
+
export class LbOptions extends HTMLElement {
|
|
22
|
+
acceptRows(result) {
|
|
23
|
+
applyRows(this, result, this.place);
|
|
24
|
+
// The identity of the row is the value of the option, so nothing on the
|
|
25
|
+
// page has to say so twice.
|
|
26
|
+
for (const option of this.querySelectorAll(`option[${ATTR_KEY}]`)) {
|
|
27
|
+
option.setAttribute("value", option.getAttribute(ATTR_KEY));
|
|
28
|
+
}
|
|
29
|
+
// A group is derived, so it goes when its last row does.
|
|
30
|
+
for (const group of this.querySelectorAll("optgroup")) {
|
|
31
|
+
if (!group.querySelector("option"))
|
|
32
|
+
group.remove();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
place = (row, tuple, template) => {
|
|
36
|
+
const select = this.querySelector("select");
|
|
37
|
+
const groupCell = template.getAttribute(ATTR_GROUP);
|
|
38
|
+
if (!groupCell) {
|
|
39
|
+
select.insertBefore(row, template);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.groupFor(select, template, tuple[groupCell] ?? "").append(row);
|
|
43
|
+
};
|
|
44
|
+
/** The group with this label, or a new one in front of the template. */
|
|
45
|
+
groupFor(select, template, label) {
|
|
46
|
+
for (const group of select.querySelectorAll("optgroup")) {
|
|
47
|
+
if (group.label === label)
|
|
48
|
+
return group;
|
|
49
|
+
}
|
|
50
|
+
const group = document.createElement("optgroup");
|
|
51
|
+
group.label = label;
|
|
52
|
+
select.insertBefore(group, template);
|
|
53
|
+
return group;
|
|
54
|
+
}
|
|
55
|
+
connectedCallback() {
|
|
56
|
+
this.addEventListener("change", () => {
|
|
57
|
+
const select = this.querySelector("select");
|
|
58
|
+
if (!select) {
|
|
59
|
+
console.error(`lb-options: change with no <select>, ignoring`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const name = this.getAttribute(ATTR_ACTION);
|
|
63
|
+
if (!name) {
|
|
64
|
+
console.error(`lb-options: no ${ATTR_ACTION}, nothing to send`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const detail = { op: "action", name, value: select.value };
|
|
68
|
+
this.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
customElements.define("lb-options", LbOptions);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lb-picker.d.ts","sourceRoot":"","sources":["../../widgets/lb-picker.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { LbOptions } from "./lb-options";
|
|
3
|
+
/**
|
|
4
|
+
* The same `<select>` as `lb-options`, with the row template supplied by
|
|
5
|
+
* the definition instead of by the page.
|
|
6
|
+
*
|
|
7
|
+
* A row template is markup, so writing it is the page author's job — until
|
|
8
|
+
* every row is one option and the only thing that varies is which columns it
|
|
9
|
+
* reads. Those are words, and words are parameters: `exp-key`, `exp-cell` and
|
|
10
|
+
* `exp-group` fill the placeholders in the template the definition already
|
|
11
|
+
* has, at build time, and the page writes one tag with no children.
|
|
12
|
+
*
|
|
13
|
+
* It is the same trade as anywhere else in expansion. A page author who wants
|
|
14
|
+
* a second element in the row, or an option built from two columns, writes
|
|
15
|
+
* `lb-options` and the template themselves. This one is for when they do
|
|
16
|
+
* not.
|
|
17
|
+
*
|
|
18
|
+
* The behavior is inherited whole, because it is the same behavior: rows land
|
|
19
|
+
* in groups, the key becomes the value, and a change sends the declared
|
|
20
|
+
* action. A distinct class exists only because `customElements.define`
|
|
21
|
+
* requires one constructor per name.
|
|
22
|
+
*/
|
|
23
|
+
class LbPicker extends LbOptions {
|
|
24
|
+
}
|
|
25
|
+
customElements.define("lb-picker", LbPicker);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lb-select.d.ts","sourceRoot":"","sources":["../../widgets/lb-select.ts"],"names":[],"mappings":""}
|