@fun-land/fun-web 0.5.0 → 1.0.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,10 +1,19 @@
1
1
  /** DOM utilities for functional element creation and manipulation */
2
- import { type FunState } from "@fun-land/fun-state";
2
+ import { type FunState, type FunRead } from "@fun-land/fun-state";
3
3
  import type { Component, ElementChild } from "./types";
4
4
  import { Accessor } from "@fun-land/accessor";
5
5
 
6
6
  export type Enhancer<El extends Element> = (element: El) => El;
7
7
 
8
+ /**
9
+ * Type-preserving Object.entries helper for objects with known keys
10
+ * @internal
11
+ */
12
+ const entries = <T extends Record<string, unknown>>(
13
+ obj: T
14
+ ): Array<[keyof T, T[keyof T]]> =>
15
+ Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
16
+
8
17
  /**
9
18
  * Create an HTML element with attributes and children
10
19
  *
@@ -22,6 +31,7 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
22
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
32
  attrs?: Record<string, any> | null,
24
33
  children?: ElementChild | ElementChild[]
34
+ // eslint-disable-next-line complexity
25
35
  ): HTMLElementTagNameMap[Tag] => {
26
36
  const element = document.createElement(tag);
27
37
 
@@ -54,6 +64,119 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
54
64
  return element;
55
65
  };
56
66
 
67
+ // Helper type to extract only writable properties
68
+ type WritableKeys<T> = {
69
+ [K in keyof T]-?: (<F>() => F extends { [Q in K]: T[K] } ? 1 : 2) extends <
70
+ F,
71
+ >() => F extends { -readonly [Q in K]: T[K] } ? 1 : 2
72
+ ? K
73
+ : never;
74
+ }[keyof T];
75
+
76
+ type HxProps<El extends Element> = Partial<{
77
+ [K in WritableKeys<El> & string]: El[K] | null | undefined;
78
+ }>;
79
+
80
+ type HxHandlers<El extends Element> = Partial<{
81
+ [K in keyof GlobalEventHandlersEventMap]: (
82
+ ev: GlobalEventHandlersEventMap[K] & { currentTarget: El }
83
+ ) => void;
84
+ }>;
85
+
86
+ type HxBindings<El extends Element> = Partial<{
87
+ [K in WritableKeys<El> & string]: FunRead<El[K]>;
88
+ }>;
89
+
90
+ type HxOptionsBase<El extends Element> = {
91
+ props?: HxProps<El>;
92
+ attrs?: Record<string, string | number | boolean | null | undefined>;
93
+ };
94
+
95
+ type HxOptions<El extends Element> = HxOptionsBase<El> & {
96
+ signal: AbortSignal;
97
+ on?: HxHandlers<El>;
98
+ bind?: HxBindings<El>;
99
+ };
100
+
101
+ /**
102
+ * Create an element with structured props, attrs, event handlers, and bindings.
103
+ *
104
+ * @example
105
+ * hx("input", {
106
+ * signal,
107
+ * props: { type: "text" },
108
+ * attrs: { "data-test": "name" },
109
+ * bind: { value: nameState },
110
+ * on: { input: (e) => nameState.set(e.currentTarget.value) },
111
+ * });
112
+ */
113
+ export function hx<Tag extends keyof HTMLElementTagNameMap>(
114
+ tag: Tag,
115
+ options: HxOptions<HTMLElementTagNameMap[Tag]>,
116
+ children?: ElementChild | ElementChild[]
117
+ ): HTMLElementTagNameMap[Tag];
118
+ // eslint-disable-next-line complexity
119
+ export function hx<Tag extends keyof HTMLElementTagNameMap>(
120
+ tag: Tag,
121
+ options: HxOptions<HTMLElementTagNameMap[Tag]>,
122
+ children?: ElementChild | ElementChild[]
123
+ ): HTMLElementTagNameMap[Tag] {
124
+ if (!options?.signal) {
125
+ throw new Error("hx: signal is required");
126
+ }
127
+
128
+ const { signal, props, attrs: attrMap, on: onMap, bind } = options;
129
+ const element = document.createElement(tag);
130
+
131
+ if (props) {
132
+ for (const [key, value] of entries(props)) {
133
+ if (value == null) continue;
134
+ element[key] = value;
135
+ }
136
+ }
137
+
138
+ if (attrMap) {
139
+ for (const [key, value] of Object.entries(attrMap)) {
140
+ if (value == null) continue;
141
+ element.setAttribute(key, String(value));
142
+ }
143
+ }
144
+
145
+ if (children != null) {
146
+ appendChildren(children)(element);
147
+ }
148
+
149
+ if (bind) {
150
+ const bindElementProperty = <
151
+ K extends WritableKeys<HTMLElementTagNameMap[Tag]> & string,
152
+ >(
153
+ key: K,
154
+ state: FunRead<HTMLElementTagNameMap[Tag][K]>
155
+ ): void => {
156
+ bindProperty<HTMLElementTagNameMap[Tag], K>(key, state, signal)(element);
157
+ };
158
+
159
+ for (const key of Object.keys(bind) as Array<
160
+ WritableKeys<HTMLElementTagNameMap[Tag]> & string
161
+ >) {
162
+ const state = bind[key];
163
+ if (!state) continue;
164
+ bindElementProperty(key, state);
165
+ }
166
+ }
167
+
168
+ if (onMap) {
169
+ for (const [event, handler] of Object.entries(onMap)) {
170
+ if (!handler) continue;
171
+ element.addEventListener(event, handler as EventListener, {
172
+ signal,
173
+ });
174
+ }
175
+ }
176
+
177
+ return element;
178
+ }
179
+
57
180
  /**
58
181
  * Append children to an element, flattening arrays and converting primitives to text nodes
59
182
  * @returns {Enhancer}
@@ -114,7 +237,7 @@ export const attrs =
114
237
  export const bindProperty =
115
238
  <E extends Element, K extends keyof E & string>(
116
239
  key: K,
117
- state: FunState<E[K]>,
240
+ state: FunRead<E[K]>,
118
241
  signal: AbortSignal
119
242
  ) =>
120
243
  (el: E): E => {
@@ -260,6 +383,7 @@ export const bindListChildren =
260
383
  rows.clear();
261
384
  };
262
385
 
386
+ // eslint-disable-next-line complexity
263
387
  const reconcile = (): void => {
264
388
  const items = list.get();
265
389
 
@@ -342,7 +466,7 @@ export const bindListChildren =
342
466
  * });
343
467
  */
