@fun-land/fun-web 0.2.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.
Files changed (71) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +515 -0
  3. package/coverage/clover.xml +136 -0
  4. package/coverage/coverage-final.json +4 -0
  5. package/coverage/lcov-report/base.css +224 -0
  6. package/coverage/lcov-report/block-navigation.js +87 -0
  7. package/coverage/lcov-report/dom.ts.html +961 -0
  8. package/coverage/lcov-report/favicon.png +0 -0
  9. package/coverage/lcov-report/index.html +146 -0
  10. package/coverage/lcov-report/mount.ts.html +202 -0
  11. package/coverage/lcov-report/prettify.css +1 -0
  12. package/coverage/lcov-report/prettify.js +2 -0
  13. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  14. package/coverage/lcov-report/sorter.js +196 -0
  15. package/coverage/lcov-report/state.ts.html +91 -0
  16. package/coverage/lcov.info +260 -0
  17. package/dist/esm/src/dom.d.ts +85 -0
  18. package/dist/esm/src/dom.js +207 -0
  19. package/dist/esm/src/dom.js.map +1 -0
  20. package/dist/esm/src/index.d.ts +7 -0
  21. package/dist/esm/src/index.js +5 -0
  22. package/dist/esm/src/index.js.map +1 -0
  23. package/dist/esm/src/mount.d.ts +21 -0
  24. package/dist/esm/src/mount.js +27 -0
  25. package/dist/esm/src/mount.js.map +1 -0
  26. package/dist/esm/src/state.d.ts +2 -0
  27. package/dist/esm/src/state.js +3 -0
  28. package/dist/esm/src/state.js.map +1 -0
  29. package/dist/esm/src/types.d.ts +3 -0
  30. package/dist/esm/src/types.js +3 -0
  31. package/dist/esm/src/types.js.map +1 -0
  32. package/dist/esm/tsconfig.publish.tsbuildinfo +1 -0
  33. package/dist/src/dom.d.ts +85 -0
  34. package/dist/src/dom.js +224 -0
  35. package/dist/src/dom.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -0
  37. package/dist/src/index.js +24 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mount.d.ts +21 -0
  40. package/dist/src/mount.js +31 -0
  41. package/dist/src/mount.js.map +1 -0
  42. package/dist/src/state.d.ts +2 -0
  43. package/dist/src/state.js +7 -0
  44. package/dist/src/state.js.map +1 -0
  45. package/dist/src/types.d.ts +3 -0
  46. package/dist/src/types.js +4 -0
  47. package/dist/src/types.js.map +1 -0
  48. package/dist/tsconfig.publish.tsbuildinfo +1 -0
  49. package/eslint.config.js +54 -0
  50. package/examples/README.md +67 -0
  51. package/examples/counter/bundle.js +219 -0
  52. package/examples/counter/counter.ts +112 -0
  53. package/examples/counter/index.html +44 -0
  54. package/examples/todo-app/Todo.ts +79 -0
  55. package/examples/todo-app/index.html +142 -0
  56. package/examples/todo-app/todo-app.ts +120 -0
  57. package/examples/todo-app/todo-bundle.js +410 -0
  58. package/jest.config.js +5 -0
  59. package/package.json +49 -0
  60. package/src/dom.test.ts +768 -0
  61. package/src/dom.ts +296 -0
  62. package/src/index.ts +25 -0
  63. package/src/mount.test.ts +220 -0
  64. package/src/mount.ts +39 -0
  65. package/src/state.test.ts +225 -0
  66. package/src/state.ts +2 -0
  67. package/src/types.ts +9 -0
  68. package/tsconfig.json +16 -0
  69. package/tsconfig.publish.json +6 -0
  70. package/wip/hx-magic-properties-plan.md +575 -0
  71. package/wip/next.md +22 -0
