@pippenly/ts-utils 1.0.0 → 1.0.1
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 +39 -6
- package/package.json +16 -4
- package/src/dom/index.ts +1 -189
- package/src/dom/resin.ts +287 -0
package/README.md
CHANGED
|
@@ -13,22 +13,55 @@ const numberElment = document.getElementById("number")!; // HTMLElement
|
|
|
13
13
|
const otherNumberElement = document.getElementById("other-number")!; // HTMLElement
|
|
14
14
|
const binding = bind(number, numberElment); // BoundResin<T>
|
|
15
15
|
const complexBinding = bind(number, otherNumberElement, {
|
|
16
|
-
|
|
16
|
+
bindTo: "innerText",
|
|
17
17
|
map: (n) => n * 2,
|
|
18
|
+
tap: (n, el) => { ... },
|
|
18
19
|
if: (n) => n > 5,
|
|
19
20
|
class: {
|
|
20
|
-
"red": (
|
|
21
|
-
"blue": (
|
|
21
|
+
"red": (n, el) => n > 10,
|
|
22
|
+
"blue": (n, el) => n <= 10
|
|
22
23
|
},
|
|
23
24
|
attr: {
|
|
24
|
-
"data-value": (
|
|
25
|
+
"data-value": (n, el) => String(n)
|
|
25
26
|
}
|
|
26
27
|
});
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
number.value = 15; // Updates number.value, which triggers the effect to update innerText
|
|
30
|
+
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
console.log("Stop receiving updates for counter binding");
|
|
33
|
+
binding.dispose();
|
|
34
|
+
}, 5000);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Two-way binding
|
|
38
|
+
For two way binding, `model(sourceResin, element, options?)` exists, where element is
|
|
39
|
+
`type ModelElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement`. Provides
|
|
40
|
+
throttle / debounce options and custom get/set if needed.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const name = resin("");
|
|
44
|
+
const m = model(name, document.querySelector<HTMLInputElement>("#name")!, {
|
|
45
|
+
debounce: 300
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const checked = resin(false);
|
|
49
|
+
const checkedModel = model(checked, document.querySelector<HTMLInputElement>("#check")!, {
|
|
50
|
+
event: 'change',
|
|
51
|
+
get: (el) => el.checked,
|
|
52
|
+
set: (v, el) => { el.checked = v; }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const age = resin(0);
|
|
56
|
+
const ageModel = model(age, document.querySelector<HTMLInputElement>("#age")!, {
|
|
57
|
+
get: (el) => el.valueAsNumber,
|
|
58
|
+
set: (v, el) => { el.valueAsNumber = v; }
|
|
59
|
+
});
|
|
30
60
|
```
|
|
31
61
|
|
|
62
|
+
|
|
63
|
+
## Utilities
|
|
64
|
+
|
|
32
65
|
# Result
|
|
33
66
|
Small utility library so I can work with error as values and explict Error type declaration
|
|
34
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pippenly/ts-utils",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,13 +33,25 @@
|
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "tsc -p tsconfig.build.json && tsc-alias",
|
|
36
|
-
"prebuild": "
|
|
37
|
-
"clean": "
|
|
36
|
+
"prebuild": "rimraf dist",
|
|
37
|
+
"clean": "rimraf dist",
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
39
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests",
|
|
40
40
|
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch --passWithNoTests"
|
|
41
41
|
},
|
|
42
|
-
"keywords": [
|
|
42
|
+
"keywords": [
|
|
43
|
+
"utility",
|
|
44
|
+
"typescript",
|
|
45
|
+
"ts",
|
|
46
|
+
"result",
|
|
47
|
+
"error-as-value",
|
|
48
|
+
"dom",
|
|
49
|
+
"reactivity",
|
|
50
|
+
"reactive",
|
|
51
|
+
"binding",
|
|
52
|
+
"dom-utils",
|
|
53
|
+
"util"
|
|
54
|
+
],
|
|
43
55
|
"author": "",
|
|
44
56
|
"license": "ISC",
|
|
45
57
|
"devDependencies": {
|
package/src/dom/index.ts
CHANGED
|
@@ -1,189 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
type State<T> = { value: T };
|
|
4
|
-
|
|
5
|
-
type Resin<T> = State<T> & { bound: false };
|
|
6
|
-
|
|
7
|
-
type BoundState<E extends HTMLElement> = {
|
|
8
|
-
bound: true;
|
|
9
|
-
element: E;
|
|
10
|
-
dispose: () => Result<void, string>;
|
|
11
|
-
} & Pick<HTMLElement, "addEventListener" | "removeEventListener">;
|
|
12
|
-
|
|
13
|
-
type BoundResin<T> = State<T> & BoundState<HTMLElement>;
|
|
14
|
-
|
|
15
|
-
type ElementBindOptions<T, E extends HTMLElement, R = T> = {
|
|
16
|
-
map?: (value: T) => R;
|
|
17
|
-
if?: (value: R) => boolean;
|
|
18
|
-
filter?: (value: R) => boolean;
|
|
19
|
-
class?: {
|
|
20
|
-
[className: string]: (element: E, value: R) => boolean;
|
|
21
|
-
},
|
|
22
|
-
attr?: {
|
|
23
|
-
[attrName: string]: (element: E, value: R) => string | null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
type TextElementTypes =
|
|
28
|
-
| HTMLParagraphElement
|
|
29
|
-
| HTMLSpanElement
|
|
30
|
-
| HTMLDivElement
|
|
31
|
-
| HTMLHeadingElement
|
|
32
|
-
| HTMLAnchorElement
|
|
33
|
-
| HTMLButtonElement
|
|
34
|
-
| HTMLLabelElement
|
|
35
|
-
| HTMLOptionElement
|
|
36
|
-
| HTMLTableCellElement;
|
|
37
|
-
|
|
38
|
-
type TextBindOptions<T, E extends TextElementTypes> = ElementBindOptions<T, E> & {
|
|
39
|
-
type: 'innerText' | 'textContent' | 'innerHTML';
|
|
40
|
-
truncate?: number;
|
|
41
|
-
mask?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
type ValueBindOptions<T, E extends HTMLElement> = ElementBindOptions<T, E> & {
|
|
45
|
-
type: 'value';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
type BindOptions<T, E extends HTMLElement> =
|
|
49
|
-
| TextBindOptions<T, E>
|
|
50
|
-
| ValueBindOptions<T, E>;
|
|
51
|
-
|
|
52
|
-
export function resin<T>(initialValue: T): Resin<T> {
|
|
53
|
-
return { value: initialValue, bound: false };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function bind<T, E extends TextElementTypes>(resin: Resin<T>, element: E, options?: TextBindOptions<T, E>): BoundResin<T>;
|
|
57
|
-
export function bind<T, E extends HTMLElement>(resin: Resin<T>, element: E, options?: ValueBindOptions<T, E>): BoundResin<T>;
|
|
58
|
-
export function bind<T, E extends HTMLElement>(resin: Resin<T>, element: E, options?: BindOptions<T, E>): BoundResin<T> {
|
|
59
|
-
const subscribers = new Set<Effect>();
|
|
60
|
-
let disposed = false;
|
|
61
|
-
let _value = resin.value;
|
|
62
|
-
|
|
63
|
-
const notify = () => {
|
|
64
|
-
if (disposed) {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
subscribers.forEach(fn => fn());
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const getValue = (): T => {
|
|
72
|
-
const currentEffect = stack[stack.length - 1];
|
|
73
|
-
if (currentEffect) {
|
|
74
|
-
subscribers.add(currentEffect);
|
|
75
|
-
}
|
|
76
|
-
return _value;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const domEffect = () => {
|
|
80
|
-
const value = getValue();
|
|
81
|
-
const mappedValue = options?.map ? options.map(value) : value;
|
|
82
|
-
|
|
83
|
-
if (options?.filter && !options.filter(mappedValue)) {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (options?.if) {
|
|
88
|
-
if (!options.if(mappedValue as ReturnType<NonNullable<typeof options.map>>)) {
|
|
89
|
-
element.style.display = "none";
|
|
90
|
-
return;
|
|
91
|
-
} else {
|
|
92
|
-
element.style.display = element.style.display || "";
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (options?.class) {
|
|
97
|
-
for (const [className, fn] of Object.entries(options.class)) {
|
|
98
|
-
if (fn(element, mappedValue)) {
|
|
99
|
-
element.classList.add(className);
|
|
100
|
-
} else {
|
|
101
|
-
element.classList.remove(className);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (options?.attr) {
|
|
107
|
-
for (const [attrName, fn] of Object.entries(options.attr)) {
|
|
108
|
-
const attrValue = fn(element, mappedValue);
|
|
109
|
-
if (attrValue === null) {
|
|
110
|
-
element.removeAttribute(attrName);
|
|
111
|
-
} else {
|
|
112
|
-
element.setAttribute(attrName, attrValue);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let valueStr = String(mappedValue);
|
|
118
|
-
if (options?.type === 'innerText' || options?.type === 'textContent') {
|
|
119
|
-
if (options?.truncate !== undefined) {
|
|
120
|
-
valueStr = valueStr.slice(0, options.truncate);
|
|
121
|
-
}
|
|
122
|
-
if (options?.mask !== undefined) {
|
|
123
|
-
valueStr = valueStr.replace(/./g, options.mask);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (options?.type === "innerText") {
|
|
128
|
-
element.innerText = valueStr;
|
|
129
|
-
} else if (options?.type === "textContent") {
|
|
130
|
-
element.textContent = valueStr;
|
|
131
|
-
} else if (options?.type === "innerHTML") {
|
|
132
|
-
element.innerHTML = valueStr;
|
|
133
|
-
} else if (options?.type === "value") {
|
|
134
|
-
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
|
|
135
|
-
element.value = valueStr;
|
|
136
|
-
} else {
|
|
137
|
-
throw new Error("Value binding is only supported on input, textarea, and select elements");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
watchEffect(domEffect);
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
...resin,
|
|
146
|
-
bound: true,
|
|
147
|
-
element,
|
|
148
|
-
addEventListener: element.addEventListener.bind(element),
|
|
149
|
-
removeEventListener: element.removeEventListener.bind(element),
|
|
150
|
-
get value(): T {
|
|
151
|
-
if (disposed) {
|
|
152
|
-
throw new Error("Cannot get value of a disposed resin");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return getValue();
|
|
156
|
-
},
|
|
157
|
-
set value(newValue: T) {
|
|
158
|
-
if (disposed) {
|
|
159
|
-
throw new Error("Cannot set value of a disposed resin");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (_value === newValue) {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
_value = newValue;
|
|
167
|
-
notify();
|
|
168
|
-
},
|
|
169
|
-
dispose: () => {
|
|
170
|
-
if (disposed) {
|
|
171
|
-
return Err("Resin is already disposed");
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
subscribers.delete(domEffect);
|
|
175
|
-
disposed = true;
|
|
176
|
-
return Ok(undefined);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
type Effect = () => void;
|
|
182
|
-
|
|
183
|
-
const stack: Effect[] = [];
|
|
184
|
-
|
|
185
|
-
export function watchEffect(fn: Effect): void {
|
|
186
|
-
stack.push(fn);
|
|
187
|
-
fn();
|
|
188
|
-
stack.pop();
|
|
189
|
-
}
|
|
1
|
+
export * from "./resin.js";
|
package/src/dom/resin.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
type Effect = () => void;
|
|
2
|
+
|
|
3
|
+
type Resin<T> = {
|
|
4
|
+
value: T;
|
|
5
|
+
dispose: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type BoundResin<T, E extends HTMLElement> = Resin<T>
|
|
9
|
+
& Pick<HTMLElement, "addEventListener" | "removeEventListener">
|
|
10
|
+
& {
|
|
11
|
+
element: E;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type BindingOptions<T, E extends HTMLElement, Mapped = T> = {
|
|
15
|
+
bindTo?: 'innerText' | 'textContent' | 'innerHTML' | 'value';
|
|
16
|
+
map?: (value: T) => Mapped;
|
|
17
|
+
tap?: (value: Mapped, element: E) => void;
|
|
18
|
+
if?: (value: Mapped) => boolean;
|
|
19
|
+
filter?: (value: Mapped) => boolean;
|
|
20
|
+
class?: {
|
|
21
|
+
[className: string]: (value: Mapped, element: E) => boolean;
|
|
22
|
+
},
|
|
23
|
+
attr?: {
|
|
24
|
+
[attrName: string]: (value: Mapped, element: E) => string | null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resin<T>(initialValue: T): Resin<T> {
|
|
29
|
+
let value = initialValue;
|
|
30
|
+
let disposed = false;
|
|
31
|
+
|
|
32
|
+
const subscribers = new Set<Effect>();
|
|
33
|
+
|
|
34
|
+
const notify = () => {
|
|
35
|
+
if (disposed) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (currentDepth > 0) {
|
|
39
|
+
for (const effect of subscribers) {
|
|
40
|
+
pending.add(effect);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
for (const effect of subscribers) {
|
|
44
|
+
effect();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const getValue = (): T => {
|
|
50
|
+
if (stack.length > 0) {
|
|
51
|
+
const effect = stack[stack.length - 1]!;
|
|
52
|
+
subscribers.add(effect);
|
|
53
|
+
effectCleanups.get(effect)?.add(subscribers);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
get value() {
|
|
60
|
+
return getValue();
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
set value(newValue: T) {
|
|
64
|
+
if (disposed) return;
|
|
65
|
+
value = newValue;
|
|
66
|
+
notify();
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
dispose() {
|
|
70
|
+
disposed = true;
|
|
71
|
+
subscribers.clear();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function bind<T, E extends HTMLElement>(resin: Resin<T>, element: E, options?: BindingOptions<T, E>): BoundResin<T, E> {
|
|
77
|
+
let bindingDisposed = false;
|
|
78
|
+
|
|
79
|
+
const mainEffect = () => {
|
|
80
|
+
if (bindingDisposed) return;
|
|
81
|
+
const value = resin.value;
|
|
82
|
+
const mappedValue = options?.map ? options.map(value) : value;
|
|
83
|
+
|
|
84
|
+
if (options?.filter && !options.filter(mappedValue)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options?.if) {
|
|
89
|
+
if (!options.if(mappedValue)) {
|
|
90
|
+
element.style.display = "none";
|
|
91
|
+
return;
|
|
92
|
+
} else {
|
|
93
|
+
element.style.display = "";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (options?.class) {
|
|
98
|
+
for (const [className, fn] of Object.entries(options.class)) {
|
|
99
|
+
if (fn(mappedValue, element)) {
|
|
100
|
+
element.classList.add(className);
|
|
101
|
+
} else {
|
|
102
|
+
element.classList.remove(className);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (options?.attr) {
|
|
108
|
+
for (const [attrName, fn] of Object.entries(options.attr)) {
|
|
109
|
+
const attrValue = fn(mappedValue, element);
|
|
110
|
+
if (attrValue === null) {
|
|
111
|
+
element.removeAttribute(attrName);
|
|
112
|
+
} else {
|
|
113
|
+
element.setAttribute(attrName, attrValue);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options?.tap) {
|
|
119
|
+
options.tap(mappedValue, element);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (options?.bindTo) {
|
|
123
|
+
switch (options.bindTo) {
|
|
124
|
+
case "innerText":
|
|
125
|
+
element.innerText = String(mappedValue);
|
|
126
|
+
break;
|
|
127
|
+
case "textContent":
|
|
128
|
+
element.textContent = String(mappedValue);
|
|
129
|
+
break;
|
|
130
|
+
case "innerHTML":
|
|
131
|
+
element.innerHTML = String(mappedValue);
|
|
132
|
+
break;
|
|
133
|
+
case "value":
|
|
134
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
|
|
135
|
+
element.value = String(mappedValue);
|
|
136
|
+
} else {
|
|
137
|
+
console.warn("bindTo 'value' is only applicable to input, textarea, and select elements.");
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stopEffect = watchEffect(mainEffect);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
get value() {
|
|
148
|
+
return resin.value;
|
|
149
|
+
},
|
|
150
|
+
set value(newValue: T) {
|
|
151
|
+
resin.value = newValue;
|
|
152
|
+
},
|
|
153
|
+
addEventListener: element.addEventListener.bind(element),
|
|
154
|
+
removeEventListener: element.removeEventListener.bind(element),
|
|
155
|
+
element,
|
|
156
|
+
dispose() {
|
|
157
|
+
bindingDisposed = true;
|
|
158
|
+
stopEffect();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const stack: Effect[] = [];
|
|
164
|
+
const pending: Set<Effect> = new Set();
|
|
165
|
+
const effectCleanups = new Map<Effect, Set<Set<Effect>>>();
|
|
166
|
+
let currentDepth = 0;
|
|
167
|
+
|
|
168
|
+
export function watchEffect(fn: Effect): () => void {
|
|
169
|
+
const cleanups = new Set<Set<Effect>>();
|
|
170
|
+
effectCleanups.set(fn, cleanups);
|
|
171
|
+
|
|
172
|
+
stack.push(fn);
|
|
173
|
+
fn();
|
|
174
|
+
stack.pop();
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
for (const set of cleanups) {
|
|
178
|
+
set.delete(fn);
|
|
179
|
+
}
|
|
180
|
+
cleanups.clear();
|
|
181
|
+
effectCleanups.delete(fn);
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function computed<T>(fn: () => T): Resin<T> {
|
|
186
|
+
const result = resin(fn());
|
|
187
|
+
const stop = watchEffect(() => {
|
|
188
|
+
result.value = fn();
|
|
189
|
+
});
|
|
190
|
+
const originalDispose = result.dispose;
|
|
191
|
+
result.dispose = () => {
|
|
192
|
+
stop();
|
|
193
|
+
originalDispose();
|
|
194
|
+
};
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function batch(fn: Effect): void {
|
|
199
|
+
currentDepth++;
|
|
200
|
+
try {
|
|
201
|
+
fn();
|
|
202
|
+
} finally {
|
|
203
|
+
currentDepth--;
|
|
204
|
+
if (currentDepth === 0) {
|
|
205
|
+
for (const effect of pending) {
|
|
206
|
+
effect();
|
|
207
|
+
}
|
|
208
|
+
pending.clear();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type ModelElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
214
|
+
|
|
215
|
+
type ModelOptions<T, E extends ModelElement> = {
|
|
216
|
+
event?: string;
|
|
217
|
+
set?: (value: T, element: E) => void;
|
|
218
|
+
get?: (element: E) => T;
|
|
219
|
+
throttle?: number;
|
|
220
|
+
debounce?: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type ModelResin<T, E extends ModelElement> = {
|
|
224
|
+
value: T;
|
|
225
|
+
element: E;
|
|
226
|
+
dispose: () => void;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function model<T, E extends ModelElement>(
|
|
230
|
+
source: Resin<T>,
|
|
231
|
+
element: E,
|
|
232
|
+
options?: ModelOptions<T, E>
|
|
233
|
+
): ModelResin<T, E> {
|
|
234
|
+
let disposed = false;
|
|
235
|
+
const eventName = options?.event ?? 'input';
|
|
236
|
+
|
|
237
|
+
const setElement = options?.set ?? ((value: T, el: E) => { el.value = String(value); });
|
|
238
|
+
const getElement = options?.get ?? ((el: E) => el.value as T);
|
|
239
|
+
|
|
240
|
+
const stopEffect = watchEffect(() => {
|
|
241
|
+
if (disposed) return;
|
|
242
|
+
setElement(source.value, element);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
let listener = () => {
|
|
246
|
+
if (disposed) return;
|
|
247
|
+
source.value = getElement(element);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (options?.throttle) {
|
|
251
|
+
let lastCall = 0;
|
|
252
|
+
listener = () => {
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
if (now - lastCall >= options.throttle!) {
|
|
255
|
+
lastCall = now;
|
|
256
|
+
source.value = getElement(element);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (options?.debounce) {
|
|
262
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
263
|
+
listener = () => {
|
|
264
|
+
if (timeout) {
|
|
265
|
+
clearTimeout(timeout);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
timeout = setTimeout(() => {
|
|
269
|
+
source.value = getElement(element);
|
|
270
|
+
timeout = null;
|
|
271
|
+
}, options.debounce);
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
element.addEventListener(eventName, listener);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
get value() { return source.value; },
|
|
279
|
+
set value(v: T) { source.value = v; },
|
|
280
|
+
element,
|
|
281
|
+
dispose() {
|
|
282
|
+
disposed = true;
|
|
283
|
+
element.removeEventListener(eventName, listener);
|
|
284
|
+
stopEffect();
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|