@fun-land/fun-web 1.0.0 → 2.0.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/CHANGELOG.md +12 -0
- package/README.md +37 -10
- package/dist/esm/src/dom.d.ts +15 -6
- package/dist/esm/src/dom.js +27 -17
- package/dist/esm/src/dom.js.map +1 -1
- package/dist/esm/tsconfig.publish.tsbuildinfo +1 -1
- package/dist/src/dom.d.ts +15 -6
- package/dist/src/dom.js +29 -18
- package/dist/src/dom.js.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/eslint.config.js +30 -23
- package/package.json +1 -1
- package/src/dom.test.ts +106 -33
- package/src/dom.ts +52 -36
- package/dist/esm/src/state.d.ts +0 -2
- package/dist/esm/src/state.js +0 -3
- package/dist/esm/src/state.js.map +0 -1
- package/dist/src/state.d.ts +0 -2
- package/dist/src/state.js +0 -7
- package/dist/src/state.js.map +0 -1
package/src/dom.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
attrs,
|
|
7
7
|
append,
|
|
8
8
|
bindProperty,
|
|
9
|
+
bindView,
|
|
9
10
|
addClass,
|
|
10
11
|
toggleClass,
|
|
11
12
|
removeClass,
|
|
@@ -80,26 +81,24 @@ describe("h()", () => {
|
|
|
80
81
|
expect(el.children.length).toBe(2);
|
|
81
82
|
});
|
|
82
83
|
|
|
83
|
-
it("should
|
|
84
|
+
it("should reject event listeners", () => {
|
|
84
85
|
const handler = jest.fn();
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
expect(() => h("button", { onclick: handler })).toThrow(
|
|
87
|
+
"Setting event handlers on dom elements without abort signal leads to memory leaks. Use `hx` or `on` instead."
|
|
88
|
+
);
|
|
88
89
|
});
|
|
89
90
|
|
|
90
|
-
it("should
|
|
91
|
+
it("should reject multiple event types", () => {
|
|
91
92
|
const clickHandler = jest.fn();
|
|
92
93
|
const mouseoverHandler = jest.fn();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
el.dispatchEvent(new MouseEvent("mouseover"));
|
|
102
|
-
expect(mouseoverHandler).toHaveBeenCalled();
|
|
94
|
+
expect(() =>
|
|
95
|
+
h("button", {
|
|
96
|
+
onclick: clickHandler,
|
|
97
|
+
onmouseover: mouseoverHandler,
|
|
98
|
+
})
|
|
99
|
+
).toThrow(
|
|
100
|
+
"Setting event handlers on dom elements without abort signal leads to memory leaks. Use `hx` or `on` instead."
|
|
101
|
+
);
|
|
103
102
|
});
|
|
104
103
|
|
|
105
104
|
it("should skip null and undefined attributes", () => {
|
|
@@ -498,6 +497,80 @@ describe("on()", () => {
|
|
|
498
497
|
});
|
|
499
498
|
});
|
|
500
499
|
|
|
500
|
+
describe("bindView()", () => {
|
|
501
|
+
it("should default to a div container", () => {
|
|
502
|
+
const controller = new AbortController();
|
|
503
|
+
const state = funState(0);
|
|
504
|
+
|
|
505
|
+
const container = bindView(controller.signal, state, (_signal, value) =>
|
|
506
|
+
h("div", null, String(value))
|
|
507
|
+
) as HTMLElement;
|
|
508
|
+
|
|
509
|
+
state.set(1);
|
|
510
|
+
|
|
511
|
+
expect(container.tagName).toBe("DIV");
|
|
512
|
+
expect(container.textContent).toBe("1");
|
|
513
|
+
|
|
514
|
+
controller.abort();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("should render into the provided container tag", () => {
|
|
518
|
+
const controller = new AbortController();
|
|
519
|
+
const state = funState(0);
|
|
520
|
+
|
|
521
|
+
const container = bindView(
|
|
522
|
+
controller.signal,
|
|
523
|
+
state,
|
|
524
|
+
(_signal, value) => h("div", null, String(value)),
|
|
525
|
+
{ tagName: "section" }
|
|
526
|
+
) as HTMLElement;
|
|
527
|
+
|
|
528
|
+
state.set(1);
|
|
529
|
+
|
|
530
|
+
expect(container.tagName).toBe("SECTION");
|
|
531
|
+
expect(container.children.length).toBe(1);
|
|
532
|
+
expect(container.textContent).toBe("1");
|
|
533
|
+
|
|
534
|
+
controller.abort();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("should abort previous render on state changes", () => {
|
|
538
|
+
const controller = new AbortController();
|
|
539
|
+
const state = funState(0);
|
|
540
|
+
let abortCount = 0;
|
|
541
|
+
let renderCount = 0;
|
|
542
|
+
|
|
543
|
+
const container = bindView(
|
|
544
|
+
controller.signal,
|
|
545
|
+
state,
|
|
546
|
+
(signal, value) => {
|
|
547
|
+
renderCount += 1;
|
|
548
|
+
signal.addEventListener("abort", () => {
|
|
549
|
+
abortCount += 1;
|
|
550
|
+
});
|
|
551
|
+
return h("div", null, String(value));
|
|
552
|
+
},
|
|
553
|
+
{ tagName: "div" }
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
expect(renderCount).toBe(1);
|
|
557
|
+
expect(abortCount).toBe(0);
|
|
558
|
+
|
|
559
|
+
state.set(1);
|
|
560
|
+
expect(renderCount).toBe(2);
|
|
561
|
+
expect(abortCount).toBe(1);
|
|
562
|
+
expect(container.textContent).toBe("1");
|
|
563
|
+
|
|
564
|
+
state.set(2);
|
|
565
|
+
expect(renderCount).toBe(3);
|
|
566
|
+
expect(abortCount).toBe(2);
|
|
567
|
+
expect(container.textContent).toBe("2");
|
|
568
|
+
|
|
569
|
+
controller.abort();
|
|
570
|
+
expect(abortCount).toBe(3);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
501
574
|
describe("pipeEndo()", () => {
|
|
502
575
|
it("should apply functions in order", () => {
|
|
503
576
|
const el = document.createElement("div");
|
|
@@ -585,7 +658,7 @@ describe("renderWhen", () => {
|
|
|
585
658
|
state: showState,
|
|
586
659
|
component: TestComponent,
|
|
587
660
|
props: { text: "Hello" },
|
|
588
|
-
signal: controller.signal
|
|
661
|
+
signal: controller.signal,
|
|
589
662
|
});
|
|
590
663
|
|
|
591
664
|
expect(container.children.length).toBe(1);
|
|
@@ -605,7 +678,7 @@ describe("renderWhen", () => {
|
|
|
605
678
|
state: showState,
|
|
606
679
|
component: TestComponent,
|
|
607
680
|
props: { text: "Hello" },
|
|
608
|
-
signal: controller.signal
|
|
681
|
+
signal: controller.signal,
|
|
609
682
|
});
|
|
610
683
|
|
|
611
684
|
expect(container.children.length).toBe(0);
|
|
@@ -621,7 +694,7 @@ describe("renderWhen", () => {
|
|
|
621
694
|
state: showState,
|
|
622
695
|
component: TestComponent,
|
|
623
696
|
props: { text: "Hello" },
|
|
624
|
-
signal: controller.signal
|
|
697
|
+
signal: controller.signal,
|
|
625
698
|
});
|
|
626
699
|
|
|
627
700
|
expect(container.children.length).toBe(0);
|
|
@@ -642,7 +715,7 @@ describe("renderWhen", () => {
|
|
|
642
715
|
state: showState,
|
|
643
716
|
component: TestComponent,
|
|
644
717
|
props: { text: "Hello" },
|
|
645
|
-
signal: controller.signal
|
|
718
|
+
signal: controller.signal,
|
|
646
719
|
});
|
|
647
720
|
|
|
648
721
|
const child = container.children[0] as HTMLElement;
|
|
@@ -673,7 +746,7 @@ describe("renderWhen", () => {
|
|
|
673
746
|
state: showState,
|
|
674
747
|
component: ComponentWithAbortListener,
|
|
675
748
|
props: { text: "Hello" },
|
|
676
|
-
signal: controller.signal
|
|
749
|
+
signal: controller.signal,
|
|
677
750
|
});
|
|
678
751
|
|
|
679
752
|
expect(abortCallback).not.toHaveBeenCalled();
|
|
@@ -702,7 +775,7 @@ describe("renderWhen", () => {
|
|
|
702
775
|
state: showState,
|
|
703
776
|
component: ComponentWithAbortListener,
|
|
704
777
|
props: { text: "Hello" },
|
|
705
|
-
signal: controller.signal
|
|
778
|
+
signal: controller.signal,
|
|
706
779
|
});
|
|
707
780
|
|
|
708
781
|
expect(abortCallback).not.toHaveBeenCalled();
|
|
@@ -720,7 +793,7 @@ describe("renderWhen", () => {
|
|
|
720
793
|
state: showState,
|
|
721
794
|
component: TestComponent,
|
|
722
795
|
props: { text: "Hello" },
|
|
723
|
-
signal: controller.signal
|
|
796
|
+
signal: controller.signal,
|
|
724
797
|
});
|
|
725
798
|
|
|
726
799
|
expect(container.children.length).toBe(0);
|
|
@@ -748,7 +821,7 @@ describe("renderWhen", () => {
|
|
|
748
821
|
state: showState,
|
|
749
822
|
component: TestComponent,
|
|
750
823
|
props: { text: "Hello" },
|
|
751
|
-
signal: controller.signal
|
|
824
|
+
signal: controller.signal,
|
|
752
825
|
}) as HTMLElement;
|
|
753
826
|
|
|
754
827
|
expect(container.style.display).toBe("contents");
|
|
@@ -764,7 +837,7 @@ describe("renderWhen", () => {
|
|
|
764
837
|
state: showState,
|
|
765
838
|
component: TestComponent,
|
|
766
839
|
props: { text: "Hello" },
|
|
767
|
-
signal: controller.signal
|
|
840
|
+
signal: controller.signal,
|
|
768
841
|
});
|
|
769
842
|
|
|
770
843
|
expect(container.children.length).toBe(1);
|
|
@@ -784,18 +857,14 @@ describe("renderWhen", () => {
|
|
|
784
857
|
_signal: AbortSignal,
|
|
785
858
|
props: { text: string; count: number }
|
|
786
859
|
) => {
|
|
787
|
-
return h(
|
|
788
|
-
"div",
|
|
789
|
-
null,
|
|
790
|
-
`${props.text}: ${props.count}`
|
|
791
|
-
);
|
|
860
|
+
return h("div", null, `${props.text}: ${props.count}`);
|
|
792
861
|
};
|
|
793
862
|
|
|
794
863
|
const container = renderWhen({
|
|
795
864
|
state: showState,
|
|
796
865
|
component: PropsComponent,
|
|
797
866
|
props: { text: "Count", count: 42 },
|
|
798
|
-
signal: controller.signal
|
|
867
|
+
signal: controller.signal,
|
|
799
868
|
});
|
|
800
869
|
|
|
801
870
|
expect(container.children[0].textContent).toBe("Count: 42");
|
|
@@ -805,7 +874,11 @@ describe("renderWhen", () => {
|
|
|
805
874
|
|
|
806
875
|
test("should work with predicate function", () => {
|
|
807
876
|
const controller = new AbortController();
|
|
808
|
-
enum Status {
|
|
877
|
+
enum Status {
|
|
878
|
+
Loading,
|
|
879
|
+
Success,
|
|
880
|
+
Error,
|
|
881
|
+
}
|
|
809
882
|
const statusState = funState(Status.Loading);
|
|
810
883
|
|
|
811
884
|
const container = renderWhen({
|
|
@@ -813,7 +886,7 @@ describe("renderWhen", () => {
|
|
|
813
886
|
predicate: (status) => status === Status.Success,
|
|
814
887
|
component: TestComponent,
|
|
815
888
|
props: { text: "Success!" },
|
|
816
|
-
signal: controller.signal
|
|
889
|
+
signal: controller.signal,
|
|
817
890
|
});
|
|
818
891
|
|
|
819
892
|
// Should not render initially
|
|
@@ -834,4 +907,4 @@ describe("renderWhen", () => {
|
|
|
834
907
|
|
|
835
908
|
controller.abort();
|
|
836
909
|
});
|
|
837
|
-
});
|
|
910
|
+
});
|
package/src/dom.ts
CHANGED
|
@@ -5,21 +5,11 @@ 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]]> =>
|
|
15
|
-
Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
|
|
16
|
-
|
|
17
8
|
/**
|
|
18
9
|
* Create an HTML element with attributes and children
|
|
19
10
|
*
|
|
20
11
|
* Convention:
|
|
21
12
|
* - Properties with dashes (data-*, aria-*) become attributes
|
|
22
|
-
* - Properties starting with 'on' become event listeners
|
|
23
13
|
* - Everything else becomes element properties
|
|
24
14
|
*
|
|
25
15
|
* @example
|
|
@@ -41,10 +31,9 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
|
|
|
41
31
|
if (value == null) continue;
|
|
42
32
|
|
|
43
33
|
if (key.startsWith("on") && typeof value === "function") {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
element.addEventListener(eventName, value);
|
|
34
|
+
throw new Error(
|
|
35
|
+
"Setting event handlers on dom elements without abort signal leads to memory leaks. Use `hx` or `on` instead."
|
|
36
|
+
);
|
|
48
37
|
} else if (key.includes("-") || key === "role") {
|
|
49
38
|
// Attribute: data-*, aria-*, role, etc.
|
|
50
39
|
element.setAttribute(key, String(value));
|
|
@@ -75,7 +64,8 @@ type WritableKeys<T> = {
|
|
|
75
64
|
|
|
76
65
|
type HxProps<El extends Element> = Partial<{
|
|
77
66
|
[K in WritableKeys<El> & string]: El[K] | null | undefined;
|
|
78
|
-
}
|
|
67
|
+
}> &
|
|
68
|
+
Record<string, unknown>;
|
|
79
69
|
|
|
80
70
|
type HxHandlers<El extends Element> = Partial<{
|
|
81
71
|
[K in keyof GlobalEventHandlersEventMap]: (
|
|
@@ -83,9 +73,12 @@ type HxHandlers<El extends Element> = Partial<{
|
|
|
83
73
|
) => void;
|
|
84
74
|
}>;
|
|
85
75
|
|
|
86
|
-
type
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
type BindableRead = {
|
|
77
|
+
get: () => unknown;
|
|
78
|
+
watch: (signal: AbortSignal, callback: (value: unknown) => void) => void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type HxBindings = Record<string, BindableRead>;
|
|
89
82
|
|
|
90
83
|
type HxOptionsBase<El extends Element> = {
|
|
91
84
|
props?: HxProps<El>;
|
|
@@ -95,7 +88,7 @@ type HxOptionsBase<El extends Element> = {
|
|
|
95
88
|
type HxOptions<El extends Element> = HxOptionsBase<El> & {
|
|
96
89
|
signal: AbortSignal;
|
|
97
90
|
on?: HxHandlers<El>;
|
|
98
|
-
bind?: HxBindings
|
|
91
|
+
bind?: HxBindings;
|
|
99
92
|
};
|
|
100
93
|
|
|
101
94
|
/**
|
|
@@ -129,9 +122,9 @@ export function hx<Tag extends keyof HTMLElementTagNameMap>(
|
|
|
129
122
|
const element = document.createElement(tag);
|
|
130
123
|
|
|
131
124
|
if (props) {
|
|
132
|
-
for (const [key, value] of entries(props)) {
|
|
125
|
+
for (const [key, value] of Object.entries(props)) {
|
|
133
126
|
if (value == null) continue;
|
|
134
|
-
element[key] = value;
|
|
127
|
+
(element as Record<string, unknown>)[key] = value;
|
|
135
128
|
}
|
|
136
129
|
}
|
|
137
130
|
|
|
@@ -147,21 +140,12 @@ export function hx<Tag extends keyof HTMLElementTagNameMap>(
|
|
|
147
140
|
}
|
|
148
141
|
|
|
149
142
|
if (bind) {
|
|
150
|
-
const
|
|
151
|
-
K extends WritableKeys<HTMLElementTagNameMap[Tag]> & string,
|
|
152
|
-
>(
|
|
153
|
-
key: K,
|
|
154
|
-
state: FunRead<HTMLElementTagNameMap[Tag][K]>
|
|
155
|
-
): void => {
|
|
156
|
-
bindProperty<HTMLElementTagNameMap[Tag], K>(key, state, signal)(element);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
for (const key of Object.keys(bind) as Array<
|
|
160
|
-
WritableKeys<HTMLElementTagNameMap[Tag]> & string
|
|
161
|
-
>) {
|
|
162
|
-
const state = bind[key];
|
|
143
|
+
for (const [key, state] of Object.entries(bind)) {
|
|
163
144
|
if (!state) continue;
|
|
164
|
-
|
|
145
|
+
(element as Record<string, unknown>)[key] = state.get();
|
|
146
|
+
state.watch(signal, (value) => {
|
|
147
|
+
(element as Record<string, unknown>)[key] = value;
|
|
148
|
+
});
|
|
165
149
|
}
|
|
166
150
|
}
|
|
167
151
|
|
|
@@ -251,6 +235,38 @@ export const bindProperty =
|
|
|
251
235
|
return el;
|
|
252
236
|
};
|
|
253
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Render a single slot from state and abort previous render on updates.
|
|
240
|
+
* Useful when render creates subscriptions, timers, or event handlers.
|
|
241
|
+
* Defaults to a "div" container when no tagName is provided.
|
|
242
|
+
*/
|
|
243
|
+
export const bindView = <
|
|
244
|
+
Tag extends keyof HTMLElementTagNameMap = "div",
|
|
245
|
+
T = unknown,
|
|
246
|
+
>(
|
|
247
|
+
signal: AbortSignal,
|
|
248
|
+
state: FunRead<T>,
|
|
249
|
+
render: (regionSignal: AbortSignal, data: T) => Element,
|
|
250
|
+
options?: { tagName?: Tag }
|
|
251
|
+
): HTMLElementTagNameMap[Tag] => {
|
|
252
|
+
const tagName = (options?.tagName ?? "div") as Tag;
|
|
253
|
+
const element = document.createElement(tagName);
|
|
254
|
+
let childCtrl: AbortController | null = null;
|
|
255
|
+
state.watch(signal, (data) => {
|
|
256
|
+
childCtrl?.abort();
|
|
257
|
+
childCtrl = new AbortController();
|
|
258
|
+
element.replaceChildren(render(childCtrl.signal, data));
|
|
259
|
+
});
|
|
260
|
+
signal.addEventListener(
|
|
261
|
+
"abort",
|
|
262
|
+
() => {
|
|
263
|
+
childCtrl?.abort();
|
|
264
|
+
},
|
|
265
|
+
{ once: true }
|
|
266
|
+
);
|
|
267
|
+
return element;
|
|
268
|
+
};
|
|
269
|
+
|
|
254
270
|
/**
|
|
255
271
|
* Add CSS classes to an element (returns element for chaining)
|
|
256
272
|
* @returns {Enhancer}
|
|
@@ -530,4 +546,4 @@ export const bindClass =
|
|
|
530
546
|
};
|
|
531
547
|
|
|
532
548
|
export const querySelectorAll = <T extends Element>(selector: string): T[] =>
|
|
533
|
-
Array.from(document.querySelectorAll(selector));
|
|
549
|
+
Array.from(document.querySelectorAll(selector));
|
package/dist/esm/src/state.d.ts
DELETED
package/dist/esm/src/state.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"state.js","sourceRoot":"","sources":["../../../src/state.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,OAAO,EAAE,QAAQ,EAAiB,MAAM,qBAAqB,CAAC"}
|
package/dist/src/state.d.ts
DELETED
package/dist/src/state.js
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.funState = void 0;
|
|
4
|
-
/** Re-export FunState and funState from fun-state with subscribe support */
|
|
5
|
-
var fun_state_1 = require("@fun-land/fun-state");
|
|
6
|
-
Object.defineProperty(exports, "funState", { enumerable: true, get: function () { return fun_state_1.funState; } });
|
|
7
|
-
//# sourceMappingURL=state.js.map
|
package/dist/src/state.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"state.js","sourceRoot":"","sources":["../../src/state.ts"],"names":[],"mappings":";;;AAAA,4EAA4E;AAC5E,iDAA8D;AAArD,qGAAA,QAAQ,OAAA"}
|