@fun-land/fun-web 0.6.0 → 1.1.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.test.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  attrs,
7
7
  append,
8
8
  bindProperty,
9
+ bindView,
9
10
  addClass,
10
11
  toggleClass,
11
12
  removeClass,
@@ -14,7 +15,7 @@ import {
14
15
  querySelectorAll,
15
16
  renderWhen,
16
17
  } from "./dom";
17
- import { funState } from "@fun-land/fun-state";
18
+ import { funState, mapRead, derive } from "@fun-land/fun-state";
18
19
 
19
20
  describe("h()", () => {
20
21
  it("should create an element", () => {
@@ -171,6 +172,44 @@ describe("hx()", () => {
171
172
  "hx: signal is required"
172
173
  );
173
174
  });
175
+
176
+ it("should accept FunRead from mapRead in bind", () => {
177
+ const controller = new AbortController();
178
+ const num = funState(5);
179
+ const doubled = mapRead(num, (n) => String(n * 2));
180
+
181
+ const el = hx("div", {
182
+ signal: controller.signal,
183
+ bind: { textContent: doubled },
184
+ });
185
+
186
+ expect(el.textContent).toBe("10");
187
+
188
+ num.set(3);
189
+ expect(el.textContent).toBe("6");
190
+
191
+ controller.abort();
192
+ });
193
+
194
+ it("should accept FunRead from derive in bind", () => {
195
+ const controller = new AbortController();
196
+ const a = funState(2);
197
+ const b = funState(3);
198
+ const sum = derive(a, b, (x, y) => x + y);
199
+
200
+ const el = hx("input", {
201
+ signal: controller.signal,
202
+ props: { type: "number" },
203
+ bind: { valueAsNumber: sum },
204
+ });
205
+
206
+ expect(el.valueAsNumber).toBe(5);
207
+
208
+ a.set(10);
209
+ expect(el.valueAsNumber).toBe(13);
210
+
211
+ controller.abort();
212
+ });
174
213
  });
175
214
 
176
215
  describe("text()", () => {
@@ -372,6 +411,60 @@ describe("bindProperty()", () => {
372
411
  expect(result).toBe(el);
373
412
  controller.abort();
374
413
  });
414
+
415
+ it("should bind FunRead from mapRead", () => {
416
+ const el = h("div");
417
+ const controller = new AbortController();
418
+
419
+ const num = funState(5);
420
+ const doubled = mapRead(num, (n) => String(n * 2));
421
+ enhance(el, bindProperty("textContent", doubled, controller.signal));
422
+
423
+ expect(el.textContent).toBe("10");
424
+
425
+ num.set(7);
426
+ expect(el.textContent).toBe("14");
427
+
428
+ controller.abort();
429
+ });
430
+
431
+ it("should bind FunRead from derive", () => {
432
+ const el = h("div");
433
+ const controller = new AbortController();
434
+
435
+ const first = funState("John");
436
+ const last = funState("Doe");
437
+ const full = derive(first, last, (f, l) => `${f} ${l}`);
438
+ enhance(el, bindProperty("textContent", full, controller.signal));
439
+
440
+ expect(el.textContent).toBe("John Doe");
441
+
442
+ first.set("Jane");
443
+ expect(el.textContent).toBe("Jane Doe");
444
+
445
+ last.set("Smith");
446
+ expect(el.textContent).toBe("Jane Smith");
447
+
448
+ controller.abort();
449
+ });
450
+
451
+ it("should compose mapRead over derive", () => {
452
+ const el = h("span");
453
+ const controller = new AbortController();
454
+
455
+ const price = funState(42);
456
+ const quantity = funState(3);
457
+ const total = derive(price, quantity, (p, q) => p * q);
458
+ const formatted = mapRead(total, (n) => `$${n.toFixed(2)}`);
459
+ enhance(el, bindProperty("textContent", formatted, controller.signal));
460
+
461
+ expect(el.textContent).toBe("$126.00");
462
+
463
+ price.set(50);
464
+ expect(el.textContent).toBe("$150.00");
465
+
466
+ controller.abort();
467
+ });
375
468
  });
376
469
 
377
470
  describe("on()", () => {
@@ -406,6 +499,76 @@ describe("on()", () => {
406
499
  });
407
500
  });
408
501
 
502
+ describe("bindView()", () => {
503
+ it("should default to a div container", () => {
504
+ const controller = new AbortController();
505
+ const state = funState(0);
506
+
507
+ const container = bindView(controller.signal, state, (_signal, value) =>
508
+ h("div", null, String(value))
509
+ ) as HTMLElement;
510
+
511
+ state.set(1);
512
+
513
+ expect(container.tagName).toBe("DIV");
514
+ expect(container.textContent).toBe("1");
515
+
516
+ controller.abort();
517
+ });
518
+
519
+ it("should render into the provided container tag", () => {
520
+ const controller = new AbortController();
521
+ const state = funState(0);
522
+
523
+ const container = bindView(
524
+ controller.signal,
525
+ state,
526
+ (_signal, value) => h("div", null, String(value)),
527
+ { tagName: "section" }
528
+ ) as HTMLElement;
529
+
530
+ state.set(1);
531
+
532
+ expect(container.tagName).toBe("SECTION");
533
+ expect(container.children.length).toBe(1);
534
+ expect(container.textContent).toBe("1");
535
+
536
+ controller.abort();
537
+ });
538
+
539
+ it("should abort previous render on state changes", () => {
540
+ const controller = new AbortController();
541
+ const state = funState(0);
542
+ let abortCount = 0;
543
+ let renderCount = 0;
544
+
545
+ const container = bindView(
546
+ controller.signal,
547
+ state,
548
+ (signal, value) => {
549
+ renderCount += 1;
550
+ signal.addEventListener("abort", () => {
551
+ abortCount += 1;
552
+ });
553
+ return h("div", null, String(value));
554
+ },
555
+ { tagName: "div" }
556
+ );
557
+
558
+ state.set(1);
559
+ expect(renderCount).toBe(1);
560
+ expect(abortCount).toBe(0);
561
+
562
+ state.set(2);
563
+ expect(renderCount).toBe(2);
564
+ expect(abortCount).toBe(1);
565
+ expect(container.textContent).toBe("2");
566
+
567
+ controller.abort();
568
+ expect(abortCount).toBe(2);
569
+ });
570
+ });
571
+
409
572
  describe("pipeEndo()", () => {
410
573
  it("should apply functions in order", () => {
411
574
  const el = document.createElement("div");
@@ -493,7 +656,7 @@ describe("renderWhen", () => {
493
656
  state: showState,
494
657
  component: TestComponent,
495
658
  props: { text: "Hello" },
496
- signal: controller.signal
659
+ signal: controller.signal,
497
660
  });
498
661
 
499
662
  expect(container.children.length).toBe(1);
@@ -513,7 +676,7 @@ describe("renderWhen", () => {
513
676
  state: showState,
514
677
  component: TestComponent,
515
678
  props: { text: "Hello" },
516
- signal: controller.signal
679
+ signal: controller.signal,
517
680
  });
518
681
 
519
682
  expect(container.children.length).toBe(0);
@@ -529,7 +692,7 @@ describe("renderWhen", () => {
529
692
  state: showState,
530
693
  component: TestComponent,
531
694
  props: { text: "Hello" },
532
- signal: controller.signal
695
+ signal: controller.signal,
533
696
  });
534
697
 
535
698
  expect(container.children.length).toBe(0);
@@ -550,7 +713,7 @@ describe("renderWhen", () => {
550
713
  state: showState,
551
714
  component: TestComponent,
552
715
  props: { text: "Hello" },
553
- signal: controller.signal
716
+ signal: controller.signal,
554
717
  });
555
718
 
556
719
  const child = container.children[0] as HTMLElement;
@@ -581,7 +744,7 @@ describe("renderWhen", () => {
581
744
  state: showState,
582
745
  component: ComponentWithAbortListener,
583
746
  props: { text: "Hello" },
584
- signal: controller.signal
747
+ signal: controller.signal,
585
748
  });
586
749
 
587
750
  expect(abortCallback).not.toHaveBeenCalled();
@@ -610,7 +773,7 @@ describe("renderWhen", () => {
610
773
  state: showState,
611
774
  component: ComponentWithAbortListener,
612
775
  props: { text: "Hello" },
613
- signal: controller.signal
776
+ signal: controller.signal,
614
777
  });
615
778
 
616
779
  expect(abortCallback).not.toHaveBeenCalled();
@@ -628,7 +791,7 @@ describe("renderWhen", () => {
628
791
  state: showState,
629
792
  component: TestComponent,
630
793
  props: { text: "Hello" },
631
- signal: controller.signal
794
+ signal: controller.signal,
632
795
  });
633
796
 
634
797
  expect(container.children.length).toBe(0);
@@ -656,7 +819,7 @@ describe("renderWhen", () => {
656
819
  state: showState,
657
820
  component: TestComponent,
658
821
  props: { text: "Hello" },
659
- signal: controller.signal
822
+ signal: controller.signal,
660
823
  }) as HTMLElement;
661
824
 
662
825
  expect(container.style.display).toBe("contents");
@@ -672,7 +835,7 @@ describe("renderWhen", () => {
672
835
  state: showState,
673
836
  component: TestComponent,
674
837
  props: { text: "Hello" },
675
- signal: controller.signal
838
+ signal: controller.signal,
676
839
  });
677
840
 
678
841
  expect(container.children.length).toBe(1);
@@ -692,18 +855,14 @@ describe("renderWhen", () => {
692
855
  _signal: AbortSignal,
693
856
  props: { text: string; count: number }
694
857
  ) => {
695
- return h(
696
- "div",
697
- null,
698
- `${props.text}: ${props.count}`
699
- );
858
+ return h("div", null, `${props.text}: ${props.count}`);
700
859
  };
701
860
 
702
861
  const container = renderWhen({
703
862
  state: showState,
704
863
  component: PropsComponent,
705
864
  props: { text: "Count", count: 42 },
706
- signal: controller.signal
865
+ signal: controller.signal,
707
866
  });
708
867
 
709
868
  expect(container.children[0].textContent).toBe("Count: 42");
@@ -713,7 +872,11 @@ describe("renderWhen", () => {
713
872
 
714
873
  test("should work with predicate function", () => {
715
874
  const controller = new AbortController();
716
- enum Status { Loading, Success, Error }
875
+ enum Status {
876
+ Loading,
877
+ Success,
878
+ Error,
879
+ }
717
880
  const statusState = funState(Status.Loading);
718
881
 
719
882
  const container = renderWhen({
@@ -721,7 +884,7 @@ describe("renderWhen", () => {
721
884
  predicate: (status) => status === Status.Success,
722
885
  component: TestComponent,
723
886
  props: { text: "Success!" },
724
- signal: controller.signal
887
+ signal: controller.signal,
725
888
  });
726
889
 
727
890
  // Should not render initially
@@ -742,4 +905,4 @@ describe("renderWhen", () => {
742
905
 
743
906
  controller.abort();
744
907
  });
745
- });
908
+ });
package/src/dom.ts CHANGED
@@ -1,5 +1,5 @@
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
 
@@ -11,7 +11,8 @@ export type Enhancer<El extends Element> = (element: El) => El;
11
11
  */
12
12
  const entries = <T extends Record<string, unknown>>(
13
13
  obj: T
14
- ): Array<[keyof T, T[keyof T]]> => Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
14
+ ): Array<[keyof T, T[keyof T]]> =>
15
+ Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
15
16
 
16
17
  /**
17
18
  * Create an HTML element with attributes and children
@@ -30,6 +31,7 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
30
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
32
  attrs?: Record<string, any> | null,
32
33
  children?: ElementChild | ElementChild[]
34
+ // eslint-disable-next-line complexity
33
35
  ): HTMLElementTagNameMap[Tag] => {
34
36
  const element = document.createElement(tag);
35
37
 
@@ -82,7 +84,7 @@ type HxHandlers<El extends Element> = Partial<{
82
84
  }>;
83
85
 
84
86
  type HxBindings<El extends Element> = Partial<{
85
- [K in WritableKeys<El> & string]: FunState<El[K]>;
87
+ [K in WritableKeys<El> & string]: FunRead<El[K]>;
86
88
  }>;
87
89
 
88
90
  type HxOptionsBase<El extends Element> = {
@@ -145,15 +147,13 @@ export function hx<Tag extends keyof HTMLElementTagNameMap>(
145
147
  }
146
148
 
147
149
  if (bind) {
148
- const bindElementProperty = <K extends WritableKeys<HTMLElementTagNameMap[Tag]> & string>(
150
+ const bindElementProperty = <
151
+ K extends WritableKeys<HTMLElementTagNameMap[Tag]> & string,
152
+ >(
149
153
  key: K,
150
- state: FunState<HTMLElementTagNameMap[Tag][K]>
154
+ state: FunRead<HTMLElementTagNameMap[Tag][K]>
151
155
  ): void => {
152
- bindProperty<HTMLElementTagNameMap[Tag], K>(
153
- key,
154
- state,
155
- signal
156
- )(element);
156
+ bindProperty<HTMLElementTagNameMap[Tag], K>(key, state, signal)(element);
157
157
  };
158
158
 
159
159
  for (const key of Object.keys(bind) as Array<
@@ -161,10 +161,7 @@ export function hx<Tag extends keyof HTMLElementTagNameMap>(
161
161
  >) {
162
162
  const state = bind[key];
163
163
  if (!state) continue;
164
- bindElementProperty(
165
- key,
166
- state
167
- );
164
+ bindElementProperty(key, state);
168
165
  }
169
166
  }
170
167
 
@@ -240,7 +237,7 @@ export const attrs =
240
237
  export const bindProperty =
241
238
  <E extends Element, K extends keyof E & string>(
242
239
  key: K,
243
- state: FunState<E[K]>,
240
+ state: FunRead<E[K]>,
244
241
  signal: AbortSignal
245
242
  ) =>
246
243
  (el: E): E => {
@@ -254,6 +251,38 @@ export const bindProperty =
254
251
  return el;
255
252
  };
256
253
 
254
+ /**
255
+ * Render a single slot from state and abort previous render on updates.
256
+ * Useful when render creates subscriptions, timers, or event handlers.
257
+ * Defaults to a "div" container when no tagName is provided.
258
+ */
259
+ export const bindView = <
260
+ Tag extends keyof HTMLElementTagNameMap = "div",
261
+ T = unknown,
262
+ >(
263
+ signal: AbortSignal,
264
+ state: FunRead<T>,
265
+ render: (regionSignal: AbortSignal, data: T) => Element,
266
+ options?: { tagName?: Tag }
267
+ ): HTMLElementTagNameMap[Tag] => {
268
+ const tagName = (options?.tagName ?? "div") as Tag;
269
+ const element = document.createElement(tagName);
270
+ let childCtrl: AbortController | null = null;
271
+ state.watch(signal, (data) => {
272
+ childCtrl?.abort();
273
+ childCtrl = new AbortController();
274
+ element.replaceChildren(render(childCtrl.signal, data));
275
+ });
276
+ signal.addEventListener(
277
+ "abort",
278
+ () => {
279
+ childCtrl?.abort();
280
+ },
281
+ { once: true }
282
+ );
283
+ return element;
284
+ };
285
+
257
286
  /**
258
287
  * Add CSS classes to an element (returns element for chaining)
259
288
  * @returns {Enhancer}
@@ -386,6 +415,7 @@ export const bindListChildren =
386
415
  rows.clear();
387
416
  };
388
417
 
418
+ // eslint-disable-next-line complexity
389
419
  const reconcile = (): void => {
390
420
  const items = list.get();
391
421
 
@@ -468,7 +498,7 @@ export const bindListChildren =
468
498
  * });
469
499
  */
470
500
  export function renderWhen<State, Props>(options: {
471
- state: FunState<State>;
501
+ state: FunRead<State>;
472
502
  predicate?: (value: State) => boolean;
473
503
  component: Component<Props>;
474
504
  props: Props;
@@ -487,6 +517,7 @@ export function renderWhen<State, Props>(options: {
487
517
  let childCtrl: AbortController | null = null;
488
518
  let childEl: Element | null = null;
489
519
 
520
+ // eslint-disable-next-line complexity
490
521
  const reconcile = () => {
491
522
  const shouldRender = predicate(state.get());
492
523
 
@@ -524,11 +555,11 @@ export function renderWhen<State, Props>(options: {
524
555
 
525
556
  /** add passed class (idempotent) to element when state returns true */
526
557
  export const bindClass =
527
- (className: string, state: FunState<boolean>, signal: AbortSignal) =>
558
+ (className: string, state: FunRead<boolean>, signal: AbortSignal) =>
528
559
  <E extends Element>(el: E): E => {
529
560
  state.watch(signal, (active) => el.classList.toggle(className, active));
530
561
  return el;
531
562
  };
532
563
 
533
564
  export const querySelectorAll = <T extends Element>(selector: string): T[] =>
534
- Array.from(document.querySelectorAll(selector));
565
+ Array.from(document.querySelectorAll(selector));
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;