diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 7dc4c0e6d01..b87ebac23b0 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -11,7 +11,7 @@ */ jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); -import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {Button} from '../src/Button'; import {FieldError} from '../src/FieldError'; @@ -21,7 +21,7 @@ import {I18nProvider} from 'react-aria/I18nProvider'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {NumberField, NumberFieldContext} from '../src/NumberField'; -import React from 'react'; +import React, {useState} from 'react'; import {Text} from '../src/Text'; import userEvent from '@testing-library/user-event'; @@ -496,4 +496,45 @@ describe('NumberField', () => { expect(input.validity.valid).toBe(true); expect(input).not.toHaveAttribute('aria-describedby'); }); + + describe('auto spinning', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + act(() => {jest.runAllTimers();}); + }); + + it.only('stops spinning if the associated button is disabled', async () => { + function NumberFieldDisabledButtons({label}) { + const [value, setValue] = useState(4); + + return ( + + + + + + + + + ); + } + let {getByRole} = render(); + let input = getByRole('textbox'); + let decrementButton = getByRole('button', {name: 'Decrease'}); + let incrementButton = getByRole('button', {name: 'Increase'}); + await user.click(incrementButton); + // manually fire these events because user.click will refuse to fire the up event if the button is disabled + fireEvent.mouseDown(decrementButton, {button: 0}); + fireEvent.mouseUp(decrementButton, {button: 0}); + await act(async () => jest.runAllTimers()); + expect(decrementButton).toBeDisabled(); + expect(input).toHaveValue('4'); + }); + }); }); diff --git a/packages/react-aria/src/interactions/usePress.ts b/packages/react-aria/src/interactions/usePress.ts index 83f768ba1a0..3a2d1317bed 100644 --- a/packages/react-aria/src/interactions/usePress.ts +++ b/packages/react-aria/src/interactions/usePress.ts @@ -196,6 +196,10 @@ export function usePress(props: PressHookProps): PressResult { pointerType: null, disposables: [] }); + let isDisabledRef = useRef(isDisabled); + useEffect(() => { + isDisabledRef.current = isDisabled; + }, [isDisabled]); let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); @@ -573,7 +577,7 @@ export function usePress(props: PressHookProps): PressResult { let clicked = false; let timeout = setTimeout(() => { if (state.isPressed && state.target instanceof HTMLElement) { - if (clicked) { + if (clicked || (state.isPressed && isDisabledRef.current)) { // eslint-disable-next-line react-hooks/rules-of-hooks cancelEvent(e); } else { @@ -704,6 +708,11 @@ export function usePress(props: PressHookProps): PressResult { if (state.target && nodeContains(state.target, getEventTarget(e) as Element) && state.pointerType != null) { // Wait for onClick to fire onPress. This avoids browser issues when the DOM // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. + if (state.isPressed && isDisabledRef.current) { + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); + return; + } } else { // eslint-disable-next-line react-hooks/rules-of-hooks cancelEvent(e);