From 8f1f809dc6035bd05f039825479a5c7106f25426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=D3=84=D5=A1?= Date: Sat, 4 May 2024 03:24:29 +0800 Subject: [PATCH] fix(switch): support uncontrolled switch in react-hook-form (#2924) * feat(switch): add @nextui-org/use-safe-layout-effect * chore(deps): add @nextui-org/use-safe-layout-effect * fix(switch): react-hook-form uncontrolled switch component * fix(switch): react-hook-form uncontrolled switch component * feat(switch): add rect-hook-form in dev dep * feat(switch): add WithReactHookFormTemplate --- .changeset/rotten-zoos-decide.md | 5 ++ packages/components/switch/package.json | 4 +- packages/components/switch/src/use-switch.ts | 23 ++++++--- .../switch/stories/switch.stories.tsx | 48 +++++++++++++++++++ pnpm-lock.yaml | 13 +++-- 5 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 .changeset/rotten-zoos-decide.md diff --git a/.changeset/rotten-zoos-decide.md b/.changeset/rotten-zoos-decide.md new file mode 100644 index 0000000000..d7e2c19803 --- /dev/null +++ b/.changeset/rotten-zoos-decide.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/switch": patch +--- + +Fixed react-hook-form uncontrolled switch component diff --git a/packages/components/switch/package.json b/packages/components/switch/package.json index 98c276a4aa..b93188a957 100644 --- a/packages/components/switch/package.json +++ b/packages/components/switch/package.json @@ -42,6 +42,7 @@ "dependencies": { "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", + "@nextui-org/use-safe-layout-effect": "workspace:*", "@react-aria/focus": "^3.16.2", "@react-aria/interactions": "^3.21.1", "@react-aria/switch": "^3.6.2", @@ -56,7 +57,8 @@ "@nextui-org/shared-icons": "workspace:*", "clean-package": "2.2.0", "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "react-hook-form": "^7.51.3" }, "clean-package": "../../../clean-package.config.json" } diff --git a/packages/components/switch/src/use-switch.ts b/packages/components/switch/src/use-switch.ts index aa209fbc00..fbf8895535 100644 --- a/packages/components/switch/src/use-switch.ts +++ b/packages/components/switch/src/use-switch.ts @@ -1,15 +1,15 @@ import type {ToggleVariantProps, ToggleSlots, SlotsToClasses} from "@nextui-org/theme"; -import type {FocusableRef} from "@react-types/shared"; import type {AriaSwitchProps} from "@react-aria/switch"; import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; import {ReactNode, Ref, useCallback, useId, useRef, useState} from "react"; import {mapPropsVariants} from "@nextui-org/system"; +import {mergeRefs} from "@nextui-org/react-utils"; +import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; import {useHover, usePress} from "@react-aria/interactions"; import {toggle} from "@nextui-org/theme"; import {chain, mergeProps} from "@react-aria/utils"; import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; -import {useFocusableRef} from "@nextui-org/react-utils"; import {useSwitch as useReactAriaSwitch} from "@react-aria/switch"; import {useMemo} from "react"; import {useToggleState} from "@react-stately/toggle"; @@ -27,7 +27,7 @@ interface Props extends HTMLNextUIProps<"input"> { /** * Ref to the DOM node. */ - ref?: Ref; + ref?: Ref; /** * The label of the switch. */ @@ -100,8 +100,9 @@ export function useSwitch(originalProps: UseSwitchProps = {}) { const Component = as || "label"; - const inputRef = useRef(null); - const domRef = useFocusableRef(ref as FocusableRef, inputRef); + const domRef = useRef(null); + + const inputRef = useRef(null); const labelId = useId(); @@ -139,6 +140,16 @@ export function useSwitch(originalProps: UseSwitchProps = {}) { const state = useToggleState(ariaSwitchProps); + // if we use `react-hook-form`, it will set the switch value using the ref in register + // i.e. setting ref.current.checked to true or false which is uncontrolled + // hence, sync the state with `ref.current.checked` + useSafeLayoutEffect(() => { + if (!inputRef.current) return; + const isInputRefChecked = !!inputRef.current.checked; + + state.setSelected(isInputRefChecked); + }, [inputRef.current]); + const { inputProps, isPressed: isPressedKeyboard, @@ -212,7 +223,7 @@ export function useSwitch(originalProps: UseSwitchProps = {}) { const getInputProps: PropGetter = (props = {}) => { return { ...mergeProps(inputProps, focusProps, props), - ref: inputRef, + ref: mergeRefs(inputRef, ref), id: inputProps.id, onChange: chain(onChange, inputProps.onChange), }; diff --git a/packages/components/switch/stories/switch.stories.tsx b/packages/components/switch/stories/switch.stories.tsx index 33a753a70b..8564dfd7e3 100644 --- a/packages/components/switch/stories/switch.stories.tsx +++ b/packages/components/switch/stories/switch.stories.tsx @@ -5,6 +5,8 @@ import {toggle} from "@nextui-org/theme"; import {VisuallyHidden} from "@react-aria/visually-hidden"; import {SunFilledIcon, MoonFilledIcon} from "@nextui-org/shared-icons"; import {clsx} from "@nextui-org/shared-utils"; +import {button} from "@nextui-org/theme"; +import {useForm} from "react-hook-form"; import {Switch, SwitchProps, SwitchThumbIconProps, useSwitch} from "../src"; @@ -131,6 +133,44 @@ const CustomWithHooksTemplate = (args: SwitchProps) => { ); }; +const WithReactHookFormTemplate = (args: SwitchProps) => { + const { + register, + formState: {errors}, + handleSubmit, + } = useForm({ + defaultValues: { + defaultTrue: true, + defaultFalse: false, + requiredField: false, + }, + }); + + const onSubmit = (data: any) => { + // eslint-disable-next-line no-console + console.log(data); + alert("Submitted value: " + JSON.stringify(data)); + }; + + return ( +
+ + By default this switch is true + + + By default this switch is false + + + This switch is required + + {errors.requiredField && This switch is required} + +
+ ); +}; + export const Default = { args: { ...defaultProps, @@ -204,3 +244,11 @@ export const CustomWithHooks = { ...defaultProps, }, }; + +export const WithReactHookForm = { + render: WithReactHookFormTemplate, + + args: { + ...defaultProps, + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d19c64507a..dcb67beea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2502,6 +2502,9 @@ importers: '@nextui-org/shared-utils': specifier: workspace:* version: link:../../utilities/shared-utils + '@nextui-org/use-safe-layout-effect': + specifier: workspace:* + version: link:../../hooks/use-safe-layout-effect '@react-aria/focus': specifier: ^3.16.2 version: 3.16.2(react@18.2.0) @@ -2542,6 +2545,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.51.3 + version: 7.51.3(react@18.2.0) packages/components/table: dependencies: @@ -5952,10 +5958,6 @@ packages: peerDependencies: '@effect-ts/otel-node': '*' peerDependenciesMeta: - '@effect-ts/core': - optional: true - '@effect-ts/otel': - optional: true '@effect-ts/otel-node': optional: true dependencies: @@ -22449,9 +22451,6 @@ packages: resolution: {integrity: sha512-W+gxAq7aQ9dJIg/XLKGcRT0cvnStFAQHPaI0pvD0U2l6IVLueUAm3nwN7lkY62zZNmlvNx6jNtE4wlbS+CyqSg==} engines: {node: '>= 12.0.0'} hasBin: true - peerDependenciesMeta: - '@parcel/core': - optional: true dependencies: '@parcel/config-default': 2.12.0(@parcel/core@2.12.0)(typescript@4.9.5) '@parcel/core': 2.12.0