diff --git a/.changeset/flat-dogs-wink.md b/.changeset/flat-dogs-wink.md new file mode 100644 index 0000000..09c349c --- /dev/null +++ b/.changeset/flat-dogs-wink.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add useControllableState hook diff --git a/packages/react/src/hooks/useControllableState.spec.tsx b/packages/react/src/hooks/useControllableState.spec.tsx new file mode 100644 index 0000000..9a4333f --- /dev/null +++ b/packages/react/src/hooks/useControllableState.spec.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useControllableState } from ":/hooks/useControllableState"; +import { Button } from ":/components/Button"; + +describe("useControllableState", () => { + const TestComponent = (props: { + value?: string; + setValue?: (value: string) => void; + }) => { + const [value, setValue] = useControllableState( + "default", + props.value, + props.setValue, + ); + + return ( +
+
Controllable value: {value}|
+ +
+ ); + }; + + it("works non controlled", async () => { + render(); + + screen.getByText("Controllable value: default|"); + + // Change internal state. + const $button = screen.getByRole("button", { name: "Set value" }); + await userEvent.click($button); + screen.getByText("Controllable value: custom value|"); + }); + + it("work controlled", async () => { + const Wrapper = () => { + const [value, setValue] = React.useState("controlled value"); + return ( + <> + + +
Controlled value: {value}|
+ + ); + }; + render(); + + const user = userEvent.setup(); + + // Default value is controlled. + screen.getByText("Controllable value: controlled value|"); + screen.getByText("Controlled value: controlled value|"); + + // Set value from parent. + const $button = screen.getByRole("button", { + name: "Set controlled value", + }); + + await user.click($button); + screen.getByText("Controllable value: new controlled value|"); + screen.getByText("Controlled value: new controlled value|"); + + // Set value from child. + const $buttonNested = screen.getByRole("button", { + name: "Set value", + }); + await user.click($buttonNested); + screen.getByText("Controllable value: custom value|"); + screen.getByText("Controlled value: custom value|"); + }); +}); diff --git a/packages/react/src/hooks/useControllableState.ts b/packages/react/src/hooks/useControllableState.ts new file mode 100644 index 0000000..ae7581b --- /dev/null +++ b/packages/react/src/hooks/useControllableState.ts @@ -0,0 +1,39 @@ +import React, { useEffect } from "react"; + +/** + * This hook is used to create a state that can be controlled by the parent. If not the state is handled internally. + * + * @param defaultValue if not controlled by the parent, this is the default value + * @param propsValue if controlled by the parent, this is the value + * @param propsCallback if controlled by the parent, this is the callback to call when the value changes + */ +export const useControllableState = ( + defaultValue: T, + propsValue?: T, + propsCallback?: (value: T) => void, +): [T, (value: T) => void] => { + const [state, setState] = React.useState( + typeof propsValue === "undefined" ? defaultValue : propsValue, + ); + + // Bottom-Up. + const onChange = (value: T) => { + if (propsCallback) { + propsCallback(value); + } else { + setState(value); + } + }; + + // Top-Down. + useEffect(() => { + if (!propsCallback) { + return; + } + if (typeof propsValue !== "undefined" && propsValue !== state) { + setState(propsValue); + } + }, [propsValue]); + + return [state, onChange]; +};