what-core 0.1.0 → 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/dist/a11y.js +425 -0
- package/dist/animation.js +531 -0
- package/dist/components.js +272 -115
- package/dist/data.js +434 -0
- package/dist/dom.js +635 -424
- package/dist/form.js +441 -0
- package/dist/h.js +191 -138
- package/dist/head.js +59 -42
- package/dist/helpers.js +125 -83
- package/dist/hooks.js +224 -134
- package/dist/index.js +2 -2
- package/dist/reactive.js +150 -107
- package/dist/scheduler.js +241 -0
- package/dist/skeleton.js +363 -0
- package/dist/store.js +113 -55
- package/dist/testing.js +367 -0
- package/dist/what.js +2 -2
- package/index.d.ts +15 -0
- package/package.json +1 -1
- package/src/components.js +93 -0
- package/src/dom.js +47 -12
- package/src/index.js +2 -2
- package/src/store.js +23 -5
package/dist/components.js
CHANGED
|
@@ -1,140 +1,297 @@
|
|
|
1
|
+
// What Framework - Component Utilities
|
|
2
|
+
// memo, lazy, Suspense, ErrorBoundary
|
|
3
|
+
|
|
1
4
|
import { h } from './h.js';
|
|
2
5
|
import { signal, effect, untrack } from './reactive.js';
|
|
6
|
+
|
|
7
|
+
// Error boundary context - components can register their error handlers here
|
|
3
8
|
export const errorBoundaryStack = [];
|
|
9
|
+
|
|
10
|
+
// --- memo ---
|
|
11
|
+
// Skip re-render if props haven't changed (shallow compare by default).
|
|
12
|
+
|
|
4
13
|
export function memo(Component, areEqual) {
|
|
5
|
-
const compare = areEqual || shallowEqual;
|
|
6
|
-
let prevProps = null;
|
|
7
|
-
let prevResult = null;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const compare = areEqual || shallowEqual;
|
|
15
|
+
let prevProps = null;
|
|
16
|
+
let prevResult = null;
|
|
17
|
+
|
|
18
|
+
function MemoWrapper(props) {
|
|
19
|
+
if (prevProps && compare(prevProps, props)) {
|
|
20
|
+
return prevResult;
|
|
21
|
+
}
|
|
22
|
+
prevProps = { ...props };
|
|
23
|
+
prevResult = Component(props);
|
|
24
|
+
return prevResult;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
MemoWrapper.displayName = `Memo(${Component.name || 'Anonymous'})`;
|
|
28
|
+
return MemoWrapper;
|
|
18
29
|
}
|
|
30
|
+
|
|
19
31
|
function shallowEqual(a, b) {
|
|
20
|
-
if (a === b) return true;
|
|
21
|
-
const keysA = Object.keys(a);
|
|
22
|
-
const keysB = Object.keys(b);
|
|
23
|
-
if (keysA.length !== keysB.length) return false;
|
|
24
|
-
for (const key of keysA) {
|
|
25
|
-
if (!Object.is(a[key], b[key])) return false;
|
|
26
|
-
}
|
|
27
|
-
return true;
|
|
32
|
+
if (a === b) return true;
|
|
33
|
+
const keysA = Object.keys(a);
|
|
34
|
+
const keysB = Object.keys(b);
|
|
35
|
+
if (keysA.length !== keysB.length) return false;
|
|
36
|
+
for (const key of keysA) {
|
|
37
|
+
if (!Object.is(a[key], b[key])) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
28
40
|
}
|
|
41
|
+
|
|
42
|
+
// --- lazy ---
|
|
43
|
+
// Code-split a component. Returns a wrapper that loads on first render.
|
|
44
|
+
|
|
29
45
|
export function lazy(loader) {
|
|
30
|
-
let Component = null;
|
|
31
|
-
let loadPromise = null;
|
|
32
|
-
let loadError = null;
|
|
33
|
-
const listeners = new Set();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
let Component = null;
|
|
47
|
+
let loadPromise = null;
|
|
48
|
+
let loadError = null;
|
|
49
|
+
const listeners = new Set();
|
|
50
|
+
|
|
51
|
+
function LazyWrapper(props) {
|
|
52
|
+
if (loadError) throw loadError;
|
|
53
|
+
if (Component) return h(Component, props);
|
|
54
|
+
|
|
55
|
+
if (!loadPromise) {
|
|
56
|
+
loadPromise = loader()
|
|
57
|
+
.then(mod => {
|
|
58
|
+
Component = mod.default || mod;
|
|
59
|
+
// Notify all waiting instances
|
|
60
|
+
listeners.forEach(fn => fn());
|
|
61
|
+
listeners.clear();
|
|
62
|
+
})
|
|
63
|
+
.catch(err => { loadError = err; });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Throw promise for Suspense to catch
|
|
67
|
+
throw loadPromise;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
LazyWrapper.displayName = 'Lazy';
|
|
71
|
+
LazyWrapper._lazy = true;
|
|
72
|
+
LazyWrapper._onLoad = (fn) => {
|
|
73
|
+
if (Component) fn();
|
|
74
|
+
else listeners.add(fn);
|
|
75
|
+
};
|
|
76
|
+
return LazyWrapper;
|
|
55
77
|
}
|
|
78
|
+
|
|
79
|
+
// --- Suspense ---
|
|
80
|
+
// Show fallback while children are loading (lazy components).
|
|
81
|
+
// Works with lazy() and async components.
|
|
82
|
+
|
|
56
83
|
export function Suspense({ fallback, children }) {
|
|
57
|
-
const loading = signal(false);
|
|
58
|
-
const pendingPromises = new Set();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
pendingPromises.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
84
|
+
const loading = signal(false);
|
|
85
|
+
const pendingPromises = new Set();
|
|
86
|
+
|
|
87
|
+
// Suspense boundary marker
|
|
88
|
+
const boundary = {
|
|
89
|
+
_suspense: true,
|
|
90
|
+
onSuspend(promise) {
|
|
91
|
+
loading.set(true);
|
|
92
|
+
pendingPromises.add(promise);
|
|
93
|
+
promise.finally(() => {
|
|
94
|
+
pendingPromises.delete(promise);
|
|
95
|
+
if (pendingPromises.size === 0) {
|
|
96
|
+
loading.set(false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
tag: '__suspense',
|
|
104
|
+
props: { boundary, fallback, loading },
|
|
105
|
+
children: Array.isArray(children) ? children : [children],
|
|
106
|
+
_vnode: true,
|
|
107
|
+
};
|
|
78
108
|
}
|
|
109
|
+
|
|
110
|
+
// --- ErrorBoundary ---
|
|
111
|
+
// Catch errors in children and show fallback.
|
|
112
|
+
// Uses a signal to track error state so it works with reactive rendering.
|
|
113
|
+
|
|
79
114
|
export function ErrorBoundary({ fallback, children, onError }) {
|
|
80
|
-
const errorState = signal(null);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
onError
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
const errorState = signal(null);
|
|
116
|
+
|
|
117
|
+
// Error handler that will be registered with the component tree
|
|
118
|
+
const handleError = (error) => {
|
|
119
|
+
errorState.set(error);
|
|
120
|
+
if (onError) {
|
|
121
|
+
try {
|
|
122
|
+
onError(error);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error('Error in onError handler:', e);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Reset function to recover from error
|
|
130
|
+
const reset = () => errorState.set(null);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
tag: '__errorBoundary',
|
|
134
|
+
props: { errorState, handleError, fallback, reset },
|
|
135
|
+
children: Array.isArray(children) ? children : [children],
|
|
136
|
+
_vnode: true,
|
|
137
|
+
};
|
|
98
138
|
}
|
|
139
|
+
|
|
140
|
+
// Helper to get current error boundary
|
|
99
141
|
export function getCurrentErrorBoundary() {
|
|
100
|
-
return errorBoundaryStack[errorBoundaryStack.length - 1] || null;
|
|
142
|
+
return errorBoundaryStack[errorBoundaryStack.length - 1] || null;
|
|
101
143
|
}
|
|
144
|
+
|
|
145
|
+
// Helper to report error to nearest boundary
|
|
102
146
|
export function reportError(error) {
|
|
103
|
-
const boundary = getCurrentErrorBoundary();
|
|
104
|
-
if (boundary) {
|
|
105
|
-
boundary.handleError(error);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
return false;
|
|
147
|
+
const boundary = getCurrentErrorBoundary();
|
|
148
|
+
if (boundary) {
|
|
149
|
+
boundary.handleError(error);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
109
153
|
}
|
|
154
|
+
|
|
155
|
+
// --- Show ---
|
|
156
|
+
// Conditional rendering component. Cleaner than ternaries.
|
|
157
|
+
|
|
110
158
|
export function Show({ when, fallback = null, children }) {
|
|
111
|
-
|
|
112
|
-
|
|
159
|
+
// when can be a signal or a value
|
|
160
|
+
const condition = typeof when === 'function' ? when() : when;
|
|
161
|
+
return condition ? children : fallback;
|
|
113
162
|
}
|
|
163
|
+
|
|
164
|
+
// --- For ---
|
|
165
|
+
// Efficient list rendering with keyed reconciliation.
|
|
166
|
+
|
|
114
167
|
export function For({ each, fallback = null, children }) {
|
|
115
|
-
const list = typeof each === 'function' ? each() : each;
|
|
116
|
-
if (!list || list.length === 0) return fallback;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return
|
|
168
|
+
const list = typeof each === 'function' ? each() : each;
|
|
169
|
+
if (!list || list.length === 0) return fallback;
|
|
170
|
+
|
|
171
|
+
// children should be a function (item, index) => vnode
|
|
172
|
+
const renderFn = Array.isArray(children) ? children[0] : children;
|
|
173
|
+
if (typeof renderFn !== 'function') {
|
|
174
|
+
console.warn('For: children must be a function');
|
|
175
|
+
return fallback;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return list.map((item, index) => renderFn(item, index));
|
|
123
179
|
}
|
|
180
|
+
|
|
181
|
+
// --- Switch / Match ---
|
|
182
|
+
// Multi-condition rendering (like switch statement).
|
|
183
|
+
|
|
124
184
|
export function Switch({ fallback = null, children }) {
|
|
125
|
-
const kids = Array.isArray(children) ? children : [children];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
const kids = Array.isArray(children) ? children : [children];
|
|
186
|
+
|
|
187
|
+
for (const child of kids) {
|
|
188
|
+
if (child && child.tag === Match) {
|
|
189
|
+
const condition = typeof child.props.when === 'function'
|
|
190
|
+
? child.props.when()
|
|
191
|
+
: child.props.when;
|
|
192
|
+
if (condition) {
|
|
193
|
+
return child.children;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return fallback;
|
|
134
199
|
}
|
|
200
|
+
|
|
201
|
+
export function Match({ when, children }) {
|
|
202
|
+
// Match is just a marker component, Switch handles the logic
|
|
203
|
+
return { tag: Match, props: { when }, children, _vnode: true };
|
|
135
204
|
}
|
|
136
|
-
|
|
205
|
+
|
|
206
|
+
// --- Island ---
|
|
207
|
+
// Deferred hydration component for islands architecture.
|
|
208
|
+
// Usage: h(Island, { component: Counter, mode: 'idle' })
|
|
209
|
+
// The babel plugin compiles <Counter client:idle /> into this.
|
|
210
|
+
|
|
211
|
+
export function Island({ component: Component, mode, mediaQuery, ...props }) {
|
|
212
|
+
const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
|
|
213
|
+
|
|
214
|
+
// We need to return a vnode that the reconciler can handle.
|
|
215
|
+
// The actual hydration scheduling happens after mount via an effect.
|
|
216
|
+
const wrapper = signal(null);
|
|
217
|
+
const hydrated = signal(false);
|
|
218
|
+
|
|
219
|
+
function doHydrate() {
|
|
220
|
+
if (hydrated()) return;
|
|
221
|
+
hydrated.set(true);
|
|
222
|
+
// Render the actual component
|
|
223
|
+
wrapper.set(h(Component, props));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Schedule hydration based on mode
|
|
227
|
+
function scheduleHydration(el) {
|
|
228
|
+
switch (mode) {
|
|
229
|
+
case 'load':
|
|
230
|
+
queueMicrotask(doHydrate);
|
|
231
|
+
break;
|
|
232
|
+
|
|
233
|
+
case 'idle':
|
|
234
|
+
if (typeof requestIdleCallback !== 'undefined') {
|
|
235
|
+
requestIdleCallback(doHydrate);
|
|
236
|
+
} else {
|
|
237
|
+
setTimeout(doHydrate, 200);
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
|
|
241
|
+
case 'visible': {
|
|
242
|
+
const observer = new IntersectionObserver((entries) => {
|
|
243
|
+
if (entries[0].isIntersecting) {
|
|
244
|
+
observer.disconnect();
|
|
245
|
+
doHydrate();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
observer.observe(el);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'interaction': {
|
|
253
|
+
const hydrate = () => {
|
|
254
|
+
el.removeEventListener('click', hydrate);
|
|
255
|
+
el.removeEventListener('focus', hydrate);
|
|
256
|
+
el.removeEventListener('mouseenter', hydrate);
|
|
257
|
+
doHydrate();
|
|
258
|
+
};
|
|
259
|
+
el.addEventListener('click', hydrate, { once: true });
|
|
260
|
+
el.addEventListener('focus', hydrate, { once: true });
|
|
261
|
+
el.addEventListener('mouseenter', hydrate, { once: true });
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'media': {
|
|
266
|
+
if (!mediaQuery) { doHydrate(); break; }
|
|
267
|
+
const mq = window.matchMedia(mediaQuery);
|
|
268
|
+
if (mq.matches) {
|
|
269
|
+
queueMicrotask(doHydrate);
|
|
270
|
+
} else {
|
|
271
|
+
const checkMedia = () => {
|
|
272
|
+
if (mq.matches) {
|
|
273
|
+
mq.removeEventListener('change', checkMedia);
|
|
274
|
+
doHydrate();
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
mq.addEventListener('change', checkMedia);
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
default:
|
|
283
|
+
// Unknown mode, hydrate immediately
|
|
284
|
+
queueMicrotask(doHydrate);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Use ref callback to get the DOM element and schedule hydration
|
|
289
|
+
const refCallback = (el) => {
|
|
290
|
+
if (el) scheduleHydration(el);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Return: show placeholder until hydrated, then show the real component
|
|
294
|
+
return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
|
|
295
|
+
hydrated() ? wrapper() : null
|
|
296
|
+
);
|
|
137
297
|
}
|
|
138
|
-
export function Match({ when, children }) {
|
|
139
|
-
return { tag: Match, props: { when }, children, _vnode: true };
|
|
140
|
-
}
|