@granularjs/core 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/README.md +576 -0
- package/dist/granular.min.js +2 -0
- package/dist/granular.min.js.map +7 -0
- package/package.json +54 -0
- package/src/core/bootstrap.js +63 -0
- package/src/core/collections/observable-array.js +204 -0
- package/src/core/component/function-component.js +82 -0
- package/src/core/context.js +172 -0
- package/src/core/dom/dom.js +25 -0
- package/src/core/dom/element.js +725 -0
- package/src/core/dom/error-boundary.js +111 -0
- package/src/core/dom/input-format.js +82 -0
- package/src/core/dom/list.js +185 -0
- package/src/core/dom/portal.js +57 -0
- package/src/core/dom/tags.js +182 -0
- package/src/core/dom/virtual-list.js +242 -0
- package/src/core/dom/when.js +138 -0
- package/src/core/events/event-hub.js +97 -0
- package/src/core/forms/form.js +127 -0
- package/src/core/internal/symbols.js +5 -0
- package/src/core/network/websocket.js +165 -0
- package/src/core/query/query-client.js +529 -0
- package/src/core/reactivity/after-flush.js +20 -0
- package/src/core/reactivity/computed.js +51 -0
- package/src/core/reactivity/concat.js +89 -0
- package/src/core/reactivity/dirty-host.js +162 -0
- package/src/core/reactivity/observe.js +421 -0
- package/src/core/reactivity/persist.js +180 -0
- package/src/core/reactivity/resolve.js +8 -0
- package/src/core/reactivity/signal.js +97 -0
- package/src/core/reactivity/state.js +294 -0
- package/src/core/renderable/render-string.js +51 -0
- package/src/core/renderable/renderable.js +21 -0
- package/src/core/renderable/renderer.js +66 -0
- package/src/core/router/router.js +865 -0
- package/src/core/runtime.js +28 -0
- package/src/index.js +42 -0
- package/types/core/bootstrap.d.ts +11 -0
- package/types/core/collections/observable-array.d.ts +25 -0
- package/types/core/component/function-component.d.ts +14 -0
- package/types/core/context.d.ts +29 -0
- package/types/core/dom/dom.d.ts +13 -0
- package/types/core/dom/element.d.ts +10 -0
- package/types/core/dom/error-boundary.d.ts +8 -0
- package/types/core/dom/input-format.d.ts +6 -0
- package/types/core/dom/list.d.ts +8 -0
- package/types/core/dom/portal.d.ts +8 -0
- package/types/core/dom/tags.d.ts +114 -0
- package/types/core/dom/virtual-list.d.ts +8 -0
- package/types/core/dom/when.d.ts +13 -0
- package/types/core/events/event-hub.d.ts +48 -0
- package/types/core/forms/form.d.ts +9 -0
- package/types/core/internal/symbols.d.ts +4 -0
- package/types/core/network/websocket.d.ts +18 -0
- package/types/core/query/query-client.d.ts +73 -0
- package/types/core/reactivity/after-flush.d.ts +4 -0
- package/types/core/reactivity/computed.d.ts +1 -0
- package/types/core/reactivity/concat.d.ts +1 -0
- package/types/core/reactivity/dirty-host.d.ts +42 -0
- package/types/core/reactivity/observe.d.ts +10 -0
- package/types/core/reactivity/persist.d.ts +1 -0
- package/types/core/reactivity/resolve.d.ts +1 -0
- package/types/core/reactivity/signal.d.ts +11 -0
- package/types/core/reactivity/state.d.ts +14 -0
- package/types/core/renderable/render-string.d.ts +2 -0
- package/types/core/renderable/renderable.d.ts +15 -0
- package/types/core/renderable/renderer.d.ts +38 -0
- package/types/core/router/router.d.ts +57 -0
- package/types/core/runtime.d.ts +26 -0
- package/types/index.d.ts +2 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Renderable } from '../renderable/renderable.js';
|
|
2
|
+
import { Renderer } from '../renderable/renderer.js';
|
|
3
|
+
import { isObservableArray } from '../collections/observable-array.js';
|
|
4
|
+
import { isSignal, readSignal, subscribeSignal } from '../reactivity/signal.js';
|
|
5
|
+
import { isState, isStatePath, readState, subscribeState } from '../reactivity/state.js';
|
|
6
|
+
|
|
7
|
+
function clamp(value, min, max) {
|
|
8
|
+
return Math.max(min, Math.min(max, value));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isNumber(value) {
|
|
12
|
+
return typeof value === 'number' && !Number.isNaN(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class VirtualListNode extends Renderable {
|
|
16
|
+
#items;
|
|
17
|
+
#renderItem;
|
|
18
|
+
#direction;
|
|
19
|
+
#overscan;
|
|
20
|
+
#itemSize;
|
|
21
|
+
#container = null;
|
|
22
|
+
#spacer = null;
|
|
23
|
+
#itemsEl = null;
|
|
24
|
+
#mounted = false;
|
|
25
|
+
#unsub = null;
|
|
26
|
+
#resizeObserver = null;
|
|
27
|
+
#viewportSize = 0;
|
|
28
|
+
#startIndex = 0;
|
|
29
|
+
#endIndex = -1;
|
|
30
|
+
#mountedValues = [];
|
|
31
|
+
#measuring = false;
|
|
32
|
+
|
|
33
|
+
constructor(items, options = {}) {
|
|
34
|
+
super();
|
|
35
|
+
this.#items = items;
|
|
36
|
+
this.#renderItem = options.render;
|
|
37
|
+
this.#direction = options.direction === 'horizontal' ? 'horizontal' : 'vertical';
|
|
38
|
+
this.#overscan = isNumber(options.overscan) ? Math.max(0, options.overscan) : 2;
|
|
39
|
+
this.#itemSize = isNumber(options.itemSize) ? options.itemSize : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
mountInto(parent, beforeNode) {
|
|
43
|
+
if (this.#mounted) return;
|
|
44
|
+
if (typeof this.#renderItem !== 'function') {
|
|
45
|
+
throw new Error('virtualList(items, options): options.render is required');
|
|
46
|
+
}
|
|
47
|
+
this.#mounted = true;
|
|
48
|
+
|
|
49
|
+
const container = document.createElement('div');
|
|
50
|
+
container.style.position = 'relative';
|
|
51
|
+
container.style.overflow = 'auto';
|
|
52
|
+
container.style.width = '100%';
|
|
53
|
+
container.style.height = '100%';
|
|
54
|
+
container.style.contain = 'layout paint';
|
|
55
|
+
|
|
56
|
+
const spacer = document.createElement('div');
|
|
57
|
+
spacer.style.position = 'relative';
|
|
58
|
+
spacer.style.width = this.#direction === 'horizontal' ? '0px' : '100%';
|
|
59
|
+
spacer.style.height = this.#direction === 'horizontal' ? '100%' : '0px';
|
|
60
|
+
|
|
61
|
+
const itemsEl = document.createElement('div');
|
|
62
|
+
itemsEl.style.position = 'absolute';
|
|
63
|
+
itemsEl.style.top = '0';
|
|
64
|
+
itemsEl.style.left = '0';
|
|
65
|
+
itemsEl.style.willChange = 'transform';
|
|
66
|
+
if (this.#direction === 'horizontal') {
|
|
67
|
+
itemsEl.style.display = 'flex';
|
|
68
|
+
itemsEl.style.flexDirection = 'row';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
container.appendChild(spacer);
|
|
72
|
+
container.appendChild(itemsEl);
|
|
73
|
+
parent.insertBefore(container, beforeNode);
|
|
74
|
+
|
|
75
|
+
this.#container = container;
|
|
76
|
+
this.#spacer = spacer;
|
|
77
|
+
this.#itemsEl = itemsEl;
|
|
78
|
+
|
|
79
|
+
container.addEventListener('scroll', this.#onScroll);
|
|
80
|
+
this.#observeResize(parent);
|
|
81
|
+
this.#updateViewport(parent);
|
|
82
|
+
this.#render();
|
|
83
|
+
this.#wire();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
unmount() {
|
|
87
|
+
if (!this.#mounted) return;
|
|
88
|
+
this.#mounted = false;
|
|
89
|
+
if (this.#unsub) this.#unsub();
|
|
90
|
+
this.#unsub = null;
|
|
91
|
+
if (this.#container) {
|
|
92
|
+
this.#container.removeEventListener('scroll', this.#onScroll);
|
|
93
|
+
}
|
|
94
|
+
if (this.#resizeObserver) {
|
|
95
|
+
this.#resizeObserver.disconnect();
|
|
96
|
+
this.#resizeObserver = null;
|
|
97
|
+
}
|
|
98
|
+
this.#cleanup();
|
|
99
|
+
this.#container?.remove();
|
|
100
|
+
this.#container = null;
|
|
101
|
+
this.#spacer = null;
|
|
102
|
+
this.#itemsEl = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#readItems() {
|
|
106
|
+
if (isObservableArray(this.#items)) return this.#items;
|
|
107
|
+
if (isSignal(this.#items)) return readSignal(this.#items) || [];
|
|
108
|
+
if (isState(this.#items) || isStatePath(this.#items)) return readState(this.#items) || [];
|
|
109
|
+
return Array.isArray(this.#items) ? this.#items : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#wire() {
|
|
113
|
+
if (isObservableArray(this.#items)) {
|
|
114
|
+
this.#unsub = this.#items.subscribe(() => this.#render());
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (isSignal(this.#items)) {
|
|
118
|
+
this.#unsub = subscribeSignal(this.#items, () => this.#render());
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (isState(this.#items) || isStatePath(this.#items)) {
|
|
122
|
+
this.#unsub = subscribeState(this.#items, () => this.#render());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#observeResize(parent) {
|
|
127
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
128
|
+
this.#resizeObserver = new ResizeObserver(() => {
|
|
129
|
+
this.#updateViewport(parent);
|
|
130
|
+
this.#render();
|
|
131
|
+
});
|
|
132
|
+
this.#resizeObserver.observe(parent);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#updateViewport(parent) {
|
|
136
|
+
const rect = parent?.getBoundingClientRect?.();
|
|
137
|
+
if (!rect) return;
|
|
138
|
+
this.#viewportSize = this.#direction === 'horizontal' ? rect.width : rect.height;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#measureItemSize() {
|
|
142
|
+
if (this.#itemSize != null) return;
|
|
143
|
+
if (!this.#itemsEl) return;
|
|
144
|
+
const first = this.#itemsEl.firstElementChild;
|
|
145
|
+
if (!first) return;
|
|
146
|
+
const rect = first.getBoundingClientRect();
|
|
147
|
+
const size = this.#direction === 'horizontal' ? rect.width : rect.height;
|
|
148
|
+
if (isNumber(size) && size > 0) this.#itemSize = size;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#cleanup() {
|
|
152
|
+
for (const r of this.#mountedValues) Renderer.unmount(r);
|
|
153
|
+
this.#mountedValues = [];
|
|
154
|
+
if (this.#itemsEl) this.#itemsEl.replaceChildren();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#renderRange(items, start, end, offset) {
|
|
158
|
+
if (!this.#itemsEl) return;
|
|
159
|
+
this.#cleanup();
|
|
160
|
+
const slice = items.slice(start, end + 1);
|
|
161
|
+
const values = [];
|
|
162
|
+
for (let i = 0; i < slice.length; i++) {
|
|
163
|
+
const index = start + i;
|
|
164
|
+
const value = this.#renderItem(slice[i], index);
|
|
165
|
+
const normalized = Renderer.normalize(value);
|
|
166
|
+
for (const r of normalized) values.push(r);
|
|
167
|
+
}
|
|
168
|
+
this.#mountedValues = values;
|
|
169
|
+
for (const r of values) {
|
|
170
|
+
if (Renderer.isRenderable(r)) {
|
|
171
|
+
r.mountInto(this.#itemsEl, null);
|
|
172
|
+
} else if (Renderer.isDomNode(r)) {
|
|
173
|
+
this.#itemsEl.appendChild(r);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (this.#direction === 'horizontal') {
|
|
177
|
+
this.#itemsEl.style.transform = `translateX(${offset}px)`;
|
|
178
|
+
} else {
|
|
179
|
+
this.#itemsEl.style.transform = `translateY(${offset}px)`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#render() {
|
|
184
|
+
if (!this.#mounted || !this.#container) return;
|
|
185
|
+
const items = this.#readItems();
|
|
186
|
+
const count = items.length;
|
|
187
|
+
if (!this.#spacer) return;
|
|
188
|
+
|
|
189
|
+
if (count === 0) {
|
|
190
|
+
this.#spacer.style.width = this.#direction === 'horizontal' ? '0px' : '100%';
|
|
191
|
+
this.#spacer.style.height = this.#direction === 'horizontal' ? '100%' : '0px';
|
|
192
|
+
this.#cleanup();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (this.#itemSize == null && !this.#measuring) {
|
|
197
|
+
this.#measuring = true;
|
|
198
|
+
this.#renderRange(items, 0, 0, 0);
|
|
199
|
+
requestAnimationFrame(() => {
|
|
200
|
+
this.#measureItemSize();
|
|
201
|
+
this.#measuring = false;
|
|
202
|
+
this.#render();
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const size = this.#itemSize || 1;
|
|
208
|
+
const viewport = this.#viewportSize || (this.#direction === 'horizontal' ? this.#container.clientWidth : this.#container.clientHeight);
|
|
209
|
+
const scrollPos = this.#direction === 'horizontal' ? this.#container.scrollLeft : this.#container.scrollTop;
|
|
210
|
+
const visibleCount = Math.ceil(viewport / size);
|
|
211
|
+
const start = clamp(Math.floor(scrollPos / size) - this.#overscan, 0, Math.max(0, count - 1));
|
|
212
|
+
const end = clamp(start + visibleCount + this.#overscan * 2 - 1, 0, count - 1);
|
|
213
|
+
const offset = start * size;
|
|
214
|
+
|
|
215
|
+
const total = count * size;
|
|
216
|
+
if (this.#direction === 'horizontal') {
|
|
217
|
+
this.#spacer.style.width = `${total}px`;
|
|
218
|
+
this.#spacer.style.height = '100%';
|
|
219
|
+
} else {
|
|
220
|
+
this.#spacer.style.height = `${total}px`;
|
|
221
|
+
this.#spacer.style.width = '100%';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (start === this.#startIndex && end === this.#endIndex) return;
|
|
225
|
+
this.#startIndex = start;
|
|
226
|
+
this.#endIndex = end;
|
|
227
|
+
this.#renderRange(items, start, end, offset);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#onScroll = () => {
|
|
231
|
+
this.#render();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
renderToString(render) {
|
|
235
|
+
const items = this.#readItems();
|
|
236
|
+
return items.map((item, index) => render(this.#renderItem(item, index))).join('');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function virtualList(items, options) {
|
|
241
|
+
return new VirtualListNode(items, options);
|
|
242
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Renderable } from '../renderable/renderable.js';
|
|
2
|
+
import { Renderer } from '../renderable/renderer.js';
|
|
3
|
+
import { createComment, clearBetween } from './dom.js';
|
|
4
|
+
import { isState, isStatePath, readState, subscribeState } from '../reactivity/state.js';
|
|
5
|
+
import { isSignal, readSignal, subscribeSignal } from '../reactivity/signal.js';
|
|
6
|
+
|
|
7
|
+
const WHEN = Symbol('zb.when');
|
|
8
|
+
|
|
9
|
+
function isValidAttributeValue(value) {
|
|
10
|
+
if (value == null) return true;
|
|
11
|
+
const type = typeof value;
|
|
12
|
+
if (type === 'string' || type === 'number' || type === 'boolean') return true;
|
|
13
|
+
if (type === 'object' && !Array.isArray(value)) return true;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WhenNode extends Renderable {
|
|
18
|
+
#source;
|
|
19
|
+
#renderTrue;
|
|
20
|
+
#renderFalse;
|
|
21
|
+
#start = null;
|
|
22
|
+
#end = null;
|
|
23
|
+
#mounted = false;
|
|
24
|
+
#unsub = null;
|
|
25
|
+
#mountedValues = [];
|
|
26
|
+
|
|
27
|
+
constructor(source, renderTrue, renderFalse) {
|
|
28
|
+
super();
|
|
29
|
+
this.#source = source;
|
|
30
|
+
this.#renderTrue = renderTrue;
|
|
31
|
+
this.#renderFalse = renderFalse;
|
|
32
|
+
Object.defineProperty(this, WHEN, { value: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mountInto(parent, beforeNode) {
|
|
36
|
+
if (this.#mounted) return;
|
|
37
|
+
this.#mounted = true;
|
|
38
|
+
this.#start = createComment('zb:when:start', 'when');
|
|
39
|
+
this.#end = createComment('zb:when:end', 'when');
|
|
40
|
+
parent.insertBefore(this.#start, beforeNode);
|
|
41
|
+
parent.insertBefore(this.#end, beforeNode);
|
|
42
|
+
|
|
43
|
+
this.#update();
|
|
44
|
+
this.#wire();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
unmount() {
|
|
48
|
+
if (!this.#mounted) return;
|
|
49
|
+
this.#mounted = false;
|
|
50
|
+
if (this.#unsub) this.#unsub();
|
|
51
|
+
this.#unsub = null;
|
|
52
|
+
this.#cleanup();
|
|
53
|
+
if (this.#start && this.#end) {
|
|
54
|
+
clearBetween(this.#start, this.#end);
|
|
55
|
+
this.#start.remove();
|
|
56
|
+
this.#end.remove();
|
|
57
|
+
}
|
|
58
|
+
this.#start = null;
|
|
59
|
+
this.#end = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#wire() {
|
|
63
|
+
if (isState(this.#source) || isStatePath(this.#source)) {
|
|
64
|
+
this.#unsub = subscribeState(this.#source, () => this.#update());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (isSignal(this.#source)) {
|
|
68
|
+
this.#unsub = subscribeSignal(this.#source, () => this.#update());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#read() {
|
|
73
|
+
if (isState(this.#source) || isStatePath(this.#source)) return !!readState(this.#source);
|
|
74
|
+
if (isSignal(this.#source)) return !!readSignal(this.#source);
|
|
75
|
+
return !!this.#source;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
readValue() {
|
|
79
|
+
const predicate = this.#read();
|
|
80
|
+
const value = predicate ? this.#renderTrue() : this.#renderFalse?.();
|
|
81
|
+
if (Renderer.isRenderable(value) || Renderer.isDomNode(value)) return undefined;
|
|
82
|
+
if (!isValidAttributeValue(value)) return undefined;
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
subscribeValue(fn) {
|
|
87
|
+
if (isState(this.#source) || isStatePath(this.#source)) {
|
|
88
|
+
return subscribeState(this.#source, () => fn(this.readValue()));
|
|
89
|
+
}
|
|
90
|
+
if (isSignal(this.#source)) {
|
|
91
|
+
return subscribeSignal(this.#source, () => fn(this.readValue()));
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#cleanup() {
|
|
97
|
+
for (const r of this.#mountedValues) Renderer.unmount(r);
|
|
98
|
+
this.#mountedValues = [];
|
|
99
|
+
if (this.#start && this.#end) clearBetween(this.#start, this.#end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#update() {
|
|
103
|
+
this.#cleanup();
|
|
104
|
+
const predicate = this.#read();
|
|
105
|
+
const value = predicate ? this.#renderTrue() : this.#renderFalse?.();
|
|
106
|
+
const values = Renderer.normalize(value);
|
|
107
|
+
this.#mountedValues = values;
|
|
108
|
+
for (const r of values) {
|
|
109
|
+
if (Renderer.isRenderable(r)) {
|
|
110
|
+
r.mountInto(this.#end.parentNode, this.#end);
|
|
111
|
+
} else if (Renderer.isDomNode(r)) {
|
|
112
|
+
this.#end.parentNode.insertBefore(r, this.#end);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
renderToString(render) {
|
|
118
|
+
const predicate = this.#read();
|
|
119
|
+
const value = predicate ? this.#renderTrue() : this.#renderFalse?.();
|
|
120
|
+
return render(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function when(source, renderTrue, renderFalse) {
|
|
125
|
+
return new WhenNode(source, renderTrue, renderFalse);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isWhen(value) {
|
|
129
|
+
return !!value && value[WHEN] === true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function readWhenValue(value) {
|
|
133
|
+
return value?.readValue?.();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function subscribeWhenValue(value, fn) {
|
|
137
|
+
return value?.subscribeValue?.(fn);
|
|
138
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal before/after event hub.
|
|
3
|
+
*
|
|
4
|
+
* - `before` handlers may return `false` to cancel the operation.
|
|
5
|
+
* - `after` handlers are fire-and-forget.
|
|
6
|
+
*/
|
|
7
|
+
export class EventHub {
|
|
8
|
+
#before = new Map(); // type -> Set<fn>
|
|
9
|
+
#after = new Map(); // type -> Set<fn>
|
|
10
|
+
#afterAny = new Set();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {'before'|'after'} phase
|
|
14
|
+
* @param {string} type
|
|
15
|
+
* @param {(payload: any, ctx: any) => (void|boolean)} fn
|
|
16
|
+
* @returns {() => void}
|
|
17
|
+
*/
|
|
18
|
+
on(phase, type, fn) {
|
|
19
|
+
const map = phase === 'before' ? this.#before : this.#after;
|
|
20
|
+
if (phase === 'after' && type === '*') {
|
|
21
|
+
this.#afterAny.add(fn);
|
|
22
|
+
return () => this.#afterAny.delete(fn);
|
|
23
|
+
}
|
|
24
|
+
let set = map.get(type);
|
|
25
|
+
if (!set) {
|
|
26
|
+
set = new Set();
|
|
27
|
+
map.set(type, set);
|
|
28
|
+
}
|
|
29
|
+
set.add(fn);
|
|
30
|
+
return () => set.delete(fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Emits a before event. Returns false when cancelled.
|
|
35
|
+
* @param {string} type
|
|
36
|
+
* @param {any} payload
|
|
37
|
+
* @param {any} ctx
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
emitBefore(type, payload, ctx) {
|
|
41
|
+
const set = this.#before.get(type);
|
|
42
|
+
if (!set) return true;
|
|
43
|
+
for (const fn of set) {
|
|
44
|
+
const r = fn(payload, ctx);
|
|
45
|
+
if (r === false) return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Emits an after event.
|
|
52
|
+
* @param {string} type
|
|
53
|
+
* @param {any} payload
|
|
54
|
+
* @param {any} ctx
|
|
55
|
+
*/
|
|
56
|
+
emitAfter(type, payload, ctx) {
|
|
57
|
+
const set = this.#after.get(type);
|
|
58
|
+
if (set) {
|
|
59
|
+
for (const fn of set) fn(payload, ctx);
|
|
60
|
+
}
|
|
61
|
+
for (const fn of this.#afterAny) fn(payload, ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns a fluent API for registering hooks.
|
|
66
|
+
* @param {'before'|'after'} phase
|
|
67
|
+
*/
|
|
68
|
+
phase(phase) {
|
|
69
|
+
const hub = this;
|
|
70
|
+
const api = {
|
|
71
|
+
/**
|
|
72
|
+
* Registers a handler for a given type.
|
|
73
|
+
* @param {string} type
|
|
74
|
+
* @param {(payload: any, ctx: any) => (void|boolean)} fn
|
|
75
|
+
*/
|
|
76
|
+
on(type, fn) {
|
|
77
|
+
return hub.on(phase, type, fn);
|
|
78
|
+
},
|
|
79
|
+
/**
|
|
80
|
+
* Registers a handler for any type.
|
|
81
|
+
* @param {(payload: any, ctx: any) => (void|boolean)} fn
|
|
82
|
+
*/
|
|
83
|
+
any(fn) {
|
|
84
|
+
return hub.on(phase, '*', fn);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return new Proxy(api, {
|
|
89
|
+
get(target, prop) {
|
|
90
|
+
if (typeof prop !== 'string') return target[prop];
|
|
91
|
+
if (prop in target) return target[prop];
|
|
92
|
+
return (fn) => hub.on(phase, prop, fn);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { state } from '../reactivity/state.js';
|
|
2
|
+
import { after } from '../reactivity/observe.js';
|
|
3
|
+
|
|
4
|
+
function isObject(value) {
|
|
5
|
+
return value !== null && typeof value === 'object';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function cloneValue(value) {
|
|
9
|
+
if (!isObject(value)) return value;
|
|
10
|
+
if (Array.isArray(value)) return value.map(cloneValue);
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const [k, v] of Object.entries(value)) {
|
|
13
|
+
out[k] = cloneValue(v);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function deepEqual(a, b) {
|
|
19
|
+
if (a === b) return true;
|
|
20
|
+
if (typeof a !== typeof b) return false;
|
|
21
|
+
if (!isObject(a) || !isObject(b)) return false;
|
|
22
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
23
|
+
if (Array.isArray(a)) {
|
|
24
|
+
if (a.length !== b.length) return false;
|
|
25
|
+
for (let i = 0; i < a.length; i++) {
|
|
26
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
const aKeys = Object.keys(a);
|
|
31
|
+
const bKeys = Object.keys(b);
|
|
32
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
33
|
+
for (const k of aKeys) {
|
|
34
|
+
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
|
35
|
+
if (!deepEqual(a[k], b[k])) return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergeErrors(target, value) {
|
|
41
|
+
if (value == null || value === true) return target;
|
|
42
|
+
if (value === false) {
|
|
43
|
+
target._form = true;
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === 'string') {
|
|
47
|
+
target._form = value;
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
if (isObject(value)) {
|
|
51
|
+
for (const [k, v] of Object.entries(value)) {
|
|
52
|
+
target[k] = v;
|
|
53
|
+
}
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function form(initial) {
|
|
60
|
+
const initialSnapshot = cloneValue(initial);
|
|
61
|
+
const values = state(cloneValue(initial));
|
|
62
|
+
const meta = state({});
|
|
63
|
+
const errors = state({});
|
|
64
|
+
const touched = state({});
|
|
65
|
+
const dirty = state(false);
|
|
66
|
+
const validators = new Set();
|
|
67
|
+
|
|
68
|
+
let runId = 0;
|
|
69
|
+
|
|
70
|
+
const runValidators = () => {
|
|
71
|
+
const current = ++runId;
|
|
72
|
+
const nextErrors = {};
|
|
73
|
+
const snapshot = values.get();
|
|
74
|
+
const tasks = [];
|
|
75
|
+
|
|
76
|
+
for (const validator of validators) {
|
|
77
|
+
try {
|
|
78
|
+
const result = validator(snapshot);
|
|
79
|
+
if (result && typeof result.then === 'function') {
|
|
80
|
+
tasks.push(
|
|
81
|
+
result.then((value) => {
|
|
82
|
+
mergeErrors(nextErrors, value);
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
mergeErrors(nextErrors, result);
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
mergeErrors(nextErrors, err?.message || true);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (tasks.length) {
|
|
94
|
+
Promise.all(tasks).then(() => {
|
|
95
|
+
if (current !== runId) return;
|
|
96
|
+
errors.set(nextErrors);
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
errors.set(nextErrors);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
after(values).change(() => {
|
|
105
|
+
const isDirty = !deepEqual(values.get(), initialSnapshot);
|
|
106
|
+
if (dirty.get() !== isDirty) dirty.set(isDirty);
|
|
107
|
+
if (validators.size) runValidators();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const reset = () => {
|
|
111
|
+
values.set(cloneValue(initialSnapshot));
|
|
112
|
+
touched.set({});
|
|
113
|
+
errors.set({});
|
|
114
|
+
dirty.set(false);
|
|
115
|
+
meta.set({});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
values,
|
|
120
|
+
meta,
|
|
121
|
+
errors,
|
|
122
|
+
touched,
|
|
123
|
+
dirty,
|
|
124
|
+
validators,
|
|
125
|
+
reset,
|
|
126
|
+
};
|
|
127
|
+
}
|