@fun-land/fun-web 0.3.1 → 0.4.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
@@ -13,6 +13,7 @@ import {
13
13
  keyedChildren,
14
14
  $,
15
15
  $$,
16
+ renderWhen,
16
17
  } from "./dom";
17
18
  import { FunState, funState } from "./state";
18
19
 
@@ -764,3 +765,271 @@ describe("$$ (querySelectorAll)", () => {
764
765
  expect(typeof results.filter).toBe("function");
765
766
  });
766
767
  });
768
+
769
+ describe("renderWhen", () => {
770
+ const TestComponent = (signal: AbortSignal, props: { text: string }) => {
771
+ const el = h("div", { className: "test-component" }, props.text);
772
+ signal.addEventListener("abort", () => {
773
+ el.dataset.aborted = "true";
774
+ });
775
+ return el;
776
+ };
777
+
778
+ test("should render component when state is initially true", () => {
779
+ const controller = new AbortController();
780
+ const showState = funState(true);
781
+
782
+ const container = renderWhen({
783
+ state: showState,
784
+ component: TestComponent,
785
+ props: { text: "Hello" },
786
+ signal: controller.signal
787
+ });
788
+
789
+ expect(container.children.length).toBe(1);
790
+ expect(container.children[0].textContent).toBe("Hello");
791
+ expect(container.children[0].classList.contains("test-component")).toBe(
792
+ true
793
+ );
794
+
795
+ controller.abort();
796
+ });
797
+
798
+ test("should not render component when state is initially false", () => {
799
+ const controller = new AbortController();
800
+ const showState = funState(false);
801
+
802
+ const container = renderWhen({
803
+ state: showState,
804
+ component: TestComponent,
805
+ props: { text: "Hello" },
806
+ signal: controller.signal
807
+ });
808
+
809
+ expect(container.children.length).toBe(0);
810
+
811
+ controller.abort();
812
+ });
813
+
814
+ test("should mount component when state changes from false to true", () => {
815
+ const controller = new AbortController();
816
+ const showState = funState(false);
817
+
818
+ const container = renderWhen({
819
+ state: showState,
820
+ component: TestComponent,
821
+ props: { text: "Hello" },
822
+ signal: controller.signal
823
+ });
824
+
825
+ expect(container.children.length).toBe(0);
826
+
827
+ showState.set(true);
828
+
829
+ expect(container.children.length).toBe(1);
830
+ expect(container.children[0].textContent).toBe("Hello");
831
+
832
+ controller.abort();
833
+ });
834
+
835
+ test("should unmount component when state changes from true to false", () => {
836
+ const controller = new AbortController();
837
+ const showState = funState(true);
838
+
839
+ const container = renderWhen({
840
+ state: showState,
841
+ component: TestComponent,
842
+ props: { text: "Hello" },
843
+ signal: controller.signal
844
+ });
845
+
846
+ const child = container.children[0] as HTMLElement;
847
+ expect(container.children.length).toBe(1);
848
+
849
+ showState.set(false);
850
+
851
+ expect(container.children.length).toBe(0);
852
+ expect(child.dataset.aborted).toBe("true");
853
+
854
+ controller.abort();
855
+ });
856
+
857
+ test("should abort component signal on unmount", () => {
858
+ const controller = new AbortController();
859
+ const showState = funState(true);
860
+ const abortCallback = jest.fn();
861
+
862
+ const ComponentWithAbortListener = (
863
+ signal: AbortSignal,
864
+ props: { text: string }
865
+ ) => {
866
+ signal.addEventListener("abort", abortCallback);
867
+ return h("div", null, props.text);
868
+ };
869
+
870
+ renderWhen({
871
+ state: showState,
872
+ component: ComponentWithAbortListener,
873
+ props: { text: "Hello" },
874
+ signal: controller.signal
875
+ });
876
+
877
+ expect(abortCallback).not.toHaveBeenCalled();
878
+
879
+ showState.set(false);
880
+
881
+ expect(abortCallback).toHaveBeenCalledTimes(1);
882
+
883
+ controller.abort();
884
+ });
885
+
886
+ test("should cleanup when parent signal aborts", () => {
887
+ const controller = new AbortController();
888
+ const showState = funState(true);
889
+ const abortCallback = jest.fn();
890
+
891
+ const ComponentWithAbortListener = (
892
+ signal: AbortSignal,
893
+ props: { text: string }
894
+ ) => {
895
+ signal.addEventListener("abort", abortCallback);
896
+ return h("div", null, props.text);
897
+ };
898
+
899
+ renderWhen({
900
+ state: showState,
901
+ component: ComponentWithAbortListener,
902
+ props: { text: "Hello" },
903
+ signal: controller.signal
904
+ });
905
+
906
+ expect(abortCallback).not.toHaveBeenCalled();
907
+
908
+ controller.abort();
909
+
910
+ expect(abortCallback).toHaveBeenCalledTimes(1);
911
+ });
912
+
913
+ test("should toggle component multiple times", () => {
914
+ const controller = new AbortController();
915
+ const showState = funState(false);
916
+
917
+ const container = renderWhen({
918
+ state: showState,
919
+ component: TestComponent,
920
+ props: { text: "Hello" },
921
+ signal: controller.signal
922
+ });
923
+
924
+ expect(container.children.length).toBe(0);
925
+
926
+ showState.set(true);
927
+ expect(container.children.length).toBe(1);
928
+
929
+ showState.set(false);
930
+ expect(container.children.length).toBe(0);
931
+
932
+ showState.set(true);
933
+ expect(container.children.length).toBe(1);
934
+
935
+ showState.set(false);
936
+ expect(container.children.length).toBe(0);
937
+
938
+ controller.abort();
939
+ });
940
+
941
+ test("should have display: contents to not affect layout", () => {
942
+ const controller = new AbortController();
943
+ const showState = funState(true);
944
+
945
+ const container = renderWhen({
946
+ state: showState,
947
+ component: TestComponent,
948
+ props: { text: "Hello" },
949
+ signal: controller.signal
950
+ }) as HTMLElement;
951
+
952
+ expect(container.style.display).toBe("contents");
953
+
954
+ controller.abort();
955
+ });
956
+
957
+ test("should stop reacting to state changes after parent abort", () => {
958
+ const controller = new AbortController();
959
+ const showState = funState(true);
960
+
961
+ const container = renderWhen({
962
+ state: showState,
963
+ component: TestComponent,
964
+ props: { text: "Hello" },
965
+ signal: controller.signal
966
+ });
967
+
968
+ expect(container.children.length).toBe(1);
969
+
970
+ controller.abort();
971
+
972
+ // Should not react to state changes after abort
973
+ showState.set(false);
974
+ expect(container.children.length).toBe(1);
975
+ });
976
+
977
+ test("should pass props to component", () => {
978
+ const controller = new AbortController();
979
+ const showState = funState(true);
980
+
981
+ const PropsComponent = (
982
+ _signal: AbortSignal,
983
+ props: { text: string; count: number }
984
+ ) => {
985
+ return h(
986
+ "div",
987
+ null,
988
+ `${props.text}: ${props.count}`
989
+ );
990
+ };
991
+
992
+ const container = renderWhen({
993
+ state: showState,
994
+ component: PropsComponent,
995
+ props: { text: "Count", count: 42 },
996
+ signal: controller.signal
997
+ });
998
+
999
+ expect(container.children[0].textContent).toBe("Count: 42");
1000
+
1001
+ controller.abort();
1002
+ });
1003
+
1004
+ test("should work with predicate function", () => {
1005
+ const controller = new AbortController();
1006
+ enum Status { Loading, Success, Error }
1007
+ const statusState = funState(Status.Loading);
1008
+
1009
+ const container = renderWhen({
1010
+ state: statusState,
1011
+ predicate: (status) => status === Status.Success,
1012
+ component: TestComponent,
1013
+ props: { text: "Success!" },
1014
+ signal: controller.signal
1015
+ });
1016
+
1017
+ // Should not render initially
1018
+ expect(container.children.length).toBe(0);
1019
+
1020
+ // Should render when predicate matches
1021
+ statusState.set(Status.Success);
1022
+ expect(container.children.length).toBe(1);
1023
+ expect(container.children[0].textContent).toBe("Success!");
1024
+
1025
+ // Should unmount when predicate doesn't match
1026
+ statusState.set(Status.Error);
1027
+ expect(container.children.length).toBe(0);
1028
+
1029
+ // Should mount again when predicate matches
1030
+ statusState.set(Status.Success);
1031
+ expect(container.children.length).toBe(1);
1032
+
1033
+ controller.abort();
1034
+ });
1035
+ });
package/src/dom.ts CHANGED
@@ -312,6 +312,77 @@ export function keyedChildren<T extends Keyed>(
312
312
  return { reconcile, dispose };
313
313
  }
