Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: prevent multiple parent focus events on click #4274

Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tricky-panthers-build.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions packages/components/checkbox/__tests__/checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div tabIndex={-1} onFocus={onFocus}>
<Checkbox data-testid="checkbox-test">Checkbox</Checkbox>
</div>,
);

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(
<Checkbox isRequired validationBehavior="native">
Expand Down
12 changes: 11 additions & 1 deletion packages/components/checkbox/src/use-checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ export function useCheckbox(props: UseCheckboxProps = {}) {

const baseStyles = clsx(classNames?.base, className);

const mouseProps = useMemo(
() => ({
onMouseDown: (e: React.MouseEvent<HTMLLabelElement>) => {
// prevent parent from being focused
e.preventDefault();
macci001 marked this conversation as resolved.
Show resolved Hide resolved
},
}),
[],
);

const getBaseProps: PropGetter = useCallback(() => {
return {
ref: domRef,
Expand All @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/components/radio/__tests__/radio.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div tabIndex={-1} onFocus={onFocus}>
<RadioGroup defaultValue="1" label="Options">
<Radio data-testid="radio-test-1" value="1">
Option 1
</Radio>
</RadioGroup>
</div>,
);

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(
<RadioGroup isRequired label="Options" validationBehavior="native">
Expand Down
12 changes: 11 additions & 1 deletion packages/components/radio/src/use-radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ export function useRadio(props: UseRadioProps) {

const baseStyles = clsx(classNames?.base, className);

const mouseProps = useMemo(
() => ({
onMouseDown: (e: React.MouseEvent<HTMLLabelElement>) => {
// prevent parent from being focused
e.preventDefault();
},
}),
[],
);

const getBaseProps: PropGetter = useCallback(
(props = {}) => {
return {
Expand All @@ -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),
};
},
[
Expand Down
16 changes: 16 additions & 0 deletions packages/components/switch/__tests__/switch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div tabIndex={-1} onFocus={onFocus}>
<Switch data-testid="switch-test">Switch</Switch>
</div>,
);

const label = wrapper.getByTestId("switch-test");

await user.click(label);

expect(onFocus).toHaveBeenCalledTimes(1);
});
});

describe("Switch with React Hook Form", () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/components/switch/src/use-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,19 @@ export function useSwitch(originalProps: UseSwitchProps = {}) {

const baseStyles = clsx(classNames?.base, className);

const mouseProps = useMemo(
() => ({
onMouseDown: (e: React.MouseEvent<HTMLLabelElement>) => {
// 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),
Expand Down
Loading