fix(checkbox): checkbox controlled state (#2754)

* 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>
This commit is contained in:
աӄա 2024-04-17 21:43:57 +08:00 committed by GitHub
parent f728c0542c
commit cadbb30cfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 160 additions and 30 deletions

View File

@ -0,0 +1,5 @@
---
"@nextui-org/checkbox": patch
---
Fixes checkbox controlled state (#2752)

View File

@ -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"
}

View File

@ -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";
@ -170,6 +171,8 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
onValueChange,
]);
const toggleState = useToggleState(ariaCheckboxProps);
const {
inputProps,
isSelected,
@ -191,7 +194,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
useReactAriaCheckbox(
{...ariaCheckboxProps, validationBehavior: "native"},
// eslint-disable-next-line
useToggleState(ariaCheckboxProps),
toggleState,
inputRef,
);
@ -242,8 +245,6 @@ 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`
@ -251,9 +252,11 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
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) {
@ -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);
@ -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),
@ -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(
() => ({
@ -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 {

View File

@ -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,
@ -133,6 +155,14 @@ export const DefaultValue = {
},
};
export const Controlled = {
render: ControlledTemplate,
args: {
...defaultProps,
},
};
export const Horizontal = {
render: Template,

View File

@ -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";
@ -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>
);
};
@ -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,
@ -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,

31
pnpm-lock.yaml generated
View File

@ -146,7 +146,7 @@ importers:
version: 4.0.2(eslint@7.32.0)(webpack@5.91.0)
eslint-plugin-import:
specifier: ^2.26.0
version: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
version: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
eslint-plugin-jest:
specifier: ^24.3.6
version: 24.7.0(@typescript-eslint/eslint-plugin@5.62.0)(eslint@7.32.0)(typescript@4.9.5)
@ -1134,6 +1134,9 @@ importers:
'@nextui-org/shared-utils':
specifier: workspace:*
version: link:../../utilities/shared-utils
'@nextui-org/use-callback-ref':
specifier: workspace:*
version: link:../../hooks/use-callback-ref
'@nextui-org/use-safe-layout-effect':
specifier: workspace:*
version: link:../../hooks/use-safe-layout-effect
@ -1192,6 +1195,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/chip:
dependencies:
@ -15962,7 +15968,7 @@ packages:
dependencies:
confusing-browser-globals: 1.0.11
eslint: 7.32.0
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
object.assign: 4.1.5
object.entries: 1.1.8
dev: true
@ -15995,7 +16001,7 @@ packages:
dependencies:
eslint: 7.32.0
eslint-config-airbnb-base: 14.2.1(eslint-plugin-import@2.29.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@7.32.0)
eslint-plugin-react: 7.34.1(eslint@7.32.0)
eslint-plugin-react-hooks: 4.6.0(eslint@7.32.0)
@ -16018,7 +16024,7 @@ packages:
eslint: 7.32.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@7.32.0)
eslint-plugin-react: 7.34.1(eslint@7.32.0)
eslint-plugin-react-hooks: 4.6.0(eslint@7.32.0)
@ -16067,7 +16073,7 @@ packages:
confusing-browser-globals: 1.0.11
eslint: 7.32.0
eslint-plugin-flowtype: 5.10.0(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
eslint-plugin-jest: 24.7.0(@typescript-eslint/eslint-plugin@5.62.0)(eslint@7.32.0)(typescript@4.9.5)
eslint-plugin-jsx-a11y: 6.8.0(eslint@7.32.0)
eslint-plugin-react: 7.34.1(eslint@7.32.0)
@ -16108,7 +16114,7 @@ packages:
dependencies:
debug: 4.3.4
eslint: 7.32.0
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
glob: 7.2.3
is-glob: 4.0.3
resolve: 1.22.8
@ -16128,7 +16134,7 @@ packages:
enhanced-resolve: 5.16.0
eslint: 7.32.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.3
is-core-module: 2.13.1
@ -16239,7 +16245,7 @@ packages:
string-natural-compare: 3.0.1
dev: true
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0):
/eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@7.32.0):
resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
@ -23349,6 +23355,15 @@ packages:
resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
dev: true
/react-hook-form@7.51.3(react@18.2.0):
resolution: {integrity: sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==}
engines: {node: '>=12.22.0'}
peerDependencies:
react: ^18.2.0
dependencies:
react: 18.2.0
dev: true
/react-icons@4.12.0(react@18.2.0):
resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==}
peerDependencies: