@fun-land/fun-web 0.5.0 → 1.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 +188 -94
- package/dist/esm/src/dom.d.ts +47 -5
- package/dist/esm/src/dom.js +62 -3
- 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 +47 -5
- package/dist/src/dom.js +66 -6
- 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 +10 -20
- package/package.json +12 -13
- package/src/dom.test.ts +157 -354
- package/src/dom.ts +136 -6
- package/src/index.ts +1 -20
- package/src/mount.test.ts +2 -2
- package/src/state.test.ts +0 -251
- package/src/state.ts +0 -2
package/src/dom.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/** DOM utilities for functional element creation and manipulation */
|
|
2
|
-
import { type FunState } from "@fun-land/fun-state";
|
|
2
|
+
import { type FunState, type FunRead } from "@fun-land/fun-state";
|
|
3
3
|
import type { Component, ElementChild } from "./types";
|
|
4
4
|
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
|
+
|
|
8
17
|
/**
|
|
9
18
|
* Create an HTML element with attributes and children
|
|
10
19
|
*
|
|
@@ -22,6 +31,7 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
|
|
|
22
31
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
32
|
attrs?: Record<string, any> | null,
|
|
24
33
|
children?: ElementChild | ElementChild[]
|
|
34
|
+
// eslint-disable-next-line complexity
|
|
25
35
|
): HTMLElementTagNameMap[Tag] => {
|
|
26
36
|
const element = document.createElement(tag);
|
|
27
37
|
|
|
@@ -54,6 +64,119 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
|
|
|
54
64
|
return element;
|
|
55
65
|
};
|
|
56
66
|
|
|
67
|
+
// Helper type to extract only writable properties
|
|
68
|
+
type WritableKeys<T> = {
|
|
69
|
+
[K in keyof T]-?: (<F>() => F extends { [Q in K]: T[K] } ? 1 : 2) extends <
|
|
70
|
+
F,
|
|
71
|
+
>() => F extends { -readonly [Q in K]: T[K] } ? 1 : 2
|
|
72
|
+
? K
|
|
73
|
+
: never;
|
|
74
|
+
}[keyof T];
|
|
75
|
+
|
|
76
|
+
type HxProps<El extends Element> = Partial<{
|
|
77
|
+
[K in WritableKeys<El> & string]: El[K] | null | undefined;
|
|
78
|
+
}>;
|
|
79
|
+
|
|
80
|
+
type HxHandlers<El extends Element> = Partial<{
|
|
81
|
+
[K in keyof GlobalEventHandlersEventMap]: (
|
|
82
|
+
ev: GlobalEventHandlersEventMap[K] & { currentTarget: El }
|
|
83
|
+
) => void;
|
|
84
|
+
}>;
|
|
85
|
+
|
|
86
|
+
type HxBindings<El extends Element> = Partial<{
|
|
87
|
+
[K in WritableKeys<El> & string]: FunRead<El[K]>;
|
|
88
|
+
}>;
|
|
89
|
+
|
|
90
|
+
type HxOptionsBase<El extends Element> = {
|
|
91
|
+
props?: HxProps<El>;
|
|
92
|
+
attrs?: Record<string, string | number | boolean | null | undefined>;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type HxOptions<El extends Element> = HxOptionsBase<El> & {
|
|
96
|
+
signal: AbortSignal;
|
|
97
|
+
on?: HxHandlers<El>;
|
|
98
|
+
bind?: HxBindings<El>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create an element with structured props, attrs, event handlers, and bindings.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* hx("input", {
|
|
106
|
+
* signal,
|
|
107
|
+
* props: { type: "text" },
|
|
108
|
+
* attrs: { "data-test": "name" },
|
|
109
|
+
* bind: { value: nameState },
|
|
110
|
+
* on: { input: (e) => nameState.set(e.currentTarget.value) },
|
|
111
|
+
* });
|
|
112
|
+
*/
|
|
113
|
+
export function hx<Tag extends keyof HTMLElementTagNameMap>(
|
|
114
|
+
tag: Tag,
|
|
115
|
+
options: HxOptions<HTMLElementTagNameMap[Tag]>,
|
|
116
|
+
children?: ElementChild | ElementChild[]
|
|
117
|
+
): HTMLElementTagNameMap[Tag];
|
|
118
|
+
// eslint-disable-next-line complexity
|
|
119
|
+
export function hx<Tag extends keyof HTMLElementTagNameMap>(
|
|
120
|
+
tag: Tag,
|
|
121
|
+
options: HxOptions<HTMLElementTagNameMap[Tag]>,
|
|
122
|
+
children?: ElementChild | ElementChild[]
|
|
123
|
+
): HTMLElementTagNameMap[Tag] {
|
|
124
|
+
if (!options?.signal) {
|
|
125
|
+
throw new Error("hx: signal is required");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { signal, props, attrs: attrMap, on: onMap, bind } = options;
|
|
129
|
+
const element = document.createElement(tag);
|
|
130
|
+
|
|
131
|
+
if (props) {
|
|
132
|
+
for (const [key, value] of entries(props)) {
|
|
133
|
+
if (value == null) continue;
|
|
134
|
+
element[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (attrMap) {
|
|
139
|
+
for (const [key, value] of Object.entries(attrMap)) {
|
|
140
|
+
if (value == null) continue;
|
|
141
|
+
element.setAttribute(key, String(value));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (children != null) {
|
|
146
|
+
appendChildren(children)(element);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (bind) {
|
|
150
|
+
const bindElementProperty = <
|
|
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];
|
|
163
|
+
if (!state) continue;
|
|
164
|
+
bindElementProperty(key, state);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (onMap) {
|
|
169
|
+
for (const [event, handler] of Object.entries(onMap)) {
|
|
170
|
+
if (!handler) continue;
|
|
171
|
+
element.addEventListener(event, handler as EventListener, {
|
|
172
|
+
signal,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return element;
|
|
178
|
+
}
|
|
179
|
+
|
|
57
180
|
/**
|
|
58
181
|
* Append children to an element, flattening arrays and converting primitives to text nodes
|
|
59
182
|
* @returns {Enhancer}
|
|
@@ -114,7 +237,7 @@ export const attrs =
|
|
|
114
237
|
export const bindProperty =
|
|
115
238
|
<E extends Element, K extends keyof E & string>(
|
|
116
239
|
key: K,
|
|
117
|
-
state:
|
|
240
|
+
state: FunRead<E[K]>,
|
|
118
241
|
signal: AbortSignal
|
|
119
242
|
) =>
|
|
120
243
|
(el: E): E => {
|
|
@@ -260,6 +383,7 @@ export const bindListChildren =
|
|
|
260
383
|
rows.clear();
|
|
261
384
|
};
|
|
262
385
|
|
|
386
|
+
// eslint-disable-next-line complexity
|
|
263
387
|
const reconcile = (): void => {
|
|
264
388
|
const items = list.get();
|
|
265
389
|
|
|
@@ -342,7 +466,7 @@ export const bindListChildren =
|
|
|
342
466
|
* });
|
|
343
467
|
*/
|
|
344
468
|
export function renderWhen<State, Props>(options: {
|
|
345
|
-
state:
|
|
469
|
+
state: FunRead<State>;
|
|
346
470
|
predicate?: (value: State) => boolean;
|
|
347
471
|
component: Component<Props>;
|
|
348
472
|
props: Props;
|
|
@@ -361,6 +485,7 @@ export function renderWhen<State, Props>(options: {
|
|
|
361
485
|
let childCtrl: AbortController | null = null;
|
|
362
486
|
let childEl: Element | null = null;
|
|
363
487
|
|
|
488
|
+
// eslint-disable-next-line complexity
|
|
364
489
|
const reconcile = () => {
|
|
365
490
|
const shouldRender = predicate(state.get());
|
|
366
491
|
|
|
@@ -396,8 +521,13 @@ export function renderWhen<State, Props>(options: {
|
|
|
396
521
|
return container;
|
|
397
522
|
}
|
|
398
523
|
|
|
399
|
-
|
|
400
|
-
|
|
524
|
+
/** add passed class (idempotent) to element when state returns true */
|
|
525
|
+
export const bindClass =
|
|
526
|
+
(className: string, state: FunRead<boolean>, signal: AbortSignal) =>
|
|
527
|
+
<E extends Element>(el: E): E => {
|
|
528
|
+
state.watch(signal, (active) => el.classList.toggle(className, active));
|
|
529
|
+
return el;
|
|
530
|
+
};
|
|
401
531
|
|
|
402
|
-
export const
|
|
532
|
+
export const querySelectorAll = <T extends Element>(selector: string): T[] =>
|
|
403
533
|
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";
|
package/src/mount.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mount } from "./mount";
|
|
2
2
|
import { h } from "./dom";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import type { Component } from "./index";
|
|
4
|
+
import { funState, type FunState } from "@fun-land/fun-state";
|
|
5
5
|
|
|
6
6
|
describe("mount()", () => {
|
|
7
7
|
let container: HTMLDivElement;
|
package/src/state.test.ts
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
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.watch(controller.signal, callback);
|
|
42
|
-
state.set({ count: 1 });
|
|
43
|
-
|
|
44
|
-
// Called with initial value, then with new value
|
|
45
|
-
expect(callback).toHaveBeenCalledTimes(2);
|
|
46
|
-
expect(callback).toHaveBeenNthCalledWith(1, { count: 0 });
|
|
47
|
-
expect(callback).toHaveBeenNthCalledWith(2, { count: 1 });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("should call subscriber multiple times", () => {
|
|
51
|
-
const state = funState({ count: 0 });
|
|
52
|
-
const controller = new AbortController();
|
|
53
|
-
const callback = jest.fn();
|
|
54
|
-
|
|
55
|
-
state.watch(controller.signal, callback);
|
|
56
|
-
state.set({ count: 1 });
|
|
57
|
-
state.set({ count: 2 });
|
|
58
|
-
|
|
59
|
-
// watch calls immediately with initial value, then on each change
|
|
60
|
-
expect(callback).toHaveBeenCalledTimes(3);
|
|
61
|
-
expect(callback).toHaveBeenNthCalledWith(1, { count: 0 });
|
|
62
|
-
expect(callback).toHaveBeenNthCalledWith(2, { count: 1 });
|
|
63
|
-
expect(callback).toHaveBeenNthCalledWith(3, { count: 2 });
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("should call focused state subscriber only when that property changes", () => {
|
|
67
|
-
const state = funState({ count: 0, name: "test" });
|
|
68
|
-
const countState = state.prop("count");
|
|
69
|
-
const controller = new AbortController();
|
|
70
|
-
const callback = jest.fn();
|
|
71
|
-
|
|
72
|
-
countState.watch(controller.signal, callback);
|
|
73
|
-
|
|
74
|
-
// Initial call with 0
|
|
75
|
-
expect(callback).toHaveBeenCalledWith(0);
|
|
76
|
-
|
|
77
|
-
// Change count - should trigger
|
|
78
|
-
state.set({ count: 1, name: "test" });
|
|
79
|
-
expect(callback).toHaveBeenCalledWith(1);
|
|
80
|
-
|
|
81
|
-
// Change name only - should not trigger
|
|
82
|
-
callback.mockClear();
|
|
83
|
-
state.set({ count: 1, name: "changed" });
|
|
84
|
-
expect(callback).not.toHaveBeenCalled();
|
|
85
|
-
|
|
86
|
-
// Change count again - should trigger
|
|
87
|
-
state.set({ count: 2, name: "changed" });
|
|
88
|
-
expect(callback).toHaveBeenCalledWith(2);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("should support multiple subscribers", () => {
|
|
92
|
-
const state = funState({ count: 0 });
|
|
93
|
-
const controller = new AbortController();
|
|
94
|
-
const callback1 = jest.fn();
|
|
95
|
-
const callback2 = jest.fn();
|
|
96
|
-
|
|
97
|
-
state.watch(controller.signal, callback1);
|
|
98
|
-
state.watch(controller.signal, callback2);
|
|
99
|
-
|
|
100
|
-
state.set({ count: 1 });
|
|
101
|
-
|
|
102
|
-
// Both should be called with initial value, then with new value
|
|
103
|
-
expect(callback1).toHaveBeenCalledTimes(2);
|
|
104
|
-
expect(callback1).toHaveBeenNthCalledWith(1, { count: 0 });
|
|
105
|
-
expect(callback1).toHaveBeenNthCalledWith(2, { count: 1 });
|
|
106
|
-
expect(callback2).toHaveBeenCalledTimes(2);
|
|
107
|
-
expect(callback2).toHaveBeenNthCalledWith(1, { count: 0 });
|
|
108
|
-
expect(callback2).toHaveBeenNthCalledWith(2, { count: 1 });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("should work with accessor-based focus", () => {
|
|
112
|
-
interface User {
|
|
113
|
-
name: string;
|
|
114
|
-
age: number;
|
|
115
|
-
}
|
|
116
|
-
const state = funState<User>({ name: "Alice", age: 30 });
|
|
117
|
-
const nameState = state.focus(prop<User>()("name"));
|
|
118
|
-
const controller = new AbortController();
|
|
119
|
-
const callback = jest.fn();
|
|
120
|
-
|
|
121
|
-
nameState.watch(controller.signal, callback);
|
|
122
|
-
|
|
123
|
-
state.set({ name: "Bob", age: 30 });
|
|
124
|
-
|
|
125
|
-
// Called with initial value "Alice", then "Bob"
|
|
126
|
-
expect(callback).toHaveBeenCalledTimes(2);
|
|
127
|
-
expect(callback).toHaveBeenNthCalledWith(1, "Alice");
|
|
128
|
-
expect(callback).toHaveBeenNthCalledWith(2, "Bob");
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("query with accessors", () => {
|
|
133
|
-
it("should query state using accessor", () => {
|
|
134
|
-
const state = funState({ count: 5 });
|
|
135
|
-
const result = state.query(prop<{ count: number }>()("count"));
|
|
136
|
-
expect(result).toEqual([5]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("should query focused state using accessor", () => {
|
|
140
|
-
interface User {
|
|
141
|
-
profile: {
|
|
142
|
-
name: string;
|
|
143
|
-
age: number;
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
const state = funState<User>({
|
|
147
|
-
profile: { name: "Alice", age: 30 },
|
|
148
|
-
});
|
|
149
|
-
const profileState = state.prop("profile");
|
|
150
|
-
const result = profileState.query(prop<User["profile"]>()("name"));
|
|
151
|
-
expect(result).toEqual(["Alice"]);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe("nested focus", () => {
|
|
156
|
-
it("should support focusing a focused state", () => {
|
|
157
|
-
interface AppState {
|
|
158
|
-
user: {
|
|
159
|
-
profile: {
|
|
160
|
-
name: string;
|
|
161
|
-
};
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
const state = funState<AppState>({
|
|
165
|
-
user: { profile: { name: "Alice" } },
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const userState = state.prop("user");
|
|
169
|
-
const profileState = userState.prop("profile");
|
|
170
|
-
const nameState = profileState.prop("name");
|
|
171
|
-
|
|
172
|
-
expect(nameState.get()).toBe("Alice");
|
|
173
|
-
|
|
174
|
-
nameState.set("Bob");
|
|
175
|
-
expect(state.get().user.profile.name).toBe("Bob");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("should trigger subscriptions on nested focused states", () => {
|
|
179
|
-
interface AppState {
|
|
180
|
-
user: {
|
|
181
|
-
profile: {
|
|
182
|
-
name: string;
|
|
183
|
-
};
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
const state = funState<AppState>({
|
|
187
|
-
user: { profile: { name: "Alice" } },
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
const userState = state.prop("user");
|
|
191
|
-
const profileState = userState.prop("profile");
|
|
192
|
-
const nameState = profileState.prop("name");
|
|
193
|
-
|
|
194
|
-
const controller = new AbortController();
|
|
195
|
-
const callback = jest.fn();
|
|
196
|
-
|
|
197
|
-
nameState.watch(controller.signal, callback);
|
|
198
|
-
|
|
199
|
-
state.set({ user: { profile: { name: "Bob" } } });
|
|
200
|
-
|
|
201
|
-
// Called with initial value "Alice", then "Bob"
|
|
202
|
-
expect(callback).toHaveBeenCalledTimes(2);
|
|
203
|
-
expect(callback).toHaveBeenNthCalledWith(1, "Alice");
|
|
204
|
-
expect(callback).toHaveBeenNthCalledWith(2, "Bob");
|
|
205
|
-
|
|
206
|
-
controller.abort();
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("should not trigger deeply focused subscription when unrelated field changes", () => {
|
|
210
|
-
interface AppState {
|
|
211
|
-
user: {
|
|
212
|
-
profile: {
|
|
213
|
-
name: string;
|
|
214
|
-
age: number;
|
|
215
|
-
};
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
const state = funState<AppState>({
|
|
219
|
-
user: { profile: { name: "Alice", age: 30 } },
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const userState = state.prop("user");
|
|
223
|
-
const profileState = userState.prop("profile");
|
|
224
|
-
const nameState = profileState.prop("name");
|
|
225
|
-
|
|
226
|
-
const controller = new AbortController();
|
|
227
|
-
const callback = jest.fn();
|
|
228
|
-
|
|
229
|
-
nameState.watch(controller.signal, callback);
|
|
230
|
-
|
|
231
|
-
// Initial call with "Alice"
|
|
232
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
233
|
-
expect(callback).toHaveBeenCalledWith("Alice");
|
|
234
|
-
|
|
235
|
-
callback.mockClear();
|
|
236
|
-
|
|
237
|
-
// Change age, not name - should not trigger again
|
|
238
|
-
state.set({ user: { profile: { name: "Alice", age: 31 } } });
|
|
239
|
-
|
|
240
|
-
expect(callback).not.toHaveBeenCalled();
|
|
241
|
-
|
|
242
|
-
// Change name - should trigger
|
|
243
|
-
state.set({ user: { profile: { name: "Bob", age: 31 } } });
|
|
244
|
-
|
|
245
|
-
expect(callback).toHaveBeenCalledWith("Bob");
|
|
246
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
247
|
-
|
|
248
|
-
controller.abort();
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
});
|
package/src/state.ts
DELETED