344
468
  export function renderWhen<State, Props>(options: {
345
- state: FunState<State>;
469
+ state: FunRead<State>;
346
470
  predicate?: (value: State) => boolean;
347
471
  component: Component<Props>;
348
472
  props: Props;
@@ -361,6 +485,7 @@ export function renderWhen<State, Props>(options: {
361
485
  let childCtrl: AbortController | null = null;
362
486
  let childEl: Element | null = null;
363
487
 
488
+ // eslint-disable-next-line complexity
364
489
  const reconcile = () => {
365
490
  const shouldRender = predicate(state.get());
366
491
 
@@ -396,8 +521,13 @@ export function renderWhen<State, Props>(options: {
396
521
  return container;
397
522
  }
398
523
 
399
- export const $ = <T extends Element>(selector: string): T | undefined =>
400
- document.querySelector<T>(selector) ?? undefined;
524
+ /** add passed class (idempotent) to element when state returns true */
525
+ export const bindClass =
526
+ (className: string, state: FunRead<boolean>, signal: AbortSignal) =>
527
+ <E extends Element>(el: E): E => {
528
+ state.watch(signal, (active) => el.classList.toggle(className, active));
529
+ return el;
530
+ };
401
531
 
402
- export const $$ = <T extends Element>(selector: string): T[] =>
532
+ export const querySelectorAll = <T extends Element>(selector: string): T[] =>
403
533
  Array.from(document.querySelectorAll(selector));
package/src/index.ts CHANGED
@@ -1,26 +1,7 @@
1
1
  // @fun-land/fun-web - Web component library for fun-land
2
2
 
3
3
  export type { Component, ElementChild } from "./types";
4
- export type { FunState } from "./state";
5
4
  export type { MountedComponent } from "./mount";
6
5
  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
- bindListChildren,
21
- renderWhen,
22
- enhance,
23
- $,
24
- $$,
25
- } from "./dom";
6
+ export * from "./dom";
26
7
  export { mount } from "./mount";
package/src/mount.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mount } from "./mount";
2
2
  import { h } from "./dom";
3
- import { funState } from "./state";
4
- import type { Component, FunState } from "./index";
3
+ import type { Component } from "./index";
4
+ import { funState, type FunState } from "@fun-land/fun-state";
5
5
 
6
6
  describe("mount()", () => {
7
7
  let container: HTMLDivElement;
package/src/state.test.ts DELETED
@@ -1,251 +0,0 @@
1
- import { funState } from "./state";
2
- import { prop } from "@fun-land/accessor";
3
-
4
- describe("funState", () => {
5
- it("should create state with initial value", () => {
6
- const state = funState({ count: 0 });
7
- expect(state.get()).toEqual({ count: 0 });
8
- });
9
-
10
- it("should update state with set", () => {
11
- const state = funState({ count: 0 });
12
- state.set({ count: 5 });
13
- expect(state.get()).toEqual({ count: 5 });
14
- });
15
-
16
- it("should update state with mod", () => {
17
- const state = funState({ count: 0 });
18
- state.mod((s: { count: number }) => ({ count: s.count + 1 }));
19
- expect(state.get()).toEqual({ count: 1 });
20
- });
21
-
22
- it("should focus on property", () => {
23
- const state = funState({ count: 0, name: "test" });
24
- const countState = state.prop("count");
25
- expect(countState.get()).toBe(0);
26
- });
27
-
28
- it("should update focused state", () => {
29
- const state = funState({ count: 0, name: "test" });
30
- const countState = state.prop("count");
31
- countState.set(5);
32
- expect(state.get()).toEqual({ count: 5, name: "test" });
33
- });
34
-
35
- describe("subscribe subscriptions", () => {
36
- it("should call subscriber when state changes", () => {
37
- const state = funState({ count: 0 });
38
- const controller = new AbortController();
39
- const callback = jest.fn();
40
-
41
- state.watch(controller.signal, callback);
42
- state.set({ count: 1 });
43
-
44
- // Called with initial value, then with new value
45
- expect(callback).toHaveBeenCalledTimes(2);
46
- expect(callback).toHaveBeenNthCalledWith(1, { count: 0 });
47
- expect(callback).toHaveBeenNthCalledWith(2, { count: 1 });
48
- });
49
-
50
- it("should call subscriber multiple times", () => {
51
- const state = funState({ count: 0 });
52
- const controller = new AbortController();
53
- const callback = jest.fn();
54
-
55
- state.watch(controller.signal, callback);
56
- state.set({ count: 1 });
57
- state.set({ count: 2 });
58
-
59
- // watch calls immediately with initial value, then on each change
60
- expect(callback).toHaveBeenCalledTimes(3);
61
- expect(callback).toHaveBeenNthCalledWith(1, { count: 0 });
62
- expect(callback).toHaveBeenNthCalledWith(2, { count: 1 });
63
- expect(callback).toHaveBeenNthCalledWith(3, { count: 2 });
64
- });
65
-
66
- it("should call focused state subscriber only when that property changes", () => {
67
- const state = funState({ count: 0, name: "test" });
68
- const countState = state.prop("count");
69
- const controller = new AbortController();
70
- const callback = jest.fn();
71
-
72
- countState.watch(controller.signal, callback);
73
-
74
- // Initial call with 0
75
- expect(callback).toHaveBeenCalledWith(0);
76
-
77
- // Change count - should trigger
78
- state.set({ count: 1, name: "test" });
79
- expect(callback).toHaveBeenCalledWith(1);
80
-
81
- // Change name only - should not trigger
82
- callback.mockClear();
83
- state.set({ count: 1, name: "changed" });
84
- expect(callback).not.toHaveBeenCalled();
85
-
86
- // Change count again - should trigger
87
- state.set({ count: 2, name: "changed" });
88
- expect(callback).toHaveBeenCalledWith(2);
89
- });
90
-
91
- it("should support multiple subscribers", () => {
92
- const state = funState({ count: 0 });
93
- const controller = new AbortController();
94
- const callback1 = jest.fn();
95
- const callback2 = jest.fn();
96
-
97
- state.watch(controller.signal, callback1);
98
- state.watch(controller.signal, callback2);
99
-
100
- state.set({ count: 1 });
101
-
102
- // Both should be called with initial value, then with new value
103
- expect(callback1).toHaveBeenCalledTimes(2);
104
- expect(callback1).toHaveBeenNthCalledWith(1, { count: 0 });
105
- expect(callback1).toHaveBeenNthCalledWith(2, { count: 1 });
106
- expect(callback2).toHaveBeenCalledTimes(2);
107
- expect(callback2).toHaveBeenNthCalledWith(1, { count: 0 });
108
- expect(callback2).toHaveBeenNthCalledWith(2, { count: 1 });
109
- });
110
-
111
- it("should work with accessor-based focus", () => {
112
- interface User {
113
- name: string;
114
- age: number;
115
- }
116
- const state = funState<User>({ name: "Alice", age: 30 });
117
- const nameState = state.focus(prop<User>()("name"));
118
- const controller = new AbortController();
119
- const callback = jest.fn();
120
-
121
- nameState.watch(controller.signal, callback);
122
-
123
- state.set({ name: "Bob", age: 30 });
124
-
125
- // Called with initial value "Alice", then "Bob"
126
- expect(callback).toHaveBeenCalledTimes(2);
127
- expect(callback).toHaveBeenNthCalledWith(1, "Alice");
128
- expect(callback).toHaveBeenNthCalledWith(2, "Bob");
129
- });
130
- });
131
-
132
- describe("query with accessors", () => {
133
- it("should query state using accessor", () => {
134
- const state = funState({ count: 5 });
135
- const result = state.query(prop<{ count: number }>()("count"));
136
- expect(result).toEqual([5]);
137
- });
138
-
139
- it("should query focused state using accessor", () => {
140
- interface User {
141
- profile: {
142
- name: string;
143
- age: number;
144
- };
145
- }
146
- const state = funState<User>({
147
- profile: { name: "Alice", age: 30 },
148
- });
149
- const profileState = state.prop("profile");
150
- const result = profileState.query(prop<User["profile"]>()("name"));
151
- expect(result).toEqual(["Alice"]);
152
- });
153
- });
154
-
155
- describe("nested focus", () => {
156
- it("should support focusing a focused state", () => {
157
- interface AppState {
158
- user: {
159
- profile: {
160
- name: string;
161
- };
162
- };
163
- }
164
- const state = funState<AppState>({
165
- user: { profile: { name: "Alice" } },
166
- });
167
-
168
- const userState = state.prop("user");
169
- const profileState = userState.prop("profile");
170
- const nameState = profileState.prop("name");
171
-
172
- expect(nameState.get()).toBe("Alice");
173
-
174
- nameState.set("Bob");
175
- expect(state.get().user.profile.name).toBe("Bob");
176
- });
177
-
178
- it("should trigger subscriptions on nested focused states", () => {
179
- interface AppState {
180
- user: {
181
- profile: {
182
- name: string;
183
- };
184
- };
185
- }
186
- const state = funState<AppState>({
187
- user: { profile: { name: "Alice" } },
188
- });
189
-
190
- const userState = state.prop("user");
191
- const profileState = userState.prop("profile");
192
- const nameState = profileState.prop("name");
193
-
194
- const controller = new AbortController();
195
- const callback = jest.fn();
196
-
197
- nameState.watch(controller.signal, callback);
198
-
199
- state.set({ user: { profile: { name: "Bob" } } });
200
-
201
- // Called with initial value "Alice", then "Bob"
202
- expect(callback).toHaveBeenCalledTimes(2);
203
- expect(callback).toHaveBeenNthCalledWith(1, "Alice");
204
- expect(callback).toHaveBeenNthCalledWith(2, "Bob");
205
-
206
- controller.abort();
207
- });
208
-
209
- it("should not trigger deeply focused subscription when unrelated field changes", () => {
210
- interface AppState {
211
- user: {
212
- profile: {
213
- name: string;
214
- age: number;
215
- };
216
- };
217
- }
218
- const state = funState<AppState>({
219
- user: { profile: { name: "Alice", age: 30 } },
220
- });
221
-
222
- const userState = state.prop("user");
223
- const profileState = userState.prop("profile");
224
- const nameState = profileState.prop("name");
225
-
226
- const controller = new AbortController();
227
- const callback = jest.fn();
228
-
229
- nameState.watch(controller.signal, callback);
230
-
231
- // Initial call with "Alice"
232
- expect(callback).toHaveBeenCalledTimes(1);
233
- expect(callback).toHaveBeenCalledWith("Alice");
234
-
235
- callback.mockClear();
236
-
237
- // Change age, not name - should not trigger again
238
- state.set({ user: { profile: { name: "Alice", age: 31 } } });
239
-
240
- expect(callback).not.toHaveBeenCalled();
241
-
242
- // Change name - should trigger
243
- state.set({ user: { profile: { name: "Bob", age: 31 } } });
244
-
245
- expect(callback).toHaveBeenCalledWith("Bob");
246
- expect(callback).toHaveBeenCalledTimes(1);
247
-
248
- controller.abort();
249
- });
250
- });
251
- });
package/src/state.ts DELETED
@@ -1,2 +0,0 @@
1
- /** Re-export FunState and funState from fun-state with subscribe support */
2
- export { funState, type FunState } from "@fun-land/fun-state";