@fun-land/fun-web 0.2.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.md +9 -0
- package/README.md +515 -0
- package/coverage/clover.xml +136 -0
- package/coverage/coverage-final.json +4 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/dom.ts.html +961 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +146 -0
- package/coverage/lcov-report/mount.ts.html +202 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +196 -0
- package/coverage/lcov-report/state.ts.html +91 -0
- package/coverage/lcov.info +260 -0
- package/dist/esm/src/dom.d.ts +85 -0
- package/dist/esm/src/dom.js +207 -0
- package/dist/esm/src/dom.js.map +1 -0
- package/dist/esm/src/index.d.ts +7 -0
- package/dist/esm/src/index.js +5 -0
- package/dist/esm/src/index.js.map +1 -0
- package/dist/esm/src/mount.d.ts +21 -0
- package/dist/esm/src/mount.js +27 -0
- package/dist/esm/src/mount.js.map +1 -0
- package/dist/esm/src/state.d.ts +2 -0
- package/dist/esm/src/state.js +3 -0
- package/dist/esm/src/state.js.map +1 -0
- package/dist/esm/src/types.d.ts +3 -0
- package/dist/esm/src/types.js +3 -0
- package/dist/esm/src/types.js.map +1 -0
- package/dist/esm/tsconfig.publish.tsbuildinfo +1 -0
- package/dist/src/dom.d.ts +85 -0
- package/dist/src/dom.js +224 -0
- package/dist/src/dom.js.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +24 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mount.d.ts +21 -0
- package/dist/src/mount.js +31 -0
- package/dist/src/mount.js.map +1 -0
- package/dist/src/state.d.ts +2 -0
- package/dist/src/state.js +7 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/types.d.ts +3 -0
- package/dist/src/types.js +4 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.publish.tsbuildinfo +1 -0
- package/eslint.config.js +54 -0
- package/examples/README.md +67 -0
- package/examples/counter/bundle.js +219 -0
- package/examples/counter/counter.ts +112 -0
- package/examples/counter/index.html +44 -0
- package/examples/todo-app/Todo.ts +79 -0
- package/examples/todo-app/index.html +142 -0
- package/examples/todo-app/todo-app.ts +120 -0
- package/examples/todo-app/todo-bundle.js +410 -0
- package/jest.config.js +5 -0
- package/package.json +49 -0
- package/src/dom.test.ts +768 -0
- package/src/dom.ts +296 -0
- package/src/index.ts +25 -0
- package/src/mount.test.ts +220 -0
- package/src/mount.ts +39 -0
- package/src/state.test.ts +225 -0
- package/src/state.ts +2 -0
- package/src/types.ts +9 -0
- package/tsconfig.json +16 -0
- package/tsconfig.publish.json +6 -0
- package/wip/hx-magic-properties-plan.md +575 -0
- package/wip/next.md +22 -0
package/src/dom.test.ts
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import {
|
|
2
|
+
h,
|
|
3
|
+
text,
|
|
4
|
+
attr,
|
|
5
|
+
addClass,
|
|
6
|
+
removeClass,
|
|
7
|
+
on,
|
|
8
|
+
pipeEndo,
|
|
9
|
+
keyedChildren,
|
|
10
|
+
$,
|
|
11
|
+
$$,
|
|
12
|
+
} from "./dom";
|
|
13
|
+
import { FunState, funState } from "./state";
|
|
14
|
+
|
|
15
|
+
describe("h()", () => {
|
|
16
|
+
it("should create an element", () => {
|
|
17
|
+
const el = h("div");
|
|
18
|
+
expect(el.tagName).toBe("DIV");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should set properties", () => {
|
|
22
|
+
const el = h("div", { id: "test", className: "foo" });
|
|
23
|
+
expect(el.id).toBe("test");
|
|
24
|
+
expect(el.className).toBe("foo");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should set attributes with dashes", () => {
|
|
28
|
+
const el = h("div", { "data-test": "value", "aria-label": "test" });
|
|
29
|
+
expect(el.getAttribute("data-test")).toBe("value");
|
|
30
|
+
expect(el.getAttribute("aria-label")).toBe("test");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should set textContent", () => {
|
|
34
|
+
const el = h("div", { textContent: "Hello" });
|
|
35
|
+
expect(el.textContent).toBe("Hello");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should append string children", () => {
|
|
39
|
+
const el = h("div", null, "Hello");
|
|
40
|
+
expect(el.textContent).toBe("Hello");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should append number children", () => {
|
|
44
|
+
const el = h("div", null, 42);
|
|
45
|
+
expect(el.textContent).toBe("42");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should append element children", () => {
|
|
49
|
+
const child = h("span", null, "child");
|
|
50
|
+
const el = h("div", null, child);
|
|
51
|
+
expect(el.children.length).toBe(1);
|
|
52
|
+
expect(el.children[0]).toBe(child);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should append array of children", () => {
|
|
56
|
+
const el = h("div", null, [h("span", null, "one"), h("span", null, "two")]);
|
|
57
|
+
expect(el.children.length).toBe(2);
|
|
58
|
+
expect(el.textContent).toBe("onetwo");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should flatten nested arrays", () => {
|
|
62
|
+
const el = h("div", null, [
|
|
63
|
+
h("span", null, "one"),
|
|
64
|
+
[h("span", null, "two"), h("span", null, "three")] as any,
|
|
65
|
+
]);
|
|
66
|
+
expect(el.children.length).toBe(3);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should skip null and undefined children", () => {
|
|
70
|
+
const el = h("div", null, [
|
|
71
|
+
h("span", null, "one"),
|
|
72
|
+
null,
|
|
73
|
+
undefined,
|
|
74
|
+
h("span", null, "two"),
|
|
75
|
+
]);
|
|
76
|
+
expect(el.children.length).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should attach event listeners", () => {
|
|
80
|
+
const handler = jest.fn();
|
|
81
|
+
const el = h("button", { onclick: handler });
|
|
82
|
+
el.click();
|
|
83
|
+
expect(handler).toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should handle multiple event types", () => {
|
|
87
|
+
const clickHandler = jest.fn();
|
|
88
|
+
const mouseoverHandler = jest.fn();
|
|
89
|
+
const el = h("button", {
|
|
90
|
+
onclick: clickHandler,
|
|
91
|
+
onmouseover: mouseoverHandler,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
el.click();
|
|
95
|
+
expect(clickHandler).toHaveBeenCalled();
|
|
96
|
+
|
|
97
|
+
el.dispatchEvent(new MouseEvent("mouseover"));
|
|
98
|
+
expect(mouseoverHandler).toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should skip null and undefined attributes", () => {
|
|
102
|
+
const el = h("div", {
|
|
103
|
+
id: "test",
|
|
104
|
+
"data-foo": null,
|
|
105
|
+
"data-bar": undefined,
|
|
106
|
+
className: "valid",
|
|
107
|
+
});
|
|
108
|
+
expect(el.id).toBe("test");
|
|
109
|
+
expect(el.className).toBe("valid");
|
|
110
|
+
expect(el.getAttribute("data-foo")).toBeNull();
|
|
111
|
+
expect(el.getAttribute("data-bar")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("text()", () => {
|
|
116
|
+
it("should set text content", () => {
|
|
117
|
+
const el = document.createElement("div");
|
|
118
|
+
text("Hello")(el);
|
|
119
|
+
expect(el.textContent).toBe("Hello");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should return element for chaining", () => {
|
|
123
|
+
const el = document.createElement("div");
|
|
124
|
+
const result = text("Hello")(el);
|
|
125
|
+
expect(result).toBe(el);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should convert numbers to strings", () => {
|
|
129
|
+
const el = document.createElement("div");
|
|
130
|
+
text(42)(el);
|
|
131
|
+
expect(el.textContent).toBe("42");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("attr()", () => {
|
|
136
|
+
it("should set attribute", () => {
|
|
137
|
+
const el = document.createElement("div");
|
|
138
|
+
attr("data-test", "value")(el);
|
|
139
|
+
expect(el.getAttribute("data-test")).toBe("value");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should return element for chaining", () => {
|
|
143
|
+
const el = document.createElement("div");
|
|
144
|
+
const result = attr("data-test", "value")(el);
|
|
145
|
+
expect(result).toBe(el);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("attrs()", () => {
|
|
150
|
+
it("should set multiple attributes", () => {
|
|
151
|
+
const el = document.createElement("div");
|
|
152
|
+
const { attrs } = require("./dom");
|
|
153
|
+
attrs({ "data-test": "value", "aria-label": "Test" })(el);
|
|
154
|
+
expect(el.getAttribute("data-test")).toBe("value");
|
|
155
|
+
expect(el.getAttribute("aria-label")).toBe("Test");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should return element for chaining", () => {
|
|
159
|
+
const el = document.createElement("div");
|
|
160
|
+
const { attrs } = require("./dom");
|
|
161
|
+
const result = attrs({ "data-test": "value" })(el);
|
|
162
|
+
expect(result).toBe(el);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("addClass()", () => {
|
|
167
|
+
it("should add a single class", () => {
|
|
168
|
+
const el = document.createElement("div");
|
|
169
|
+
addClass("foo")(el);
|
|
170
|
+
expect(el.classList.contains("foo")).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should add multiple classes", () => {
|
|
174
|
+
const el = document.createElement("div");
|
|
175
|
+
addClass("foo", "bar")(el);
|
|
176
|
+
expect(el.classList.contains("foo")).toBe(true);
|
|
177
|
+
expect(el.classList.contains("bar")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should return element for chaining", () => {
|
|
181
|
+
const el = document.createElement("div");
|
|
182
|
+
const result = addClass("foo")(el);
|
|
183
|
+
expect(result).toBe(el);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("removeClass()", () => {
|
|
188
|
+
it("should remove a class", () => {
|
|
189
|
+
const el = document.createElement("div");
|
|
190
|
+
el.classList.add("foo", "bar");
|
|
191
|
+
removeClass("foo")(el);
|
|
192
|
+
expect(el.classList.contains("foo")).toBe(false);
|
|
193
|
+
expect(el.classList.contains("bar")).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should return element for chaining", () => {
|
|
197
|
+
const el = document.createElement("div");
|
|
198
|
+
const result = removeClass("foo")(el);
|
|
199
|
+
expect(result).toBe(el);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("toggleClass()", () => {
|
|
204
|
+
it("should toggle a class on", () => {
|
|
205
|
+
const el = document.createElement("div");
|
|
206
|
+
const { toggleClass } = require("./dom");
|
|
207
|
+
toggleClass("foo")(el);
|
|
208
|
+
expect(el.classList.contains("foo")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should toggle a class off", () => {
|
|
212
|
+
const el = document.createElement("div");
|
|
213
|
+
const { toggleClass } = require("./dom");
|
|
214
|
+
el.classList.add("foo");
|
|
215
|
+
toggleClass("foo")(el);
|
|
216
|
+
expect(el.classList.contains("foo")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should force add with true", () => {
|
|
220
|
+
const el = document.createElement("div");
|
|
221
|
+
const { toggleClass } = require("./dom");
|
|
222
|
+
toggleClass("foo", true)(el);
|
|
223
|
+
expect(el.classList.contains("foo")).toBe(true);
|
|
224
|
+
toggleClass("foo", true)(el);
|
|
225
|
+
expect(el.classList.contains("foo")).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should force remove with false", () => {
|
|
229
|
+
const el = document.createElement("div");
|
|
230
|
+
const { toggleClass } = require("./dom");
|
|
231
|
+
el.classList.add("foo");
|
|
232
|
+
toggleClass("foo", false)(el);
|
|
233
|
+
expect(el.classList.contains("foo")).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should return element for chaining", () => {
|
|
237
|
+
const el = document.createElement("div");
|
|
238
|
+
const { toggleClass } = require("./dom");
|
|
239
|
+
const result = toggleClass("foo")(el);
|
|
240
|
+
expect(result).toBe(el);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("append()", () => {
|
|
245
|
+
it("should append a single child", () => {
|
|
246
|
+
const parent = document.createElement("div");
|
|
247
|
+
const child = document.createElement("span");
|
|
248
|
+
const { append } = require("./dom");
|
|
249
|
+
append(child)(parent);
|
|
250
|
+
expect(parent.children.length).toBe(1);
|
|
251
|
+
expect(parent.children[0]).toBe(child);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should append multiple children", () => {
|
|
255
|
+
const parent = document.createElement("div");
|
|
256
|
+
const child1 = document.createElement("span");
|
|
257
|
+
const child2 = document.createElement("p");
|
|
258
|
+
const { append } = require("./dom");
|
|
259
|
+
append(child1, child2)(parent);
|
|
260
|
+
expect(parent.children.length).toBe(2);
|
|
261
|
+
expect(parent.children[0]).toBe(child1);
|
|
262
|
+
expect(parent.children[1]).toBe(child2);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should return element for chaining", () => {
|
|
266
|
+
const parent = document.createElement("div");
|
|
267
|
+
const child = document.createElement("span");
|
|
268
|
+
const { append } = require("./dom");
|
|
269
|
+
const result = append(child)(parent);
|
|
270
|
+
expect(result).toBe(parent);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("bindProperty()", () => {
|
|
275
|
+
it("should set initial property value from state", () => {
|
|
276
|
+
const el = document.createElement("input") as HTMLInputElement;
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const { bindProperty } = require("./dom");
|
|
279
|
+
|
|
280
|
+
const state = funState("hello");
|
|
281
|
+
bindProperty(el, "value", state, controller.signal);
|
|
282
|
+
|
|
283
|
+
expect(el.value).toBe("hello");
|
|
284
|
+
controller.abort();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should update property when state changes", () => {
|
|
288
|
+
const el = document.createElement("input") as HTMLInputElement;
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const { bindProperty } = require("./dom");
|
|
291
|
+
|
|
292
|
+
const state = funState("hello");
|
|
293
|
+
bindProperty(el, "value", state, controller.signal);
|
|
294
|
+
|
|
295
|
+
expect(el.value).toBe("hello");
|
|
296
|
+
|
|
297
|
+
state.set("world");
|
|
298
|
+
expect(el.value).toBe("world");
|
|
299
|
+
|
|
300
|
+
controller.abort();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("should stop updating after signal aborts", () => {
|
|
304
|
+
const el = document.createElement("input") as HTMLInputElement;
|
|
305
|
+
const controller = new AbortController();
|
|
306
|
+
const { bindProperty } = require("./dom");
|
|
307
|
+
|
|
308
|
+
const state = funState("hello");
|
|
309
|
+
bindProperty(el, "value", state, controller.signal);
|
|
310
|
+
|
|
311
|
+
controller.abort();
|
|
312
|
+
|
|
313
|
+
state.set("world");
|
|
314
|
+
expect(el.value).toBe("hello"); // Should not update after abort
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("should return element for chaining", () => {
|
|
318
|
+
const el = document.createElement("input") as HTMLInputElement;
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const { bindProperty } = require("./dom");
|
|
321
|
+
|
|
322
|
+
const state = funState("hello");
|
|
323
|
+
const result = bindProperty(el, "value", state, controller.signal);
|
|
324
|
+
|
|
325
|
+
expect(result).toBe(el);
|
|
326
|
+
controller.abort();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("on()", () => {
|
|
331
|
+
it("should attach event listener with signal", () => {
|
|
332
|
+
const el = document.createElement("button");
|
|
333
|
+
const handler = jest.fn();
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
|
|
336
|
+
on(el, "click", handler, controller.signal);
|
|
337
|
+
el.click();
|
|
338
|
+
|
|
339
|
+
expect(handler).toHaveBeenCalled();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should cleanup listener on abort", () => {
|
|
343
|
+
const el = document.createElement("button");
|
|
344
|
+
const handler = jest.fn();
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
|
|
347
|
+
on(el, "click", handler, controller.signal);
|
|
348
|
+
controller.abort();
|
|
349
|
+
el.click();
|
|
350
|
+
|
|
351
|
+
expect(handler).not.toHaveBeenCalled();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should return element for chaining", () => {
|
|
355
|
+
const el = document.createElement("button");
|
|
356
|
+
const controller = new AbortController();
|
|
357
|
+
const result = on(el, "click", () => {}, controller.signal);
|
|
358
|
+
expect(result).toBe(el);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("pipeEndo()", () => {
|
|
363
|
+
it("should apply functions in order", () => {
|
|
364
|
+
const el = document.createElement("div");
|
|
365
|
+
const result = pipeEndo(
|
|
366
|
+
text("Hello"),
|
|
367
|
+
addClass("foo", "bar"),
|
|
368
|
+
attr("data-test", "value")
|
|
369
|
+
)(el);
|
|
370
|
+
|
|
371
|
+
expect(result).toBe(el);
|
|
372
|
+
expect(el.textContent).toBe("Hello");
|
|
373
|
+
expect(el.classList.contains("foo")).toBe(true);
|
|
374
|
+
expect(el.classList.contains("bar")).toBe(true);
|
|
375
|
+
expect(el.getAttribute("data-test")).toBe("value");
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
type Keyed = { key: string };
|
|
380
|
+
type Item = Keyed & { label: string };
|
|
381
|
+
|
|
382
|
+
function makeAbortSignal(): {
|
|
383
|
+
controller: AbortController;
|
|
384
|
+
signal: AbortSignal;
|
|
385
|
+
} {
|
|
386
|
+
const controller = new AbortController();
|
|
387
|
+
return { controller, signal: controller.signal };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Because the renderRow above can't reliably read the key from itemState in this harness,
|
|
392
|
+
* we’ll make a dedicated renderRow for tests that receives the key by closure from the list.
|
|
393
|
+
*
|
|
394
|
+
* This version doesn't require list.focus, so it's robust.
|
|
395
|
+
*/
|
|
396
|
+
function setupKeyedChildrenWithKeyAwareRenderer(
|
|
397
|
+
parent: Element,
|
|
398
|
+
signal: AbortSignal,
|
|
399
|
+
listState: { get: () => Item[]; subscribe: FunState<Item[]>["subscribe"] }
|
|
400
|
+
) {
|
|
401
|
+
const mountCountByKey = new Map<string, number>();
|
|
402
|
+
const abortCountByKey = new Map<string, number>();
|
|
403
|
+
const elByKey = new Map<string, Element>();
|
|
404
|
+
|
|
405
|
+
const api = keyedChildren<Item>(parent, signal, listState as any, (row) => {
|
|
406
|
+
// In your real impl, itemState.get().key should exist.
|
|
407
|
+
const item = row.state.get();
|
|
408
|
+
const k = item.key;
|
|
409
|
+
|
|
410
|
+
const el = document.createElement("li");
|
|
411
|
+
el.dataset.key = k;
|
|
412
|
+
el.textContent = item.label;
|
|
413
|
+
|
|
414
|
+
elByKey.set(k, el);
|
|
415
|
+
mountCountByKey.set(k, (mountCountByKey.get(k) ?? 0) + 1);
|
|
416
|
+
|
|
417
|
+
row.signal.addEventListener("abort", () => {
|
|
418
|
+
abortCountByKey.set(k, (abortCountByKey.get(k) ?? 0) + 1);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return el;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return { api, mountCountByKey, abortCountByKey, elByKey };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
describe("keyedChildren", () => {
|
|
428
|
+
test("mounts initial rows in order", () => {
|
|
429
|
+
const container = document.createElement("ul");
|
|
430
|
+
const { controller, signal } = makeAbortSignal();
|
|
431
|
+
|
|
432
|
+
const listState = funState<Item[]>([
|
|
433
|
+
{ key: "a", label: "A" },
|
|
434
|
+
{ key: "b", label: "B" },
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
// Use the key-aware renderer; it expects itemState.get()
|
|
438
|
+
const { elByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
439
|
+
container,
|
|
440
|
+
signal,
|
|
441
|
+
listState
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(container.children).toHaveLength(2);
|
|
445
|
+
expect((container.children[0] as HTMLElement).dataset.key).toBe("a");
|
|
446
|
+
expect((container.children[1] as HTMLElement).dataset.key).toBe("b");
|
|
447
|
+
expect(elByKey.get("a")?.textContent).toBe("A");
|
|
448
|
+
expect(elByKey.get("b")?.textContent).toBe("B");
|
|
449
|
+
|
|
450
|
+
controller.abort();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("does not recreate existing row elements when item contents change", () => {
|
|
454
|
+
const container = document.createElement("ul");
|
|
455
|
+
const { controller, signal } = makeAbortSignal();
|
|
456
|
+
|
|
457
|
+
const listState = funState<Item[]>([
|
|
458
|
+
{ key: "a", label: "A" },
|
|
459
|
+
{ key: "b", label: "B" },
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
const { mountCountByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
463
|
+
container,
|
|
464
|
+
signal,
|
|
465
|
+
listState
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const firstElA = container.children[0] as Element;
|
|
469
|
+
const firstElB = container.children[1] as Element;
|
|
470
|
+
|
|
471
|
+
// Update label of "a" (array ref changes)
|
|
472
|
+
listState.set([
|
|
473
|
+
{ key: "a", label: "A!" },
|
|
474
|
+
{ key: "b", label: "B" },
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
// Elements should be the same instances (not remounted)
|
|
478
|
+
expect(container.children[0]).toBe(firstElA);
|
|
479
|
+
expect(container.children[1]).toBe(firstElB);
|
|
480
|
+
|
|
481
|
+
// Mount counts should still be 1 each
|
|
482
|
+
expect(mountCountByKey.get("a")).toBe(1);
|
|
483
|
+
expect(mountCountByKey.get("b")).toBe(1);
|
|
484
|
+
|
|
485
|
+
controller.abort();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("reorders by moving existing nodes, without recreating them", () => {
|
|
489
|
+
const container = document.createElement("ul");
|
|
490
|
+
const { controller, signal } = makeAbortSignal();
|
|
491
|
+
|
|
492
|
+
const listeners = new Set<(xs: Item[]) => void>();
|
|
493
|
+
let items: Item[] = [
|
|
494
|
+
{ key: "a", label: "A" },
|
|
495
|
+
{ key: "b", label: "B" },
|
|
496
|
+
{ key: "c", label: "C" },
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
const listState: FunState<Item[]> = {
|
|
500
|
+
get: () => items,
|
|
501
|
+
query: () => {
|
|
502
|
+
throw new Error("not used");
|
|
503
|
+
},
|
|
504
|
+
mod: (f: (items: Item[]) => Item[]) => {
|
|
505
|
+
items = f(items);
|
|
506
|
+
listeners.forEach((l) => l(items));
|
|
507
|
+
},
|
|
508
|
+
set: (v: Item[]) => {
|
|
509
|
+
items = v;
|
|
510
|
+
listeners.forEach((l) => l(items));
|
|
511
|
+
},
|
|
512
|
+
focus: (acc: any) =>
|
|
513
|
+
({
|
|
514
|
+
get: () => acc.query(items)[0],
|
|
515
|
+
subscribe: (_s: AbortSignal, _cb: any) => void 0,
|
|
516
|
+
}) as any,
|
|
517
|
+
prop: () => {
|
|
518
|
+
throw new Error("not used");
|
|
519
|
+
},
|
|
520
|
+
subscribe: (sig: AbortSignal, cb: (items: Item[]) => void) => {
|
|
521
|
+
listeners.add(cb);
|
|
522
|
+
sig.addEventListener(
|
|
523
|
+
"abort",
|
|
524
|
+
() => {
|
|
525
|
+
listeners.delete(cb);
|
|
526
|
+
},
|
|
527
|
+
{ once: true }
|
|
528
|
+
);
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const { mountCountByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
533
|
+
container,
|
|
534
|
+
signal,
|
|
535
|
+
listState
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const elA = container.children[0];
|
|
539
|
+
const elB = container.children[1];
|
|
540
|
+
const elC = container.children[2];
|
|
541
|
+
|
|
542
|
+
// Reorder
|
|
543
|
+
listState.set([
|
|
544
|
+
{ key: "c", label: "C" },
|
|
545
|
+
{ key: "a", label: "A" },
|
|
546
|
+
{ key: "b", label: "B" },
|
|
547
|
+
]);
|
|
548
|
+
|
|
549
|
+
expect((container.children[0] as HTMLElement).dataset.key).toBe("c");
|
|
550
|
+
expect((container.children[1] as HTMLElement).dataset.key).toBe("a");
|
|
551
|
+
expect((container.children[2] as HTMLElement).dataset.key).toBe("b");
|
|
552
|
+
|
|
553
|
+
// Same element identity (moved, not recreated)
|
|
554
|
+
expect(container.children[1]).toBe(elA);
|
|
555
|
+
expect(container.children[2]).toBe(elB);
|
|
556
|
+
expect(container.children[0]).toBe(elC);
|
|
557
|
+
|
|
558
|
+
// Not remounted
|
|
559
|
+
expect(mountCountByKey.get("a")).toBe(1);
|
|
560
|
+
expect(mountCountByKey.get("b")).toBe(1);
|
|
561
|
+
expect(mountCountByKey.get("c")).toBe(1);
|
|
562
|
+
|
|
563
|
+
controller.abort();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("removes rows when keys disappear and aborts their row controllers", () => {
|
|
567
|
+
const container = document.createElement("ul");
|
|
568
|
+
const { controller, signal } = makeAbortSignal();
|
|
569
|
+
|
|
570
|
+
const listeners = new Set<(xs: Item[]) => void>();
|
|
571
|
+
let items: Item[] = [
|
|
572
|
+
{ key: "a", label: "A" },
|
|
573
|
+
{ key: "b", label: "B" },
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
const listState: FunState<Item[]> = {
|
|
577
|
+
get: () => items,
|
|
578
|
+
query: () => {
|
|
579
|
+
throw new Error("not used");
|
|
580
|
+
},
|
|
581
|
+
mod: (f: (items: Item[]) => Item[]) => {
|
|
582
|
+
items = f(items);
|
|
583
|
+
listeners.forEach((l) => l(items));
|
|
584
|
+
},
|
|
585
|
+
set: (v: Item[]) => {
|
|
586
|
+
items = v;
|
|
587
|
+
listeners.forEach((l) => l(items));
|
|
588
|
+
},
|
|
589
|
+
focus: (acc: any) =>
|
|
590
|
+
({
|
|
591
|
+
get: () => acc.query(items)[0],
|
|
592
|
+
subscribe: (_s: AbortSignal, _cb: any) => void 0,
|
|
593
|
+
}) as any,
|
|
594
|
+
prop: () => {
|
|
595
|
+
throw new Error("not used");
|
|
596
|
+
},
|
|
597
|
+
subscribe: (sig: AbortSignal, cb: (items: Item[]) => void) => {
|
|
598
|
+
listeners.add(cb);
|
|
599
|
+
sig.addEventListener(
|
|
600
|
+
"abort",
|
|
601
|
+
() => {
|
|
602
|
+
listeners.delete(cb);
|
|
603
|
+
},
|
|
604
|
+
{ once: true }
|
|
605
|
+
);
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const { abortCountByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
610
|
+
container,
|
|
611
|
+
signal,
|
|
612
|
+
listState
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
expect(container.children).toHaveLength(2);
|
|
616
|
+
|
|
617
|
+
// Remove "a"
|
|
618
|
+
listState.set([{ key: "b", label: "B" }]);
|
|
619
|
+
|
|
620
|
+
expect(container.children).toHaveLength(1);
|
|
621
|
+
expect((container.children[0] as HTMLElement).dataset.key).toBe("b");
|
|
622
|
+
|
|
623
|
+
// "a" row controller should have been aborted once
|
|
624
|
+
expect(abortCountByKey.get("a")).toBe(1);
|
|
625
|
+
|
|
626
|
+
controller.abort();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("unsubscribes from list updates on parent abort", () => {
|
|
630
|
+
const container = document.createElement("ul");
|
|
631
|
+
const { controller, signal } = makeAbortSignal();
|
|
632
|
+
|
|
633
|
+
const list = funState<Item[]>([{ key: "a", label: "A" }]);
|
|
634
|
+
|
|
635
|
+
// Subscribe and verify callback is called before abort
|
|
636
|
+
const callback = jest.fn();
|
|
637
|
+
list.subscribe(signal, callback);
|
|
638
|
+
|
|
639
|
+
list.set([{ key: "a", label: "A2" }]);
|
|
640
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
641
|
+
|
|
642
|
+
// After abort, callback should not be called
|
|
643
|
+
controller.abort();
|
|
644
|
+
|
|
645
|
+
list.set([{ key: "a", label: "A3" }]);
|
|
646
|
+
expect(callback).toHaveBeenCalledTimes(1); // Still 1, not called again
|
|
647
|
+
|
|
648
|
+
void container;
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("throws error on duplicate keys", () => {
|
|
652
|
+
const container = document.createElement("ul");
|
|
653
|
+
const { controller, signal } = makeAbortSignal();
|
|
654
|
+
|
|
655
|
+
const listState = funState<Item[]>([
|
|
656
|
+
{ key: "a", label: "A" },
|
|
657
|
+
{ key: "a", label: "B" }, // duplicate key!
|
|
658
|
+
]);
|
|
659
|
+
|
|
660
|
+
expect(() => {
|
|
661
|
+
keyedChildren(container, signal, listState, () => {
|
|
662
|
+
return document.createElement("li");
|
|
663
|
+
});
|
|
664
|
+
}).toThrow('keyedChildren: duplicate key "a"');
|
|
665
|
+
|
|
666
|
+
controller.abort();
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
describe("$ (querySelector)", () => {
|
|
671
|
+
beforeEach(() => {
|
|
672
|
+
document.body.innerHTML = "";
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("should return element if found", () => {
|
|
676
|
+
const div = h("div", { id: "test", className: "my-class" });
|
|
677
|
+
document.body.appendChild(div);
|
|
678
|
+
|
|
679
|
+
const result = $<HTMLDivElement>("#test");
|
|
680
|
+
expect(result).toBe(div);
|
|
681
|
+
expect(result?.id).toBe("test");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("should return undefined if not found", () => {
|
|
685
|
+
const result = $("#nonexistent");
|
|
686
|
+
expect(result).toBeUndefined();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("should work with class selectors", () => {
|
|
690
|
+
const div = h("div", { className: "test-class" });
|
|
691
|
+
document.body.appendChild(div);
|
|
692
|
+
|
|
693
|
+
const result = $(".test-class");
|
|
694
|
+
expect(result).toBe(div);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("should return first matching element", () => {
|
|
698
|
+
const div1 = h("div", { className: "item" });
|
|
699
|
+
const div2 = h("div", { className: "item" });
|
|
700
|
+
document.body.appendChild(div1);
|
|
701
|
+
document.body.appendChild(div2);
|
|
702
|
+
|
|
703
|
+
const result = $(".item");
|
|
704
|
+
expect(result).toBe(div1);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("should infer element type", () => {
|
|
708
|
+
const input = h("input", { type: "text", id: "myinput" });
|
|
709
|
+
document.body.appendChild(input);
|
|
710
|
+
|
|
711
|
+
const result = $<HTMLInputElement>("#myinput");
|
|
712
|
+
expect(result).toBe(input);
|
|
713
|
+
// TypeScript should know this is HTMLInputElement
|
|
714
|
+
expect(result!.value).toBeDefined();
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
describe("$$ (querySelectorAll)", () => {
|
|
719
|
+
beforeEach(() => {
|
|
720
|
+
document.body.innerHTML = "";
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("should return array of matching elements", () => {
|
|
724
|
+
const div1 = h("div", { className: "item" });
|
|
725
|
+
const div2 = h("div", { className: "item" });
|
|
726
|
+
const div3 = h("div", { className: "item" });
|
|
727
|
+
document.body.appendChild(div1);
|
|
728
|
+
document.body.appendChild(div2);
|
|
729
|
+
document.body.appendChild(div3);
|
|
730
|
+
|
|
731
|
+
const results = $$(".item");
|
|
732
|
+
expect(results).toHaveLength(3);
|
|
733
|
+
expect(results[0]).toBe(div1);
|
|
734
|
+
expect(results[1]).toBe(div2);
|
|
735
|
+
expect(results[2]).toBe(div3);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("should return empty array if no matches", () => {
|
|
739
|
+
const results = $$(".nonexistent");
|
|
740
|
+
expect(results).toEqual([]);
|
|
741
|
+
expect(Array.isArray(results)).toBe(true);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("should work with complex selectors", () => {
|
|
745
|
+
const container = h("div", { id: "container" });
|
|
746
|
+
const item1 = h("span", { className: "item" });
|
|
747
|
+
const item2 = h("span", { className: "item" });
|
|
748
|
+
container.appendChild(item1);
|
|
749
|
+
container.appendChild(item2);
|
|
750
|
+
document.body.appendChild(container);
|
|
751
|
+
|
|
752
|
+
const results = $$<HTMLSpanElement>("#container .item");
|
|
753
|
+
expect(results).toHaveLength(2);
|
|
754
|
+
expect(results[0]).toBe(item1);
|
|
755
|
+
expect(results[1]).toBe(item2);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("should return true array, not NodeList", () => {
|
|
759
|
+
const div1 = h("div", { className: "item" });
|
|
760
|
+
document.body.appendChild(div1);
|
|
761
|
+
|
|
762
|
+
const results = $$(".item");
|
|
763
|
+
expect(Array.isArray(results)).toBe(true);
|
|
764
|
+
// Should have array methods
|
|
765
|
+
expect(typeof results.map).toBe("function");
|
|
766
|
+
expect(typeof results.filter).toBe("function");
|
|
767
|
+
});
|
|
768
|
+
});
|