@fun-land/fun-web 0.3.0 → 0.3.2

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.
Files changed (36) hide show
  1. package/README.md +65 -0
  2. package/dist/esm/src/dom.d.ts +10 -0
  3. package/dist/esm/src/dom.js +40 -0
  4. package/dist/esm/src/dom.js.map +1 -1
  5. package/dist/esm/src/index.d.ts +1 -1
  6. package/dist/esm/src/index.js +1 -1
  7. package/dist/esm/src/index.js.map +1 -1
  8. package/dist/esm/tsconfig.publish.tsbuildinfo +1 -1
  9. package/dist/src/dom.d.ts +10 -0
  10. package/dist/src/dom.js +41 -0
  11. package/dist/src/dom.js.map +1 -1
  12. package/dist/src/index.d.ts +1 -1
  13. package/dist/src/index.js +2 -1
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  16. package/examples/counter/bundle.js +54 -62
  17. package/examples/todo-app/AddTodoForm.ts +46 -0
  18. package/examples/todo-app/DraggableTodoList.ts +173 -0
  19. package/examples/todo-app/README.md +172 -0
  20. package/examples/todo-app/Todo.ts +77 -30
  21. package/examples/todo-app/TodoApp.js +585 -0
  22. package/examples/todo-app/TodoApp.ts +70 -0
  23. package/examples/todo-app/TodoAppState.ts +32 -0
  24. package/examples/todo-app/TodoState.ts +5 -0
  25. package/examples/todo-app/index.html +2 -131
  26. package/examples/todo-app/todo-app.css +294 -0
  27. package/package.json +8 -6
  28. package/src/dom.test.ts +237 -0
  29. package/src/dom.ts +51 -0
  30. package/src/index.ts +1 -0
  31. package/wip/Screenshot 2026-01-13 at 11.56.08 AM.png +0 -0
  32. package/wip/Screenshot 2026-01-13 at 12.08.22 PM.png +0 -0
  33. package/wip/Screenshot 2026-01-13 at 12.11.14 PM.png +0 -0
  34. package/wip/Screenshot 2026-01-13 at 2.49.08 AM.png +0 -0
  35. package/examples/todo-app/todo-app.ts +0 -117
  36. package/examples/todo-app/todo-bundle.js +0 -410
package/src/dom.ts CHANGED
@@ -312,6 +312,57 @@ export function keyedChildren<T extends Keyed>(
312
312
  return { reconcile, dispose };
313
313
  }
314
314
 
315
+ /**
316
+ * Conditionally render a component based on a boolean FunState.
317
+ * Returns a container element that mounts/unmounts the component as the state changes.
318
+ *
319
+ * @example
320
+ * const showDetails = state(false);
321
+ * const detailsEl = renderWhen(showDetails, DetailsComponent, {id: 123}, signal);
322
+ * parent.appendChild(detailsEl);
323
+ */
324
+ export function renderWhen<Props>(
325
+ state: FunState<boolean>,
326
+ comp: (signal: AbortSignal, props: Props) => Element,
327
+ props: Props,
328
+ signal: AbortSignal
329
+ ): Element {
330
+ const container = document.createElement("span");
331
+ container.style.display = "contents";
332
+ let childCtrl: AbortController | null = null;
333
+ let childEl: Element | null = null;
334
+
335
+ const reconcile = () => {
336
+ const shouldRender = state.get();
337
+
338
+ if (shouldRender && !childEl) {
339
+ // Mount the component
340
+ childCtrl = new AbortController();
341
+ childEl = comp(childCtrl.signal, props);
342
+ container.appendChild(childEl);
343
+ } else if (!shouldRender && childEl) {
344
+ // Unmount the component
345
+ childCtrl?.abort();
346
+ childEl.remove();
347
+ childEl = null;
348
+ childCtrl = null;
349
+ }
350
+ };
351
+
352
+ // React to state changes
353
+ state.watch(signal, reconcile);
354
+
355
+ // Clean up when parent aborts
356
+ signal.addEventListener("abort", () => {
357
+ childCtrl?.abort();
358
+ }, { once: true });
359
+
360
+ // Initial render
361
+ reconcile();
362
+
363
+ return container;
364
+ }
365
+
315
366
  export const $ = <T extends Element>(selector: string): T | undefined =>
316
367
  document.querySelector<T>(selector) ?? undefined;
317
368
 
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  bindProperty,
21
21
  bindPropertyTo,
22
22
  keyedChildren,