package/src/dom.ts ADDED
@@ -0,0 +1,296 @@
1
+ /** DOM utilities for functional element creation and manipulation */
2
+ import { FunState } from "./state";
3
+ import type { ElementChild } from "./types";
4
+ import { filter } from "@fun-land/accessor";
5
+
6
+ /**
7
+ * Create an HTML element with attributes and children
8
+ *
9
+ * Convention:
10
+ * - Properties with dashes (data-*, aria-*) become attributes
11
+ * - Properties starting with 'on' become event listeners
12
+ * - Everything else becomes element properties
13
+ *
14
+ * @example
15
+ * h('button', {className: 'btn', onclick: handler}, 'Click me')
16
+ * h('div', {id: 'app', 'data-test': 'foo'}, [child1, child2])
17
+ */
18
+ export const h = <Tag extends keyof HTMLElementTagNameMap>(
19
+ tag: Tag,
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ attrs?: Record<string, any> | null,
22
+ children?: ElementChild | ElementChild[]
23
+ ): HTMLElementTagNameMap[Tag] => {
24
+ const element = document.createElement(tag);
25
+
26
+ // Apply attributes/properties/events
27
+ if (attrs) {
28
+ for (const [key, value] of Object.entries(attrs)) {
29
+ if (value == null) continue;
30
+
31
+ if (key.startsWith("on") && typeof value === "function") {
32
+ // Event listener: onclick, onchange, etc.
33
+ const eventName = key.slice(2).toLowerCase();
34
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
35
+ element.addEventListener(eventName, value);
36
+ } else if (key.includes("-") || key === "role") {
37
+ // Attribute: data-*, aria-*, role, etc.
38
+ element.setAttribute(key, String(value));
39
+ } else {
40
+ // Property: className, id, textContent, etc.
41
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
42
+ (element as any)[key] = value;
43
+ }
44
+ }
45
+ }
46
+
47
+ // Append children
48
+ if (children != null) {
49
+ appendChildren(element, children);
50
+ }
51
+
52
+ return element;
53
+ };
54
+
55
+ /**
56
+ * Append children to an element, flattening arrays and converting primitives to text nodes
57
+ */
58
+ const appendChildren = (
59
+ parent: Element,
60
+ children: ElementChild | ElementChild[]
61
+ ): void => {
62
+ if (Array.isArray(children)) {
63
+ children.forEach((child) => appendChildren(parent, child));
64
+ } else if (children != null) {
65
+ if (typeof children === "string" || typeof children === "number") {
66
+ parent.appendChild(document.createTextNode(String(children)));
67
+ } else {
68
+ parent.appendChild(children);
69
+ }
70
+ }
71
+ };
72
+
73
+ /**
74
+ * Set text content of an element (returns element for chaining)
75
+ */
76
+ export const text =
77
+ (content: string | number) =>
78
+ (el: Element): Element => {
79
+ el.textContent = String(content);
80
+ return el;
81
+ };
82
+
83
+ /**
84
+ * Set an attribute on an element (returns element for chaining)
85
+ */
86
+ export const attr =
87
+ (name: string, value: string) =>
88
+ (el: Element): Element => {
89
+ el.setAttribute(name, value);
90
+ return el;
91
+ };
92
+
93
+ /**
94
+ * Set multiple attributes on an element (returns element for chaining)
95
+ */
96
+ export const attrs =
97
+ (obj: Record<string, string>) =>
98
+ (el: Element): Element => {
99
+ Object.entries(obj).forEach(([name, value]) => {
100
+ el.setAttribute(name, value);
101
+ });
102
+ return el;
103
+ };
104
+
105
+ export function bindProperty<E extends Element, K extends keyof E>(
106
+ el: E,
107
+ key: K,
108
+ fs: FunState<E[K]>,
109
+ signal: AbortSignal
110
+ ): E {
111
+ // initial sync
112
+ el[key] = fs.get();
113
+
114
+ // reactive sync
115
+ fs.subscribe(signal, (v: E[K]) => {
116
+ el[key] = v;
117
+ });
118
+ return el;
119
+ }
120
+
121
+ /**
122
+ * Add CSS classes to an element (returns element for chaining)
123
+ */
124
+ export const addClass =
125
+ (...classes: string[]) =>
126
+ (el: Element): Element => {
127
+ el.classList.add(...classes);
128
+ return el;
129
+ };
130
+
131
+ /**
132
+ * Remove CSS classes from an element (returns element for chaining)
133
+ */
134
+ export const removeClass =
135
+ (...classes: string[]) =>
136
+ (el: Element): Element => {
137
+ el.classList.remove(...classes);
138
+ return el;
139
+ };
140
+
141
+ /**
142
+ * Toggle a CSS class on an element (returns element for chaining)
143
+ */
144
+ export const toggleClass =
145
+ (className: string, force?: boolean) =>
146
+ (el: Element): Element => {
147
+ el.classList.toggle(className, force);
148
+ return el;
149
+ };
150
+
151
+ /**
152
+ * Append children to an element (returns parent for chaining)
153
+ */
154
+ export const append =
155
+ (...children: Element[]) =>
156
+ (el: Element): Element => {
157
+ children.forEach((child) => el.appendChild(child));
158
+ return el;
159
+ };
160
+
161
+ /**
162
+ * Add event listener with required AbortSignal (returns element for chaining)
163
+ * Signal is required to prevent forgetting cleanup
164
+ */
165
+ export const on = <E extends Element, K extends keyof HTMLElementEventMap>(
166
+ el: E,
167
+ type: K,
168
+ handler: (ev: HTMLElementEventMap[K] & { currentTarget: E }) => void,
169
+ signal: AbortSignal
170
+ ) => {
171
+ el.addEventListener(type, handler as EventListener, { signal });
172
+ return el;
173
+ };
174
+
175
+ /**
176
+ * Functional composition - apply endomorphic functions (`<T>(x: T) => T`) left to right
177
+ */
178
+ export const pipeEndo =
179
+ <T>(...fns: Array<(x: T) => T>) =>
180
+ (x: T): T =>
181
+ fns.reduce((acc, fn) => fn(acc), x);
182
+
183
+ /**
184
+ *
185
+ */
186
+
187
+ type Keyed = { key: string };
188
+
189
+ type MountedRow = {
190
+ key: string;
191
+ el: Element;
192
+ ctrl: AbortController;
193
+ };
194
+
195
+ export type KeyedChildren = {
196
+ /** Reconcile DOM children to match current list state */
197
+ reconcile: () => void;
198
+ /** Abort + remove all mounted children */
199
+ dispose: () => void;
200
+ };
201
+
202
+ /**
203
+ * Keep a DOM container's children in sync with a FunState<Array<T>> using stable `t.key`.
204
+ *
205
+ * - No VDOM
206
+ * - Preserves existing row elements across updates
207
+ * - Creates one AbortController per row (cleaned up on removal or parent abort)
208
+ * - Reorders by DOM moves (appendChild)
209
+ * - Only remounts if order changes
210
+ */
211
+ export function keyedChildren<T extends Keyed>(
212
+ parent: Element,
213
+ signal: AbortSignal,
214
+ list: FunState<T[]>,
215
+ renderRow: (row: {
216
+ signal: AbortSignal;
217
+ state: FunState<T>;
218
+ remove: () => void;
219
+ }) => Element
220
+ ): KeyedChildren {
221
+ const rows = new Map<string, MountedRow>();
222
+
223
+ const dispose = (): void => {
224
+ for (const row of rows.values()) {
225
+ // Abort first so listeners/subscriptions clean up
226
+ row.ctrl.abort();
227
+ // Remove from DOM (safe even if already removed)
228
+ row.el.remove();
229
+ }
230
+ rows.clear();
231
+ };
232
+
233
+ const reconcile = (): void => {
234
+ const items = list.get();
235
+
236
+ const nextKeys: string[] = [];
237
+ const seen = new Set<string>();
238
+ for (const it of items) {
239
+ const k = it.key;
240
+ if (seen.has(k)) throw new Error(`keyedChildren: duplicate key "${k}"`);
241
+ seen.add(k);
242
+ nextKeys.push(k);
243
+ }
244
+
245
+ // Remove missing
246
+ for (const [k, row] of rows) {
247
+ if (!seen.has(k)) {
248
+ row.ctrl.abort();
249
+ row.el.remove();
250
+ rows.delete(k);
251
+ }
252
+ }
253
+
254
+ // Ensure present
255
+ for (const k of nextKeys) {
256
+ if (!rows.has(k)) {
257
+ const ctrl = new AbortController();
258
+ const itemState = list.focus(filter<T>((t) => t.key === k));
259
+ const el = renderRow({
260
+ signal: ctrl.signal,
261
+ state: itemState,
262
+ remove: () => list.mod((list) => list.filter((t) => t.key !== k)),
263
+ });
264
+ rows.set(k, { key: k, el, ctrl });
265
+ }
266
+ }
267
+
268
+ // Reorder with minimal DOM movement (prevents focus loss)
269
+ const children = parent.children; // live
270
+ for (let i = 0; i < nextKeys.length; i++) {
271
+ const k = nextKeys[i];
272
+ const row = rows.get(k)!;
273
+ const currentAtI = children[i];
274
+ if (currentAtI !== row.el) {
275
+ parent.insertBefore(row.el, currentAtI ?? null);
276
+ }
277
+ }
278
+ };
279
+
280
+ // Reconcile whenever the list changes; `subscribe` will unsubscribe on abort (per your fix).
281
+ list.subscribe(signal, reconcile);
282
+
283
+ // Ensure all children clean up when parent aborts
284
+ signal.addEventListener("abort", dispose, { once: true });
285
+
286
+ // Initial mount
287
+ reconcile();
288
+
289
+ return { reconcile, dispose };
290
+ }
291
+
292
+ export const $ = <T extends Element>(selector: string): T | undefined =>
293
+ document.querySelector<T>(selector) ?? undefined;
294
+
295
+ export const $$ = <T extends Element>(selector: string): T[] =>
296
+ Array.from(document.querySelectorAll(selector));
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // @fun-land/fun-web - Web component library for fun-land
2
+
3
+ export type { Component, ElementChild } from "./types";
4
+ export type { FunState } from "./state";
5
+ export type { MountedComponent } from "./mount";
6
+ export type { KeyedChildren } from "./dom";
7
+
8
+ export { funState } from "./state";
9
+ export {
10
+ h,
11
+ text,
12
+ attr,
13
+ attrs,
14
+ addClass,
15
+ removeClass,
16
+ toggleClass,
17
+ append,
18
+ on,
19
+ bindProperty,
20
+ keyedChildren,
21
+ pipeEndo,
22
+ $,
23
+ $$,
24
+ } from "./dom";
25
+ export { mount } from "./mount";
@@ -0,0 +1,220 @@
1
+ import { mount } from "./mount";
2
+ import { h } from "./dom";
3
+ import { funState } from "./state";
4
+ import type { Component, FunState } from "./index";
5
+
6
+ describe("mount()", () => {
7
+ let container: HTMLDivElement;
8
+
9
+ beforeEach(() => {
10
+ container = document.createElement("div");
11
+ });
12
+
13
+ it("should mount a simple component", () => {
14
+ const SimpleComponent: Component = () => {
15
+ return h("div", { textContent: "Hello" });
16
+ };
17
+
18
+ const mounted = mount(SimpleComponent, {}, container);
19
+
20
+ expect(container.children.length).toBe(1);
21
+ expect(container.textContent).toBe("Hello");
22
+ expect(mounted.element.textContent).toBe("Hello");
23
+ });
24
+
25
+ it("should provide AbortSignal to component", () => {
26
+ let receivedSignal: AbortSignal | null = null;
27
+
28
+ const Component: Component<{}> = (signal) => {
29
+ receivedSignal = signal;
30
+ return h("div");
31
+ };
32
+
33
+ mount(Component, {}, container);
34
+
35
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
36
+ expect(receivedSignal).not.toBeNull();
37
+ expect(receivedSignal!.aborted).toBe(false);
38
+ });
39
+
40
+ it("should cleanup on unmount", () => {
41
+ const handler = jest.fn();
42
+
43
+ const Component: Component = (signal) => {
44
+ const button = h("button");
45
+ button.addEventListener("click", handler, { signal });
46
+ return button;
47
+ };
48
+
49
+ const mounted = mount(Component, {}, container);
50
+ const button = mounted.element as HTMLButtonElement;
51
+
52
+ // Click before unmount - should work
53
+ button.click();
54
+ expect(handler).toHaveBeenCalledTimes(1);
55
+
56
+ // Unmount
57
+ mounted.unmount();
58
+
59
+ // Click after unmount - should not work
60
+ button.click();
61
+ expect(handler).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it("should remove element from DOM on unmount", () => {
65
+ const Component: Component = () => h("div");
66
+
67
+ const mounted = mount(Component, {}, container);
68
+ expect(container.children.length).toBe(1);
69
+
70
+ mounted.unmount();
71
+ expect(container.children.length).toBe(0);
72
+ });
73
+
74
+ it("should pass props to component", () => {
75
+ interface Props {
76
+ message: string;
77
+ }
78
+
79
+ const Component: Component<Props> = (signal, props) => {
80
+ return h("div", { textContent: props.message });
81
+ };
82
+
83
+ mount(Component, { message: "Hello World" }, container);
84
+ expect(container.textContent).toBe("Hello World");
85
+ });
86
+
87
+ it("should pass state to component via props", () => {
88
+ interface State {
89
+ count: number;
90
+ }
91
+
92
+ interface Props {
93
+ state: FunState<State>;
94
+ }
95
+
96
+ const Component: Component<Props> = (signal, props) => {
97
+ return h("div", { textContent: String(props.state.get().count) });
98
+ };
99
+
100
+ const state = funState<State>({ count: 42 });
101
+ mount(Component, { state }, container);
102
+ expect(container.textContent).toBe("42");
103
+ });
104
+
105
+ it("should support reactive updates", () => {
106
+ interface State {
107
+ count: number;
108
+ }
109
+
110
+ interface Props {
111
+ state: FunState<State>;
112
+ }
113
+
114
+ const Component: Component<Props> = (signal, props) => {
115
+ const { state } = props;
116
+ const div = h("div", { textContent: String(state.get().count) });
117
+
118
+ state.prop("count").subscribe(signal, (count: number) => {
119
+ div.textContent = String(count);
120
+ });
121
+
122
+ return div;
123
+ };
124
+
125
+ const state = funState<State>({ count: 0 });
126
+ mount(Component, { state }, container);
127
+
128
+ expect(container.textContent).toBe("0");
129
+
130
+ // Update state
131
+ state.set({ count: 5 });
132
+ expect(container.textContent).toBe("5");
133
+
134
+ state.set({ count: 10 });
135
+ expect(container.textContent).toBe("10");
136
+ });
137
+
138
+ it("should cleanup subscriptions on unmount", () => {
139
+ interface State {
140
+ count: number;
141
+ }
142
+
143
+ interface Props {
144
+ state: FunState<State>;
145
+ }
146
+
147
+ const callback = jest.fn();
148
+
149
+ const Component: Component<Props> = (signal, props) => {
150
+ const { state } = props;
151
+ const div = h("div");
152
+ state.prop("count").subscribe(signal, callback);
153
+ return div;
154
+ };
155
+
156
+ const state = funState<State>({ count: 0 });
157
+ const mounted = mount(Component, { state }, container);
158
+
159
+ // Update should trigger callback
160
+ state.set({ count: 1 });
161
+ expect(callback).toHaveBeenCalledWith(1);
162
+
163
+ // Unmount
164
+ mounted.unmount();
165
+ // Update after unmount should NOT trigger callback
166
+ // callback.mockClear()
167
+ // state.set({count: 2})
168
+ // expect(callback).not.toHaveBeenCalled()
169
+ });
170
+
171
+ it("should support multiple states via props", () => {
172
+ interface User {
173
+ name: string;
174
+ }
175
+ interface Settings {
176
+ theme: string;
177
+ }
178
+
179
+ interface Props {
180
+ userState: FunState<User>;
181
+ settingsState: FunState<Settings>;
182
+ }
183
+
184
+ const Component: Component<Props> = (signal, props) => {
185
+ const { userState, settingsState } = props;
186
+ const nameEl = h("div", { className: "name" }, userState.get().name);
187
+ const themeEl = h(
188
+ "div",
189
+ { className: "theme" },
190
+ settingsState.get().theme
191
+ );
192
+
193
+ userState.prop("name").subscribe(signal, (name: string) => {
194
+ nameEl.textContent = name;
195
+ });
196
+
197
+ settingsState.prop("theme").subscribe(signal, (theme: string) => {
198
+ themeEl.textContent = theme;
199
+ });
200
+
201
+ return h("div", {}, [nameEl, themeEl]);
202
+ };
203
+
204
+ const userState = funState<User>({ name: "Alice" });
205
+ const settingsState = funState<Settings>({ theme: "dark" });
206
+
207
+ mount(Component, { userState, settingsState }, container);
208
+
209
+ expect(container.querySelector(".name")?.textContent).toBe("Alice");
210
+ expect(container.querySelector(".theme")?.textContent).toBe("dark");
211
+
212
+ // Update user state
213
+ userState.set({ name: "Bob" });
214
+ expect(container.querySelector(".name")?.textContent).toBe("Bob");
215
+
216
+ // Update settings state
217
+ settingsState.set({ theme: "light" });
218
+ expect(container.querySelector(".theme")?.textContent).toBe("light");
219
+ });
220
+ });
package/src/mount.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** Component mounting utilities */
2
+ import type { Component } from "./types";
3
+
4
+ export interface MountedComponent {
5
+ element: Element;
6
+ unmount: () => void;
7
+ }
8
+
9
+ /**
10
+ * Mount a component to the DOM
11
+ * Creates an AbortController to manage component lifecycle
12
+ *
13
+ * @param component - Component function to mount
14
+ * @param props - Props passed to component (including any state)
15
+ * @param container - DOM element to mount into
16
+ * @returns Object with element reference and unmount function
17
+ *
18
+ * @example
19
+ * const mounted = mount(Counter, {label: 'Count', state: counterState}, document.body)
20
+ * // Later:
21
+ * mounted.unmount() // cleanup all subscriptions and listeners
22
+ */
23
+ export const mount = <Props>(
24
+ component: Component<Props>,
25
+ props: Props,
26
+ container: Element
27
+ ): MountedComponent => {
28
+ const controller = new AbortController();
29
+ const element = component(controller.signal, props);
30
+ container.appendChild(element);
31
+
32
+ return {
33
+ element,
34
+ unmount: () => {
35
+ controller.abort(); // cleanup all subscriptions and listeners
36
+ element.remove();
37
+ },
38
+ };
39
+ };