@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,575 @@
|
|
|
1
|
+
# Magic Properties for fun-web Element Constructor
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Add new `hx()` helper function with magic properties `$bind`, `$on`, and `$model` to enable inline reactive bindings, reducing boilerplate while maintaining type safety and explicit signal management. The `$model` property provides automatic two-way binding sugar.
|
|
5
|
+
|
|
6
|
+
## User Decisions (Confirmed)
|
|
7
|
+
- ✅ **Approach**: Non-curried `hx(signal, tag, attrs)` - signal as first parameter
|
|
8
|
+
- ✅ **Naming**: Use `$` prefix (`$bind`, `$on`, `$model`)
|
|
9
|
+
- ✅ **Initial scope**: Include `$bind`, `$on`, and `$model` (not `$class`/`$style` yet)
|
|
10
|
+
- ✅ **Two-way binding**: Include `$model` sugar for automatic two-way binding
|
|
11
|
+
- ✅ **File structure**: Keep hx() in separate files (`hx.ts`, `hx.test.ts`) since it's experimental
|
|
12
|
+
|
|
13
|
+
## Current State
|
|
14
|
+
Users must chain `bindProperty()` and `on()` calls after creating elements:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
const input = h("input", { type: "text" });
|
|
18
|
+
bindProperty(input, "value", state.prop("name"), signal);
|
|
19
|
+
on(input, "input", (e) => state.prop("name").set(e.currentTarget.value), signal);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Proposed API
|
|
23
|
+
|
|
24
|
+
### New hx() Function
|
|
25
|
+
```typescript
|
|
26
|
+
// Signal is explicit as first parameter
|
|
27
|
+
const input = hx(signal, "input", {
|
|
28
|
+
type: "text",
|
|
29
|
+
$model: { value: state.prop("name") }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Can mix with regular h() for static elements
|
|
33
|
+
const heading = h("h1", {}, "Login Form");
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Real-world Example
|
|
37
|
+
```typescript
|
|
38
|
+
const LoginForm: Component<{}, [FunWebState<LoginState>]> = (signal, props, state) => {
|
|
39
|
+
return h("form", { className: "login" }, [
|
|
40
|
+
h("h2", {}, "Login"),
|
|
41
|
+
|
|
42
|
+
// Email input with automatic two-way binding using $model
|
|
43
|
+
hx(signal, "input", {
|
|
44
|
+
type: "email",
|
|
45
|
+
placeholder: "Email",
|
|
46
|
+
$model: { value: state.prop("email") }
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
// Password input with automatic two-way binding
|
|
50
|
+
hx(signal, "input", {
|
|
51
|
+
type: "password",
|
|
52
|
+
placeholder: "Password",
|
|
53
|
+
$model: { value: state.prop("password") }
|
|
54
|
+
}),
|
|
55
|
+
|
|
56
|
+
// Submit button with custom handler
|
|
57
|
+
hx(signal, "button", {
|
|
58
|
+
type: "submit",
|
|
59
|
+
$on: { click: handleSubmit }
|
|
60
|
+
})
|
|
61
|
+
]);
|
|
62
|
+
};
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Implementation Plan
|
|
66
|
+
|
|
67
|
+
### Phase 1: Core Implementation
|
|
68
|
+
|
|
69
|
+
**File: `/packages/fun-web/src/hx.ts`** (new file - keeping experimental features separate)
|
|
70
|
+
|
|
71
|
+
1. **Create new hx.ts file** with hx() function (non-curried, signal as first param):
|
|
72
|
+
```typescript
|
|
73
|
+
// src/hx.ts - Experimental magic properties for fun-web
|
|
74
|
+
import { bindProperty, on, appendChildren } from "./dom";
|
|
75
|
+
import type { ElementChild } from "./types";
|
|
76
|
+
import type { FunWebState } from "./state";
|
|
77
|
+
|
|
78
|
+
export interface MagicAttrs<E extends Element> {
|
|
79
|
+
$bind?: Partial<{
|
|
80
|
+
[K in keyof E]: FunWebState<E[K]>
|
|
81
|
+
}>;
|
|
82
|
+
$on?: Partial<{
|
|
83
|
+
[K in keyof HTMLElementEventMap]: (
|
|
84
|
+
ev: HTMLElementEventMap[K] & { currentTarget: E }
|
|
85
|
+
) => void
|
|
86
|
+
}>;
|
|
87
|
+
$model?: Partial<{
|
|
88
|
+
[K in keyof E]: FunWebState<E[K]>
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const hx = <Tag extends keyof HTMLElementTagNameMap>(
|
|
93
|
+
signal: AbortSignal,
|
|
94
|
+
tag: Tag,
|
|
95
|
+
attrs?: (Record<string, any> & MagicAttrs<HTMLElementTagNameMap[Tag]>) | null,
|
|
96
|
+
children?: ElementChild | ElementChild[]
|
|
97
|
+
): HTMLElementTagNameMap[Tag] => {
|
|
98
|
+
const element = document.createElement(tag);
|
|
99
|
+
|
|
100
|
+
if (attrs) {
|
|
101
|
+
const { $bind, $on, $model, ...regularAttrs } = attrs;
|
|
102
|
+
|
|
103
|
+
// Process regular attributes (reuse h() logic)
|
|
104
|
+
for (const [key, value] of Object.entries(regularAttrs)) {
|
|
105
|
+
if (value == null) continue;
|
|
106
|
+
|
|
107
|
+
if (key.startsWith("on") && typeof value === "function") {
|
|
108
|
+
const eventName = key.slice(2).toLowerCase();
|
|
109
|
+
element.addEventListener(eventName, value);
|
|
110
|
+
} else if (key.includes("-") || key === "role") {
|
|
111
|
+
element.setAttribute(key, String(value));
|
|
112
|
+
} else {
|
|
113
|
+
(element as any)[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Process $model magic property (two-way binding sugar)
|
|
118
|
+
if ($model) {
|
|
119
|
+
for (const [key, state] of Object.entries($model)) {
|
|
120
|
+
// Bind property (state → element)
|
|
121
|
+
bindProperty(element, key as any, state as any, signal);
|
|
122
|
+
|
|
123
|
+
// Auto-detect event and setup listener (element → state)
|
|
124
|
+
const eventType = getEventForProperty(key, element.tagName);
|
|
125
|
+
on(element, eventType as any, (e: any) => {
|
|
126
|
+
(state as any).set(e.currentTarget[key]);
|
|
127
|
+
}, signal);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Process $bind magic property
|
|
132
|
+
if ($bind) {
|
|
133
|
+
for (const [key, state] of Object.entries($bind)) {
|
|
134
|
+
bindProperty(element, key as any, state as any, signal);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Process $on magic property
|
|
139
|
+
if ($on) {
|
|
140
|
+
for (const [type, handler] of Object.entries($on)) {
|
|
141
|
+
on(element, type as any, handler as any, signal);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Append children (reuse appendChildren from h())
|
|
147
|
+
if (children != null) {
|
|
148
|
+
appendChildren(element, children);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return element;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Helper to auto-detect the correct event type for a property
|
|
155
|
+
function getEventForProperty(prop: string, tagName: string): string {
|
|
156
|
+
if (prop === "checked") return "change";
|
|
157
|
+
if (prop === "value" && tagName === "SELECT") return "change";
|
|
158
|
+
if (prop === "value") return "input";
|
|
159
|
+
return "change"; // default
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Note**: hx() will duplicate some attribute processing logic from h(), but keeps experimental features isolated.
|
|
164
|
+
|
|
165
|
+
**File: `/packages/fun-web/src/index.ts`**
|
|
166
|
+
|
|
167
|
+
2. **Export hx() and types from the new file**:
|
|
168
|
+
```typescript
|
|
169
|
+
export { hx, type MagicAttrs } from "./hx";
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Phase 2: Testing
|
|
173
|
+
|
|
174
|
+
**File: `/packages/fun-web/src/hx.test.ts`** (new file - separate tests for experimental feature)
|
|
175
|
+
|
|
176
|
+
Create comprehensive test suite:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { hx } from "./hx";
|
|
180
|
+
import { useFunWebState } from "./state";
|
|
181
|
+
|
|
182
|
+
describe("hx() magic properties", () => {
|
|
183
|
+
let signal: AbortSignal;
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
signal = controller.signal;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("$bind", () => {
|
|
191
|
+
it("should set initial property value from state", () => {
|
|
192
|
+
const state = useFunWebState("hello");
|
|
193
|
+
const input = hx(signal, "input", {
|
|
194
|
+
$bind: { value: state }
|
|
195
|
+
}) as HTMLInputElement;
|
|
196
|
+
expect(input.value).toBe("hello");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should update property when state changes", () => {
|
|
200
|
+
const state = useFunWebState("hello");
|
|
201
|
+
const input = hx(signal, "input", {
|
|
202
|
+
$bind: { value: state }
|
|
203
|
+
}) as HTMLInputElement;
|
|
204
|
+
|
|
205
|
+
state.set("world");
|
|
206
|
+
expect(input.value).toBe("world");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should stop updating after signal aborts", () => {
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const state = useFunWebState("hello");
|
|
212
|
+
const input = hx(controller.signal, "input", {
|
|
213
|
+
$bind: { value: state }
|
|
214
|
+
}) as HTMLInputElement;
|
|
215
|
+
|
|
216
|
+
controller.abort();
|
|
217
|
+
state.set("world");
|
|
218
|
+
expect(input.value).toBe("hello");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should support binding multiple properties", () => {
|
|
222
|
+
const valueState = useFunWebState("test");
|
|
223
|
+
const disabledState = useFunWebState(true);
|
|
224
|
+
const input = hx(signal, "input", {
|
|
225
|
+
$bind: {
|
|
226
|
+
value: valueState,
|
|
227
|
+
disabled: disabledState
|
|
228
|
+
}
|
|
229
|
+
}) as HTMLInputElement;
|
|
230
|
+
|
|
231
|
+
expect(input.value).toBe("test");
|
|
232
|
+
expect(input.disabled).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("$on", () => {
|
|
237
|
+
it("should attach event listeners", () => {
|
|
238
|
+
const handler = jest.fn();
|
|
239
|
+
const button = hx(signal, "button", {
|
|
240
|
+
$on: { click: handler }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
button.click();
|
|
244
|
+
expect(handler).toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should support multiple event listeners", () => {
|
|
248
|
+
const clickHandler = jest.fn();
|
|
249
|
+
const mouseoverHandler = jest.fn();
|
|
250
|
+
const button = hx(signal, "button", {
|
|
251
|
+
$on: {
|
|
252
|
+
click: clickHandler,
|
|
253
|
+
mouseover: mouseoverHandler
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
button.click();
|
|
258
|
+
expect(clickHandler).toHaveBeenCalled();
|
|
259
|
+
|
|
260
|
+
button.dispatchEvent(new MouseEvent("mouseover"));
|
|
261
|
+
expect(mouseoverHandler).toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should cleanup listeners on signal abort", () => {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const handler = jest.fn();
|
|
267
|
+
const button = hx(controller.signal, "button", {
|
|
268
|
+
$on: { click: handler }
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
controller.abort();
|
|
272
|
+
button.click();
|
|
273
|
+
expect(handler).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("$bind and $on together", () => {
|
|
278
|
+
it("should support both in same element (two-way binding)", () => {
|
|
279
|
+
const state = useFunWebState("hello");
|
|
280
|
+
const input = hx(signal, "input", {
|
|
281
|
+
$bind: { value: state },
|
|
282
|
+
$on: {
|
|
283
|
+
input: (e) => state.set(e.currentTarget.value)
|
|
284
|
+
}
|
|
285
|
+
}) as HTMLInputElement;
|
|
286
|
+
|
|
287
|
+
expect(input.value).toBe("hello");
|
|
288
|
+
|
|
289
|
+
// Simulate user typing
|
|
290
|
+
input.value = "world";
|
|
291
|
+
input.dispatchEvent(new Event("input"));
|
|
292
|
+
|
|
293
|
+
expect(state.get()).toBe("world");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("$model", () => {
|
|
298
|
+
it("should create automatic two-way binding for input value", () => {
|
|
299
|
+
const state = useFunWebState("hello");
|
|
300
|
+
const input = hx(signal, "input", {
|
|
301
|
+
type: "text",
|
|
302
|
+
$model: { value: state }
|
|
303
|
+
}) as HTMLInputElement;
|
|
304
|
+
|
|
305
|
+
// Initial binding (state → element)
|
|
306
|
+
expect(input.value).toBe("hello");
|
|
307
|
+
|
|
308
|
+
// Simulate user typing (element → state)
|
|
309
|
+
input.value = "world";
|
|
310
|
+
input.dispatchEvent(new Event("input"));
|
|
311
|
+
expect(state.get()).toBe("world");
|
|
312
|
+
|
|
313
|
+
// State changes should update element
|
|
314
|
+
state.set("foo");
|
|
315
|
+
expect(input.value).toBe("foo");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("should create automatic two-way binding for checkbox checked", () => {
|
|
319
|
+
const state = useFunWebState(false);
|
|
320
|
+
const checkbox = hx(signal, "input", {
|
|
321
|
+
type: "checkbox",
|
|
322
|
+
$model: { checked: state }
|
|
323
|
+
}) as HTMLInputElement;
|
|
324
|
+
|
|
325
|
+
expect(checkbox.checked).toBe(false);
|
|
326
|
+
|
|
327
|
+
checkbox.checked = true;
|
|
328
|
+
checkbox.dispatchEvent(new Event("change"));
|
|
329
|
+
expect(state.get()).toBe(true);
|
|
330
|
+
|
|
331
|
+
state.set(false);
|
|
332
|
+
expect(checkbox.checked).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should use 'change' event for select elements", () => {
|
|
336
|
+
const state = useFunWebState("option1");
|
|
337
|
+
const select = hx(signal, "select", {
|
|
338
|
+
$model: { value: state }
|
|
339
|
+
}) as HTMLSelectElement;
|
|
340
|
+
|
|
341
|
+
expect(select.value).toBe("option1");
|
|
342
|
+
|
|
343
|
+
select.value = "option2";
|
|
344
|
+
select.dispatchEvent(new Event("change"));
|
|
345
|
+
expect(state.get()).toBe("option2");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("should cleanup on signal abort", () => {
|
|
349
|
+
const controller = new AbortController();
|
|
350
|
+
const state = useFunWebState("hello");
|
|
351
|
+
const input = hx(controller.signal, "input", {
|
|
352
|
+
$model: { value: state }
|
|
353
|
+
}) as HTMLInputElement;
|
|
354
|
+
|
|
355
|
+
controller.abort();
|
|
356
|
+
|
|
357
|
+
// After abort, element changes shouldn't update state
|
|
358
|
+
input.value = "world";
|
|
359
|
+
input.dispatchEvent(new Event("input"));
|
|
360
|
+
expect(state.get()).toBe("hello");
|
|
361
|
+
|
|
362
|
+
// And state changes shouldn't update element
|
|
363
|
+
state.set("foo");
|
|
364
|
+
expect(input.value).toBe("world");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("should be simpler than explicit $bind + $on", () => {
|
|
368
|
+
// This is a documentation test showing the benefit
|
|
369
|
+
const state = useFunWebState("test");
|
|
370
|
+
|
|
371
|
+
// Explicit way (verbose)
|
|
372
|
+
const input1 = hx(signal, "input", {
|
|
373
|
+
$bind: { value: state },
|
|
374
|
+
$on: { input: (e) => state.set(e.currentTarget.value) }
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Sugar way (concise)
|
|
378
|
+
const input2 = hx(signal, "input", {
|
|
379
|
+
$model: { value: state }
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Both should behave identically
|
|
383
|
+
expect(input1.value).toBe("test");
|
|
384
|
+
expect(input2.value).toBe("test");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("regular attributes", () => {
|
|
389
|
+
it("should process regular attributes alongside magic properties", () => {
|
|
390
|
+
const state = useFunWebState("test");
|
|
391
|
+
const input = hx(signal, "input", {
|
|
392
|
+
type: "text",
|
|
393
|
+
className: "form-input",
|
|
394
|
+
placeholder: "Enter name",
|
|
395
|
+
$bind: { value: state }
|
|
396
|
+
}) as HTMLInputElement;
|
|
397
|
+
|
|
398
|
+
expect(input.type).toBe("text");
|
|
399
|
+
expect(input.className).toBe("form-input");
|
|
400
|
+
expect(input.placeholder).toBe("Enter name");
|
|
401
|
+
expect(input.value).toBe("test");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("type inference", () => {
|
|
406
|
+
it("should infer correct element type from tag", () => {
|
|
407
|
+
// This is a compile-time test - if it compiles, it passes
|
|
408
|
+
const input = hx(signal, "input", {});
|
|
409
|
+
const _value: string = input.value; // HTMLInputElement has value
|
|
410
|
+
|
|
411
|
+
const div = hx(signal, "div", {});
|
|
412
|
+
// @ts-expect-error - HTMLDivElement doesn't have value property
|
|
413
|
+
const _noValue: string = div.value;
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Phase 3: Documentation
|
|
420
|
+
|
|
421
|
+
**File: `/packages/fun-web/README.md`**
|
|
422
|
+
|
|
423
|
+
Add new section after "Best Practices":
|
|
424
|
+
|
|
425
|
+
```markdown
|
|
426
|
+
## Magic Properties with hx()
|
|
427
|
+
|
|
428
|
+
For more concise inline bindings, use the `hx()` helper:
|
|
429
|
+
|
|
430
|
+
### Basic Usage
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
import { hx } from "@fun-land/fun-web";
|
|
434
|
+
|
|
435
|
+
const MyComponent: Component<{}, [FunWebState<State>]> = (signal, props, state) => {
|
|
436
|
+
// Use hx() with $model for simple two-way binding
|
|
437
|
+
const input = hx(signal, "input", {
|
|
438
|
+
type: "text",
|
|
439
|
+
placeholder: "Enter your name",
|
|
440
|
+
$model: { value: state.prop("name") }
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Mix with regular h() for static elements
|
|
444
|
+
const label = h("label", {}, "Name:");
|
|
445
|
+
|
|
446
|
+
return h("div", {}, [label, input]);
|
|
447
|
+
};
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Magic Properties
|
|
451
|
+
|
|
452
|
+
**`$bind`**: Bind element properties to reactive state
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
$bind: { value: state.prop("name") } // Syncs input.value with state.name
|
|
456
|
+
$bind: { checked: state.prop("agreed") } // Syncs checkbox.checked with state.agreed
|
|
457
|
+
$bind: { disabled: state.prop("isDisabled") } // Syncs button.disabled with state
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Equivalent to `bindProperty(el, "value", state.prop("name"), signal)` but inline.
|
|
461
|
+
|
|
462
|
+
**`$on`**: Attach event listeners
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
$on: { click: () => console.log("clicked") }
|
|
466
|
+
$on: { input: (e) => state.set(e.currentTarget.value) }
|
|
467
|
+
$on: { submit: (e) => { e.preventDefault(); handleSubmit(); } }
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Equivalent to `on(el, "click", handler, signal)` but inline.
|
|
471
|
+
|
|
472
|
+
**`$model`**: Automatic two-way binding (combines $bind + $on)
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
$model: { value: state.prop("name") } // Auto two-way binding for input.value
|
|
476
|
+
$model: { checked: state.prop("agreed") } // Auto two-way binding for checkbox.checked
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Automatically:
|
|
480
|
+
- Binds property from state to element (like $bind)
|
|
481
|
+
- Sets up event listener to sync changes back to state (like $on)
|
|
482
|
+
- Detects the correct event type based on property and element:
|
|
483
|
+
- `checked` → `change` event
|
|
484
|
+
- `value` on `<select>` → `change` event
|
|
485
|
+
- `value` on other elements → `input` event
|
|
486
|
+
|
|
487
|
+
### Two-Way Binding Patterns
|
|
488
|
+
|
|
489
|
+
**Explicit two-way binding** with $bind + $on:
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
const input = hx(signal, "input", {
|
|
493
|
+
type: "email",
|
|
494
|
+
$bind: { value: state.prop("email") },
|
|
495
|
+
$on: { input: (e) => state.prop("email").set(e.currentTarget.value) }
|
|
496
|
+
});
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Automatic two-way binding** with $model (recommended for simple cases):
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
const input = hx(signal, "input", {
|
|
503
|
+
type: "email",
|
|
504
|
+
$model: { value: state.prop("email") }
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Use `$model` when the default event detection works for you. Use explicit `$bind` + `$on` when you need custom event handling or transformation logic.
|
|
509
|
+
|
|
510
|
+
### When to use hx() vs h()
|
|
511
|
+
|
|
512
|
+
**Use `hx()` when:**
|
|
513
|
+
- Element needs reactive bindings (`$bind`)
|
|
514
|
+
- Element needs event listeners that update state (`$on`)
|
|
515
|
+
- You want inline syntax for cleaner code
|
|
516
|
+
|
|
517
|
+
**Use regular `h()` when:**
|
|
518
|
+
- Element is static (no bindings or event listeners)
|
|
519
|
+
- You prefer explicit `bindProperty()` and `on()` calls
|
|
520
|
+
- Building non-reactive components
|
|
521
|
+
|
|
522
|
+
Both approaches work together - use what fits the situation.
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
**File: `/packages/fun-web/examples/todo-app/todo-app.ts`**
|
|
526
|
+
|
|
527
|
+
Refactor one component to demonstrate hx():
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
// Before: verbose with separate calls
|
|
531
|
+
const Todo: Component<TodoProps, [FunWebState<TodoState>]> = (signal, props, state) => {
|
|
532
|
+
const checkbox = h("input", { type: "checkbox" });
|
|
533
|
+
bindProperty(checkbox, "checked", state.prop("checked"), signal);
|
|
534
|
+
on(checkbox, "change", (e) => {
|
|
535
|
+
state.prop("checked").set(e.currentTarget.checked);
|
|
536
|
+
}, signal);
|
|
537
|
+
|
|
538
|
+
// ... more elements ...
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// After: concise with hx() and $model
|
|
542
|
+
const Todo: Component<TodoProps, [FunWebState<TodoState>]> = (signal, props, state) => {
|
|
543
|
+
const checkbox = hx(signal, "input", {
|
|
544
|
+
type: "checkbox",
|
|
545
|
+
$model: { checked: state.prop("checked") }
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const labelInput = hx(signal, "input", {
|
|
549
|
+
type: "text",
|
|
550
|
+
$model: { value: state.prop("label") }
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ... more elements ...
|
|
554
|
+
};
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
## Verification Plan
|
|
558
|
+
|
|
559
|
+
1. **Tests pass**: All 71 existing tests + new hx() tests
|
|
560
|
+
2. **Type safety**: IDE autocomplete works, no type errors
|
|
561
|
+
3. **Examples build**: `yarn build:examples` succeeds
|
|
562
|
+
4. **Bundle size**: Check impact (expect <500 bytes increase)
|
|
563
|
+
5. **Manual testing**: Try hx() in examples, verify cleanup works
|
|
564
|
+
|
|
565
|
+
## Critical Files
|
|
566
|
+
|
|
567
|
+
- `/packages/fun-web/src/hx.ts` - **NEW FILE** - hx() function with magic properties (experimental)
|
|
568
|
+
- `/packages/fun-web/src/hx.test.ts` - **NEW FILE** - Comprehensive test suite for hx()
|
|
569
|
+
- `/packages/fun-web/src/index.ts` - Add exports for hx and MagicAttrs
|
|
570
|
+
- `/packages/fun-web/examples/todo-app/todo-app.ts` - Refactor to demonstrate hx()
|
|
571
|
+
- `/packages/fun-web/README.md` - Document magic properties API
|
|
572
|
+
|
|
573
|
+
## Next Steps
|
|
574
|
+
|
|
575
|
+
Ready to implement hx() with all three magic properties: `$bind`, `$on`, and `$model`.
|
package/wip/next.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Ready to ship:
|
|
2
|
+
|
|
3
|
+
2. Update root CLAUDE.md - Document fun-web in the monorepo guide.
|
|
4
|
+
3. Publish to npm - Run lerna publish to release it.
|
|
5
|
+
|
|
6
|
+
Polish before shipping:
|
|
7
|
+
|
|
8
|
+
5. Performance testing - Benchmark keyedChildren with large lists
|
|
9
|
+
6. Add more examples - Maybe a form validation example or simple SPA
|
|
10
|
+
|
|
11
|
+
New features:
|
|
12
|
+
|
|
13
|
+
7. Conditional rendering helper - Similar to keyedChildren but for if/else
|
|
14
|
+
8. Animation helpers - Utilities for transitions when elements mount/unmount
|
|
15
|
+
9. Portal support - Render components outside their parent container
|
|
16
|
+
10. Server integration - Document patterns for hydrating server-rendered HTML
|
|
17
|
+
|
|
18
|
+
Documentation:
|
|
19
|
+
|
|
20
|
+
11. Migration guide - For users coming from React or other frameworks
|
|
21
|
+
12. API comparison table - Show fun-web equivalents to React patterns
|
|
22
|
+
13. Video walkthrough - Record building something with fun-web
|