diff --git a/packages/@react-aria/actiongroup/src/useActionGroupItem.ts b/packages/@react-aria/actiongroup/src/useActionGroupItem.ts index 89b73c6259e..4c999cc8803 100644 --- a/packages/@react-aria/actiongroup/src/useActionGroupItem.ts +++ b/packages/@react-aria/actiongroup/src/useActionGroupItem.ts @@ -11,9 +11,9 @@ */ import {DOMAttributes, FocusableElement} from '@react-types/shared'; -import {Key, RefObject, useEffect, useRef} from 'react'; +import {Key, RefObject, useEffect} from 'react'; import {ListState} from '@react-stately/list'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, useEffectEvent} from '@react-aria/utils'; import {PressProps} from '@react-aria/interactions'; export interface AriaActionGroupItemProps { @@ -43,18 +43,18 @@ export function useActionGroupItem(props: AriaActionGroupItemProps, state: Li } let isFocused = props.key === state.selectionManager.focusedKey; - let lastRender = useRef({isFocused, state}); - lastRender.current = {isFocused, state}; + let onRemovedWithFocus = useEffectEvent(() => { + if (isFocused) { + state.selectionManager.setFocusedKey(null); + } + }); // If the focused item is removed from the DOM, reset the focused key to null. - // eslint-disable-next-line arrow-body-style useEffect(() => { return () => { - if (lastRender.current.isFocused) { - lastRender.current.state.selectionManager.setFocusedKey(null); - } + onRemovedWithFocus(); }; - }, []); + }, [onRemovedWithFocus]); return { buttonProps: mergeProps(buttonProps, { diff --git a/packages/@react-aria/spinbutton/src/useSpinButton.ts b/packages/@react-aria/spinbutton/src/useSpinButton.ts index e02d4f99dc9..590d669ee38 100644 --- a/packages/@react-aria/spinbutton/src/useSpinButton.ts +++ b/packages/@react-aria/spinbutton/src/useSpinButton.ts @@ -15,8 +15,8 @@ import {AriaButtonProps} from '@react-types/button'; import {DOMAttributes, InputBase, RangeInputBase, Validation, ValueBase} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {useCallback, useEffect, useRef} from 'react'; -import {useGlobalListeners} from '@react-aria/utils'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; +import {useEffectEvent, useGlobalListeners} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -39,7 +39,6 @@ export interface SpinbuttonAria { export function useSpinButton( props: SpinButtonProps ): SpinbuttonAria { - const _async = useRef(); let { value, textValue, @@ -55,18 +54,17 @@ export function useSpinButton( onDecrementToMin, onIncrementToMax } = props; - const stringFormatter = useLocalizedStringFormatter(intlMessages); - const propsRef = useRef(props); - propsRef.current = props; - const clearAsync = () => clearTimeout(_async.current); + const stringFormatter = useLocalizedStringFormatter(intlMessages); - // eslint-disable-next-line arrow-body-style + const _async = useRef(); + const clearAsync = useCallback(() => clearTimeout(_async.current), [_async]); + // only run on unmount useEffect(() => { return () => clearAsync(); - }, []); + }, [clearAsync]); - let onKeyDown = (e) => { + let onKeyDown = useCallback((e) => { if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || isReadOnly) { return; } @@ -113,22 +111,24 @@ export function useSpinButton( } break; } - }; + }, [isReadOnly, onIncrementPage, onIncrement, onDecrementPage, onDecrement, onDecrementToMin, onIncrementToMax]); let isFocused = useRef(false); - let onFocus = () => { + let onFocus = useCallback(() => { isFocused.current = true; - }; + }, [isFocused]); - let onBlur = () => { + let onBlur = useCallback(() => { isFocused.current = false; - }; + }, [isFocused]); // Replace Unicode hyphen-minus (U+002D) with minus sign (U+2212). // This ensures that macOS VoiceOver announces it as "minus" even with other characters between the minus sign // and the number (e.g. currency symbol). Otherwise it announces nothing because it assumes the character is a hyphen. // In addition, replace the empty string with the word "Empty" so that iOS VoiceOver does not read "50%" for an empty field. - textValue = textValue === '' ? stringFormatter.format('Empty') : (textValue || `${value}`).replace('-', '\u2212'); + textValue = useMemo( + () => textValue === '' ? stringFormatter.format('Empty') : (textValue || `${value}`).replace('-', '\u2212') + , [textValue, stringFormatter, value]); useEffect(() => { if (isFocused.current) { @@ -137,45 +137,37 @@ export function useSpinButton( } }, [textValue]); - const onIncrementPressStart = useCallback( - (initialStepDelay: number) => { - clearAsync(); - propsRef.current.onIncrement(); - // Start spinning after initial delay - _async.current = window.setTimeout( - () => { - if (isNaN(maxValue) || isNaN(value) || value < maxValue) { - onIncrementPressStart(60); - } - }, - initialStepDelay - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [onIncrement, maxValue, value] - ); - - const onDecrementPressStart = useCallback( - (initialStepDelay: number) => { - clearAsync(); - propsRef.current.onDecrement(); - // Start spinning after initial delay - _async.current = window.setTimeout( - () => { - if (isNaN(minValue) || isNaN(value) || value > minValue) { - onDecrementPressStart(60); - } - }, - initialStepDelay - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [onDecrement, minValue, value] - ); + let onIncrementPressStart = useEffectEvent((initialStepDelay: number) => { + clearAsync(); + onIncrement(); + // Start spinning after initial delay + _async.current = window.setTimeout( + () => { + if (isNaN(maxValue) || isNaN(value) || value < maxValue) { + onIncrementPressStart(60); + } + }, + initialStepDelay + ); + }); + + let onDecrementPressStart = useEffectEvent((initialStepDelay: number) => { + clearAsync(); + onDecrement(); + // Start spinning after initial delay + _async.current = window.setTimeout( + () => { + if (isNaN(minValue) || isNaN(value) || value > minValue) { + onDecrementPressStart(60); + } + }, + initialStepDelay + ); + }); - let cancelContextMenu = (e) => { + let cancelContextMenu = useCallback((e) => { e.preventDefault(); - }; + }, []); let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); @@ -194,26 +186,26 @@ export function useSpinButton( onBlur }, incrementButtonProps: { - onPressStart: () => { + onPressStart: useCallback(() => { onIncrementPressStart(400); addGlobalListener(window, 'contextmenu', cancelContextMenu); - }, - onPressEnd: () => { + }, [onIncrementPressStart, addGlobalListener, cancelContextMenu]), + onPressEnd: useCallback(() => { clearAsync(); removeAllGlobalListeners(); - }, + }, [clearAsync, removeAllGlobalListeners]), onFocus, onBlur }, decrementButtonProps: { - onPressStart: () => { + onPressStart: useCallback(() => { onDecrementPressStart(400); addGlobalListener(window, 'contextmenu', cancelContextMenu); - }, - onPressEnd: () => { + }, [onDecrementPressStart, addGlobalListener, cancelContextMenu]), + onPressEnd: useCallback(() => { clearAsync(); removeAllGlobalListeners(); - }, + }, [clearAsync, removeAllGlobalListeners]), onFocus, onBlur } diff --git a/packages/@react-aria/utils/src/useEffectEvent.ts b/packages/@react-aria/utils/src/useEffectEvent.ts index 66ebdd2396f..f47ae391e1b 100644 --- a/packages/@react-aria/utils/src/useEffectEvent.ts +++ b/packages/@react-aria/utils/src/useEffectEvent.ts @@ -13,13 +13,13 @@ import {useCallback, useRef} from 'react'; import {useLayoutEffect} from './useLayoutEffect'; -export function useEffectEvent(fn) { +export function useEffectEvent(fn: T): T { const ref = useRef(null); useLayoutEffect(() => { ref.current = fn; }, [fn]); - return useCallback((...args) => { + return useCallback((...args) => { const f = ref.current; return f(...args); - }, []); + }, []) as T; } diff --git a/packages/@react-spectrum/actiongroup/test/ActionGroup.test.js b/packages/@react-spectrum/actiongroup/test/ActionGroup.test.js index bb1809fc212..bc46787f8c3 100644 --- a/packages/@react-spectrum/actiongroup/test/ActionGroup.test.js +++ b/packages/@react-spectrum/actiongroup/test/ActionGroup.test.js @@ -772,6 +772,41 @@ describe('ActionGroup', function () { expect(buttons[1]).toHaveAttribute('tabIndex', '0'); }); + it('moves focus if the focused button was removed', function () { + let onAction = jest.fn(); + let tree = render( + + + One + Two + Three + Four + + + ); + + let actiongroup = tree.getByRole('toolbar'); + let buttons = within(actiongroup).getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('tabIndex', '0'); + expect(buttons[1]).toHaveAttribute('tabIndex', '0'); + + act(() => buttons[2].focus()); + tree.rerender( + + + One + Two + Four + + + ); + actiongroup = tree.getByRole('toolbar'); + buttons = within(actiongroup).getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('tabIndex', '0'); + expect(buttons[1]).toHaveAttribute('tabIndex', '0'); + expect(buttons[2]).toHaveAttribute('tabIndex', '0'); + }); + it('passes aria labeling props through to menu button if it is the only child', function () { jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function () { if (this instanceof HTMLButtonElement) { diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index 90d402cab32..42aa4d5dcf8 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -280,6 +280,7 @@ describe('NumberField', function () { act(() => {textField.blur();}); expect(onChangeSpy).toHaveBeenLastCalledWith(5); expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(textField).toHaveAttribute('value', '5'); triggerPress(incrementButton); expect(onChangeSpy).toHaveBeenLastCalledWith(10); expect(onChangeSpy).toHaveBeenCalledTimes(3); @@ -881,12 +882,8 @@ describe('NumberField', function () { act(() => {textField.focus();}); textField.setSelectionRange(2, 3); userEvent.type(textField, '{backspace}'); - expect(announce).toHaveBeenCalledTimes(2); - expect(announce).toHaveBeenLastCalledWith('−$0.00', 'assertive'); textField.setSelectionRange(2, 2); typeText(textField, '1'); - expect(announce).toHaveBeenCalledTimes(3); - expect(announce).toHaveBeenLastCalledWith('−$1.00', 'assertive'); textField.setSelectionRange(3, 3); typeText(textField, '8'); expect(textField).toHaveAttribute('value', '($18.00)'); @@ -894,15 +891,11 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', '($18.00)'); expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy).toHaveBeenLastCalledWith(-18); - expect(announce).toHaveBeenCalledTimes(4); - expect(announce).toHaveBeenLastCalledWith('−$18.00', 'assertive'); act(() => {textField.focus();}); textField.setSelectionRange(7, 8); userEvent.type(textField, '{backspace}'); expect(textField).toHaveAttribute('value', '($18.00'); - expect(announce).toHaveBeenCalledTimes(5); - expect(announce).toHaveBeenLastCalledWith('$18.00', 'assertive'); act(() => {textField.blur();}); expect(textField).toHaveAttribute('value', '$18.00'); expect(onChangeSpy).toHaveBeenCalledTimes(3); @@ -910,12 +903,8 @@ describe('NumberField', function () { act(() => {textField.focus();}); userEvent.clear(textField); - expect(announce).toHaveBeenCalledTimes(6); - expect(announce).toHaveBeenLastCalledWith('Empty', 'assertive'); typeText(textField, '($32)'); expect(textField).toHaveAttribute('value', '($32)'); - expect(announce).toHaveBeenCalledTimes(9); - expect(announce).toHaveBeenLastCalledWith('−$32.00', 'assertive'); act(() => {textField.blur();}); expect(textField).toHaveAttribute('value', '($32.00)'); expect(onChangeSpy).toHaveBeenCalledTimes(4); @@ -929,10 +918,9 @@ describe('NumberField', function () { let {textField} = renderNumberField({onChange: onChangeSpy, formatOptions: {style: 'currency', currency: 'USD', currencySign: 'accounting'}}, {locale: 'ar-AE'}); act(() => {textField.focus();}); - userEvent.type(textField, '(10)'); + typeText(textField, '(10)'); expect(textField).toHaveAttribute('value', '(10)'); - expect(announce).toHaveBeenCalledTimes(3); - expect(announce).toHaveBeenLastCalledWith('؜−١٠٫٠٠ US$', 'assertive'); + expect(announce).toHaveBeenCalledTimes(0); act(() => {textField.blur();}); expect(textField).toHaveAttribute('value', '(US$10.00)'); expect(onChangeSpy).toHaveBeenCalledTimes(1); @@ -962,17 +950,11 @@ describe('NumberField', function () { }); textField.setSelectionRange(1, 2); userEvent.type(textField, '{backspace}'); - expect(announce).toHaveBeenCalledTimes(2); - expect(announce).toHaveBeenLastCalledWith('−0,00 $', 'assertive'); textField.setSelectionRange(1, 1); typeText(textField, '1'); - expect(announce).toHaveBeenCalledTimes(3); - expect(announce).toHaveBeenLastCalledWith('−1,00 $', 'assertive'); textField.setSelectionRange(2, 2); typeText(textField, '8'); expect(textField).toHaveAttribute('value', '-18,00 $'); - expect(announce).toHaveBeenCalledTimes(4); - expect(announce).toHaveBeenLastCalledWith('−18,00 $', 'assertive'); act(() => { textField.blur(); }); diff --git a/packages/@react-stately/color/src/useColorFieldState.ts b/packages/@react-stately/color/src/useColorFieldState.ts index e02ce7f1ec7..a2da83df1c3 100644 --- a/packages/@react-stately/color/src/useColorFieldState.ts +++ b/packages/@react-stately/color/src/useColorFieldState.ts @@ -12,9 +12,9 @@ import {Color, ColorFieldProps} from '@react-types/color'; import {parseColor} from './Color'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {useColor} from './useColor'; import {useControlledState} from '@react-stately/utils'; -import {useMemo, useRef, useState} from 'react'; export interface ColorFieldState { /** @@ -71,40 +71,41 @@ export function useColorFieldState( let initialValue = useColor(value); let initialDefaultValue = useColor(defaultValue); - let [colorValue, setColorValue] = useControlledState(initialValue, initialDefaultValue, onChange); - let [inputValue, setInputValue] = useState(() => (value || defaultValue) && colorValue ? colorValue.toString('hex') : ''); + let [colorValue, setColorValue] = useControlledState(initialValue, initialDefaultValue, onChange); + let [inputValue, _setInputValue] = useState(() => (value || defaultValue) && colorValue ? colorValue.toString('hex') : ''); + let [parsedColor, setParsedColor] = useState(colorValue); + let [forceSync, setForceSync] = useState(false); - let safelySetColorValue = (newColor: Color) => { - if (!colorValue || !newColor) { - setColorValue(newColor); - return; - } - if (newColor.toHexInt() !== colorValue.toHexInt()) { - setColorValue(newColor); - return; - } - }; - - let prevValue = useRef(colorValue); - if (prevValue.current !== colorValue) { - setInputValue(colorValue ? colorValue.toString('hex') : ''); - prevValue.current = colorValue; - } - - - let parsedValue = useMemo(() => { + let setInputValue = useCallback((val) => { + _setInputValue(val); let color; try { - color = parseColor(inputValue.startsWith('#') ? inputValue : `#${inputValue}`); + color = parseColor(val.startsWith('#') ? val : `#${val}`); } catch (err) { color = null; } - return color; - }, [inputValue]); - let parsed = useRef(null); - parsed.current = parsedValue; + setParsedColor(color); + }, [_setInputValue, setParsedColor]); + + let prevValue = useRef(colorValue); + useEffect(() => { + if (forceSync || prevValue.current !== colorValue) { + setInputValue(colorValue ? colorValue.toString('hex') : ''); + setForceSync(false); + prevValue.current = colorValue; + } + }, [forceSync, colorValue, setInputValue]); + + let safelySetColorValue = useCallback((newColor: Color) => { + if (!colorValue || !newColor) { + setColorValue(newColor); + } else if (newColor.toHexInt() !== colorValue.toHexInt()) { + setColorValue(newColor); + } + return newColor; + }, [colorValue, setColorValue]); - let commit = () => { + let commit = useCallback(() => { // Set to empty state if input value is empty if (!inputValue.length) { safelySetColorValue(null); @@ -113,22 +114,20 @@ export function useColorFieldState( } // if it failed to parse, then reset input to formatted version of current number - if (parsed.current == null) { + if (parsedColor == null) { setInputValue(colorValue ? colorValue.toString('hex') : ''); return; } - safelySetColorValue(parsed.current); + let newColor = safelySetColorValue(parsedColor); // in a controlled state, the numberValue won't change, so we won't go back to our old input without help - let newColorValue = ''; - if (colorValue) { - newColorValue = colorValue.toString('hex'); + if (value !== undefined || colorsAreEqual(newColor, colorValue)) { + setForceSync(true); } - setInputValue(newColorValue); - }; + }, [inputValue, safelySetColorValue, setInputValue, value, colorValue, parsedColor]); - let increment = () => { - let newValue = addColorValue(parsed.current, step); + let increment = useCallback(() => { + let newValue = addColorValue(parsedColor, step); // if we've arrived at the same value that was previously in the state, the // input value should be updated to match // ex type 4, press increment, highlight the number in the input, type 4 again, press increment @@ -137,9 +136,9 @@ export function useColorFieldState( setInputValue(newValue.toString('hex')); } safelySetColorValue(newValue); - }; - let decrement = () => { - let newValue = addColorValue(parsed.current, -step); + }, [parsedColor, safelySetColorValue, colorValue, setInputValue, step]); + let decrement = useCallback(() => { + let newValue = addColorValue(parsedColor, -step); // if we've arrived at the same value that was previously in the state, the // input value should be updated to match // ex type 4, press increment, highlight the number in the input, type 4 again, press increment @@ -148,9 +147,13 @@ export function useColorFieldState( setInputValue(newValue.toString('hex')); } safelySetColorValue(newValue); - }; - let incrementToMax = () => safelySetColorValue(MAX_COLOR); - let decrementToMin = () => safelySetColorValue(MIN_COLOR); + }, [parsedColor, safelySetColorValue, colorValue, setInputValue, step]); + let incrementToMax = useCallback(() => { + safelySetColorValue(MAX_COLOR); + }, [safelySetColorValue]); + let decrementToMin = useCallback(() => { + safelySetColorValue(MIN_COLOR); + }, [safelySetColorValue]); let validate = (value: string) => value === '' || !!value.match(/^#?[0-9a-f]{0,6}$/i)?.[0]; @@ -178,3 +181,12 @@ function addColorValue(color: Color, step: number) { } return newColor; } + +function colorsAreEqual(color1: Color, color2: Color) { + if (!color1 && !color2) { + return true; + } else if (!color1 || !color2) { + return false; + } + return color1.toHexInt() === color2.toHexInt(); +} diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts index c75ba28610d..63151e84bed 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -13,7 +13,7 @@ import {clamp, snapValueToStep, useControlledState} from '@react-stately/utils'; import {NumberFieldProps} from '@react-types/numberfield'; import {NumberFormatter, NumberParser} from '@internationalized/number'; -import {useCallback, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; export interface NumberFieldState { /** @@ -88,38 +88,44 @@ export function useNumberFieldState( } = props; let [numberValue, setNumberValue] = useControlledState(value, isNaN(defaultValue) ? NaN : defaultValue, onChange); - let [inputValue, setInputValue] = useState(() => isNaN(numberValue) ? '' : new NumberFormatter(locale, formatOptions).format(numberValue)); + let [inputValue, _setInputValue] = useState(() => isNaN(numberValue) ? '' : new NumberFormatter(locale, formatOptions).format(numberValue)); let numberParser = useMemo(() => new NumberParser(locale, formatOptions), [locale, formatOptions]); let numberingSystem = useMemo(() => numberParser.getNumberingSystem(inputValue), [numberParser, inputValue]); let formatter = useMemo(() => new NumberFormatter(locale, {...formatOptions, numberingSystem}), [locale, formatOptions, numberingSystem]); let intlOptions = useMemo(() => formatter.resolvedOptions(), [formatter]); let format = useCallback((value: number) => (isNaN(value) || value === null) ? '' : formatter.format(value), [formatter]); + let [parsedValue, setParsedValue] = useState(numberValue); + let [forceSync, setForceSync] = useState(false); let clampStep = !isNaN(step) ? step : 1; if (intlOptions.style === 'percent' && isNaN(step)) { clampStep = 0.01; } + let setInputValue = useCallback((val: string) => { + _setInputValue(val); + let num = numberParser.parse(val); + setParsedValue(num); + }, [_setInputValue, numberParser, setParsedValue]); + // Update the input value when the number value or format options change. This is done // in a useEffect so that the controlled behavior is correct and we only update the // textfield after prop changes. let prevValue = useRef(numberValue); let prevLocale = useRef(locale); let prevFormatOptions = useRef(formatOptions); - if (!Object.is(numberValue, prevValue.current) || locale !== prevLocale.current || formatOptions !== prevFormatOptions.current) { - setInputValue(format(numberValue)); - prevValue.current = numberValue; - prevLocale.current = locale; - prevFormatOptions.current = formatOptions; - } - - // Store last parsed value in a ref so it can be used by increment/decrement below - let parsedValue = useMemo(() => numberParser.parse(inputValue), [numberParser, inputValue]); - let parsed = useRef(0); - parsed.current = parsedValue; + useEffect(() => { + if (forceSync || !Object.is(numberValue, prevValue.current) || locale !== prevLocale.current || formatOptions !== prevFormatOptions.current) { + setInputValue(format(numberValue)); + setForceSync(false); + prevValue.current = numberValue; + prevLocale.current = locale; + prevFormatOptions.current = formatOptions; + } + }, [forceSync, numberValue, locale, formatOptions, setInputValue, format]); - let commit = () => { + let commit = useCallback(() => { // Set to empty state if input value is empty if (!inputValue.length) { setNumberValue(NaN); @@ -128,7 +134,7 @@ export function useNumberFieldState( } // if it failed to parse, then reset input to formatted version of current number - if (isNaN(parsed.current)) { + if (isNaN(parsedValue)) { setInputValue(format(numberValue)); return; } @@ -136,20 +142,23 @@ export function useNumberFieldState( // Clamp to min and max, round to the nearest step, and round to specified number of digits let clampedValue: number; if (isNaN(step)) { - clampedValue = clamp(parsed.current, minValue, maxValue); + clampedValue = clamp(parsedValue, minValue, maxValue); } else { - clampedValue = snapValueToStep(parsed.current, minValue, maxValue, step); + clampedValue = snapValueToStep(parsedValue, minValue, maxValue, step); } clampedValue = numberParser.parse(format(clampedValue)); + setNumberValue(clampedValue); // in a controlled state, the numberValue won't change, so we won't go back to our old input without help - setInputValue(format(value === undefined ? clampedValue : numberValue)); - }; + if (value !== undefined || clampedValue === numberValue) { + setForceSync(true); + } + }, [inputValue, setNumberValue, setInputValue, value, format, numberValue, parsedValue, step, minValue, maxValue, numberParser]); - let safeNextStep = (operation: '+' | '-', minMax: number) => { - let prev = parsed.current; + let safeNextStep = useCallback((operation: '+' | '-', minMax: number) => { + let prev = parsedValue; if (isNaN(prev)) { // if the input is empty, start from the min/max value when incrementing/decrementing, @@ -171,9 +180,9 @@ export function useNumberFieldState( clampStep ); } - }; + }, [parsedValue, minValue, maxValue, clampStep]); - let increment = () => { + let increment = useCallback(() => { let newValue = safeNextStep('+', minValue); // if we've arrived at the same value that was previously in the state, the @@ -185,9 +194,9 @@ export function useNumberFieldState( } setNumberValue(newValue); - }; + }, [safeNextStep, setInputValue, minValue, format, numberValue, setNumberValue]); - let decrement = () => { + let decrement = useCallback(() => { let newValue = safeNextStep('-', maxValue); if (newValue === numberValue) { @@ -195,19 +204,19 @@ export function useNumberFieldState( } setNumberValue(newValue); - }; + }, [safeNextStep, setInputValue, maxValue, format, numberValue, setNumberValue]); - let incrementToMax = () => { + let incrementToMax = useCallback(() => { if (maxValue != null) { setNumberValue(snapValueToStep(maxValue, minValue, maxValue, clampStep)); } - }; + }, [maxValue, setNumberValue, minValue, clampStep]); - let decrementToMin = () => { + let decrementToMin = useCallback(() => { if (minValue != null) { setNumberValue(minValue); } - }; + }, [minValue, setNumberValue]); let canIncrement = useMemo(() => ( !isDisabled && @@ -243,7 +252,7 @@ export function useNumberFieldState( canDecrement, minValue, maxValue, - numberValue: parsedValue, + numberValue, setInputValue, inputValue, commit