diff --git a/.changeset/tricky-panthers-build.md b/.changeset/tricky-panthers-build.md
new file mode 100644
index 0000000000..00028f2f3e
--- /dev/null
+++ b/.changeset/tricky-panthers-build.md
@@ -0,0 +1,7 @@
+---
+"@nextui-org/checkbox": patch
+"@nextui-org/switch": patch
+"@nextui-org/radio": patch
+---
+
+fixed checkbox, radio, and switch triggering focus on focusable parent multiple times (#4260)
diff --git a/packages/components/checkbox/__tests__/checkbox.test.tsx b/packages/components/checkbox/__tests__/checkbox.test.tsx
index e88356bb57..08a42a0741 100644
--- a/packages/components/checkbox/__tests__/checkbox.test.tsx
+++ b/packages/components/checkbox/__tests__/checkbox.test.tsx
@@ -90,6 +90,22 @@ describe("Checkbox", () => {
expect(onFocus).toHaveBeenCalled();
});
+ it("should trigger focus on focusable parent once after click", async () => {
+ const onFocus = jest.fn();
+
+ const wrapper = render(
+
+ Checkbox
+
,
+ );
+
+ const label = wrapper.getByTestId("checkbox-test");
+
+ await user.click(label);
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ });
+
it("should have required attribute when isRequired with native validationBehavior", () => {
const {container} = render(
diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts
index 376541cb1d..406d1d41b1 100644
--- a/packages/components/checkbox/src/use-checkbox.ts
+++ b/packages/components/checkbox/src/use-checkbox.ts
@@ -264,6 +264,16 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
const baseStyles = clsx(classNames?.base, className);
+ const mouseProps = useMemo(
+ () => ({
+ onMouseDown: (e: React.MouseEvent) => {
+ // prevent parent from being focused
+ e.preventDefault();
+ },
+ }),
+ [],
+ );
+
const getBaseProps: PropGetter = useCallback(() => {
return {
ref: domRef,
@@ -277,7 +287,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) {
"data-readonly": dataAttr(inputProps.readOnly),
"data-focus-visible": dataAttr(isFocusVisible),
"data-indeterminate": dataAttr(isIndeterminate),
- ...mergeProps(hoverProps, otherProps),
+ ...mergeProps(hoverProps, mouseProps, otherProps),
};
}, [
slots,
diff --git a/packages/components/radio/__tests__/radio.test.tsx b/packages/components/radio/__tests__/radio.test.tsx
index 3d2d512714..07d435b381 100644
--- a/packages/components/radio/__tests__/radio.test.tsx
+++ b/packages/components/radio/__tests__/radio.test.tsx
@@ -142,6 +142,26 @@ describe("Radio", () => {
expect(onFocus).toHaveBeenCalled();
});
+ it("should trigger focus on focusable parent once after click", async () => {
+ const onFocus = jest.fn();
+
+ const wrapper = render(
+
+
+
+ Option 1
+
+
+
,
+ );
+
+ const label = wrapper.getByTestId("radio-test-1");
+
+ await user.click(label);
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ });
+
it("should have required attribute when isRequired with native validationBehavior", () => {
const {getByRole, getAllByRole} = render(
diff --git a/packages/components/radio/src/use-radio.ts b/packages/components/radio/src/use-radio.ts
index b171b1e006..c34d941d43 100644
--- a/packages/components/radio/src/use-radio.ts
+++ b/packages/components/radio/src/use-radio.ts
@@ -150,6 +150,16 @@ export function useRadio(props: UseRadioProps) {
const baseStyles = clsx(classNames?.base, className);
+ const mouseProps = useMemo(
+ () => ({
+ onMouseDown: (e: React.MouseEvent) => {
+ // prevent parent from being focused
+ e.preventDefault();
+ },
+ }),
+ [],
+ );
+
const getBaseProps: PropGetter = useCallback(
(props = {}) => {
return {
@@ -166,7 +176,7 @@ export function useRadio(props: UseRadioProps) {
"data-hover-unselected": dataAttr(isHovered && !isSelected),
"data-readonly": dataAttr(inputProps.readOnly),
"aria-required": dataAttr(isRequired),
- ...mergeProps(hoverProps, otherProps),
+ ...mergeProps(hoverProps, mouseProps, otherProps),
};
},
[
diff --git a/packages/components/switch/__tests__/switch.test.tsx b/packages/components/switch/__tests__/switch.test.tsx
index b8ae156774..ff73d60628 100644
--- a/packages/components/switch/__tests__/switch.test.tsx
+++ b/packages/components/switch/__tests__/switch.test.tsx
@@ -184,6 +184,22 @@ describe("Switch", () => {
expect(wrapper.getByTestId("start-icon")).toBeInTheDocument();
expect(wrapper.getByTestId("end-icon")).toBeInTheDocument();
});
+
+ it("should trigger focus on focusable parent once after click", async () => {
+ const onFocus = jest.fn();
+
+ const wrapper = render(
+
+ Switch
+
,
+ );
+
+ const label = wrapper.getByTestId("switch-test");
+
+ await user.click(label);
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ });
});
describe("Switch with React Hook Form", () => {
diff --git a/packages/components/switch/src/use-switch.ts b/packages/components/switch/src/use-switch.ts
index 34adb52e54..9621a87b88 100644
--- a/packages/components/switch/src/use-switch.ts
+++ b/packages/components/switch/src/use-switch.ts
@@ -178,9 +178,19 @@ export function useSwitch(originalProps: UseSwitchProps = {}) {
const baseStyles = clsx(classNames?.base, className);
+ const mouseProps = useMemo(
+ () => ({
+ onMouseDown: (e: React.MouseEvent) => {
+ // prevent parent from being focused
+ e.preventDefault();
+ },
+ }),
+ [],
+ );
+
const getBaseProps: PropGetter = (props) => {
return {
- ...mergeProps(hoverProps, otherProps, props),
+ ...mergeProps(hoverProps, mouseProps, otherProps, props),
ref: domRef,
className: slots.base({class: clsx(baseStyles, props?.className)}),
"data-disabled": dataAttr(isDisabled),