@fun-land/fun-web 0.3.2 → 0.5.0-alpha.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/src/dom.ts CHANGED
@@ -1,7 +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";
4
- import { filter } from "@fun-land/accessor";
3
+ import type { Component, ElementChild } from "./types";
4
+ import { Accessor } from "@fun-land/accessor";
5
5
 
6
6
  export type Enhancer<El extends Element> = (element: El) => El;
7
7
 
@@ -48,7 +48,7 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
48
48
 
49
49
  // Append children
50
50
  if (children != null) {
51
- appendChildren(element, children);
51
+ appendChildren(children)(element);
52
52
  }
53
53
 
54
54
  return element;
@@ -56,41 +56,44 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
56
56
 
57
57
  /**
58
58
  * Append children to an element, flattening arrays and converting primitives to text nodes
59
+ * @returns {Enhancer}
59
60
  */
60
- const appendChildren = (
61
- parent: Element,
62
- children: ElementChild | ElementChild[]
63
- ): void => {
64
- if (Array.isArray(children)) {
65
- children.forEach((child) => appendChildren(parent, child));
66
- } else if (children != null) {
67
- if (typeof children === "string" || typeof children === "number") {
68
- parent.appendChild(document.createTextNode(String(children)));
69
- } else {
70
- parent.appendChild(children);
61
+ const appendChildren =
62
+ (children: ElementChild | ElementChild[]) =>
63
+ <E extends HTMLElement>(parent: E): E => {
64
+ if (Array.isArray(children)) {
65
+ children.forEach((child) => appendChildren(child)(parent));
66
+ } else if (children != null) {
67
+ if (typeof children === "string" || typeof children === "number") {
68
+ parent.appendChild(document.createTextNode(String(children)));
69
+ } else {
70
+ parent.appendChild(children);
71
+ }
71
72
  }
72
- }
73
- };
73
+ return parent;
74
+ };
74
75
 
75
76
  /**
76
77
  * Set text content of an element (returns element for chaining)
78
+ * @returns {Enhancer}
77
79
  */
78
80
  export const text =
79
81
  (content: string | number) =>
80
82
  <El extends Element>(el: El): El => {
81
83
  el.textContent = String(content);
82
84
  return el;
83
- };;
85
+ };
84
86
 
85
87
  /**
86
88
  * Set an attribute on an element (returns element for chaining)
89
+ * @returns {Enhancer}
87
90
  */
88
91
  export const attr =
89
92
  (name: string, value: string) =>
90
93
  <El extends Element>(el: El): El => {
91
94
  el.setAttribute(name, value);
92
95
  return el;
93
- };;
96
+ };
94
97
 
95
98
  /**
96
99
  * Set multiple attributes on an element (returns element for chaining)
@@ -102,55 +105,54 @@ export const attrs =
102
105
  el.setAttribute(name, value);
103
106
  });
104
107
  return el;
105
- };;
106
-
107
- export function bindProperty<E extends Element, K extends keyof E>(
108
- el: E,
109
- key: K,
110
- fs: FunState<E[K]>,
111
- signal: AbortSignal
112
- ): E {
113
- // initial sync
114
- el[key] = fs.get();
115
-
116
- // reactive sync
117
- fs.watch(signal, (v: E[K]) => {
118
- el[key] = v;
119
- });
120
- return el;
121
- }
108
+ };
122
109
 
123
- export const bindPropertyTo =
110
+ /**
111
+ * update property `key` of element when state updates
112
+ * @returns {Enhancer}
113
+ */
114
+ export const bindProperty =
124
115
  <E extends Element, K extends keyof E & string>(
125
116
  key: K,
126
117
  state: FunState<E[K]>,
127
118
  signal: AbortSignal
128
119
  ) =>
129
- (el: E): E =>
130
- bindProperty(el, key, state, signal);
120
+ (el: E): E => {
121
+ // initial sync
122
+ el[key] = state.get();
123
+
124
+ // reactive sync
125
+ state.watch(signal, (v: E[K]) => {
126
+ el[key] = v;
127
+ });
128
+ return el;
129
+ };
131
130
 
132
131
  /**
133
132
  * Add CSS classes to an element (returns element for chaining)
133
+ * @returns {Enhancer}
134
134
  */
135
135
  export const addClass =
136
136
  (...classes: string[]) =>
137
137
  <El extends Element>(el: El): El => {
138
138
  el.classList.add(...classes);
139
139
  return el;
140
- };;
140
+ };
141
141
 
