@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { funState } from "./state";
|
|
2
|
+
import { prop } from "@fun-land/accessor";
|
|
3
|
+
|
|
4
|
+
describe("funState", () => {
|
|
5
|
+
it("should create state with initial value", () => {
|
|
6
|
+
const state = funState({ count: 0 });
|
|
7
|
+
expect(state.get()).toEqual({ count: 0 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should update state with set", () => {
|
|
11
|
+
const state = funState({ count: 0 });
|
|
12
|
+
state.set({ count: 5 });
|
|
13
|
+
expect(state.get()).toEqual({ count: 5 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should update state with mod", () => {
|
|
17
|
+
const state = funState({ count: 0 });
|
|
18
|
+
state.mod((s: { count: number }) => ({ count: s.count + 1 }));
|
|
19
|
+
expect(state.get()).toEqual({ count: 1 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should focus on property", () => {
|
|
23
|
+
const state = funState({ count: 0, name: "test" });
|
|
24
|
+
const countState = state.prop("count");
|
|
25
|
+
expect(countState.get()).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should update focused state", () => {
|
|
29
|
+
const state = funState({ count: 0, name: "test" });
|
|
30
|
+
const countState = state.prop("count");
|
|
31
|
+
countState.set(5);
|
|
32
|
+
expect(state.get()).toEqual({ count: 5, name: "test" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("subscribe subscriptions", () => {
|
|
36
|
+
it("should call subscriber when state changes", () => {
|
|
37
|
+
const state = funState({ count: 0 });
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const callback = jest.fn();
|
|
40
|
+
|
|
41
|
+
state.subscribe(controller.signal, callback);
|
|
42
|
+
state.set({ count: 1 });
|
|
43
|
+
|
|
44
|
+
expect(callback).toHaveBeenCalledWith({ count: 1 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should call subscriber multiple times", () => {
|
|
48
|
+
const state = funState({ count: 0 });
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const callback = jest.fn();
|
|
51
|
+
|
|
52
|
+
state.subscribe(controller.signal, callback);
|
|
53
|
+
state.set({ count: 1 });
|
|
54
|
+
state.set({ count: 2 });
|
|
55
|
+
|
|
56
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
57
|
+
expect(callback).toHaveBeenNthCalledWith(1, { count: 1 });
|
|
58
|
+
expect(callback).toHaveBeenNthCalledWith(2, { count: 2 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should call focused state subscriber only when that property changes", () => {
|
|
62
|
+
const state = funState({ count: 0, name: "test" });
|
|
63
|
+
const countState = state.prop("count");
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const callback = jest.fn();
|
|
66
|
+
|
|
67
|
+
countState.subscribe(controller.signal, callback);
|
|
68
|
+
|
|
69
|
+
// Change count - should trigger
|
|
70
|
+
state.set({ count: 1, name: "test" });
|
|
71
|
+
expect(callback).toHaveBeenCalledWith(1);
|
|
72
|
+
|
|
73
|
+
// Change name only - should not trigger
|
|
74
|
+
callback.mockClear();
|
|
75
|
+
state.set({ count: 1, name: "changed" });
|
|
76
|
+
expect(callback).not.toHaveBeenCalled();
|
|
77
|
+
|
|
78
|
+
// Change count again - should trigger
|
|
79
|
+
state.set({ count: 2, name: "changed" });
|
|
80
|
+
expect(callback).toHaveBeenCalledWith(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should support multiple subscribers", () => {
|
|
84
|
+
const state = funState({ count: 0 });
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const callback1 = jest.fn();
|
|
87
|
+
const callback2 = jest.fn();
|
|
88
|
+
|
|
89
|
+
state.subscribe(controller.signal, callback1);
|
|
90
|
+
state.subscribe(controller.signal, callback2);
|
|
91
|
+
|
|
92
|
+
state.set({ count: 1 });
|
|
93
|
+
|
|
94
|
+
expect(callback1).toHaveBeenCalledWith({ count: 1 });
|
|
95
|
+
expect(callback2).toHaveBeenCalledWith({ count: 1 });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should work with accessor-based focus", () => {
|
|
99
|
+
interface User {
|
|
100
|
+
name: string;
|
|
101
|
+
age: number;
|
|
102
|
+
}
|
|
103
|
+
const state = funState<User>({ name: "Alice", age: 30 });
|
|
104
|
+
const nameState = state.focus(prop<User>()("name"));
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const callback = jest.fn();
|
|
107
|
+
|
|
108
|
+
nameState.subscribe(controller.signal, callback);
|
|
109
|
+
|
|
110
|
+
state.set({ name: "Bob", age: 30 });
|
|
111
|
+
|
|
112
|
+
expect(callback).toHaveBeenCalledWith("Bob");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("query with accessors", () => {
|
|
117
|
+
it("should query state using accessor", () => {
|
|
118
|
+
const state = funState({ count: 5 });
|
|
119
|
+
const result = state.query(prop<{ count: number }>()("count"));
|
|
120
|
+
expect(result).toEqual([5]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should query focused state using accessor", () => {
|
|
124
|
+
interface User {
|
|
125
|
+
profile: {
|
|
126
|
+
name: string;
|
|
127
|
+
age: number;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const state = funState<User>({
|
|
131
|
+
profile: { name: "Alice", age: 30 },
|
|
132
|
+
});
|
|
133
|
+
const profileState = state.prop("profile");
|
|
134
|
+
const result = profileState.query(prop<User["profile"]>()("name"));
|
|
135
|
+
expect(result).toEqual(["Alice"]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("nested focus", () => {
|
|
140
|
+
it("should support focusing a focused state", () => {
|
|
141
|
+
interface AppState {
|
|
142
|
+
user: {
|
|
143
|
+
profile: {
|
|
144
|
+
name: string;
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const state = funState<AppState>({
|
|
149
|
+
user: { profile: { name: "Alice" } },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const userState = state.prop("user");
|
|
153
|
+
const profileState = userState.prop("profile");
|
|
154
|
+
const nameState = profileState.prop("name");
|
|
155
|
+
|
|
156
|
+
expect(nameState.get()).toBe("Alice");
|
|
157
|
+
|
|
158
|
+
nameState.set("Bob");
|
|
159
|
+
expect(state.get().user.profile.name).toBe("Bob");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should trigger subscriptions on nested focused states", () => {
|
|
163
|
+
interface AppState {
|
|
164
|
+
user: {
|
|
165
|
+
profile: {
|
|
166
|
+
name: string;
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const state = funState<AppState>({
|
|
171
|
+
user: { profile: { name: "Alice" } },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const userState = state.prop("user");
|
|
175
|
+
const profileState = userState.prop("profile");
|
|
176
|
+
const nameState = profileState.prop("name");
|
|
177
|
+
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
const callback = jest.fn();
|
|
180
|
+
|
|
181
|
+
nameState.subscribe(controller.signal, callback);
|
|
182
|
+
|
|
183
|
+
state.set({ user: { profile: { name: "Bob" } } });
|
|
184
|
+
|
|
185
|
+
expect(callback).toHaveBeenCalledWith("Bob");
|
|
186
|
+
|
|
187
|
+
controller.abort();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should not trigger deeply focused subscription when unrelated field changes", () => {
|
|
191
|
+
interface AppState {
|
|
192
|
+
user: {
|
|
193
|
+
profile: {
|
|
194
|
+
name: string;
|
|
195
|
+
age: number;
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const state = funState<AppState>({
|
|
200
|
+
user: { profile: { name: "Alice", age: 30 } },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const userState = state.prop("user");
|
|
204
|
+
const profileState = userState.prop("profile");
|
|
205
|
+
const nameState = profileState.prop("name");
|
|
206
|
+
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const callback = jest.fn();
|
|
209
|
+
|
|
210
|
+
nameState.subscribe(controller.signal, callback);
|
|
211
|
+
|
|
212
|
+
// Change age, not name
|
|
213
|
+
state.set({ user: { profile: { name: "Alice", age: 31 } } });
|
|
214
|
+
|
|
215
|
+
expect(callback).not.toHaveBeenCalled();
|
|
216
|
+
|
|
217
|
+
// Change name
|
|
218
|
+
state.set({ user: { profile: { name: "Bob", age: 31 } } });
|
|
219
|
+
|
|
220
|
+
expect(callback).toHaveBeenCalledWith("Bob");
|
|
221
|
+
|
|
222
|
+
controller.abort();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/state.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Core types for fun-web */
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
4
|
+
export type Component<Props = {}> = (
|
|
5
|
+
signal: AbortSignal,
|
|
6
|
+
props: Props
|
|
7
|
+
) => Element
|
|
8
|
+
|
|
9
|
+
export type ElementChild = string | number | Element | null | undefined
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"incremental": true,
|
|
4
|
+
"strict": true,
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"sourceMap": true,
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"target": "ES2015",
|
|
10
|
+
"moduleResolution": "node",
|
|
11
|
+
"module": "CommonJS",
|
|
12
|
+
"composite": true,
|
|
13
|
+
"lib": ["ES2015", "DOM"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"]
|
|
16
|
+
}
|