diff --git a/packages/@react-aria/numberfield/package.json b/packages/@react-aria/numberfield/package.json index d04a2411fd3..dbd1bea32a9 100644 --- a/packages/@react-aria/numberfield/package.json +++ b/packages/@react-aria/numberfield/package.json @@ -28,6 +28,7 @@ "dependencies": { "@react-aria/i18n": "^3.12.11", "@react-aria/interactions": "^3.25.4", + "@react-aria/live-announcer": "^3.4.4", "@react-aria/spinbutton": "^3.6.17", "@react-aria/textfield": "^3.18.0", "@react-aria/utils": "^3.30.0", diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 08da12b97a5..353b9d68677 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -10,11 +10,13 @@ * governing permissions and limitations under the License. */ +import {announce} from '@react-aria/live-announcer'; import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; import {chain, filterDOMProps, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; -import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import { + type ClipboardEvent, + type ClipboardEventHandler, InputHTMLAttributes, LabelHTMLAttributes, RefObject, @@ -22,6 +24,8 @@ import { useMemo, useState } from 'react'; +import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; +import {flushSync} from 'react-dom'; // @ts-ignore import intlMessages from '../intl/*.json'; import {NumberFieldState} from '@react-stately/numberfield'; @@ -91,12 +95,24 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt } = state; const stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/numberfield'); + let commitAndAnnounce = useCallback(() => { + let oldValue = inputRef.current?.value ?? ''; + // Set input value to normalized valid value + flushSync(() => { + commit(); + }); + // Note: this announcement will be skipped if the user is keyboard navigating to a new + // focusable element because that target will be announced instead, even though this is + // assertive. This is expected VO behaviour. + if (inputRef.current?.value !== oldValue) { + announce(inputRef.current?.value ?? '', 'assertive'); + } + }, [commit, inputRef]); let inputId = useId(id); let {focusProps} = useFocus({ onBlur() { - // Set input value to normalized valid value - commit(); + commitAndAnnounce(); } }); @@ -181,6 +197,23 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt } }; + let onPaste: ClipboardEventHandler = (e: ClipboardEvent) => { + props.onPaste?.(e); + let inputElement = e.target as HTMLInputElement; + // we can only handle the case where the paste takes over the entire input, otherwise things get very complicated + // trying to calculate the new string based on what the paste is replacing and where in the source string it is + if (inputElement && + ((inputElement.selectionEnd ?? -1) - (inputElement.selectionStart ?? 0)) === inputElement.value.length + ) { + e.preventDefault(); + // commit so that the user gets to see what it formats to immediately + // paste happens before inputRef's value is updated, so have to prevent the default and do it ourselves + // spin button will then handle announcing the new value, this should work with controlled state as well + // because the announcement is done as a result of the new rendered input value if there is one + commit(e.clipboardData?.getData?.('text/plain')?.trim() ?? ''); + } + }; + let domProps = filterDOMProps(props); let onKeyDownEnter = useCallback((e) => { if (e.nativeEvent.isComposing) { @@ -223,6 +256,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt onFocusChange, onKeyDown: useMemo(() => chain(onKeyDownEnter, onKeyDown), [onKeyDownEnter, onKeyDown]), onKeyUp, + onPaste, description, errorMessage }, state, inputRef); diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index c5574309aec..17395b00e83 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -925,17 +925,21 @@ describe('NumberField', function () { expect(announce).toHaveBeenCalledTimes(5); expect(announce).toHaveBeenLastCalledWith('$18.00', 'assertive'); act(() => {textField.blur();}); + expect(announce).toHaveBeenCalledTimes(6); + expect(announce).toHaveBeenLastCalledWith('$18.00', 'assertive'); expect(textField).toHaveAttribute('value', '$18.00'); expect(onChangeSpy).toHaveBeenCalledTimes(3); expect(onChangeSpy).toHaveBeenLastCalledWith(18); act(() => {textField.focus();}); await user.clear(textField); - expect(announce).toHaveBeenCalledTimes(6); + expect(announce).toHaveBeenCalledTimes(7); expect(announce).toHaveBeenLastCalledWith('Empty', 'assertive'); await user.keyboard('($32)'); expect(textField).toHaveAttribute('value', '($32)'); - expect(announce).toHaveBeenCalledTimes(9); + expect(announce).toHaveBeenNthCalledWith(8, '$3.00', 'assertive'); + expect(announce).toHaveBeenNthCalledWith(9, '$32.00', 'assertive'); + expect(announce).toHaveBeenCalledTimes(10); expect(announce).toHaveBeenLastCalledWith('−$32.00', 'assertive'); act(() => {textField.blur();}); expect(textField).toHaveAttribute('value', '($32.00)'); diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts index 9902a739c6a..5bc374a4313 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -52,8 +52,9 @@ export interface NumberFieldState extends FormValidationState { * to the minimum and maximum values of the field, and snapped to the nearest step value. * This will fire the `onChange` prop with the new value, and if uncontrolled, update the `numberValue`. * Typically this is called when the field is blurred. + * @param value - The value to commit. If not provided, the current input value is used. */ - commit(): void, + commit(value?: string): void, /** Increments the current input value to the next step boundary, and fires `onChange`. */ increment(): void, /** Decrements the current input value to the next step boundary, and fires `onChange`. */ @@ -146,16 +147,21 @@ export function useNumberFieldState( } let parsedValue = useMemo(() => numberParser.parse(inputValue), [numberParser, inputValue]); - let commit = () => { + let commit = (overrideValue?: string) => { + let newInputValue = overrideValue === undefined ? inputValue : overrideValue; + let newParsedValue = parsedValue; + if (overrideValue !== undefined) { + newParsedValue = numberParser.parse(newInputValue); + } // Set to empty state if input value is empty - if (!inputValue.length) { + if (!newInputValue.length) { setNumberValue(NaN); setInputValue(value === undefined ? '' : format(numberValue)); return; } // if it failed to parse, then reset input to formatted version of current number - if (isNaN(parsedValue)) { + if (isNaN(newParsedValue)) { setInputValue(format(numberValue)); return; } @@ -163,9 +169,9 @@ export function useNumberFieldState( // Clamp to min and max, round to the nearest step, and round to specified number of digits let clampedValue: number; if (step === undefined || isNaN(step)) { - clampedValue = clamp(parsedValue, minValue, maxValue); + clampedValue = clamp(newParsedValue, minValue, maxValue); } else { - clampedValue = snapValueToStep(parsedValue, minValue, maxValue, step); + clampedValue = snapValueToStep(newParsedValue, minValue, maxValue, step); } clampedValue = numberParser.parse(format(clampedValue)); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 14c3e0db770..100220e4fff 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -10,7 +10,9 @@ * governing permissions and limitations under the License. */ +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, Group, Input, Label, NumberField, NumberFieldContext, Text} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -183,4 +185,40 @@ describe('NumberField', () => { expect(input).not.toHaveAttribute('aria-describedby'); expect(numberfield).not.toHaveAttribute('data-invalid'); }); + + it('supports onChange', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + await user.tab(); + await user.clear(input); + await user.keyboard('1024'); + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledWith(1024); + }); + + it('should support pasting into a format', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + await user.tab(); + await user.clear(input); + await user.paste('1,024'); + expect(input).toHaveValue('$1,024.00'); + expect(announce).toHaveBeenCalledTimes(2); + expect(announce).toHaveBeenLastCalledWith('$1,024.00', 'assertive'); + expect(onChange).toHaveBeenCalledWith(1024); + }); + + it('should not change the input value if the new value is not accepted', async () => { + let {getByRole} = render(); + let input = getByRole('textbox'); + await user.tab(); + await user.clear(input); + await user.paste('1024'); + expect(input).toHaveValue('200'); + expect(announce).toHaveBeenLastCalledWith('200', 'assertive'); + await user.keyboard('{Enter}'); + expect(input).toHaveValue('200'); + }); }); diff --git a/yarn.lock b/yarn.lock index a1a89e85aac..e9301b27ad5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5890,6 +5890,7 @@ __metadata: dependencies: "@react-aria/i18n": "npm:^3.12.11" "@react-aria/interactions": "npm:^3.25.4" + "@react-aria/live-announcer": "npm:^3.4.4" "@react-aria/spinbutton": "npm:^3.6.17" "@react-aria/textfield": "npm:^3.18.0" "@react-aria/utils": "npm:^3.30.0"