diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 596497a88a3..f0524a94319 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -397,6 +397,46 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(onChange).toHaveBeenLastCalledWith('c'); }); + it('should fire a native form onChange event when a radio is clicked', async () => { + let formOnChange = jest.fn(); + let onChange = jest.fn(); + let {getAllByRole} = render( +
+ + + ); + let radios = getAllByRole('radio'); + + await user.click(radios[1]); + expect(radios[1]).toBeChecked(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith('b'); + expect(formOnChange).toHaveBeenCalledTimes(1); + }); + + it('should fire a native form onChange event when selection changes via the keyboard', async () => { + let formOnChange = jest.fn(); + let onChange = jest.fn(); + let {getAllByRole} = render( +
+ + + ); + let radios = getAllByRole('radio'); + + await user.tab(); + expect(radios[0]).toHaveFocus(); + + // user-event implements its own radio-group arrow navigation, which bypasses our own code + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + + expect(radios[1]).toBeChecked(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith('b'); + expect(formOnChange).toHaveBeenCalledTimes(1); + }); + it('should support read only state', () => { let className = ({isReadOnly}) => (isReadOnly ? 'readonly' : ''); let {getByRole, getAllByRole} = renderGroup({isReadOnly: true, className}, {className}); diff --git a/packages/react-aria/src/radio/useRadio.ts b/packages/react-aria/src/radio/useRadio.ts index a00ba7e4aa3..277b91aa2b1 100644 --- a/packages/react-aria/src/radio/useRadio.ts +++ b/packages/react-aria/src/radio/useRadio.ts @@ -101,8 +101,7 @@ export function useRadio( let checked = state.selectedValue === value; - let onChange = e => { - e.stopPropagation(); + let onChange = () => { state.setSelectedValue(value); }; diff --git a/packages/react-aria/src/radio/useRadioGroup.ts b/packages/react-aria/src/radio/useRadioGroup.ts index 317d3b8b7c9..ef9ba2eec95 100644 --- a/packages/react-aria/src/radio/useRadioGroup.ts +++ b/packages/react-aria/src/radio/useRadioGroup.ts @@ -114,6 +114,20 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState // Call focus on nextElem so that keyboard navigation scrolls the radio into view nextElem.focus(); state.setSelectedValue(nextElem.value); + + // The state update above only changes the DOM `checked` property via React's controlled + // re-render — it does not dispatch any native event the way a real click does. React's own + // change detection for checkbox/radio inputs is keyed off the "click" event (a legacy + // browser-compat shim), not "change" or "input", so we replay a native click to make an + // ancestor
(or any other native DOM listener) observe keyboard-driven + // selection the same way it observes a mouse click. + let checkedSetter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(nextElem), + 'checked' + )!.set!; + checkedSetter.call(nextElem, true); + nextElem.dispatchEvent(new MouseEvent('click', {bubbles: true})); + return true; } return false;