@fun-land/fun-web 0.4.0 → 0.5.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 +104 -93
- package/dist/esm/src/dom.d.ts +26 -27
- package/dist/esm/src/dom.js +59 -44
- package/dist/esm/src/dom.js.map +1 -1
- package/dist/esm/src/index.d.ts +1 -1
- package/dist/esm/src/index.js +1 -1
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/src/types.d.ts +1 -1
- package/dist/esm/tsconfig.publish.tsbuildinfo +1 -1
- package/dist/src/dom.d.ts +26 -27
- package/dist/src/dom.js +62 -49
- package/dist/src/dom.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +2 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/types.d.ts +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/examples/README.md +1 -1
- package/examples/todo-app/AddTodoForm.ts +4 -4
- package/examples/todo-app/DraggableTodoList.ts +52 -47
- package/examples/todo-app/README.md +1 -1
- package/examples/todo-app/Todo.ts +7 -7
- package/examples/todo-app/TodoApp.js +94 -51
- package/examples/todo-app/TodoApp.ts +4 -9
- package/package.json +5 -5
- package/src/dom.test.ts +289 -290
- package/src/dom.ts +168 -155
- package/src/index.ts +1 -3
- package/src/types.ts +3 -4
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
mount,
|
|
37
37
|
bindProperty,
|
|
38
38
|
on,
|
|
39
|
+
enhance,
|
|
39
40
|
type Component,
|
|
40
41
|
type FunState,
|
|
41
42
|
} from "@fun-land/fun-web";
|
|
@@ -45,11 +46,11 @@ const Counter: Component<{state: FunState<number>}> = (signal, {state}) => {
|
|
|
45
46
|
// Component runs once - no re-rendering on state changes
|
|
46
47
|
const button = h("button", {}, `Count: ${state.get()}`);
|
|
47
48
|
|
|
48
|
-
// bindProperty subscribes to the
|
|
49
|
-
|
|
49
|
+
// bindProperty subscribes to the state and updates named property
|
|
50
|
+
enhance(button, bindProperty("textContent", state, signal));
|
|
50
51
|
|
|
51
52
|
// Event handlers never go stale (component doesn't re-run)
|
|
52
|
-
|
|
53
|
+
enhance(button, on("click", () => state.mod((n) => n + 1), signal));
|
|
53
54
|
|
|
54
55
|
return button;
|
|
55
56
|
};
|
|
@@ -72,8 +73,8 @@ const Counter: Component<{ count: FunState<number> }> = (signal, props) => {
|
|
|
72
73
|
const button = h("button", {}, "Increment");
|
|
73
74
|
|
|
74
75
|
// This subscription handles updates, not re-rendering
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
enhance(display, bindProperty("textContent", props.count, signal));
|
|
77
|
+
enhance(button, on("click", () => props.count.mod(n => n + 1), signal));
|
|
77
78
|
|
|
78
79
|
return h("div", {}, [display, button]);
|
|
79
80
|
// Component function exits, but subscriptions keep working
|
|
@@ -120,8 +121,8 @@ const FunWebCounter: Component<{ count: FunState<number> }> = (signal, props) =>
|
|
|
120
121
|
const button = h("button", {}, "+");
|
|
121
122
|
|
|
122
123
|
// No useCallback needed - this closure never goes stale
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
enhance(button, on("click", () => props.count.mod(n => n + 1), signal));
|
|
125
|
+
enhance(display, bindProperty("textContent", props.count, signal));
|
|
125
126
|
|
|
126
127
|
return h("div", {}, [display, button]);
|
|
127
128
|
};
|
|
@@ -191,7 +192,7 @@ const Dashboard: Component<{
|
|
|
191
192
|
const nameEl = h("div");
|
|
192
193
|
const nameState: FunState<string> = state.prop("user").prop("name");
|
|
193
194
|
|
|
194
|
-
|
|
195
|
+
enhance(nameEl, bindProperty("textContent", nameState, signal));
|
|
195
196
|
// nameEl.textContent stays in sync with nameState
|
|
196
197
|
```
|
|
197
198
|
|
|
@@ -199,24 +200,18 @@ bindProperty(nameEl, "textContent", nameState, signal);
|
|
|
199
200
|
|
|
200
201
|
```typescript
|
|
201
202
|
const button = h("button", {}, "Click me");
|
|
202
|
-
|
|
203
|
+
enhance(button, on("click", (e: MouseEvent & { currentTarget: HTMLButtonElement }) => {
|
|
203
204
|
console.log(e.currentTarget.textContent);
|
|
204
|
-
}, signal);
|
|
205
|
+
}, signal));
|
|
205
206
|
```
|
|
206
207
|
|
|
207
208
|
**Chain for two-way bindings:**
|
|
208
209
|
|
|
209
210
|
```typescript
|
|
210
|
-
const input =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
state.prop("name"),
|
|
215
|
-
signal
|
|
216
|
-
),
|
|
217
|
-
"input",
|
|
218
|
-
(e) => state.prop("name").set(e.currentTarget.value),
|
|
219
|
-
signal
|
|
211
|
+
const input = enhance(
|
|
212
|
+
h("input", { type: "text" }),
|
|
213
|
+
bindProperty("value", state.prop("name"), signal),
|
|
214
|
+
on("input", (e) => state.prop("name").set(e.currentTarget.value), signal)
|
|
220
215
|
);
|
|
221
216
|
```
|
|
222
217
|
|
|
@@ -240,8 +235,8 @@ const MyComponent: Component<{ state: FunState<State> }> = (signal, {state}) =>
|
|
|
240
235
|
const button = h("button");
|
|
241
236
|
|
|
242
237
|
// All three clean up when signal aborts
|
|
243
|
-
|
|
244
|
-
|
|
238
|
+
enhance(display, bindProperty("textContent", state.prop("count"), signal));
|
|
239
|
+
enhance(button, on("click", () => state.mod(increment), signal));
|
|
245
240
|
state.watch(signal, (s) => console.log("Changed:", s));
|
|
246
241
|
|
|
247
242
|
return h("div", {}, [display, button]);
|
|
@@ -275,7 +270,7 @@ const baz = state.focus(bazFromState).get()
|
|
|
275
270
|
|
|
276
271
|
```typescript
|
|
277
272
|
// ✅ Good
|
|
278
|
-
|
|
273
|
+
enhance(element, bindProperty("textContent", state.prop("count"), signal));
|
|
279
274
|
|
|
280
275
|
// ❌ Avoid (when bindProperty works)
|
|
281
276
|
state.prop("count").watch(signal, (count) => {
|
|
@@ -287,9 +282,9 @@ state.prop("count").watch(signal, (count) => {
|
|
|
287
282
|
|
|
288
283
|
```typescript
|
|
289
284
|
// ✅ Good - types inferred
|
|
290
|
-
|
|
285
|
+
enhance(button, on("click", (e) => {
|
|
291
286
|
e.currentTarget.disabled = true; // TypeScript knows it's HTMLButtonElement
|
|
292
|
-
}, signal);
|
|
287
|
+
}, signal));
|
|
293
288
|
|
|
294
289
|
// ❌ Avoid - loses type information
|
|
295
290
|
button.addEventListener("click", (e) => {
|
|
@@ -341,102 +336,93 @@ const div = h("div", { id: "app" }, [
|
|
|
341
336
|
- Everything else → property assignment
|
|
342
337
|
|
|
343
338
|
#### bindProperty
|
|
344
|
-
```ts
|
|
345
|
-
<E extends Element, K extends keyof E>(
|
|
346
|
-
el: E,
|
|
347
|
-
key: K,
|
|
348
|
-
fs: FunState<E[K]>,
|
|
349
|
-
signal: AbortSignal
|
|
350
|
-
): E
|
|
351
|
-
```
|
|
352
339
|
|
|
353
|
-
Bind element property to state. Returns
|
|
340
|
+
Bind element property to state. Returns `Enhancer`.
|
|
354
341
|
|
|
355
342
|
```typescript
|
|
356
|
-
const input
|
|
357
|
-
|
|
343
|
+
const input = enhance(
|
|
344
|
+
h("input"),
|
|
345
|
+
bindProperty("value", state.prop("name"), signal)
|
|
346
|
+
);
|
|
358
347
|
// input.value syncs with state.name
|
|
359
348
|
```
|
|
360
349
|
|
|
361
350
|
#### on
|
|
362
|
-
```ts
|
|
363
|
-
<E extends Element, K extends keyof HTMLElementEventMap>(
|
|
364
|
-
el: E,
|
|
365
|
-
type: K,
|
|
366
|
-
handler: (ev: HTMLElementEventMap[K] & { currentTarget: E }) => void,
|
|
367
|
-
signal: AbortSignal
|
|
368
|
-
): E
|
|
369
|
-
```
|
|
370
351
|
|
|
371
|
-
Add type-safe event listener. Returns
|
|
352
|
+
Add type-safe event listener. Returns `Enhancer`.
|
|
372
353
|
|
|
373
354
|
```typescript
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
e
|
|
377
|
-
|
|
355
|
+
const button = enhance(
|
|
356
|
+
h("button"),
|
|
357
|
+
on("click", (e) => {
|
|
358
|
+
e.currentTarget.disabled = true;
|
|
359
|
+
}, signal)
|
|
360
|
+
);
|
|
378
361
|
```
|
|
379
362
|
|
|
380
|
-
####
|
|
381
|
-
```ts
|
|
382
|
-
<T extends { key: string }>(
|
|
383
|
-
parent: Element,
|
|
384
|
-
signal: AbortSignal,
|
|
385
|
-
list: FunState<T[]>,
|
|
386
|
-
renderRow: (row: {
|
|
387
|
-
signal: AbortSignal;
|
|
388
|
-
state: FunState<T>;
|
|
389
|
-
remove: () => void;
|
|
390
|
-
}) => Element
|
|
391
|
-
): KeyedChildren<T>
|
|
392
|
-
```
|
|
363
|
+
#### bindListChildren
|
|
393
364
|
|
|
394
|
-
Render and reconcile keyed lists efficiently. Each row gets its own AbortSignal for cleanup and a focused state.
|
|
365
|
+
Render and reconcile keyed lists efficiently. Each row gets its own AbortSignal for cleanup and a focused state. Returns `Enhancer`.
|
|
395
366
|
|
|
396
367
|
```typescript
|
|
368
|
+
import { Acc } from "@fun-land/accessor";
|
|
369
|
+
|
|
397
370
|
interface Todo {
|
|
398
|
-
|
|
371
|
+
id: string;
|
|
399
372
|
label: string;
|
|
400
373
|
done: boolean;
|
|
401
374
|
}
|
|
402
375
|
|
|
403
376
|
const todos: FunState<Todo[]> = funState([
|
|
404
|
-
{
|
|
377
|
+
{ id: "a", label: "First", done: false }
|
|
405
378
|
]);
|
|
406
379
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
380
|
+
const list = enhance(
|
|
381
|
+
h("ul"),
|
|
382
|
+
bindListChildren({
|
|
383
|
+
signal,
|
|
384
|
+
state: todos,
|
|
385
|
+
key: Acc<Todo>().prop("id"),
|
|
386
|
+
row: ({ signal, state, remove }) => {
|
|
387
|
+
const li = h("li");
|
|
388
|
+
|
|
389
|
+
// state is a focused FunState<Todo> for this item
|
|
390
|
+
enhance(li, bindProperty("textContent", state.prop("label"), signal));
|
|
391
|
+
|
|
392
|
+
// remove() removes this item from the list
|
|
393
|
+
const deleteBtn = h("button", { textContent: "Delete" });
|
|
394
|
+
enhance(deleteBtn, on("click", remove, signal));
|
|
395
|
+
|
|
396
|
+
li.appendChild(deleteBtn);
|
|
397
|
+
return li;
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
);
|
|
420
401
|
```
|
|
421
402
|
|
|
422
403
|
#### renderWhen
|
|
423
404
|
```ts
|
|
424
|
-
<Props>(
|
|
425
|
-
state: FunState<
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
405
|
+
function renderWhen<State, Props>(options: {
|
|
406
|
+
state: FunState<State>;
|
|
407
|
+
predicate?: (value: State) => boolean;
|
|
408
|
+
component: Component<Props>;
|
|
409
|
+
props: Props;
|
|
410
|
+
signal: AbortSignal;
|
|
411
|
+
}): Element
|
|
430
412
|
```
|
|
431
413
|
|
|
432
|
-
Conditionally render a component based on
|
|
414
|
+
Conditionally render a component based on state and an optional predicate. Returns a container element that mounts/unmounts the component as the condition changes.
|
|
433
415
|
|
|
434
416
|
**Key features:**
|
|
435
|
-
- Component is mounted when
|
|
417
|
+
- Component is mounted when condition is `true`, unmounted when `false`
|
|
418
|
+
- With boolean state, component mounts when state is `true`
|
|
419
|
+
- With predicate, component mounts when `predicate(state)` returns `true`
|
|
436
420
|
- Each mount gets its own AbortController for proper cleanup
|
|
437
421
|
- Container uses `display: contents` to not affect layout
|
|
438
422
|
- Multiple toggles create fresh component instances each time
|
|
439
423
|
|
|
424
|
+
**Example with boolean state:**
|
|
425
|
+
|
|
440
426
|
```typescript
|
|
441
427
|
const ShowDetails: Component<{ user: User }> = (signal, { user }) => {
|
|
442
428
|
return h("div", { className: "details" }, [
|
|
@@ -450,22 +436,47 @@ const App: Component = (signal) => {
|
|
|
450
436
|
const userState = funState({ email: "alice@example.com", joinDate: "2024" });
|
|
451
437
|
|
|
452
438
|
const toggleBtn = h("button", { textContent: "Toggle Details" });
|
|
453
|
-
|
|
439
|
+
enhance(toggleBtn, on("click", () => {
|
|
454
440
|
showDetailsState.mod(show => !show);
|
|
455
|
-
}, signal);
|
|
441
|
+
}, signal));
|
|
456
442
|
|
|
457
443
|
// Details component mounts/unmounts based on showDetailsState
|
|
458
|
-
const detailsEl = renderWhen(
|
|
459
|
-
showDetailsState,
|
|
460
|
-
ShowDetails,
|
|
461
|
-
{ user: userState.get() },
|
|
444
|
+
const detailsEl = renderWhen({
|
|
445
|
+
state: showDetailsState,
|
|
446
|
+
component: ShowDetails,
|
|
447
|
+
props: { user: userState.get() },
|
|
462
448
|
signal
|
|
463
|
-
);
|
|
449
|
+
});
|
|
464
450
|
|
|
465
451
|
return h("div", {}, [toggleBtn, detailsEl]);
|
|
466
452
|
};
|
|
467
453
|
```
|
|
468
454
|
|
|
455
|
+
**Example with predicate:**
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
enum Status { Loading, Success, Error }
|
|
459
|
+
|
|
460
|
+
const SuccessMessage: Component<{ message: string }> = (signal, { message }) => {
|
|
461
|
+
return h("div", { className: "success" }, message);
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const App: Component = (signal) => {
|
|
465
|
+
const statusState = funState(Status.Loading);
|
|
466
|
+
|
|
467
|
+
// Only render SuccessMessage when status is Success
|
|
468
|
+
const successEl = renderWhen({
|
|
469
|
+
state: statusState,
|
|
470
|
+
predicate: (status) => status === Status.Success,
|
|
471
|
+
component: SuccessMessage,
|
|
472
|
+
props: { message: "Operation completed!" },
|
|
473
|
+
signal
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return h("div", {}, [successEl]);
|
|
477
|
+
};
|
|
478
|
+
```
|
|
479
|
+
|
|
469
480
|
**When to use:**
|
|
470
481
|
- Conditionally showing/hiding expensive components (better than CSS `display: none`)
|
|
471
482
|
- Mounting components that need full cleanup when hidden
|
package/dist/esm/src/dom.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** DOM utilities for functional element creation and manipulation */
|
|
2
2
|
import { type FunState } from "@fun-land/fun-state";
|
|
3
|
-
import type { ElementChild } from "./types";
|
|
3
|
+
import type { Component, ElementChild } from "./types";
|
|
4
|
+
import { Accessor } from "@fun-land/accessor";
|
|
4
5
|
export type Enhancer<El extends Element> = (element: El) => El;
|
|
5
6
|
/**
|
|
6
7
|
* Create an HTML element with attributes and children
|
|
@@ -17,28 +18,36 @@ export type Enhancer<El extends Element> = (element: El) => El;
|
|
|
17
18
|
export declare const h: <Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attrs?: Record<string, any> | null, children?: ElementChild | ElementChild[]) => HTMLElementTagNameMap[Tag];
|
|
18
19
|
/**
|
|
19
20
|
* Set text content of an element (returns element for chaining)
|
|
21
|
+
* @returns {Enhancer}
|
|
20
22
|
*/
|
|
21
23
|
export declare const text: (content: string | number) => <El extends Element>(el: El) => El;
|
|
22
24
|
/**
|
|
23
25
|
* Set an attribute on an element (returns element for chaining)
|
|
26
|
+
* @returns {Enhancer}
|
|
24
27
|
*/
|
|
25
28
|
export declare const attr: (name: string, value: string) => <El extends Element>(el: El) => El;
|
|
26
29
|
/**
|
|
27
30
|
* Set multiple attributes on an element (returns element for chaining)
|
|
28
31
|
*/
|
|
29
32
|
export declare const attrs: (obj: Record<string, string>) => <El extends Element>(el: El) => El;
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* update property `key` of element when state updates
|
|
35
|
+
* @returns {Enhancer}
|
|
36
|
+
*/
|
|
37
|
+
export declare const bindProperty: <E extends Element, K extends keyof E & string>(key: K, state: FunState<E[K]>, signal: AbortSignal) => (el: E) => E;
|
|
32
38
|
/**
|
|
33
39
|
* Add CSS classes to an element (returns element for chaining)
|
|
40
|
+
* @returns {Enhancer}
|
|
34
41
|
*/
|
|
35
42
|
export declare const addClass: (...classes: string[]) => <El extends Element>(el: El) => El;
|
|
36
43
|
/**
|
|
37
44
|
* Remove CSS classes from an element (returns element for chaining)
|
|
45
|
+
* @returns {Enhancer}
|
|
38
46
|
*/
|
|
39
47
|
export declare const removeClass: (...classes: string[]) => <El extends Element>(el: El) => El;
|
|
40
48
|
/**
|
|
41
49
|
* Toggle a CSS class on an element (returns element for chaining)
|
|
50
|
+
* @returns {Enhancer}
|
|
42
51
|
*/
|
|
43
52
|
export declare const toggleClass: (className: string, force?: boolean) => (el: Element) => Element;
|
|
44
53
|
/**
|
|
@@ -49,24 +58,15 @@ export declare const append: (...children: Element[]) => <El extends Element>(el
|
|
|
49
58
|
/**
|
|
50
59
|
* Add event listener with required AbortSignal (returns element for chaining)
|
|
51
60
|
* Signal is required to prevent forgetting cleanup
|
|
61
|
+
* @returns {Enhancer}
|
|
52
62
|
*/
|
|
53
|
-
export declare const on: <E extends Element, K extends keyof
|
|
54
|
-
currentTarget: E;
|
|
55
|
-
}) => void, signal: AbortSignal) => E;
|
|
56
|
-
/** Enhancer version of `on()` */
|
|
57
|
-
export declare const onTo: <E extends Element, K extends keyof HTMLElementEventMap>(type: K, handler: (ev: HTMLElementEventMap[K] & {
|
|
63
|
+
export declare const on: <E extends Element, K extends keyof GlobalEventHandlersEventMap>(type: K, handler: (ev: GlobalEventHandlersEventMap[K] & {
|
|
58
64
|
currentTarget: E;
|
|
59
65
|
}) => void, signal: AbortSignal) => (el: E) => E;
|
|
60
66
|
/**
|
|
61
67
|
* Apply enhancers to an HTMLElement.
|
|
62
68
|
*/
|
|
63
69
|
export declare const enhance: <El extends Element>(x: El, ...fns: Array<Enhancer<El>>) => El;
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*/
|
|
67
|
-
type Keyed = {
|
|
68
|
-
key: string;
|
|
69
|
-
};
|
|
70
70
|
export type KeyedChildren = {
|
|
71
71
|
/** Reconcile DOM children to match current list state */
|
|
72
72
|
reconcile: () => void;
|
|
@@ -74,19 +74,19 @@ export type KeyedChildren = {
|
|
|
74
74
|
dispose: () => void;
|
|
75
75
|
};
|
|
76
76
|
/**
|
|
77
|
-
* Keep
|
|
78
|
-
*
|
|
79
|
-
* - No VDOM
|
|
80
|
-
* - Preserves existing row elements across updates
|
|
81
|
-
* - Creates one AbortController per row (cleaned up on removal or parent abort)
|
|
82
|
-
* - Reorders by DOM moves (appendChild)
|
|
83
|
-
* - Only remounts if order changes
|
|
77
|
+
* Keep an element's children in sync with a FunState<T[]> by stable identity.
|
|
78
|
+
* Cleanup is driven by `signal` abort (no handle returned).
|
|
84
79
|
*/
|
|
85
|
-
export declare
|
|
80
|
+
export declare const bindListChildren: <T>(options: {
|
|
86
81
|
signal: AbortSignal;
|
|
87
|
-
state: FunState<T>;
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
state: FunState<T[]>;
|
|
83
|
+
key: Accessor<T, string>;
|
|
84
|
+
row: (args: {
|
|
85
|
+
signal: AbortSignal;
|
|
86
|
+
state: FunState<T>;
|
|
87
|
+
remove: () => void;
|
|
88
|
+
}) => Element;
|
|
89
|
+
}) => <El extends Element>(parent: El) => El;
|
|
90
90
|
/**
|
|
91
91
|
* Conditionally render a component based on state and an optional predicate.
|
|
92
92
|
* Returns a container element that mounts/unmounts the component as the condition changes.
|
|
@@ -116,10 +116,9 @@ export declare function keyedChildren<T extends Keyed>(parent: Element, signal:
|
|
|
116
116
|
export declare function renderWhen<State, Props>(options: {
|
|
117
117
|
state: FunState<State>;
|
|
118
118
|
predicate?: (value: State) => boolean;
|
|
119
|
-
component:
|
|
119
|
+
component: Component<Props>;
|
|
120
120
|
props: Props;
|
|
121
121
|
signal: AbortSignal;
|
|
122
122
|
}): Element;
|
|
123
123
|
export declare const $: <T extends Element>(selector: string) => T | undefined;
|
|
124
124
|
export declare const $$: <T extends Element>(selector: string) => T[];
|
|
125
|
-
export {};
|
package/dist/esm/src/dom.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { filter } from "@fun-land/accessor";
|
|
2
1
|
/**
|
|
3
2
|
* Create an HTML element with attributes and children
|
|
4
3
|
*
|
|
@@ -39,16 +38,17 @@ attrs, children) => {
|
|
|
39
38
|
}
|
|
40
39
|
// Append children
|
|
41
40
|
if (children != null) {
|
|
42
|
-
appendChildren(
|
|
41
|
+
appendChildren(children)(element);
|
|
43
42
|
}
|
|
44
43
|
return element;
|
|
45
44
|
};
|
|
46
45
|
/**
|
|
47
46
|
* Append children to an element, flattening arrays and converting primitives to text nodes
|
|
47
|
+
* @returns {Enhancer}
|
|
48
48
|
*/
|
|
49
|
-
const appendChildren = (
|
|
49
|
+
const appendChildren = (children) => (parent) => {
|
|
50
50
|
if (Array.isArray(children)) {
|
|
51
|
-
children.forEach((child) => appendChildren(
|
|
51
|
+
children.forEach((child) => appendChildren(child)(parent));
|
|
52
52
|
}
|
|
53
53
|
else if (children != null) {
|
|
54
54
|
if (typeof children === "string" || typeof children === "number") {
|
|
@@ -58,23 +58,24 @@ const appendChildren = (parent, children) => {
|
|
|
58
58
|
parent.appendChild(children);
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
return parent;
|
|
61
62
|
};
|
|
62
63
|
/**
|
|
63
64
|
* Set text content of an element (returns element for chaining)
|
|
65
|
+
* @returns {Enhancer}
|
|
64
66
|
*/
|
|
65
67
|
export const text = (content) => (el) => {
|
|
66
68
|
el.textContent = String(content);
|
|
67
69
|
return el;
|
|
68
70
|
};
|
|
69
|
-
;
|
|
70
71
|
/**
|
|
71
72
|
* Set an attribute on an element (returns element for chaining)
|
|
73
|
+
* @returns {Enhancer}
|
|
72
74
|
*/
|
|
73
75
|
export const attr = (name, value) => (el) => {
|
|
74
76
|
el.setAttribute(name, value);
|
|
75
77
|
return el;
|
|
76
78
|
};
|
|
77
|
-
;
|
|
78
79
|
/**
|
|
79
80
|
* Set multiple attributes on an element (returns element for chaining)
|
|
80
81
|
*/
|
|
@@ -84,35 +85,38 @@ export const attrs = (obj) => (el) => {
|
|
|
84
85
|
});
|
|
85
86
|
return el;
|
|
86
87
|
};
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* update property `key` of element when state updates
|
|
90
|
+
* @returns {Enhancer}
|
|
91
|
+
*/
|
|
92
|
+
export const bindProperty = (key, state, signal) => (el) => {
|
|
89
93
|
// initial sync
|
|
90
|
-
el[key] =
|
|
94
|
+
el[key] = state.get();
|
|
91
95
|
// reactive sync
|
|
92
|
-
|
|
96
|
+
state.watch(signal, (v) => {
|
|
93
97
|
el[key] = v;
|
|
94
98
|
});
|
|
95
99
|
return el;
|
|
96
|
-
}
|
|
97
|
-
export const bindPropertyTo = (key, state, signal) => (el) => bindProperty(el, key, state, signal);
|
|
100
|
+
};
|
|
98
101
|
/**
|
|
99
102
|
* Add CSS classes to an element (returns element for chaining)
|
|
103
|
+
* @returns {Enhancer}
|
|
100
104
|
*/
|
|
101
105
|
export const addClass = (...classes) => (el) => {
|
|
102
106
|
el.classList.add(...classes);
|
|
103
107
|
return el;
|
|
104
108
|
};
|
|
105
|
-
;
|
|
106
109
|
/**
|
|
107
110
|
* Remove CSS classes from an element (returns element for chaining)
|
|
111
|
+
* @returns {Enhancer}
|
|
108
112
|
*/
|
|
109
113
|
export const removeClass = (...classes) => (el) => {
|
|
110
114
|
el.classList.remove(...classes);
|
|
111
115
|
return el;
|
|
112
116
|
};
|
|
113
|
-
;
|
|
114
117
|
/**
|
|
115
118
|
* Toggle a CSS class on an element (returns element for chaining)
|
|
119
|
+
* @returns {Enhancer}
|
|
116
120
|
*/
|
|
117
121
|
export const toggleClass = (className, force) => (el) => {
|
|
118
122
|
el.classList.toggle(className, force);
|
|
@@ -126,37 +130,52 @@ export const append = (...children) => (el) => {
|
|
|
126
130
|
children.forEach((child) => el.appendChild(child));
|
|
127
131
|
return el;
|
|
128
132
|
};
|
|
129
|
-
;
|
|
130
133
|
/**
|
|
131
134
|
* Add event listener with required AbortSignal (returns element for chaining)
|
|
132
135
|
* Signal is required to prevent forgetting cleanup
|
|
136
|
+
* @returns {Enhancer}
|
|
133
137
|
*/
|
|
134
|
-
export const on = (
|
|
138
|
+
export const on = (type, handler, signal) => (el) => {
|
|
135
139
|
el.addEventListener(type, handler, { signal });
|
|
136
140
|
return el;
|
|
137
141
|
};
|
|
138
|
-
/** Enhancer version of `on()` */
|
|
139
|
-
export const onTo = (type, handler, signal) => (el) => on(el, type, handler, signal);
|
|
140
142
|
/**
|
|
141
143
|
* Apply enhancers to an HTMLElement.
|
|
142
144
|
*/
|
|
143
145
|
export const enhance = (x, ...fns) => fns.reduce((acc, fn) => fn(acc), x);
|
|
146
|
+
const keyOf = (keyAcc, item) => {
|
|
147
|
+
const k = keyAcc.query(item)[0];
|
|
148
|
+
if (k == null)
|
|
149
|
+
throw new Error("bindListChildren: key accessor returned no value");
|
|
150
|
+
return k;
|
|
151
|
+
};
|
|
152
|
+
const byKey = (keyAcc, key) => ({
|
|
153
|
+
query: (xs) => {
|
|
154
|
+
const hit = xs.find((t) => keyOf(keyAcc, t) === key);
|
|
155
|
+
return hit ? [hit] : [];
|
|
156
|
+
},
|
|
157
|
+
mod: (f) => (xs) => {
|
|
158
|
+
let found = false;
|
|
159
|
+
return xs.map((t) => {
|
|
160
|
+
if (keyOf(keyAcc, t) !== key)
|
|
161
|
+
return t;
|
|
162
|
+
if (found)
|
|
163
|
+
throw new Error(`bindListChildren: duplicate key "${key}"`);
|
|
164
|
+
found = true;
|
|
165
|
+
return f(t);
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
});
|
|
144
169
|
/**
|
|
145
|
-
* Keep
|
|
146
|
-
*
|
|
147
|
-
* - No VDOM
|
|
148
|
-
* - Preserves existing row elements across updates
|
|
149
|
-
* - Creates one AbortController per row (cleaned up on removal or parent abort)
|
|
150
|
-
* - Reorders by DOM moves (appendChild)
|
|
151
|
-
* - Only remounts if order changes
|
|
170
|
+
* Keep an element's children in sync with a FunState<T[]> by stable identity.
|
|
171
|
+
* Cleanup is driven by `signal` abort (no handle returned).
|
|
152
172
|
*/
|
|
153
|
-
export
|
|
173
|
+
export const bindListChildren = (options) => (parent) => {
|
|
174
|
+
const { signal, state: list, key: keyAcc, row: renderRow } = options;
|
|
154
175
|
const rows = new Map();
|
|
155
176
|
const dispose = () => {
|
|
156
177
|
for (const row of rows.values()) {
|
|
157
|
-
// Abort first so listeners/subscriptions clean up
|
|
158
178
|
row.ctrl.abort();
|
|
159
|
-
// Remove from DOM (safe even if already removed)
|
|
160
179
|
row.el.remove();
|
|
161
180
|
}
|
|
162
181
|
rows.clear();
|
|
@@ -166,13 +185,13 @@ export function keyedChildren(parent, signal, list, renderRow) {
|
|
|
166
185
|
const nextKeys = [];
|
|
167
186
|
const seen = new Set();
|
|
168
187
|
for (const it of items) {
|
|
169
|
-
const k = it
|
|
188
|
+
const k = keyOf(keyAcc, it);
|
|
170
189
|
if (seen.has(k))
|
|
171
|
-
throw new Error(`
|
|
190
|
+
throw new Error(`bindListChildren: duplicate key "${k}"`);
|
|
172
191
|
seen.add(k);
|
|
173
192
|
nextKeys.push(k);
|
|
174
193
|
}
|
|
175
|
-
//
|
|
194
|
+
// remove missing
|
|
176
195
|
for (const [k, row] of rows) {
|
|
177
196
|
if (!seen.has(k)) {
|
|
178
197
|
row.ctrl.abort();
|
|
@@ -180,38 +199,34 @@ export function keyedChildren(parent, signal, list, renderRow) {
|
|
|
180
199
|
rows.delete(k);
|
|
181
200
|
}
|
|
182
201
|
}
|
|
183
|
-
//
|
|
202
|
+
// ensure present
|
|
184
203
|
for (const k of nextKeys) {
|
|
185
204
|
if (!rows.has(k)) {
|
|
186
205
|
const ctrl = new AbortController();
|
|
187
|
-
const itemState = list.focus(
|
|
206
|
+
const itemState = list.focus(byKey(keyAcc, k));
|
|
188
207
|
const el = renderRow({
|
|
189
208
|
signal: ctrl.signal,
|
|
190
209
|
state: itemState,
|
|
191
|
-
remove: () => list.mod((
|
|
210
|
+
remove: () => list.mod((xs) => xs.filter((t) => keyOf(keyAcc, t) !== k)),
|
|
192
211
|
});
|
|
193
|
-
rows.set(k, {
|
|
212
|
+
rows.set(k, { el, ctrl });
|
|
194
213
|
}
|
|
195
214
|
}
|
|
196
|
-
//
|
|
215
|
+
// reorder with minimal DOM moves
|
|
197
216
|
const children = parent.children; // live
|
|
198
217
|
for (let i = 0; i < nextKeys.length; i++) {
|
|
199
218
|
const k = nextKeys[i];
|
|
200
219
|
const row = rows.get(k);
|
|
201
220
|
const currentAtI = children[i];
|
|
202
|
-
if (currentAtI !== row.el)
|
|
221
|
+
if (currentAtI !== row.el)
|
|
203
222
|
parent.insertBefore(row.el, currentAtI !== null && currentAtI !== void 0 ? currentAtI : null);
|
|
204
|
-
}
|
|
205
223
|
}
|
|
206
224
|
};
|
|
207
|
-
// Reconcile whenever the list changes; `subscribe` will unsubscribe on abort (per your fix).
|
|
208
225
|
list.watch(signal, reconcile);
|
|
209
|
-
// Ensure all children clean up when parent aborts
|
|
210
226
|
signal.addEventListener("abort", dispose, { once: true });
|
|
211
|
-
// Initial mount
|
|
212
227
|
reconcile();
|
|
213
|
-
return
|
|
214
|
-
}
|
|
228
|
+
return parent;
|
|
229
|
+
};
|
|
215
230
|
/**
|
|
216
231
|
* Conditionally render a component based on state and an optional predicate.
|
|
217
232
|
* Returns a container element that mounts/unmounts the component as the condition changes.
|
|
@@ -239,7 +254,7 @@ export function keyedChildren(parent, signal, list, renderRow) {
|
|
|
239
254
|
* });
|
|
240
255
|
*/
|
|
241
256
|
export function renderWhen(options) {
|
|
242
|
-
const { state, predicate = (x) => x, component, props, signal } = options;
|
|
257
|
+
const { state, predicate = (x) => x, component, props, signal, } = options;
|
|
243
258
|
const container = document.createElement("span");
|
|
244
259
|
container.style.display = "contents";
|
|
245
260
|
let childCtrl = null;
|