142
142
  /**
143
143
  * Remove CSS classes from an element (returns element for chaining)
144
+ * @returns {Enhancer}
144
145
  */
145
146
  export const removeClass =
146
147
  (...classes: string[]) =>
147
148
  <El extends Element>(el: El): El => {
148
149
  el.classList.remove(...classes);
149
150
  return el;
150
- };;
151
+ };
151
152
 
152
153
  /**
153
154
  * Toggle a CSS class on an element (returns element for chaining)
155
+ * @returns {Enhancer}
154
156
  */
155
157
  export const toggleClass =
156
158
  (className: string, force?: boolean) =>
@@ -168,31 +170,23 @@ export const append =
168
170
  <El extends Element>(el: El): El => {
169
171
  children.forEach((child) => el.appendChild(child));
170
172
  return el;
171
- };;
173
+ };
172
174
 
173
175
  /**
174
176
  * Add event listener with required AbortSignal (returns element for chaining)
175
177
  * Signal is required to prevent forgetting cleanup
178
+ * @returns {Enhancer}
176
179
  */
177
- export const on = <E extends Element, K extends keyof HTMLElementEventMap>(
178
- el: E,
179
- type: K,
180
- handler: (ev: HTMLElementEventMap[K] & { currentTarget: E }) => void,
181
- signal: AbortSignal
182
- ) => {
183
- el.addEventListener(type, handler as EventListener, { signal });
184
- return el;
185
- };
186
-
187
- /** Enhancer version of `on()` */
188
- export const onTo =
180
+ export const on =
189
181
  <E extends Element, K extends keyof HTMLElementEventMap>(
190
182
  type: K,
191
183
  handler: (ev: HTMLElementEventMap[K] & { currentTarget: E }) => void,
192
184
  signal: AbortSignal
193
185
  ) =>
194
- (el: E): E =>
195
- on(el, type, handler, signal);
186
+ (el: E): E => {
187
+ el.addEventListener(type, handler as EventListener, { signal });
188
+ return el;
189
+ };
196
190
 
