Skip to content

Commit

Permalink
fix(checkbox): checkbox controlled state (#2754)
Browse files Browse the repository at this point in the history
* fix(checkbox): checkbox controlled state

* feat(checkbox): add @nextui-org/use-callback-ref

* chore(deps): pnpm-lock.yaml

* fix(checkbox): handle checkbox group

* fix(checkbox): rely on react aria logic (#2760)

* fix(checkbox): add missing dependency in useCheckbox hook

---------

Co-authored-by: Junior Garcia <jrgarciadev@gmail.com>
  • Loading branch information
wingkwong and jrgarciadev committed Apr 17, 2024
1 parent f728c05 commit cadbb30
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/wicked-bananas-shave.md
@@ -0,0 +1,5 @@
---
"@nextui-org/checkbox": patch
---

Fixes checkbox controlled state (#2752)
22 changes: 12 additions & 10 deletions packages/components/checkbox/package.json
Expand Up @@ -34,35 +34,37 @@
"postpack": "clean-package restore"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"@nextui-org/system": ">=2.0.0",
"@nextui-org/theme": ">=2.1.0",
"@nextui-org/system": ">=2.0.0"
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/react-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/use-callback-ref": "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",
"@react-aria/utils": "^3.23.2",
"@react-aria/visually-hidden": "^3.8.10",
"@react-stately/checkbox": "^3.6.3",
"@react-stately/toggle": "^3.7.2",
"@react-aria/utils": "^3.23.2",
"@react-types/checkbox": "^3.7.1",
"@react-types/shared": "^3.22.1"
},
"devDependencies": {
"@nextui-org/theme": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/chip": "workspace:*",
"@nextui-org/user": "workspace:*",
"@nextui-org/link": "workspace:*",
"@nextui-org/shared-icons": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/user": "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"
}
27 changes: 15 additions & 12 deletions packages/components/checkbox/src/use-checkbox.ts
Expand Up @@ -6,6 +6,7 @@ import {ReactNode, Ref, useCallback, useId, useState} from "react";
import {useMemo, useRef} from "react";
import {useToggleState} from "@react-stately/toggle";
import {checkbox} from "@nextui-org/theme";
import {useCallbackRef} from "@nextui-org/use-callback-ref";
import {useHover, usePress} from "@react-aria/interactions";
import {useFocusRing} from "@react-aria/focus";
import {mergeProps, chain} from "@react-aria/utils";
Expand Down Expand Up @@ -170,6 +171,8 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
onValueChange,
]);

const toggleState = useToggleState(ariaCheckboxProps);

const {
inputProps,
isSelected,
Expand All @@ -191,7 +194,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
useReactAriaCheckbox(
{...ariaCheckboxProps, validationBehavior: "native"},
// eslint-disable-next-line
useToggleState(ariaCheckboxProps),
toggleState,
inputRef,
);

Expand Down Expand Up @@ -242,18 +245,18 @@ 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);
toggleState.setSelected(isInputRefChecked);
}, [inputRef.current]);

const onChangeProp = useCallbackRef(onChange);

const handleCheckboxChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (isReadOnly || isDisabled) {
Expand All @@ -262,9 +265,9 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
return;
}

setIsChecked(!isChecked);
onChangeProp?.(event);
},
[isReadOnly, isDisabled, isChecked],
[isReadOnly, isDisabled, onChangeProp],
);

const baseStyles = clsx(classNames?.base, className);
Expand All @@ -274,7 +277,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
ref: domRef,
className: slots.base({class: baseStyles}),
"data-disabled": dataAttr(isDisabled),
"data-selected": dataAttr(isSelected || isIndeterminate || isChecked),
"data-selected": dataAttr(isSelected || isIndeterminate),
"data-invalid": dataAttr(isInvalid),
"data-hover": dataAttr(isHovered),
"data-focus": dataAttr(isFocused),
Expand Down Expand Up @@ -315,10 +318,10 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
const getInputProps: PropGetter = useCallback(() => {
return {
ref: mergeRefs(inputRef, ref),
...mergeProps(inputProps, focusProps, {checked: isChecked}),
onChange: chain(inputProps.onChange, onChange, handleCheckboxChange),
...mergeProps(inputProps, focusProps),
onChange: chain(inputProps.onChange, handleCheckboxChange),
};
}, [inputProps, focusProps, onChange, handleCheckboxChange]);
}, [inputProps, focusProps, handleCheckboxChange]);

