From e013394845bd85da491028f261f146ef0c8ea91d Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 6 Feb 2026 11:37:21 -0600 Subject: [PATCH 1/3] feat: numberfield announce pasted value (#8604) * feat: numberfield announce pasted value * fix tests * fix lint * add controlled tests * fix lint for real * fix lint * use better blur announcement and fix paste behaviour not to parse out of turn * fix lint * fix merge * remove obsolete code comment --- packages/@react-aria/numberfield/package.json | 1 + .../numberfield/src/useNumberField.ts | 38 +++++++++++++++++-- .../numberfield/test/NumberField.test.js | 8 +++- .../numberfield/src/useNumberFieldState.ts | 18 ++++++--- .../test/NumberField.test.js | 38 +++++++++++++++++++ yarn.lock | 1 + 6 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/@react-aria/numberfield/package.json b/packages/@react-aria/numberfield/package.json index 0249536233f..eeb40fff6cc 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.15", "@react-aria/interactions": "^3.27.0", + "@react-aria/live-announcer": "^3.4.4", "@react-aria/spinbutton": "^3.7.1", "@react-aria/textfield": "^3.18.4", "@react-aria/utils": "^3.33.0", diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 670d79d69e0..5c117bc7767 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,22 @@ 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(); + }); + + 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 +195,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 +254,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 b4d536036bf..ae7fb7c4130 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'; @@ -212,4 +214,40 @@ describe('NumberField', () => { expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledWith(1024); }); + + 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 32a4f9f2661..54984eab68a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5877,6 +5877,7 @@ __metadata: dependencies: "@react-aria/i18n": "npm:^3.12.15" "@react-aria/interactions": "npm:^3.27.0" + "@react-aria/live-announcer": "npm:^3.4.4" "@react-aria/spinbutton": "npm:^3.7.1" "@react-aria/textfield": "npm:^3.18.4" "@react-aria/utils": "npm:^3.33.0" From 64e60eb8e22c6a47f075c922c9c10113cab0353d Mon Sep 17 00:00:00 2001 From: Rob Hannay Date: Fri, 6 Feb 2026 18:05:04 +0000 Subject: [PATCH 2/3] fix: export HeaderProps type from react-aria-components (#9613) Co-authored-by: Claude Opus 4.5 --- packages/react-aria-components/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index fe3d37dfe97..080121a6d9c 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -111,6 +111,7 @@ export type {FileTriggerProps} from './FileTrigger'; export type {FormProps} from './Form'; export type {GridListProps, GridListRenderProps, GridListItemProps, GridListItemRenderProps, GridListLoadMoreItemProps, GridListSectionProps} from './GridList'; export type {GroupProps, GroupRenderProps} from './Group'; +export type {HeaderProps} from './Header'; export type {HeadingProps} from './Heading'; export type {InputProps, InputRenderProps} from './Input'; export type {SectionProps, CollectionRenderer} from './Collection'; From 9d28c3330d3920dd65e638f87fc3176643fb9ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Eorvaldur=20Tumi=20Baldursson?= Date: Fri, 6 Feb 2026 18:37:06 +0000 Subject: [PATCH 3/3] fix: add date picker placeholder for Icelandic (#9600) --- packages/@react-stately/datepicker/src/placeholders.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-stately/datepicker/src/placeholders.ts b/packages/@react-stately/datepicker/src/placeholders.ts index a85c25e31a9..8564a91b2e8 100644 --- a/packages/@react-stately/datepicker/src/placeholders.ts +++ b/packages/@react-stately/datepicker/src/placeholders.ts @@ -56,6 +56,7 @@ const placeholders = new LocalizedStringDictionary({ hu: {year: 'éééé', month: 'hh', day: 'nn'}, ia: {year: 'aaaa', month: 'mm', day: 'dd'}, id: {year: 'tttt', month: 'bb', day: 'hh'}, + is: {year: 'áááá', month: 'mm', day: 'dd'}, it: {year: 'aaaa', month: 'mm', day: 'gg'}, ja: {year: '年', month: '月', day: '日'}, ka: {year: 'წწწწ', month: 'თთ', day: 'რრ'},