197
191
  /**
198
192
  * Apply enhancers to an HTMLElement.
@@ -202,18 +196,6 @@ export const enhance = <El extends Element>(
202
196
  ...fns: Array<Enhancer<El>>
203
197
  ) => fns.reduce((acc, fn) => fn(acc), x);
204
198
 
205
- /**
206
- *
207
- */
208
-
209
- type Keyed = { key: string };
210
-
211
- type MountedRow = {
212
- key: string;
213
- el: Element;
214
- ctrl: AbortController;
215
- };
216
-
217
199
  export type KeyedChildren = {
218
200
  /** Reconcile DOM children to match current list state */
219
201
  reconcile: () => void;
@@ -221,124 +203,169 @@ export type KeyedChildren = {
221
203
  dispose: () => void;
222
204
  };
223
205
 
224
- /**
225
- * Keep a DOM container's children in sync with a FunState<Array<T>> using stable `t.key`.
226
- *
227
- * - No VDOM
228
- * - Preserves existing row elements across updates
229
- * - Creates one AbortController per row (cleaned up on removal or parent abort)
230
- * - Reorders by DOM moves (appendChild)
231
- * - Only remounts if order changes
232
- */
233
- export function keyedChildren<T extends Keyed>(
234
- parent: Element,
235
- signal: AbortSignal,
236
- list: FunState<T[]>,
237
- renderRow: (row: {
238
- signal: AbortSignal;
239
- state: FunState<T>;
240
- remove: () => void;
241
- }) => Element
242
- ): KeyedChildren {
243
- const rows = new Map<string, MountedRow>();
244
-
245
- const dispose = (): void => {
246
- for (const row of rows.values()) {
247
- // Abort first so listeners/subscriptions clean up
248
- row.ctrl.abort();
249
- // Remove from DOM (safe even if already removed)
250
- row.el.remove();
251
- }
252
- rows.clear();
253
- };
206
+ const keyOf = <T>(keyAcc: Accessor<T, string>, item: T): string => {
207
+ const k = keyAcc.query(item)[0];
208
+ if (k == null)
209
+ throw new Error("bindListChildren: key accessor returned no value");
210
+ return k;
211
+ };
254
212
 
255
- const reconcile = (): void => {
256
- const items = list.get();
213
+ const byKey = <T>(
214
+ keyAcc: Accessor<T, string>,
215
+ key: string
216
+ ): Accessor<T[], T> => ({
217
+ query: (xs) => {
218
+ const hit = xs.find((t) => keyOf(keyAcc, t) === key);
219
+ return hit ? [hit] : [];
220
+ },
221
+ mod: (f) => (xs) => {
222
+ let found = false;
223
+ return xs.map((t) => {
224
+ if (keyOf(keyAcc, t) !== key) return t;
225
+ if (found) throw new Error(`bindListChildren: duplicate key "${key}"`);
226
+ found = true;
227
+ return f(t);
228
+ });
229
+ },
230
+ });
257
231
 
258
- const nextKeys: string[] = [];
259
- const seen = new Set<string>();
260
- for (const it of items) {
261
- const k = it.key;
262
- if (seen.has(k)) throw new Error(`keyedChildren: duplicate key "${k}"`);
263
- seen.add(k);
264
- nextKeys.push(k);
265
- }
232
+ type MountedRow = { el: Element; ctrl: AbortController };
266
233
 
267
- // Remove missing
268
- for (const [k, row] of rows) {
269
- if (!seen.has(k)) {
234
+ /**
235
+ * Keep an element's children in sync with a FunState<T[]> by stable identity.
236
+ * Cleanup is driven by `signal` abort (no handle returned).
237
+ */
238
+ export const bindListChildren =
239
+ <T>(options: {
240
+ signal: AbortSignal;
241
+ state: FunState<T[]>;
242
+ key: Accessor<T, string>;
243
+ row: (args: {
244
+ signal: AbortSignal;
245
+ state: FunState<T>;
246
+ remove: () => void;
247
+ }) => Element;
248
+ }) =>
249
+ <El extends Element>(parent: El): El => {
250
+ const { signal, state: list, key: keyAcc, row: renderRow } = options;
251
+ const rows = new Map<string, MountedRow>();
252
+
253
+ const dispose = (): void => {
254
+ for (const row of rows.values()) {
270
255
  row.ctrl.abort();
271
256
  row.el.remove();
272
- rows.delete(k);
273
257
  }
274
- }
258
+ rows.clear();
259
+ };
260
+
261
+ const reconcile = (): void => {
262
+ const items = list.get();
263
+
264
+ const nextKeys: string[] = [];
265
+ const seen = new Set<string>();
266
+ for (const it of items) {
267
+ const k = keyOf(keyAcc, it);
268
+ if (seen.has(k))
269
+ throw new Error(`bindListChildren: duplicate key "${k}"`);
270
+ seen.add(k);
271
+ nextKeys.push(k);
272
+ }
275
273
 
276
- // Ensure present
277
- for (const k of nextKeys) {
278
- if (!rows.has(k)) {
279
- const ctrl = new AbortController();
280
- const itemState = list.focus(filter<T>((t) => t.key === k));
281
- const el = renderRow({
282
- signal: ctrl.signal,
283
- state: itemState,
284
- remove: () =>
285
- list.mod((list: T[]) => list.filter((t) => t.key !== k)),
286
- });
287
- rows.set(k, { key: k, el, ctrl });
274
+ // remove missing
275
+ for (const [k, row] of rows) {
276
+ if (!seen.has(k)) {
277
+ row.ctrl.abort();
278
+ row.el.remove();
279
+ rows.delete(k);
280
+ }
288
281
  }
289
- }
290
282
 
291
- // Reorder with minimal DOM movement (prevents focus loss)
292
- const children = parent.children; // live
293
- for (let i = 0; i < nextKeys.length; i++) {
294
- const k = nextKeys[i];
295
- const row = rows.get(k)!;
296
- const currentAtI = children[i];
297
- if (currentAtI !== row.el) {
298
- parent.insertBefore(row.el, currentAtI ?? null);
283
+ // ensure present
284
+ for (const k of nextKeys) {
285
+ if (!rows.has(k)) {
286
+ const ctrl = new AbortController();
287
+ const itemState = list.focus(byKey(keyAcc, k));
288
+ const el = renderRow({
289
+ signal: ctrl.signal,
290
+ state: itemState,
291
+ remove: () =>
292
+ list.mod((xs) => xs.filter((t) => keyOf(keyAcc, t) !== k)),
293
+ });
294
+ rows.set(k, { el, ctrl });
295
+ }
299
296
  }
300
- }
301
- };
302
297
 
303
- // Reconcile whenever the list changes; `subscribe` will unsubscribe on abort (per your fix).
304
- list.watch(signal, reconcile);
298
+ // reorder with minimal DOM moves
299
+ const children = parent.children; // live
300
+ for (let i = 0; i < nextKeys.length; i++) {
301
+ const k = nextKeys[i];
302
+ const row = rows.get(k)!;
303
+ const currentAtI = children[i];
304
+ if (currentAtI !== row.el)
305
+ parent.insertBefore(row.el, currentAtI ?? null);
306
+ }
307
+ };
305
308
 
306
- // Ensure all children clean up when parent aborts
307
- signal.addEventListener("abort", dispose, { once: true });
309
+ list.watch(signal, reconcile);
310
+ signal.addEventListener("abort", dispose, { once: true });
311
+ reconcile();
308
312
 
309
- // Initial mount
310
- reconcile();
311
-
312
- return { reconcile, dispose };
313
- }
313
+ return parent;
314
+ };
314
315
 
315
316
  /**
316
- * Conditionally render a component based on a boolean FunState.
317
- * Returns a container element that mounts/unmounts the component as the state changes.
317
+ * Conditionally render a component based on state and an optional predicate.
318
+ * Returns a container element that mounts/unmounts the component as the condition changes.
318
319
  *
319
320
  * @example
320
- * const showDetails = state(false);
321
- * const detailsEl = renderWhen(showDetails, DetailsComponent, {id: 123}, signal);
321
+ * // With boolean state
322
+ * const showDetails = funState(false);
323
+ * const detailsEl = renderWhen({
324
+ * state: showDetails,
325
+ * component: DetailsComponent,
326
+ * props: {id: 123},
327
+ * signal
328
+ * });
322
329
  * parent.appendChild(detailsEl);
330
+ *
331
+ * @example
332
+ * // With predicate
333
+ * const rollType = funState(RollType.action);
334
+ * const actionEl = renderWhen({
335
+ * state: rollType,
336
+ * predicate: (type) => type === RollType.action,
337
+ * component: ActionForm,
338
+ * props: {roll, uid},
339
+ * signal
340
+ * });
323
341
  */
324
- export function renderWhen<Props>(
325
- state: FunState<boolean>,
326
- comp: (signal: AbortSignal, props: Props) => Element,
327
- props: Props,
328
- signal: AbortSignal
329
- ): Element {
342
+ export function renderWhen<State, Props>(options: {
343
+ state: FunState<State>;
344
+ predicate?: (value: State) => boolean;
345
+ component: Component<Props>;
346
+ props: Props;
347
+ signal: AbortSignal;
348
+ }): Element {
349
+ const {
350
+ state,
351
+ predicate = (x) => x as unknown as boolean,
352
+ component,
353
+ props,
354
+ signal,
355
+ } = options;
356
+
330
357
  const container = document.createElement("span");
331
358
  container.style.display = "contents";
332
359
  let childCtrl: AbortController | null = null;
333
360
  let childEl: Element | null = null;
334
361
 
335
362
  const reconcile = () => {
336
- const shouldRender = state.get();
363
+ const shouldRender = predicate(state.get());
337
364
 
338
365
  if (shouldRender && !childEl) {
339
366
  // Mount the component
340
367
  childCtrl = new AbortController();
341
- childEl = comp(childCtrl.signal, props);
368
+ childEl = component(childCtrl.signal, props);
342
369
  container.appendChild(childEl);
343
370
  } else if (!shouldRender && childEl) {
344
371
  // Unmount the component
@@ -353,9 +380,13 @@ export function renderWhen<Props>(
353
380
  state.watch(signal, reconcile);
354
381
 
355
382
  // Clean up when parent aborts
356
- signal.addEventListener("abort", () => {
357
- childCtrl?.abort();
358
- }, { once: true });
383
+ signal.addEventListener(
384
+ "abort",
385
+ () => {
386
+ childCtrl?.abort();
387
+ },
388
+ { once: true }
389
+ );
359
390
 
360
391
  // Initial render
361
392
  reconcile();
package/src/index.ts CHANGED
@@ -16,10 +16,8 @@ export {
16
16
  toggleClass,
17
17
  append,
18
18
  on,
19
- onTo,
20
19
  bindProperty,
21
- bindPropertyTo,
22
- keyedChildren,
20
+ bindListChildren,
23
21
  renderWhen,
24
22
  enhance,
25
23
  $,
package/src/types.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  /** Core types for fun-web */
2
2
 
3
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
4
- export type Component<Props = {}> = (
5
- signal: AbortSignal,
6
- props: Props
7
- ) => Element
3
+ export type Component<Props = {}> = (signal: AbortSignal, props: Props) => Element;
8
4
 
9
- export type ElementChild = string | number | Element | null | undefined
5
+ export type ElementChild = string | number | Element | null | undefined;