diff --git a/.changeset/many-ways-laugh.md b/.changeset/many-ways-laugh.md new file mode 100644 index 0000000000..b691bda140 --- /dev/null +++ b/.changeset/many-ways-laugh.md @@ -0,0 +1,8 @@ +--- +"@nextui-org/autocomplete": patch +"@nextui-org/checkbox": patch +"@nextui-org/input": patch +"@nextui-org/select": patch +--- + +Fixed react-hook-form uncontrolled components (#1969) diff --git a/packages/components/autocomplete/package.json b/packages/components/autocomplete/package.json index 679294e166..8619609b76 100644 --- a/packages/components/autocomplete/package.json +++ b/packages/components/autocomplete/package.json @@ -52,6 +52,7 @@ "@nextui-org/button": "workspace:*", "@nextui-org/use-aria-button": "workspace:*", "@nextui-org/shared-icons": "workspace:*", + "@nextui-org/use-safe-layout-effect": "workspace:*", "@react-aria/combobox": "^3.8.4", "@react-aria/focus": "^3.16.2", "@react-aria/i18n": "^3.10.2", diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 2d1e8fb9c6..acb76e09f9 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -1,6 +1,7 @@ import type {AutocompleteVariantProps, SlotsToClasses, AutocompleteSlots} from "@nextui-org/theme"; import {DOMAttributes, HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system"; +import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; import {autocomplete} from "@nextui-org/theme"; import {useFilter} from "@react-aria/i18n"; import {FilterFn, useComboBoxState} from "@react-stately/combobox"; @@ -298,6 +299,21 @@ export function useAutocomplete(originalProps: UseAutocomplete ? state.isOpen && !!state.collection.size : state.isOpen; + // if we use `react-hook-form`, it will set the native input value using the ref in register + // i.e. setting ref.current.value to something which is uncontrolled + // hence, sync the state with `ref.current.value` + useSafeLayoutEffect(() => { + if (!inputRef.current) return; + + const key = inputRef.current.value; + const item = state.collection.getItem(key); + + if (item) { + state.setSelectedKey(key); + state.setInputValue(item.textValue); + } + }, [inputRef.current, state]); + // apply the same with to the popover as the select useEffect(() => { if (isOpen && popoverRef.current && inputWrapperRef.current) { diff --git a/packages/components/checkbox/__tests__/checkbox.test.tsx b/packages/components/checkbox/__tests__/checkbox.test.tsx index ee7bf6a643..c3e7308b74 100644 --- a/packages/components/checkbox/__tests__/checkbox.test.tsx +++ b/packages/components/checkbox/__tests__/checkbox.test.tsx @@ -12,7 +12,7 @@ describe("Checkbox", () => { }); it("ref should be forwarded", () => { - const ref = React.createRef(); + const ref = React.createRef(); render(Option); expect(ref.current).not.toBeNull(); diff --git a/packages/components/checkbox/package.json b/packages/components/checkbox/package.json index b31fdb963d..14e6260f4a 100644 --- a/packages/components/checkbox/package.json +++ b/packages/components/checkbox/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/checkbox": "^3.14.1", "@react-aria/focus": "^3.16.2", "@react-aria/interactions": "^3.21.1", diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts index 6d6e7a71a0..b7ca0d2001 100644 --- a/packages/components/checkbox/src/use-checkbox.ts +++ b/packages/components/checkbox/src/use-checkbox.ts @@ -8,14 +8,14 @@ import {useToggleState} from "@react-stately/toggle"; import {checkbox} from "@nextui-org/theme"; import {useHover, usePress} from "@react-aria/interactions"; import {useFocusRing} from "@react-aria/focus"; -import {chain, mergeProps} from "@react-aria/utils"; -import {useFocusableRef} from "@nextui-org/react-utils"; +import {mergeProps, chain} from "@react-aria/utils"; import {__DEV__, warn, clsx, dataAttr, safeAriaLabel} from "@nextui-org/shared-utils"; import { useCheckbox as useReactAriaCheckbox, useCheckboxGroupItem as useReactAriaCheckboxGroupItem, } from "@react-aria/checkbox"; -import {FocusableRef} from "@react-types/shared"; +import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; +import {mergeRefs} from "@nextui-org/react-utils"; import {useCheckboxGroupContext} from "./checkbox-group-context"; @@ -31,7 +31,7 @@ interface Props extends Omit, keyof CheckboxVariantProp /** * Ref to the DOM node. */ - ref?: Ref; + ref?: Ref; /** * The label of the checkbox. */ @@ -118,8 +118,9 @@ export function useCheckbox(props: UseCheckboxProps = {}) { const Component = as || "label"; + const domRef = useRef(null); + const inputRef = useRef(null); - const domRef = useFocusableRef(ref as FocusableRef, inputRef); // This workaround might become unnecessary once the following issue is resolved // https://github.com/adobe/react-spectrum/issues/5693 @@ -235,6 +236,31 @@ export function useCheckbox(props: UseCheckboxProps = {}) { [color, size, radius, isInvalid, lineThrough, isDisabled, disableAnimation], ); + const [isChecked, setIsChecked] = useState(!!defaultSelected || !!isSelected); + + // if we use `react-hook-form`, it will set the checkbox 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; + + setIsChecked(isInputRefChecked); + }, [inputRef.current]); + + const handleCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + if (isReadOnly || isDisabled) { + event.preventDefault(); + + return; + } + + setIsChecked(!isChecked); + }, + [isReadOnly, isDisabled, isChecked], + ); + const baseStyles = clsx(classNames?.base, className); const getBaseProps: PropGetter = useCallback(() => { @@ -242,7 +268,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) { ref: domRef, className: slots.base({class: baseStyles}), "data-disabled": dataAttr(isDisabled), - "data-selected": dataAttr(isSelected || isIndeterminate), + "data-selected": dataAttr(isSelected || isIndeterminate || isChecked), "data-invalid": dataAttr(isInvalid), "data-hover": dataAttr(isHovered), "data-focus": dataAttr(isFocused), @@ -282,11 +308,11 @@ export function useCheckbox(props: UseCheckboxProps = {}) { const getInputProps: PropGetter = useCallback(() => { return { - ref: inputRef, - ...mergeProps(inputProps, focusProps), - onChange: chain(inputProps.onChange, onChange), + ref: mergeRefs(inputRef, ref), + ...mergeProps(inputProps, focusProps, {checked: isChecked}), + onChange: chain(inputProps.onChange, onChange, handleCheckboxChange), }; - }, [inputProps, focusProps, onChange]); + }, [inputProps, focusProps, onChange, handleCheckboxChange]); const getLabelProps: PropGetter = useCallback( () => ({ @@ -299,12 +325,12 @@ export function useCheckbox(props: UseCheckboxProps = {}) { const getIconProps = useCallback( () => ({ - isSelected: isSelected, + isSelected: isSelected || isChecked, isIndeterminate: !!isIndeterminate, disableAnimation: !!disableAnimation, className: slots.icon({class: classNames?.icon}), } as CheckboxIconProps), - [slots, classNames?.icon, isSelected, isIndeterminate, disableAnimation], + [slots, classNames?.icon, isSelected, isIndeterminate, disableAnimation, isChecked], ); return { diff --git a/packages/components/input/src/use-input.ts b/packages/components/input/src/use-input.ts index b36ea4796b..b3704f485a 100644 --- a/packages/components/input/src/use-input.ts +++ b/packages/components/input/src/use-input.ts @@ -115,27 +115,27 @@ export function useInput( + const [isFocusWithin, setFocusWithin] = useState(false); + + const Component = as || "div"; + + const domRef = useDOMRef(ref); + const baseDomRef = useDOMRef(baseRef); + const inputWrapperRef = useDOMRef(wrapperRef); + const innerWrapperRef = useDOMRef(innerWrapperRefProp); + + const [inputValue, setInputValue] = useControlledState( props.value, props.defaultValue ?? "", handleValueChange, ); - const [isFocusWithin, setFocusWithin] = useState(false); - - const Component = as || "div"; - const isFilledByDefault = ["date", "time", "month", "week", "range"].includes(type!); const isFilled = !isEmpty(inputValue) || isFilledByDefault; const isFilledWithin = isFilled || isFocusWithin; const baseStyles = clsx(classNames?.base, className, isFilled ? "is-filled" : ""); const isMultiline = originalProps.isMultiline; - const domRef = useDOMRef(ref); - const baseDomRef = useDOMRef(baseRef); - const inputWrapperRef = useDOMRef(wrapperRef); - const innerWrapperRef = useDOMRef(innerWrapperRefProp); - const handleClear = useCallback(() => { setInputValue(""); @@ -156,7 +156,7 @@ export function useInput = { @@ -251,6 +252,15 @@ export function useSelect(originalProps: UseSelectProps) { }), }; + // if we use `react-hook-form`, it will set the native select value using the ref in register + // i.e. setting ref.current.value to something which is uncontrolled + // hence, sync the state with `ref.current.value` + useSafeLayoutEffect(() => { + if (!domRef.current) return; + + state.setSelectedKeys(new Set([...state.selectedKeys, domRef.current.value])); + }, [domRef.current]); + const { labelProps, triggerProps, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92c952449e..adca03d5c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -719,6 +719,9 @@ importers: '@nextui-org/use-aria-button': specifier: workspace:* version: link:../../hooks/use-aria-button + '@nextui-org/use-safe-layout-effect': + specifier: workspace:* + version: link:../../hooks/use-safe-layout-effect '@react-aria/combobox': specifier: ^3.8.4 version: 3.8.4(react-dom@18.2.0)(react@18.2.0) @@ -1131,6 +1134,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/checkbox': specifier: ^3.14.1 version: 3.14.1(react@18.2.0) @@ -2223,6 +2229,9 @@ importers: '@nextui-org/use-aria-multiselect': specifier: workspace:* version: link:../../hooks/use-aria-multiselect + '@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) @@ -5879,10 +5888,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: @@ -11850,11 +11855,11 @@ packages: /@types/color-convert@2.0.3: resolution: {integrity: sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==} dependencies: - '@types/color-name': 1.1.3 + '@types/color-name': 1.1.4 dev: true - /@types/color-name@1.1.3: - resolution: {integrity: sha512-87W6MJCKZYDhLAx/J1ikW8niMvmGRyY+rpUxWpL1cO7F8Uu5CHuQoFv+R0/L5pgNdW4jTyda42kv60uwVIPjLw==} + /@types/color-name@1.1.4: + resolution: {integrity: sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg==} dev: true /@types/color@3.0.6: @@ -22373,9 +22378,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