const getLabelProps: PropGetter = useCallback(
() => ({
Expand All @@ -331,12 +334,12 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
const getIconProps = useCallback(
() =>
({
isSelected: isSelected || isChecked,
isSelected: isSelected,
isIndeterminate: !!isIndeterminate,
disableAnimation: !!disableAnimation,
className: slots.icon({class: classNames?.icon}),
} as CheckboxIconProps),
[slots, classNames?.icon, isSelected, isIndeterminate, disableAnimation, isChecked],
[slots, classNames?.icon, isSelected, isIndeterminate, disableAnimation],
);

return {
Expand Down
30 changes: 30 additions & 0 deletions packages/components/checkbox/stories/checkbox-group.stories.tsx
Expand Up @@ -107,6 +107,28 @@ const FormTemplate = (args: CheckboxGroupProps) => {
);
};

const ControlledTemplate = (args: CheckboxGroupProps) => {
const [selected, setSelected] = React.useState<string[]>(["buenos-aires"]);

React.useEffect(() => {
// eslint-disable-next-line no-console
console.log("Checkbox ", selected);
}, [selected]);

return (
<div className="flex flex-col gap-2">
<CheckboxGroup {...args} label="Select cities" value={selected} onValueChange={setSelected}>
<Checkbox value="buenos-aires">Buenos Aires</Checkbox>
<Checkbox value="sydney">Sydney</Checkbox>
<Checkbox value="san-francisco">San Francisco</Checkbox>
<Checkbox value="london">London</Checkbox>
<Checkbox value="tokyo">Tokyo</Checkbox>
</CheckboxGroup>
<p className="text-default-500">Selected: {selected.join(", ")}</p>
</div>
);
};

export const Default = {
render: Template,

Expand All @@ -133,6 +155,14 @@ export const DefaultValue = {
},
};

export const Controlled = {
render: ControlledTemplate,

args: {
...defaultProps,
},
};

export const Horizontal = {
render: Template,

Expand Down
75 changes: 75 additions & 0 deletions packages/components/checkbox/stories/checkbox.stories.tsx
Expand Up @@ -3,6 +3,7 @@ import {Meta} from "@storybook/react";
import {checkbox} from "@nextui-org/theme";
import {CloseIcon} from "@nextui-org/shared-icons";
import {button} from "@nextui-org/theme";
import {useForm} from "react-hook-form";

import {Checkbox, CheckboxIconProps, CheckboxProps} from "../src";

Expand Down Expand Up @@ -60,6 +61,7 @@ const ControlledTemplate = (args: CheckboxProps) => {
Subscribe (controlled)
</Checkbox>
<p className="text-default-500">Selected: {selected ? "true" : "false"}</p>
<button onClick={() => setSelected(!selected)}>Toggle</button>
</div>
);
};
Expand All @@ -83,6 +85,63 @@ const FormTemplate = (args: CheckboxProps) => {
);
};

const GroupTemplate = (args: CheckboxProps) => {
const items = ["Apple", "Banana", "Orange", "Mango"];

const [selectedItems, setSelectedItems] = React.useState<string[]>([]);

const isSelected = (value: string) => {
return selectedItems.some((selected) => selected === value);
};

const handleValueChange = (value: string) => {
setSelectedItems([value]);
};

return (
<div className="text-white flex flex-col gap-2">
<h2>List of Fruits</h2>

{items.map((item, index) => (
<Checkbox
{...args}
key={index}
className="text-white"
color="primary"
isSelected={isSelected(item)}
onValueChange={() => handleValueChange(item)}
>
{item} {isSelected(item) ? "/ state: true" : "/ state: false"}
</Checkbox>
))}
</div>
);
};

const WithReactHookFormTemplate = (args: CheckboxProps) => {
const {
register,
formState: {errors},
handleSubmit,
} = useForm();

const onSubmit = (data: any) => {
// eslint-disable-next-line no-console
console.log(data);
alert("Submitted value: " + data.example);
};

return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<Checkbox {...args} {...register("example", {required: true})} />
{errors.example && <span className="text-danger">This field is required</span>}
<button className={button({class: "w-fit"})} type="submit">
Submit
</button>
</form>
);
};

export const Default = {
args: {
...defaultProps,
Expand Down Expand Up @@ -110,6 +169,22 @@ export const CustomIconNode = {
},
};

export const Group = {
render: GroupTemplate,

args: {
...defaultProps,
},
};

export const WithReactHookForm = {
render: WithReactHookFormTemplate,

args: {
...defaultProps,
},
};

export const CustomIconFunction = {
args: {
...defaultProps,
Expand Down

0 comments on commit cadbb30

Please sign in to comment.