23
+ renderWhen,
23
24
  enhance,
24
25
  $,
25
26
  $$,
@@ -1,117 +0,0 @@
1
- import {
2
- h,
3
- funState,
4
- mount,
5
- bindPropertyTo,
6
- onTo,
7
- keyedChildren,
8
- type Component,
9
- enhance,
10
- } from "../../src/index";
11
- import { prepend, flow, Acc } from "@fun-land/accessor";
12
- import { TodoState, Todo } from "./Todo";
13
-
14
- // ===== Types =====
15
-
16
- interface TodoAppState {
17
- value: string;
18
- items: TodoState[];
19
- }
20
-
21
- // ===== Accessors and helpers =====
22
-
23
- const stateFoci = Acc<TodoAppState>();
24
-
25
- const addItem = (state: TodoAppState): TodoAppState =>
26
- stateFoci.prop("items").mod(
27
- prepend<TodoState>({
28
- checked: false,
29
- label: state.value,
30
- priority: 1,
31
- key: crypto.randomUUID(),
32
- })
33
- )(state);
34
-
35
- const clearValue = stateFoci.prop("value").set("");
36
-
37
- const markAllDone = stateFoci.prop("items").all().prop("checked").set(true);
38
-
39
- // ===== Todo App Component =====
40
-
41
- const initialState: TodoAppState = {
42
- value: "",
43
- items: [
44
- { checked: false, label: "Learn fun-web", priority: 0, key: "asdf" },
45
- { checked: true, label: "Build something cool", priority: 1, key: "fdas" },
46
- ],
47
- };
48
-
49
- const TodoApp: Component = (signal) => {
50
- const state = funState(initialState);
51
- const input = enhance(
52
- h("input", {
53
- type: "text",
54
- value: state.get().value,
55
- placeholder: "Add a todo...",
56
- }),
57
- bindPropertyTo("value", state.prop("value"), signal),
58
- onTo(
59
- "input",
60
- (e) => {
61
- state.prop("value").set(e.currentTarget.value);
62
- },
63
- signal
64
- )
65
- );
66
-
67
- const addBtn = h("button", { type: "submit", textContent: "Add" });
68
-
69
- const form = enhance(
70
- h("form", {}, [input, addBtn]),
71
- onTo(
72
- "submit",
73
- (e) => {
74
- e.preventDefault();
75
- if (state.get().value.trim()) {
76
- state.mod(flow(addItem, clearValue));
77
- }
78
- },
79
- signal
80
- )
81
- );
82
-
83
- // Because `on` returns the element you can pipe through
84
- const markAllBtn = enhance(
85
- h("button", { textContent: "Mark All Done" }),
86
- onTo(
87
- "click",
88
- () => {
89
- state.mod(markAllDone);
90
- },
91
- signal
92
- )
93
- );
94
-
95
- const todoList = h("ul", {});
96
- keyedChildren(todoList, signal, state.prop("items"), (row) =>
97
- Todo(row.signal, {
98
- removeItem: row.remove,
99
- state: row.state,
100
- })
101
- );
102
-
103
- return h("div", { className: "todo-app" }, [
104
- h("h1", { textContent: "Todo App" }),
105
- form,
106
- h("div", {}, [markAllBtn, h("span", { textContent: "" })]),
107
- todoList,
108
- ]);
109
- };
110
-
111
- // ===== Initialize =====
112
-
113
- const app = document.getElementById("app");
114
-
115
- if (app) {
116
- mount(TodoApp, {}, app);
117
- }
@@ -1,410 +0,0 @@
1
- "use strict";
2
- (() => {
3
- // ../fun-state/node_modules/@fun-land/accessor/dist/esm/util.js
4
- var flow = (f, g) => (x) => g(f(x));
5
- var K = (a) => (_b) => a;
6
- var flatmap = (f) => (xs) => {
7
- let out = [];
8
- for (const x of xs) {
9
- out = out.concat(f(x));
10
- }
11
- return out;
12
- };
13
-
14
- // ../fun-state/node_modules/@fun-land/accessor/dist/esm/accessor.js
15
- var prop = () => (k) => ({
16
- query: (obj) => [obj[k]],
17
- mod: (transform) => (obj) => Object.assign(Object.assign({}, obj), { [k]: transform(obj[k]) })
18
- });
19
- var _comp = (acc1, acc2) => ({
20
- query: flow(acc1.query, flatmap(acc2.query)),
21
- mod: flow(acc2.mod, acc1.mod)
22
- });
23
- function comp(...accs) {
24
- return accs.reduce(_comp);
25
- }
26
- var set = (acc) => flow(K, acc.mod);
27
-
28
- // ../fun-state/dist/esm/src/FunState.js
29
- var pureState = ({ getState, modState, subscribe }) => {
30
- const setState = (v) => {
31
- modState(() => v);
32
- };
33
- const focus = (acc) => subState({ getState, modState, subscribe }, acc);
34
- const subscribeToState = (signal, callback) => {
35
- const unsubscribe = subscribe(callback);
36
- signal.addEventListener("abort", unsubscribe, { once: true });
37
- };
38
- const fs = {
39
- get: getState,
40
- query: (acc) => acc.query(getState()),
41
- mod: modState,
42
- set: setState,
43
- focus,
44
- prop: flow(prop(), focus),
45
- subscribe: subscribeToState
46
- };
47
- return fs;
48
- };
49
- var subState = ({ getState, modState, subscribe }, accessor) => {
50
- const props = prop();
51
- const _get = () => accessor.query(getState())[0];
52
- const _mod = flow(accessor.mod, modState);
53
- function createFocusedSubscribe() {
54
- return (listener) => {
55
- let lastValue = _get();
56
- return subscribe((parentState) => {
57
- const newValue = accessor.query(parentState)[0];
58
- if (newValue !== lastValue) {
59
- lastValue = newValue;
60
- listener(newValue);
61
- }
62
- });
63
- };
64
- }
65
- const focus = (acc) => subState({ getState: _get, modState: _mod, subscribe: createFocusedSubscribe() }, acc);
66
- const _prop = flow(props, focus);
67
- const subscribeToState = (signal, callback) => {
68
- let lastValue = _get();
69
- const unsubscribe = subscribe((parentState) => {
70
- const newValue = accessor.query(parentState)[0];
71
- if (newValue !== lastValue) {
72
- lastValue = newValue;
73
- callback(newValue);
74
- }
75
- });
76
- signal.addEventListener("abort", unsubscribe, { once: true });
77
- };
78
- return {
79
- get: _get,
80
- query: (acc) => comp(accessor, acc).query(getState()),
81
- mod: _mod,
82
- set: flow(set(accessor), modState),
83
- focus,
84
- prop: _prop,
85
- subscribe: subscribeToState
86
- };
87
- };
88
- var standaloneEngine = (initialState2) => {
89
- let state = initialState2;
90
- const listeners = /* @__PURE__ */ new Set();
91
- const getState = () => state;
92
- const modState = (f) => {
93
- state = f(getState());
94
- listeners.forEach((listener) => listener(state));
95
- };
96
- const subscribe = (listener) => {
97
- listeners.add(listener);
98
- return () => listeners.delete(listener);
99
- };
100
- return { getState, modState, subscribe };
101
- };
102
- var funState = (initialState2) => pureState(standaloneEngine(initialState2));
103
-
104
- // ../accessor/dist/esm/util.js
105
- var flow2 = (f, g) => (x) => g(f(x));
106
- var K2 = (a) => (_b) => a;
107
- var flatmap2 = (f) => (xs) => {
108
- let out = [];
109
- for (const x of xs) {
110
- out = out.concat(f(x));
111
- }
112
- return out;
113
- };
114
- var prepend = (x) => (xs) => [x, ...xs];
115
-
116
- // ../accessor/dist/esm/accessor.js
117
- var prop2 = () => (k) => ({
118
- query: (obj) => [obj[k]],
119
- mod: (transform) => (obj) => Object.assign(Object.assign({}, obj), { [k]: transform(obj[k]) })
120
- });
121
- var index2 = (i) => ({
122
- query: (s) => [s[i]],
123
- mod: (f) => (xs) => xs.map((x, j) => i === j ? f(x) : x)
124
- });
125
- var _comp2 = (acc1, acc2) => ({
126
- query: flow2(acc1.query, flatmap2(acc2.query)),
127
- mod: flow2(acc2.mod, acc1.mod)
128
- });
129
- function comp2(...accs) {
130
- return accs.reduce(_comp2);
131
- }
132
- var all2 = () => ({
133
- query: (xs) => xs,
134
- mod: (transform) => (xs) => xs.map(transform)
135
- });
136
- var filter = (pred) => ({
137
- query: (xs) => xs.filter(pred),
138
- mod: (transform) => (s) => s.map((x) => pred(x) ? transform(x) : x)
139
- });
140
- var unit = () => ({
141
- query: (x) => [x],
142
- mod: (transform) => (x) => transform(x)
143
- });
144
- var optional = () => ({
145
- mod: (f) => (s) => s !== void 0 ? f(s) : s,
146
- query: (s) => s !== void 0 ? [s] : []
147
- });
148
- function Acc(acc = unit()) {
149
- return focusedAcc(acc);
150
- }
151
- var focusedAcc = (acc) => ({
152
- query: (struct) => acc.query(struct),
153
- get: (struct) => {
154
- var _a;
155
- return (_a = acc.query(struct)[0]) !== null && _a !== void 0 ? _a : void 0;
156
- },
157
- mod: acc.mod,
158
- set: flow2(K2, acc.mod),
159
- focus: (bcc) => focusedAcc(comp2(acc, bcc)),
160
- prop(k) {
161
- return this.focus(prop2()(k));
162
- },
163
- at: (idx) => focusedAcc(comp2(acc, index2(idx))),
164
- all: () => focusedAcc(comp2(acc, all2())),
165
- optional: () => focusedAcc(comp2(acc, optional()))
166
- });
167
-
168
- // src/dom.ts
169
- var h = (tag, attrs2, children) => {
170
- const element = document.createElement(tag);
171
- if (attrs2) {
172
- for (const [key, value] of Object.entries(attrs2)) {
173
- if (value == null)
174
- continue;
175
- if (key.startsWith("on") && typeof value === "function") {
176
- const eventName = key.slice(2).toLowerCase();
177
- element.addEventListener(eventName, value);
178
- } else if (key.includes("-") || key === "role") {
179
- element.setAttribute(key, String(value));
180
- } else {
181
- element[key] = value;
182
- }
183
- }
184
- }
185
- if (children != null) {
186
- appendChildren(element, children);
187
- }
188
- return element;
189
- };
190
- var appendChildren = (parent, children) => {
191
- if (Array.isArray(children)) {
192
- children.forEach((child) => appendChildren(parent, child));
193
- } else if (children != null) {
194
- if (typeof children === "string" || typeof children === "number") {
195
- parent.appendChild(document.createTextNode(String(children)));
196
- } else {
197
- parent.appendChild(children);
198
- }
199
- }
200
- };
201
- function bindProperty(el, key, fs, signal) {
202
- el[key] = fs.get();
203
- fs.subscribe(signal, (v) => {
204
- el[key] = v;
205
- });
206
- return el;
207
- }
208
- var on = (el, type, handler, signal) => {
209
- el.addEventListener(type, handler, { signal });
210
- return el;
211
- };
212
- function keyedChildren(parent, signal, list, renderRow) {
213
- const rows = /* @__PURE__ */ new Map();
214
- const dispose = () => {
215
- for (const row of rows.values()) {
216
- row.ctrl.abort();
217
- row.el.remove();
218
- }
219
- rows.clear();
220
- };
221
- const reconcile = () => {
222
- const items = list.get();
223
- const nextKeys = [];
224
- const seen = /* @__PURE__ */ new Set();
225
- for (const it of items) {
226
- const k = it.key;
227
- if (seen.has(k))
228
- throw new Error(`keyedChildren: duplicate key "${k}"`);
229
- seen.add(k);
230
- nextKeys.push(k);
231
- }
232
- for (const [k, row] of rows) {
233
- if (!seen.has(k)) {
234
- row.ctrl.abort();
235
- row.el.remove();
236
- rows.delete(k);
237
- }
238
- }
239
- for (const k of nextKeys) {
240
- if (!rows.has(k)) {
241
- const ctrl = new AbortController();
242
- const itemState = list.focus(filter((t) => t.key === k));
243
- const el = renderRow(ctrl.signal, itemState);
244
- rows.set(k, { key: k, el, ctrl });
245
- }
246
- }
247
- const children = parent.children;
248
- for (let i = 0; i < nextKeys.length; i++) {
249
- const k = nextKeys[i];
250
- const row = rows.get(k);
251
- const currentAtI = children[i];
252
- if (currentAtI !== row.el) {
253
- parent.insertBefore(row.el, currentAtI ?? null);
254
- }
255
- }
256
- };
257
- list.subscribe(signal, reconcile);
258
- signal.addEventListener("abort", dispose, { once: true });
259
- reconcile();
260
- return { reconcile, dispose };
261
- }
262
-
263
- // src/mount.ts
264
- var mount = (component, props, container) => {
265
- const controller = new AbortController();
266
- const element = component(controller.signal, props);
267
- container.appendChild(element);
268
- return {
269
- element,
270
- unmount: () => {
271
- controller.abort();
272
- element.remove();
273
- }
274
- };
275
- };
276
-
277
- // examples/todo-app/Todo.ts
278
- var Todo = (signal, { state, removeItem }) => {
279
- const prioritySelect = h("select", {}, [
280
- h("option", { value: "0" }, "High"),
281
- h("option", { value: "1" }, "Low")
282
- ]);
283
- prioritySelect.value = String(state.get().priority);
284
- state.prop("priority").subscribe(signal, (priority) => {
285
- prioritySelect.value = String(priority);
286
- });
287
- prioritySelect.addEventListener(
288
- "change",
289
- (e) => {
290
- state.prop("priority").set(+e.currentTarget.value);
291
- },
292
- { signal }
293
- );
294
- const checkbox = h("input", { type: "checkbox" });
295
- bindProperty(checkbox, "checked", state.prop("checked"), signal);
296
- on(
297
- checkbox,
298
- "change",
299
- (e) => {
300
- state.prop("checked").set(e.currentTarget.checked);
301
- },
302
- signal
303
- );
304
- const labelInput = on(
305
- bindProperty(
306
- h("input", {
307
- type: "text"
308
- }),
309
- "value",
310
- state.prop("label"),
311
- signal
312
- ),
313
- "input",
314
- (e) => {
315
- state.prop("label").set(e.currentTarget.value);
316
- },
317
- signal
318
- );
319
- return h("li", {}, [
320
- checkbox,
321
- prioritySelect,
322
- labelInput,
323
- // you can even inline to go tacit
324
- on(h("button", { textContent: "X" }), "click", removeItem, signal)
325
- ]);
326
- };
327
-
328
- // examples/todo-app/todo-app.ts
329
- var stateFoci = Acc();
330
- var addItem = (state) => stateFoci.prop("items").mod(
331
- prepend({
332
- checked: false,
333
- label: state.value,
334
- priority: 1,
335
- key: crypto.randomUUID()
336
- })
337
- )(state);
338
- var clearValue = stateFoci.prop("value").set("");
339
- var markAllDone = stateFoci.prop("items").all().prop("checked").set(true);
340
- var removeByKey = (key) => stateFoci.prop("items").mod((xs) => xs.filter((t) => t.key !== key));
341
- var initialState = {
342
- value: "",
343
- items: [
344
- { checked: false, label: "Learn fun-web", priority: 0, key: "asdf" },
345
- { checked: true, label: "Build something cool", priority: 1, key: "fdas" }
346
- ]
347
- };
348
- var TodoApp = (signal) => {
349
- const state = funState(initialState);
350
- const input = bindProperty(
351
- h("input", {
352
- type: "text",
353
- value: state.get().value,
354
- placeholder: "Add a todo..."
355
- }),
356
- "value",
357
- state.prop("value"),
358
- signal
359
- );
360
- on(
361
- input,
362
- "input",
363
- (e) => {
364
- state.prop("value").set(e.currentTarget.value);
365
- },
366
- signal
367
- );
368
- const addBtn = h("button", { type: "submit", textContent: "Add" });
369
- const form = on(
370
- h("form", {}, [input, addBtn]),
371
- "submit",
372
- (e) => {
373
- e.preventDefault();
374
- if (state.get().value.trim()) {
375
- state.mod(flow2(addItem, clearValue));
376
- }
377
- },
378
- signal
379
- );
380
- const markAllBtn = on(
381
- h("button", { textContent: "Mark All Done" }),
382
- "click",
383
- () => {
384
- state.mod(markAllDone);
385
- },
386
- signal
387
- );
388
- const allDoneText = h("span", { textContent: "" });
389
- const todoList = h("ul", {});
390
- keyedChildren(
391
- todoList,
392
- signal,
393
- state.prop("items"),
394
- (rowSignal, todoState) => Todo(rowSignal, {
395
- removeItem: () => state.mod(removeByKey(todoState.prop("key").get())),
396
- state: todoState
397
- })
398
- );
399
- return h("div", { className: "todo-app" }, [
400
- h("h1", { textContent: "Todo App" }),
401
- form,
402
- h("div", {}, [markAllBtn, allDoneText]),
403
- todoList
404
- ]);
405
- };
406
- var app = document.getElementById("app");
407
- if (app) {
408
- mount(TodoApp, {}, app);
409
- }
410
- })();