@fun-land/fun-web 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -92
- package/dist/esm/src/dom.d.ts +44 -2
- package/dist/esm/src/dom.js +57 -2
- package/dist/esm/src/dom.js.map +1 -1
- package/dist/esm/src/index.d.ts +1 -3
- package/dist/esm/src/index.js +1 -2
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/tsconfig.publish.tsbuildinfo +1 -1
- package/dist/src/dom.d.ts +44 -2
- package/dist/src/dom.js +61 -5
- package/dist/src/dom.js.map +1 -1
- package/dist/src/index.d.ts +1 -3
- package/dist/src/index.js +16 -19
- package/dist/src/index.js.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/examples/counter/counter.ts +2 -7
- package/examples/todo-app/DraggableTodoList.ts +1 -1
- package/examples/todo-app/Todo.ts +28 -30
- package/examples/todo-app/TodoApp.ts +7 -16
- package/package.json +3 -3
- package/src/dom.test.ts +65 -354
- package/src/dom.ts +134 -3
- package/src/index.ts +1 -20
- package/src/state.test.ts +0 -251
- package/src/state.ts +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fun-land/fun-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A web library for component-based development using fun-land",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"module": "dist/esm/src/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@fun-land/accessor": "4.0.2",
|
|
33
|
-
"@fun-land/fun-state": "9.0
|
|
33
|
+
"@fun-land/fun-state": "9.1.0",
|
|
34
34
|
"esbuild": "^0.24.2"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"url": "https://github.com/fun-land/fun-land/issues"
|
|
50
50
|
},
|
|
51
51
|
"license": "MIT",
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "1968bacf79ec442f1e1808721a150f4f2b0cbea8"
|
|
53
53
|
}
|
package/src/dom.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
h,
|
|
3
|
+
hx,
|
|
3
4
|
text,
|
|
4
5
|
attr,
|
|
5
6
|
attrs,
|
|
@@ -10,11 +11,10 @@ import {
|
|
|
10
11
|
removeClass,
|
|
11
12
|
on,
|
|
12
13
|
enhance,
|
|
13
|
-
|
|
14
|
-
$$,
|
|
14
|
+
querySelectorAll,
|
|
15
15
|
renderWhen,
|
|
16
16
|
} from "./dom";
|
|
17
|
-
import {
|
|
17
|
+
import { funState } from "@fun-land/fun-state";
|
|
18
18
|
|
|
19
19
|
describe("h()", () => {
|
|
20
20
|
it("should create an element", () => {
|
|
@@ -116,6 +116,63 @@ describe("h()", () => {
|
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
describe("hx()", () => {
|
|
120
|
+
it("should apply props and attrs", () => {
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const el = hx("div", {
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
props: { id: "test", className: "foo" },
|
|
125
|
+
attrs: { "data-test": "value", role: "button" },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(el.id).toBe("test");
|
|
129
|
+
expect(el.className).toBe("foo");
|
|
130
|
+
expect(el.getAttribute("data-test")).toBe("value");
|
|
131
|
+
expect(el.getAttribute("role")).toBe("button");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should append children", () => {
|
|
135
|
+
const child = h("span", null, "child");
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const el = hx("div", { signal: controller.signal }, child);
|
|
138
|
+
|
|
139
|
+
expect(el.children.length).toBe(1);
|
|
140
|
+
expect(el.textContent).toBe("child");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should bind properties and events with signal", () => {
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const state = funState("hello");
|
|
146
|
+
const handler = jest.fn();
|
|
147
|
+
|
|
148
|
+
const el = hx("input", {
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
bind: { value: state },
|
|
151
|
+
on: { input: handler },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(el.value).toBe("hello");
|
|
155
|
+
|
|
156
|
+
state.set("world");
|
|
157
|
+
expect(el.value).toBe("world");
|
|
158
|
+
|
|
159
|
+
el.dispatchEvent(new Event("input"));
|
|
160
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
161
|
+
|
|
162
|
+
controller.abort();
|
|
163
|
+
state.set("after");
|
|
164
|
+
el.dispatchEvent(new Event("input"));
|
|
165
|
+
expect(el.value).toBe("world");
|
|
166
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should require signal", () => {
|
|
170
|
+
expect(() => (hx as unknown as (tag: string) => Element)("div")).toThrow(
|
|
171
|
+
"hx: signal is required"
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
119
176
|
describe("text()", () => {
|
|
120
177
|
it("should set text content", () => {
|
|
121
178
|
const el = document.createElement("div");
|
|
@@ -367,353 +424,7 @@ describe("pipeEndo()", () => {
|
|
|
367
424
|
});
|
|
368
425
|
});
|
|
369
426
|
|
|
370
|
-
|
|
371
|
-
type Item = Keyed & { label: string };
|
|
372
|
-
|
|
373
|
-
function makeAbortSignal(): {
|
|
374
|
-
controller: AbortController;
|
|
375
|
-
signal: AbortSignal;
|
|
376
|
-
} {
|
|
377
|
-
const controller = new AbortController();
|
|
378
|
-
return { controller, signal: controller.signal };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Because the renderRow above can't reliably read the key from itemState in this harness,
|
|
383
|
-
* we’ll make a dedicated renderRow for tests that receives the key by closure from the list.
|
|
384
|
-
*
|
|
385
|
-
* This version doesn't require list.focus, so it's robust.
|
|
386
|
-
*/
|
|
387
|
-
// function setupKeyedChildrenWithKeyAwareRenderer(
|
|
388
|
-
// parent: Element,
|
|
389
|
-
// signal: AbortSignal,
|
|
390
|
-
// listState: { get: () => Item[]; watch: FunState<Item[]>["watch"] }
|
|
391
|
-
// ) {
|
|
392
|
-
// const mountCountByKey = new Map<string, number>();
|
|
393
|
-
// const abortCountByKey = new Map<string, number>();
|
|
394
|
-
// const elByKey = new Map<string, Element>();
|
|
395
|
-
|
|
396
|
-
// const api = keyedChildren<Item>(parent, signal, listState as any, (row) => {
|
|
397
|
-
// // In your real impl, itemState.get().key should exist.
|
|
398
|
-
// const item = row.state.get();
|
|
399
|
-
// const k = item.key;
|
|
400
|
-
|
|
401
|
-
// const el = document.createElement("li");
|
|
402
|
-
// el.dataset.key = k;
|
|
403
|
-
// el.textContent = item.label;
|
|
404
|
-
|
|
405
|
-
// elByKey.set(k, el);
|
|
406
|
-
// mountCountByKey.set(k, (mountCountByKey.get(k) ?? 0) + 1);
|
|
407
|
-
|
|
408
|
-
// row.signal.addEventListener("abort", () => {
|
|
409
|
-
// abortCountByKey.set(k, (abortCountByKey.get(k) ?? 0) + 1);
|
|
410
|
-
// });
|
|
411
|
-
|
|
412
|
-
// return el;
|
|
413
|
-
// });
|
|
414
|
-
|
|
415
|
-
// return { api, mountCountByKey, abortCountByKey, elByKey };
|
|
416
|
-
// }
|
|
417
|
-
|
|
418
|
-
// describe("keyedChildren", () => {
|
|
419
|
-
// test("mounts initial rows in order", () => {
|
|
420
|
-
// const container = document.createElement("ul");
|
|
421
|
-
// const { controller, signal } = makeAbortSignal();
|
|
422
|
-
|
|
423
|
-
// const listState = funState<Item[]>([
|
|
424
|
-
// { key: "a", label: "A" },
|
|
425
|
-
// { key: "b", label: "B" },
|
|
426
|
-
// ]);
|
|
427
|
-
|
|
428
|
-
// // Use the key-aware renderer; it expects itemState.get()
|
|
429
|
-
// const { elByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
430
|
-
// container,
|
|
431
|
-
// signal,
|
|
432
|
-
// listState
|
|
433
|
-
// );
|
|
434
|
-
|
|
435
|
-
// expect(container.children).toHaveLength(2);
|
|
436
|
-
// expect((container.children[0] as HTMLElement).dataset.key).toBe("a");
|
|
437
|
-
// expect((container.children[1] as HTMLElement).dataset.key).toBe("b");
|
|
438
|
-
// expect(elByKey.get("a")?.textContent).toBe("A");
|
|
439
|
-
// expect(elByKey.get("b")?.textContent).toBe("B");
|
|
440
|
-
|
|
441
|
-
// controller.abort();
|
|
442
|
-
// });
|
|
443
|
-
|
|
444
|
-
// test("does not recreate existing row elements when item contents change", () => {
|
|
445
|
-
// const container = document.createElement("ul");
|
|
446
|
-
// const { controller, signal } = makeAbortSignal();
|
|
447
|
-
|
|
448
|
-
// const listState = funState<Item[]>([
|
|
449
|
-
// { key: "a", label: "A" },
|
|
450
|
-
// { key: "b", label: "B" },
|
|
451
|
-
// ]);
|
|
452
|
-
|
|
453
|
-
// const { mountCountByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
454
|
-
// container,
|
|
455
|
-
// signal,
|
|
456
|
-
// listState
|
|
457
|
-
// );
|
|
458
|
-
|
|
459
|
-
// const firstElA = container.children[0] as Element;
|
|
460
|
-
// const firstElB = container.children[1] as Element;
|
|
461
|
-
|
|
462
|
-
// // Update label of "a" (array ref changes)
|
|
463
|
-
// listState.set([
|
|
464
|
-
// { key: "a", label: "A!" },
|
|
465
|
-
// { key: "b", label: "B" },
|
|
466
|
-
// ]);
|
|
467
|
-
|
|
468
|
-
// // Elements should be the same instances (not remounted)
|
|
469
|
-
// expect(container.children[0]).toBe(firstElA);
|
|
470
|
-
// expect(container.children[1]).toBe(firstElB);
|
|
471
|
-
|
|
472
|
-
// // Mount counts should still be 1 each
|
|
473
|
-
// expect(mountCountByKey.get("a")).toBe(1);
|
|
474
|
-
// expect(mountCountByKey.get("b")).toBe(1);
|
|
475
|
-
|
|
476
|
-
// controller.abort();
|
|
477
|
-
// });
|
|
478
|
-
|
|
479
|
-
// test("reorders by moving existing nodes, without recreating them", () => {
|
|
480
|
-
// const container = document.createElement("ul");
|
|
481
|
-
// const { controller, signal } = makeAbortSignal();
|
|
482
|
-
|
|
483
|
-
// const listeners = new Set<(xs: Item[]) => void>();
|
|
484
|
-
// let items: Item[] = [
|
|
485
|
-
// { key: "a", label: "A" },
|
|
486
|
-
// { key: "b", label: "B" },
|
|
487
|
-
// { key: "c", label: "C" },
|
|
488
|
-
// ];
|
|
489
|
-
|
|
490
|
-
// const listState: FunState<Item[]> = {
|
|
491
|
-
// get: () => items,
|
|
492
|
-
// query: () => {
|
|
493
|
-
// throw new Error("not used");
|
|
494
|
-
// },
|
|
495
|
-
// mod: (f: (items: Item[]) => Item[]) => {
|
|
496
|
-
// items = f(items);
|
|
497
|
-
// listeners.forEach((l) => l(items));
|
|
498
|
-
// },
|
|
499
|
-
// set: (v: Item[]) => {
|
|
500
|
-
// items = v;
|
|
501
|
-
// listeners.forEach((l) => l(items));
|
|
502
|
-
// },
|
|
503
|
-
// focus: (acc: any) =>
|
|
504
|
-
// ({
|
|
505
|
-
// get: () => acc.query(items)[0],
|
|
506
|
-
// watch: (_s: AbortSignal, _cb: any) => void 0,
|
|
507
|
-
// }) as any,
|
|
508
|
-
// prop: () => {
|
|
509
|
-
// throw new Error("not used");
|
|
510
|
-
// },
|
|
511
|
-
// watch: (sig: AbortSignal, cb: (items: Item[]) => void) => {
|
|
512
|
-
// listeners.add(cb);
|
|
513
|
-
// sig.addEventListener(
|
|
514
|
-
// "abort",
|
|
515
|
-
// () => {
|
|
516
|
-
// listeners.delete(cb);
|
|
517
|
-
// },
|
|
518
|
-
// { once: true }
|
|
519
|
-
// );
|
|
520
|
-
// },
|
|
521
|
-
// watchAll: (_sig: AbortSignal, _cb: (values: Item[][]) => void) => {
|
|
522
|
-
// throw new Error("watchAll not used in these tests");
|
|
523
|
-
// },
|
|
524
|
-
// };
|
|
525
|
-
|
|
526
|
-
// const { mountCountByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
527
|
-
// container,
|
|
528
|
-
// signal,
|
|
529
|
-
// listState
|
|
530
|
-
// );
|
|
531
|
-
|
|
532
|
-
// const elA = container.children[0];
|
|
533
|
-
// const elB = container.children[1];
|
|
534
|
-
// const elC = container.children[2];
|
|
535
|
-
|
|
536
|
-
// // Reorder
|
|
537
|
-
// listState.set([
|
|
538
|
-
// { key: "c", label: "C" },
|
|
539
|
-
// { key: "a", label: "A" },
|
|
540
|
-
// { key: "b", label: "B" },
|
|
541
|
-
// ]);
|
|
542
|
-
|
|
543
|
-
// expect((container.children[0] as HTMLElement).dataset.key).toBe("c");
|
|
544
|
-
// expect((container.children[1] as HTMLElement).dataset.key).toBe("a");
|
|
545
|
-
// expect((container.children[2] as HTMLElement).dataset.key).toBe("b");
|
|
546
|
-
|
|
547
|
-
// // Same element identity (moved, not recreated)
|
|
548
|
-
// expect(container.children[1]).toBe(elA);
|
|
549
|
-
// expect(container.children[2]).toBe(elB);
|
|
550
|
-
// expect(container.children[0]).toBe(elC);
|
|
551
|
-
|
|
552
|
-
// // Not remounted
|
|
553
|
-
// expect(mountCountByKey.get("a")).toBe(1);
|
|
554
|
-
// expect(mountCountByKey.get("b")).toBe(1);
|
|
555
|
-
// expect(mountCountByKey.get("c")).toBe(1);
|
|
556
|
-
|
|
557
|
-
// controller.abort();
|
|
558
|
-
// });
|
|
559
|
-
|
|
560
|
-
// test("removes rows when keys disappear and aborts their row controllers", () => {
|
|
561
|
-
// const container = document.createElement("ul");
|
|
562
|
-
// const { controller, signal } = makeAbortSignal();
|
|
563
|
-
|
|
564
|
-
// const listeners = new Set<(xs: Item[]) => void>();
|
|
565
|
-
// let items: Item[] = [
|
|
566
|
-
// { key: "a", label: "A" },
|
|
567
|
-
// { key: "b", label: "B" },
|
|
568
|
-
// ];
|
|
569
|
-
|
|
570
|
-
// const listState: FunState<Item[]> = {
|
|
571
|
-
// get: () => items,
|
|
572
|
-
// query: () => {
|
|
573
|
-
// throw new Error("not used");
|
|
574
|
-
// },
|
|
575
|
-
// mod: (f: (items: Item[]) => Item[]) => {
|
|
576
|
-
// items = f(items);
|
|
577
|
-
// listeners.forEach((l) => l(items));
|
|
578
|
-
// },
|
|
579
|
-
// set: (v: Item[]) => {
|
|
580
|
-
// items = v;
|
|
581
|
-
// listeners.forEach((l) => l(items));
|
|
582
|
-
// },
|
|
583
|
-
// focus: (acc: any) =>
|
|
584
|
-
// ({
|
|
585
|
-
// get: () => acc.query(items)[0],
|
|
586
|
-
// watch: (_s: AbortSignal, _cb: any) => void 0,
|
|
587
|
-
// }) as any,
|
|
588
|
-
// prop: () => {
|
|
589
|
-
// throw new Error("not used");
|
|
590
|
-
// },
|
|
591
|
-
// watch: (sig: AbortSignal, cb: (items: Item[]) => void) => {
|
|
592
|
-
// listeners.add(cb);
|
|
593
|
-
// sig.addEventListener(
|
|
594
|
-
// "abort",
|
|
595
|
-
// () => {
|
|
596
|
-
// listeners.delete(cb);
|
|
597
|
-
// },
|
|
598
|
-
// { once: true }
|
|
599
|
-
// );
|
|
600
|
-
// },
|
|
601
|
-
// watchAll: (_sig: AbortSignal, _cb: (values: Item[][]) => void) => {
|
|
602
|
-
// throw new Error("watchAll not used in these tests");
|
|
603
|
-
// },
|
|
604
|
-
// };
|
|
605
|
-
|
|
606
|
-
// const { abortCountByKey } = setupKeyedChildrenWithKeyAwareRenderer(
|
|
607
|
-
// container,
|
|
608
|
-
// signal,
|
|
609
|
-
// listState
|
|
610
|
-
// );
|
|
611
|
-
|
|
612
|
-
// expect(container.children).toHaveLength(2);
|
|
613
|
-
|
|
614
|
-
// // Remove "a"
|
|
615
|
-
// listState.set([{ key: "b", label: "B" }]);
|
|
616
|
-
|
|
617
|
-
// expect(container.children).toHaveLength(1);
|
|
618
|
-
// expect((container.children[0] as HTMLElement).dataset.key).toBe("b");
|
|
619
|
-
|
|
620
|
-
// // "a" row controller should have been aborted once
|
|
621
|
-
// expect(abortCountByKey.get("a")).toBe(1);
|
|
622
|
-
|
|
623
|
-
// controller.abort();
|
|
624
|
-
// });
|
|
625
|
-
|
|
626
|
-
// test("unsubscribes from list updates on parent abort", () => {
|
|
627
|
-
// const container = document.createElement("ul");
|
|
628
|
-
// const { controller, signal } = makeAbortSignal();
|
|
629
|
-
|
|
630
|
-
// const list = funState<Item[]>([{ key: "a", label: "A" }]);
|
|
631
|
-
|
|
632
|
-
// // Subscribe and verify callback is called before abort
|
|
633
|
-
// const callback = jest.fn();
|
|
634
|
-
// list.watch(signal, callback);
|
|
635
|
-
|
|
636
|
-
// list.set([{ key: "a", label: "A2" }]);
|
|
637
|
-
// // Called with initial value, then with updated value
|
|
638
|
-
// expect(callback).toHaveBeenCalledTimes(2);
|
|
639
|
-
|
|
640
|
-
// // After abort, callback should not be called
|
|
641
|
-
// controller.abort();
|
|
642
|
-
|
|
643
|
-
// list.set([{ key: "a", label: "A3" }]);
|
|
644
|
-
// expect(callback).toHaveBeenCalledTimes(2); // Still 2, not called again
|
|
645
|
-
|
|
646
|
-
// void container;
|
|
647
|
-
// });
|
|
648
|
-
|
|
649
|
-
// test("throws error on duplicate keys", () => {
|
|
650
|
-
// const container = document.createElement("ul");
|
|
651
|
-
// const { controller, signal } = makeAbortSignal();
|
|
652
|
-
|
|
653
|
-
// const listState = funState<Item[]>([
|
|
654
|
-
// { key: "a", label: "A" },
|
|
655
|
-
// { key: "a", label: "B" }, // duplicate key!
|
|
656
|
-
// ]);
|
|
657
|
-
|
|
658
|
-
// expect(() => {
|
|
659
|
-
// keyedChildren(container, signal, listState, () => {
|
|
660
|
-
// return document.createElement("li");
|
|
661
|
-
// });
|
|
662
|
-
// }).toThrow('keyedChildren: duplicate key "a"');
|
|
663
|
-
|
|
664
|
-
// controller.abort();
|
|
665
|
-
// });
|
|
666
|
-
// });
|
|
667
|
-
|
|
668
|
-
describe("$ (querySelector)", () => {
|
|
669
|
-
beforeEach(() => {
|
|
670
|
-
document.body.innerHTML = "";
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
test("should return element if found", () => {
|
|
674
|
-
const div = h("div", { id: "test", className: "my-class" });
|
|
675
|
-
document.body.appendChild(div);
|
|
676
|
-
|
|
677
|
-
const result = $<HTMLDivElement>("#test");
|
|
678
|
-
expect(result).toBe(div);
|
|
679
|
-
expect(result?.id).toBe("test");
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
test("should return undefined if not found", () => {
|
|
683
|
-
const result = $("#nonexistent");
|
|
684
|
-
expect(result).toBeUndefined();
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
test("should work with class selectors", () => {
|
|
688
|
-
const div = h("div", { className: "test-class" });
|
|
689
|
-
document.body.appendChild(div);
|
|
690
|
-
|
|
691
|
-
const result = $(".test-class");
|
|
692
|
-
expect(result).toBe(div);
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
test("should return first matching element", () => {
|
|
696
|
-
const div1 = h("div", { className: "item" });
|
|
697
|
-
const div2 = h("div", { className: "item" });
|
|
698
|
-
document.body.appendChild(div1);
|
|
699
|
-
document.body.appendChild(div2);
|
|
700
|
-
|
|
701
|
-
const result = $(".item");
|
|
702
|
-
expect(result).toBe(div1);
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
test("should infer element type", () => {
|
|
706
|
-
const input = h("input", { type: "text", id: "myinput" });
|
|
707
|
-
document.body.appendChild(input);
|
|
708
|
-
|
|
709
|
-
const result = $<HTMLInputElement>("#myinput");
|
|
710
|
-
expect(result).toBe(input);
|
|
711
|
-
// TypeScript should know this is HTMLInputElement
|
|
712
|
-
expect(result!.value).toBeDefined();
|
|
713
|
-
});
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
describe("$$ (querySelectorAll)", () => {
|
|
427
|
+
describe("querySelectorAll()", () => {
|
|
717
428
|
beforeEach(() => {
|
|
718
429
|
document.body.innerHTML = "";
|
|
719
430
|
});
|
|
@@ -726,7 +437,7 @@ describe("$$ (querySelectorAll)", () => {
|
|
|
726
437
|
document.body.appendChild(div2);
|
|
727
438
|
document.body.appendChild(div3);
|
|
728
439
|
|
|
729
|
-
const results =
|
|
440
|
+
const results = querySelectorAll(".item");
|
|
730
441
|
expect(results).toHaveLength(3);
|
|
731
442
|
expect(results[0]).toBe(div1);
|
|
732
443
|
expect(results[1]).toBe(div2);
|
|
@@ -734,7 +445,7 @@ describe("$$ (querySelectorAll)", () => {
|
|
|
734
445
|
});
|
|
735
446
|
|
|
736
447
|
test("should return empty array if no matches", () => {
|
|
737
|
-
const results =
|
|
448
|
+
const results = querySelectorAll(".nonexistent");
|
|
738
449
|
expect(results).toEqual([]);
|
|
739
450
|
expect(Array.isArray(results)).toBe(true);
|
|
740
451
|
});
|
|
@@ -747,7 +458,7 @@ describe("$$ (querySelectorAll)", () => {
|
|
|
747
458
|
container.appendChild(item2);
|
|
748
459
|
document.body.appendChild(container);
|
|
749
460
|
|
|
750
|
-
const results =
|
|
461
|
+
const results = querySelectorAll<HTMLSpanElement>("#container .item");
|
|
751
462
|
expect(results).toHaveLength(2);
|
|
752
463
|
expect(results[0]).toBe(item1);
|
|
753
464
|
expect(results[1]).toBe(item2);
|
|
@@ -757,7 +468,7 @@ describe("$$ (querySelectorAll)", () => {
|
|
|
757
468
|
const div1 = h("div", { className: "item" });
|
|
758
469
|
document.body.appendChild(div1);
|
|
759
470
|
|
|
760
|
-
const results =
|
|
471
|
+
const results = querySelectorAll(".item");
|
|
761
472
|
expect(Array.isArray(results)).toBe(true);
|
|
762
473
|
// Should have array methods
|
|
763
474
|
expect(typeof results.map).toBe("function");
|
package/src/dom.ts
CHANGED
|
@@ -5,6 +5,14 @@ import { Accessor } from "@fun-land/accessor";
|
|
|
5
5
|
|
|
6
6
|
export type Enhancer<El extends Element> = (element: El) => El;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Type-preserving Object.entries helper for objects with known keys
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
const entries = <T extends Record<string, unknown>>(
|
|
13
|
+
obj: T
|
|
14
|
+
): Array<[keyof T, T[keyof T]]> => Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
|
|
15
|
+
|
|
8
16
|
/**
|
|
9
17
|
* Create an HTML element with attributes and children
|
|
10
18
|
*
|
|
@@ -54,6 +62,124 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
|
|
|
54
62
|
return element;
|
|
55
63
|
};
|
|
56
64
|
|
|
65
|
+
// Helper type to extract only writable properties
|
|
66
|
+
type WritableKeys<T> = {
|
|
67
|
+
[K in keyof T]-?: (<F>() => F extends { [Q in K]: T[K] } ? 1 : 2) extends <
|
|
68
|
+
F,
|
|
69
|
+
>() => F extends { -readonly [Q in K]: T[K] } ? 1 : 2
|
|
70
|
+
? K
|
|
71
|
+
: never;
|
|
72
|
+
}[keyof T];
|
|
73
|
+
|
|
74
|
+
type HxProps<El extends Element> = Partial<{
|
|
75
|
+
[K in WritableKeys<El> & string]: El[K] | null | undefined;
|
|
76
|
+
}>;
|
|
77
|
+
|
|
78
|
+
type HxHandlers<El extends Element> = Partial<{
|
|
79
|
+
[K in keyof GlobalEventHandlersEventMap]: (
|
|
80
|
+
ev: GlobalEventHandlersEventMap[K] & { currentTarget: El }
|
|
81
|
+
) => void;
|
|
82
|
+
}>;
|
|
83
|
+
|
|
84
|
+
type HxBindings<El extends Element> = Partial<{
|
|
85
|
+
[K in WritableKeys<El> & string]: FunState<El[K]>;
|
|
86
|
+
}>;
|
|
87
|
+
|
|
88
|
+
type HxOptionsBase<El extends Element> = {
|
|
89
|
+
props?: HxProps<El>;
|
|
90
|
+
attrs?: Record<string, string | number | boolean | null | undefined>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type HxOptions<El extends Element> = HxOptionsBase<El> & {
|
|
94
|
+
signal: AbortSignal;
|
|
95
|
+
on?: HxHandlers<El>;
|
|
96
|
+
bind?: HxBindings<El>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create an element with structured props, attrs, event handlers, and bindings.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* hx("input", {
|
|
104
|
+
* signal,
|
|
105
|
+
* props: { type: "text" },
|
|
106
|
+
* attrs: { "data-test": "name" },
|
|
107
|
+
* bind: { value: nameState },
|
|
108
|
+
* on: { input: (e) => nameState.set(e.currentTarget.value) },
|
|
109
|
+
* });
|
|
110
|
+
*/
|
|
111
|
+
export function hx<Tag extends keyof HTMLElementTagNameMap>(
|
|
112
|
+
tag: Tag,
|
|
113
|
+
options: HxOptions<HTMLElementTagNameMap[Tag]>,
|
|
114
|
+
children?: ElementChild | ElementChild[]
|
|
115
|
+
): HTMLElementTagNameMap[Tag];
|
|
116
|
+
// eslint-disable-next-line complexity
|
|
117
|
+
export function hx<Tag extends keyof HTMLElementTagNameMap>(
|
|
118
|
+
tag: Tag,
|
|
119
|
+
options: HxOptions<HTMLElementTagNameMap[Tag]>,
|
|
120
|
+
children?: ElementChild | ElementChild[]
|
|
121
|
+
): HTMLElementTagNameMap[Tag] {
|
|
122
|
+
if (!options?.signal) {
|
|
123
|
+
throw new Error("hx: signal is required");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { signal, props, attrs: attrMap, on: onMap, bind } = options;
|
|
127
|
+
const element = document.createElement(tag);
|
|
128
|
+
|
|
129
|
+
if (props) {
|
|
130
|
+
for (const [key, value] of entries(props)) {
|
|
131
|
+
if (value == null) continue;
|
|
132
|
+
element[key] = value;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (attrMap) {
|
|
137
|
+
for (const [key, value] of Object.entries(attrMap)) {
|
|
138
|
+
if (value == null) continue;
|
|
139
|
+
element.setAttribute(key, String(value));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (children != null) {
|
|
144
|
+
appendChildren(children)(element);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (bind) {
|
|
148
|
+
const bindElementProperty = <K extends WritableKeys<HTMLElementTagNameMap[Tag]> & string>(
|
|
149
|
+
key: K,
|
|
150
|
+
state: FunState<HTMLElementTagNameMap[Tag][K]>
|
|
151
|
+
): void => {
|
|
152
|
+
bindProperty<HTMLElementTagNameMap[Tag], K>(
|
|
153
|
+
key,
|
|
154
|
+
state,
|
|
155
|
+
signal
|
|
156
|
+
)(element);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
for (const key of Object.keys(bind) as Array<
|
|
160
|
+
WritableKeys<HTMLElementTagNameMap[Tag]> & string
|
|
161
|
+
>) {
|
|
162
|
+
const state = bind[key];
|
|
163
|
+
if (!state) continue;
|
|
164
|
+
bindElementProperty(
|
|
165
|
+
key,
|
|
166
|
+
state
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (onMap) {
|
|
172
|
+
for (const [event, handler] of Object.entries(onMap)) {
|
|
173
|
+
if (!handler) continue;
|
|
174
|
+
element.addEventListener(event, handler as EventListener, {
|
|
175
|
+
signal,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return element;
|
|
181
|
+
}
|
|
182
|
+
|
|
57
183
|
/**
|
|
58
184
|
* Append children to an element, flattening arrays and converting primitives to text nodes
|
|
59
185
|
* @returns {Enhancer}
|
|
@@ -396,8 +522,13 @@ export function renderWhen<State, Props>(options: {
|
|
|
396
522
|
return container;
|
|
397
523
|
}
|
|
398
524
|
|
|
399
|
-
|
|
400
|
-
|
|
525
|
+
/** add passed class (idempotent) to element when state returns true */
|
|
526
|
+
export const bindClass =
|
|
527
|
+
(className: string, state: FunState<boolean>, signal: AbortSignal) =>
|
|
528
|
+
<E extends Element>(el: E): E => {
|
|
529
|
+
state.watch(signal, (active) => el.classList.toggle(className, active));
|
|
530
|
+
return el;
|
|
531
|
+
};
|
|
401
532
|
|
|
402
|
-
export const
|
|
533
|
+
export const querySelectorAll = <T extends Element>(selector: string): T[] =>
|
|
403
534
|
Array.from(document.querySelectorAll(selector));
|
package/src/index.ts
CHANGED
|
@@ -1,26 +1,7 @@
|
|
|
1
1
|
// @fun-land/fun-web - Web component library for fun-land
|
|
2
2
|
|
|
3
3
|
export type { Component, ElementChild } from "./types";
|
|
4
|
-
export type { FunState } from "./state";
|
|
5
4
|
export type { MountedComponent } from "./mount";
|
|
6
5
|
export type { KeyedChildren } from "./dom";
|
|
7
|
-
|
|
8
|
-
export { funState } from "./state";
|
|
9
|
-
export {
|
|
10
|
-
h,
|
|
11
|
-
text,
|
|
12
|
-
attr,
|
|
13
|
-
attrs,
|
|
14
|
-
addClass,
|
|
15
|
-
removeClass,
|
|
16
|
-
toggleClass,
|
|
17
|
-
append,
|
|
18
|
-
on,
|
|
19
|
-
bindProperty,
|
|
20
|
-
bindListChildren,
|
|
21
|
-
renderWhen,
|
|
22
|
-
enhance,
|
|
23
|
-
$,
|
|
24
|
-
$$,
|
|
25
|
-
} from "./dom";
|
|
6
|
+
export * from "./dom";
|
|
26
7
|
export { mount } from "./mount";
|