@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/README.md +6 -24
- package/dist/esm/src/dom.d.ts +53 -31
- package/dist/esm/src/dom.js +83 -50
- 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/tsconfig.publish.tsbuildinfo +1 -1
- package/dist/src/dom.d.ts +53 -31
- package/dist/src/dom.js +86 -55
- 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/tsconfig.publish.tsbuildinfo +1 -1
- package/examples/todo-app/AddTodoForm.ts +4 -4
- package/examples/todo-app/DraggableTodoList.ts +52 -47
- package/examples/todo-app/Todo.ts +7 -7
- package/examples/todo-app/TodoApp.js +94 -51
- package/examples/todo-app/TodoApp.ts +9 -9
- package/package.json +2 -2
- package/src/dom.test.ts +92 -60
- package/src/dom.ts +190 -159
- package/src/index.ts +1 -3
- package/src/types.ts +2 -6
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 {
|
|
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(
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
309
|
+
list.watch(signal, reconcile);
|
|
310
|
+
signal.addEventListener("abort", dispose, { once: true });
|
|
311
|
+
reconcile();
|
|
308
312
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return { reconcile, dispose };
|
|
313
|
-
}
|
|
313
|
+
return parent;
|
|
314
|
+
};
|
|
314
315
|
|
|
315
316
|
/**
|
|
316
|
-
* Conditionally render a component based on
|
|
317
|
-
* Returns a container element that mounts/unmounts the component as the
|
|
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
|
-
*
|
|
321
|
-
* const
|
|
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<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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 =
|
|
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(
|
|
357
|
-
|
|
358
|
-
|
|
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
package/src/types.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/** Core types for fun-web */
|
|
2
2
|
|
|
3
|
-
|
|
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;
|