314
314
 
315
+ /**
316
+ * Conditionally render a component based on state and an optional predicate.
317
+ * Returns a container element that mounts/unmounts the component as the condition changes.
318
+ *
319
+ * @example
320
+ * // With boolean state
321
+ * const showDetails = funState(false);
322
+ * const detailsEl = renderWhen({
323
+ * state: showDetails,
324
+ * component: DetailsComponent,
325
+ * props: {id: 123},
326
+ * signal
327
+ * });
328
+ * parent.appendChild(detailsEl);
329
+ *
330
+ * @example
331
+ * // With predicate
332
+ * const rollType = funState(RollType.action);
333
+ * const actionEl = renderWhen({
334
+ * state: rollType,
335
+ * predicate: (type) => type === RollType.action,
336
+ * component: ActionForm,
337
+ * props: {roll, uid},
338
+ * signal
339
+ * });
340
+ */
341
+ export function renderWhen<State, Props>(options: {
342
+ state: FunState<State>
343
+ predicate?: (value: State) => boolean
344
+ component: (signal: AbortSignal, props: Props) => Element
345
+ props: Props
346
+ signal: AbortSignal
347
+ }): Element {
348
+ const { state, predicate = (x) => x as unknown as boolean, component, props, signal } = options;
349
+
350
+ const container = document.createElement("span");
351
+ container.style.display = "contents";
352
+ let childCtrl: AbortController | null = null;
353
+ let childEl: Element | null = null;
354
+
355
+ const reconcile = () => {
356
+ const shouldRender = predicate(state.get());
357
+
358
+ if (shouldRender && !childEl) {
359
+ // Mount the component
360
+ childCtrl = new AbortController();
361
+ childEl = component(childCtrl.signal, props);
362
+ container.appendChild(childEl);
363
+ } else if (!shouldRender && childEl) {
364
+ // Unmount the component
365
+ childCtrl?.abort();
366
+ childEl.remove();
367
+ childEl = null;
368
+ childCtrl = null;
369
+ }
370
+ };
371
+
372
+ // React to state changes
373
+ state.watch(signal, reconcile);
374
+
375
+ // Clean up when parent aborts
376
+ signal.addEventListener("abort", () => {
377
+ childCtrl?.abort();
378
+ }, { once: true });
379
+
380
+ // Initial render
381
+ reconcile();
382
+
383
+ return container;
384
+ }
385
+
315
386
  export const $ = <T extends Element>(selector: string): T | undefined =>
316
387
  document.querySelector<T>(selector) ?? undefined;
317
388
 
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
  $$,