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