From a9e8bc7e44d3b84006030097436b8a5156189815 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 19 Mar 2026 09:55:08 +1100 Subject: [PATCH 1/3] fix: spin button continues to spin after buttons are disabled --- .../spinbutton/src/useSpinButton.ts | 21 ++++++++++ .../stories/NumberField.stories.tsx | 21 ++++++++++ .../test/NumberField.test.js | 41 ++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/spinbutton/src/useSpinButton.ts b/packages/@react-aria/spinbutton/src/useSpinButton.ts index 3855e176c59..e40c75be587 100644 --- a/packages/@react-aria/spinbutton/src/useSpinButton.ts +++ b/packages/@react-aria/spinbutton/src/useSpinButton.ts @@ -148,10 +148,21 @@ export function useSpinButton( clearAsync(); }, [clearAsync]); + let incrementButtonRef = useRef(null); + let decrementButtonRef = useRef(null); + const onIncrementEvent = useEffectEvent(onIncrement ?? noop); const onDecrementEvent = useEffectEvent(onDecrement ?? noop); const stepUpEvent = useEffectEvent(() => { + let incrementButton = incrementButtonRef.current; + if (incrementButton) { + let ariaDisabled = incrementButton.getAttribute('aria-disabled'); + let isDisabled = incrementButton.disabled; + if (ariaDisabled === 'true' || isDisabled) { + return; + } + } if (maxValue === undefined || isNaN(maxValue) || value === undefined || isNaN(value) || value < maxValue) { onIncrementEvent(); onIncrementPressStartEvent(60); @@ -166,6 +177,14 @@ export function useSpinButton( }); const stepDownEvent = useEffectEvent(() => { + let decrementButton = decrementButtonRef.current; + if (decrementButton) { + let ariaDisabled = decrementButton.getAttribute('aria-disabled'); + let isDisabled = decrementButton.disabled; + if (ariaDisabled === 'true' || isDisabled) { + return; + } + } if (minValue === undefined || isNaN(minValue) || value === undefined || isNaN(value) || value > minValue) { onDecrementEvent(); onDecrementPressStartEvent(60); @@ -225,6 +244,7 @@ export function useSpinButton( }, incrementButtonProps: { onPressStart: (e) => { + incrementButtonRef.current = e.target as HTMLButtonElement; clearAsync(); if (e.pointerType !== 'touch') { onIncrement?.(); @@ -261,6 +281,7 @@ export function useSpinButton( }, decrementButtonProps: { onPressStart: (e) => { + decrementButtonRef.current = e.target as HTMLButtonElement; clearAsync(); if (e.pointerType !== 'touch') { onDecrement?.(); diff --git a/packages/react-aria-components/stories/NumberField.stories.tsx b/packages/react-aria-components/stories/NumberField.stories.tsx index 1139db3641b..e59b8527e99 100644 --- a/packages/react-aria-components/stories/NumberField.stories.tsx +++ b/packages/react-aria-components/stories/NumberField.stories.tsx @@ -12,6 +12,8 @@ import {Button, FieldError, Group, I18nProvider, Input, Label, NumberField, NumberFieldProps} from 'react-aria-components'; import {Meta, StoryObj} from '@storybook/react'; +import Minus from 'lucide-react/dist/esm/icons/minus'; +import Plus from 'lucide-react/dist/esm/icons/plus'; import React, {useState} from 'react'; import './styles.css'; @@ -94,3 +96,22 @@ export const ArabicNumberFieldExample = { ) }; + +export function Test() { + const [value, setValue] = useState(4); + + return ( + + + + + + + + + ); +} diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 1da1aafcc0a..b815c228e2f 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -14,7 +14,7 @@ jest.mock('@react-aria/live-announcer'); import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; import {Button, FieldError, Form, Group, I18nProvider, Input, Label, NumberField, NumberFieldContext, Text} from '../'; -import React from 'react'; +import React, { useState } from 'react'; import userEvent from '@testing-library/user-event'; let TestNumberField = (props) => ( @@ -488,4 +488,43 @@ 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('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); + await user.click(decrementButton); + await act(async () => jest.runAllTimers()); + expect(decrementButton).toBeDisabled(); + expect(input).toHaveValue('4'); + }); + }); }); From 451b5cfb31120b4212f48d00edfbbe01e437d912 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 19 Mar 2026 09:55:45 +1100 Subject: [PATCH 2/3] remove extra story, it's covered by a test --- .../stories/NumberField.stories.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/react-aria-components/stories/NumberField.stories.tsx b/packages/react-aria-components/stories/NumberField.stories.tsx index e59b8527e99..d9e413e3a6f 100644 --- a/packages/react-aria-components/stories/NumberField.stories.tsx +++ b/packages/react-aria-components/stories/NumberField.stories.tsx @@ -96,22 +96,3 @@ export const ArabicNumberFieldExample = { ) }; - -export function Test() { - const [value, setValue] = useState(4); - - return ( - - - - - - - - - ); -} From 3a71e0e3f90fd0dc99ffc0ce9ca7963570400447 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 19 Mar 2026 10:57:25 +1100 Subject: [PATCH 3/3] Alternative implementation --- .../@react-aria/interactions/src/usePress.ts | 11 +++++++++- .../spinbutton/src/useSpinButton.ts | 21 ------------------- .../test/NumberField.test.js | 8 ++++--- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index b24a383df12..a77e00b63e9 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -200,6 +200,10 @@ export function usePress(props: PressHookProps): PressResult { pointerType: null, disposables: [] }); + let isDisabledRef = useRef(isDisabled); + useEffect(() => { + isDisabledRef.current = isDisabled; + }, [isDisabled]); let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); @@ -577,7 +581,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 { @@ -708,6 +712,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); diff --git a/packages/@react-aria/spinbutton/src/useSpinButton.ts b/packages/@react-aria/spinbutton/src/useSpinButton.ts index e40c75be587..3855e176c59 100644 --- a/packages/@react-aria/spinbutton/src/useSpinButton.ts +++ b/packages/@react-aria/spinbutton/src/useSpinButton.ts @@ -148,21 +148,10 @@ export function useSpinButton( clearAsync(); }, [clearAsync]); - let incrementButtonRef = useRef(null); - let decrementButtonRef = useRef(null); - const onIncrementEvent = useEffectEvent(onIncrement ?? noop); const onDecrementEvent = useEffectEvent(onDecrement ?? noop); const stepUpEvent = useEffectEvent(() => { - let incrementButton = incrementButtonRef.current; - if (incrementButton) { - let ariaDisabled = incrementButton.getAttribute('aria-disabled'); - let isDisabled = incrementButton.disabled; - if (ariaDisabled === 'true' || isDisabled) { - return; - } - } if (maxValue === undefined || isNaN(maxValue) || value === undefined || isNaN(value) || value < maxValue) { onIncrementEvent(); onIncrementPressStartEvent(60); @@ -177,14 +166,6 @@ export function useSpinButton( }); const stepDownEvent = useEffectEvent(() => { - let decrementButton = decrementButtonRef.current; - if (decrementButton) { - let ariaDisabled = decrementButton.getAttribute('aria-disabled'); - let isDisabled = decrementButton.disabled; - if (ariaDisabled === 'true' || isDisabled) { - return; - } - } if (minValue === undefined || isNaN(minValue) || value === undefined || isNaN(value) || value > minValue) { onDecrementEvent(); onDecrementPressStartEvent(60); @@ -244,7 +225,6 @@ export function useSpinButton( }, incrementButtonProps: { onPressStart: (e) => { - incrementButtonRef.current = e.target as HTMLButtonElement; clearAsync(); if (e.pointerType !== 'touch') { onIncrement?.(); @@ -281,7 +261,6 @@ export function useSpinButton( }, decrementButtonProps: { onPressStart: (e) => { - decrementButtonRef.current = e.target as HTMLButtonElement; clearAsync(); if (e.pointerType !== 'touch') { onDecrement?.(); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index b815c228e2f..42f21bea8ad 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/live-announcer'); -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/live-announcer'; import {Button, FieldError, Form, Group, I18nProvider, Input, Label, NumberField, NumberFieldContext, Text} from '../'; import React, { useState } from 'react'; @@ -497,7 +497,7 @@ describe('NumberField', () => { act(() => {jest.runAllTimers();}); }); - it('stops spinning if the associated button is disabled', async () => { + it.only('stops spinning if the associated button is disabled', async () => { function NumberFieldDisabledButtons({label}) { const [value, setValue] = useState(4); @@ -521,7 +521,9 @@ describe('NumberField', () => { let decrementButton = getByRole('button', {name: 'Decrease'}); let incrementButton = getByRole('button', {name: 'Increase'}); await user.click(incrementButton); - await user.click(decrementButton); + // 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');