@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 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 past state and updates named property
49
- bindProperty(button, "textContent", state, signal);
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
- on(button, "click", () => state.mod((n) => n + 1), signal);
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
- bindProperty(display, "textContent", props.count, signal);
76
- on(button, "click", () => props.count.mod(n => n + 1), signal);
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
- on(button, "click", () => props.count.mod(n => n + 1), signal);
124
- bindProperty(display, "textContent", props.count, signal);
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
- bindProperty(nameEl, "textContent", nameState, signal);
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
- on(button, "click", (e: MouseEvent & { currentTarget: HTMLButtonElement }) => {
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 = on(
211
- bindProperty(
212
- h("input", { type: "text" }),
213
- "value",
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
- bindProperty(display, "textContent", state.prop("count"), signal);
244
- on(button, "click", () => state.mod(increment), signal);
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
- bindProperty(element, "textContent", state.prop("count"), signal);
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
- on(button, "click", (e) => {
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 element for chaining.
340
+ Bind element property to state. Returns `Enhancer`.
354
341
 
355
342
  ```typescript
356
- const input: HTMLInputElement = h("input");
357
- bindProperty(input, "value", state.prop("name"), signal);
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 element for chaining.
352
+ Add type-safe event listener. Returns `Enhancer`.
372
353
 
373
354
  ```typescript
374
- on(h("button"), "click", (e) => {
375
- // e.currentTarget is typed as HTMLButtonElement
376
- e.currentTarget.disabled = true;
377
- }, signal);
355
+ const button = enhance(
356
+ h("button"),
357
+ on("click", (e) => {
358
+ e.currentTarget.disabled = true;
359
+ }, signal)
360
+ );
378
361
  ```
379
362
 
380
- #### keyedChildren
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
- key: string;
371
+ id: string;
399
372
  label: string;
400
373
  done: boolean;
401
374
  }
402
375
 
403
376
  const todos: FunState<Todo[]> = funState([
404
- { key: "a", label: "First", done: false }
377
+ { id: "a", label: "First", done: false }
405
378
  ]);
406
379
 
407
- keyedChildren(h("ul"), signal, todos, (row) => {
408
- const li = h("li");
409
-
410
- // row.state is a focused FunState<Todo> for this item
411
- bindProperty(li, "textContent", row.state.prop("label"), row.signal);
412
-
413
- // row.remove() removes this item from the list
414
- const deleteBtn = h("button", { textContent: "Delete" });
415
- on(deleteBtn, "click", row.remove, row.signal);
416
-
417
- li.appendChild(deleteBtn);
418
- return li;
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<boolean>,
426
- comp: (signal: AbortSignal, props: Props) => Element,
427
- props: Props,
428
- signal: AbortSignal
429
- ): Element
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 a boolean state. Returns a container element that mounts/unmounts the component as the state changes.
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 state becomes `true`, unmounted when `false`
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
- on(toggleBtn, "click", () => {
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
@@ -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
- export declare function bindProperty<E extends Element, K extends keyof E>(el: E, key: K, fs: FunState<E[K]>, signal: AbortSignal): E;
31
- export declare const bindPropertyTo: <E extends Element, K extends keyof E & string>(key: K, state: FunState<E[K]>, signal: AbortSignal) => (el: E) => E;
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 HTMLElementEventMap>(el: E, type: K, handler: (ev: HTMLElementEventMap[K] & {
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 a DOM container's children in sync with a FunState<Array<T>> using stable `t.key`.
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 function keyedChildren<T extends Keyed>(parent: Element, signal: AbortSignal, list: FunState<T[]>, renderRow: (row: {
80
+ export declare const bindListChildren: <T>(options: {
86
81
  signal: AbortSignal;
87
- state: FunState<T>;
88
- remove: () => void;
89
- }) => Element): KeyedChildren;
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: (signal: AbortSignal, props: Props) => Element;
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 {};
@@ -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(element, children);
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 = (parent, children) => {
49
+ const appendChildren = (children) => (parent) => {
50
50
  if (Array.isArray(children)) {
51
- children.forEach((child) => appendChildren(parent, child));
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
- export function bindProperty(el, key, fs, signal) {
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] = fs.get();
94
+ el[key] = state.get();
91
95
  // reactive sync
92
- fs.watch(signal, (v) => {
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 = (el, type, handler, signal) => {
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 a DOM container's children in sync with a FunState<Array<T>> using stable `t.key`.
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 function keyedChildren(parent, signal, list, renderRow) {
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.key;
188
+ const k = keyOf(keyAcc, it);
170
189
  if (seen.has(k))
171
- throw new Error(`keyedChildren: duplicate key "${k}"`);
190
+ throw new Error(`bindListChildren: duplicate key "${k}"`);
172
191
  seen.add(k);
173
192
  nextKeys.push(k);
174
193
  }
175
- // Remove missing
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
- // Ensure present
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(filter((t) => t.key === k));
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((list) => list.filter((t) => t.key !== k)),
210
+ remove: () => list.mod((xs) => xs.filter((t) => keyOf(keyAcc, t) !== k)),
192
211
  });
193
- rows.set(k, { key: k, el, ctrl });
212
+ rows.set(k, { el, ctrl });
194
213
  }
195
214
  }
196
- // Reorder with minimal DOM movement (prevents focus loss)
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 { reconcile, dispose };
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;