diff --git a/packages/@react-aria/utils/src/useFormReset.ts b/packages/@react-aria/utils/src/useFormReset.ts index c6c14398e69..1b34a79fa41 100644 --- a/packages/@react-aria/utils/src/useFormReset.ts +++ b/packages/@react-aria/utils/src/useFormReset.ts @@ -14,23 +14,50 @@ import {RefObject} from '@react-types/shared'; import {useEffect, useRef} from 'react'; import {useEffectEvent} from './useEffectEvent'; +type ResetEvent = Event & { + reactAriaReDispatched?: boolean, + reactAriaShouldReset?: boolean +}; + export function useFormReset( ref: RefObject | undefined, initialValue: T, onReset: (value: T) => void ): void { let resetValue = useRef(initialValue); - let handleReset = useEffectEvent(() => { - if (onReset) { + + /** + * Because event.stopPropagation() does not preventDefault and because we attach directly to the form element unlike React + * we need to create a new event which lets us monitor stopPropagation and preventDefault. This allows us to call onReset + * as the browser would natively. + */ + let formListener = useEffectEvent((e: ResetEvent) => { + if (e.reactAriaReDispatched || e.target !== ref?.current?.form) { + // This is the re-dispatched event. Or it's for a different form. + return; + } + if (e.reactAriaShouldReset === undefined) { + let event: ResetEvent = new Event('reset', {bubbles: true, cancelable: true}); + event.reactAriaReDispatched = true; + if (e.defaultPrevented) { + event.preventDefault(); + } + e.stopPropagation(); + e.preventDefault(); + + e.reactAriaShouldReset = e.target?.dispatchEvent(event) ?? false; + }; + if (onReset && e.reactAriaShouldReset) { onReset(resetValue.current); } }); useEffect(() => { let form = ref?.current?.form; - form?.addEventListener('reset', handleReset); + let document = form?.ownerDocument; + document?.addEventListener('reset', formListener, true); return () => { - form?.removeEventListener('reset', handleReset); + document?.removeEventListener('reset', formListener, true); }; - }, [ref, handleReset]); + }, [ref, formListener]); } diff --git a/packages/@react-aria/utils/test/useFormReset.test.tsx b/packages/@react-aria/utils/test/useFormReset.test.tsx new file mode 100644 index 00000000000..4aec20aeca6 --- /dev/null +++ b/packages/@react-aria/utils/test/useFormReset.test.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import React, {useRef} from 'react'; +import {useFormReset} from '../'; + +describe('useFormReset', () => { + it('should call onReset on reset', () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( +
+ + +
+ ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).toHaveBeenCalled(); + }); + + it('should call onReset on reset even if event is stopped', () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( + e.stopPropagation()}> + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).toHaveBeenCalled(); + }); + + it('should call every onReset on reset', () => { + const onReset1 = jest.fn(); + const onReset2 = jest.fn(); + const Form = () => { + const ref1 = useRef(null); + useFormReset(ref1, '', onReset1); + const ref2 = useRef(null); + useFormReset(ref2, '', onReset2); + return ( + + + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset1).toHaveBeenCalled(); + expect(onReset2).toHaveBeenCalled(); + }); + + it('should not call onReset if reset is cancelled', async () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( + e.preventDefault()}> + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).not.toHaveBeenCalled(); + }); + + it('should not call onReset if reset is cancelled in capture phase', async () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( + e.preventDefault()}> + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).not.toHaveBeenCalled(); + }); + + it('should not call any onReset if reset is cancelled', () => { + const onReset1 = jest.fn(); + const onReset2 = jest.fn(); + const Form = () => { + const ref1 = useRef(null); + useFormReset(ref1, '', onReset1); + const ref2 = useRef(null); + useFormReset(ref2, '', onReset2); + return ( + e.preventDefault()}> + + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset1).not.toHaveBeenCalled(); + expect(onReset2).not.toHaveBeenCalled(); + }); +});