@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/README.md +65 -0
- package/dist/esm/src/dom.d.ts +33 -0
- package/dist/esm/src/dom.js +58 -0
- package/dist/esm/src/dom.js.map +1 -1
- package/dist/esm/src/index.d.ts +1 -1
- package/dist/esm/src/index.js +1 -1
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/tsconfig.publish.tsbuildinfo +1 -1
- package/dist/src/dom.d.ts +33 -0
- package/dist/src/dom.js +59 -0
- package/dist/src/dom.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/examples/todo-app/TodoApp.ts +20 -8
- package/package.json +2 -2
- package/src/dom.test.ts +269 -0
- package/src/dom.ts +71 -0
- package/src/index.ts +1 -0
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
|
|