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),