diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 06e5d42e526..427017983c2 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -13,7 +13,7 @@ import {announce} from '@react-aria/live-announcer'; import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; -import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId, useLayoutEffect} from '@react-aria/utils'; import { type ClipboardEvent, type ClipboardEventHandler, @@ -262,6 +262,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt }, state, inputRef); useFormReset(inputRef, state.defaultNumberValue, state.setNumberValue); + useNativeValidation(state, props.validationBehavior, props.commitBehavior, inputRef, state.minValue, state.maxValue, props.step, state.numberValue); let inputProps: InputHTMLAttributes = mergeProps( spinButtonProps, @@ -363,3 +364,69 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt validationDetails }; } + +let numberInput: HTMLInputElement | null = null; + +function useNativeValidation( + state: NumberFieldState, + validationBehavior: 'native' | 'aria' | undefined, + commitBehavior: 'snap' | 'validate' | undefined, + inputRef: RefObject, + min: number | undefined, + max: number | undefined, + step: number | undefined, + value: number | undefined +) { + useLayoutEffect(() => { + let input = inputRef.current; + if (commitBehavior !== 'validate' || state.realtimeValidation.isInvalid || !input || input.disabled) { + return; + } + + // Create a native number input and use it to implement validation of min/max/step. + // This lets us get the native validation message provided by the browser instead of needing our own translations. + if (!numberInput && typeof document !== 'undefined') { + numberInput = document.createElement('input'); + numberInput.type = 'number'; + } + + if (!numberInput) { + // For TypeScript. + return; + } + + numberInput.min = min != null && !isNaN(min) ? String(min) : ''; + numberInput.max = max != null && !isNaN(max) ? String(max) : ''; + numberInput.step = step != null && !isNaN(step) ? String(step) : ''; + numberInput.value = value != null && !isNaN(value) ? String(value) : ''; + + // Merge validity with the visible text input (for other validations like required). + let valid = input.validity.valid && numberInput.validity.valid; + let validationMessage = input.validationMessage || numberInput.validationMessage; + let validity = { + isInvalid: !valid, + validationErrors: validationMessage ? [validationMessage] : [], + validationDetails: { + badInput: input.validity.badInput, + customError: input.validity.customError, + patternMismatch: input.validity.patternMismatch, + rangeOverflow: numberInput.validity.rangeOverflow, + rangeUnderflow: numberInput.validity.rangeUnderflow, + stepMismatch: numberInput.validity.stepMismatch, + tooLong: input.validity.tooLong, + tooShort: input.validity.tooShort, + typeMismatch: input.validity.typeMismatch, + valueMissing: input.validity.valueMissing, + valid + } + }; + + state.updateValidation(validity); + + // Block form submission if validation behavior is native. + // This won't overwrite any user-defined validation message because we checked realtimeValidation above. + if (validationBehavior === 'native' && !numberInput.validity.valid) { + input.setCustomValidity(numberInput.validationMessage); + } + }); +} diff --git a/packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx b/packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx index 0703e37be51..3531c828c51 100644 --- a/packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx +++ b/packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx @@ -207,6 +207,12 @@ Step3WithMin2Max21.story = { name: 'step = 3 with min = 2, max = 21' }; +export const InteractOutsideBehaviorNone: NumberFieldStory = () => render({step: 3, minValue: 2, maxValue: 21, commitBehavior: 'validate'}); + +InteractOutsideBehaviorNone.story = { + name: 'commitBehavior = validate' +}; + export const AutoFocus: NumberFieldStory = () => render({autoFocus: true}); AutoFocus.story = { diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index c3941a229a1..707e75ead51 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -335,6 +335,29 @@ describe('NumberField', function () { expect(container).not.toHaveAttribute('aria-invalid'); }); + it.each` + Name + ${'NumberField'} + `('$Name will allow typing of a number less than the min when value snapping is disabled', async () => { + let { + container, + textField + } = renderNumberField({onChange: onChangeSpy, minValue: 10, commitBehavior: 'validate'}); + + expect(container).not.toHaveAttribute('aria-invalid'); + + act(() => {textField.focus();}); + await user.clear(textField); + await user.keyboard('5'); + expect(onChangeSpy).toHaveBeenCalledTimes(0); + expect(textField).toHaveAttribute('value', '5'); + act(() => {textField.blur();}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenCalledWith(5); + expect(textField).toHaveAttribute('value', '5'); + expect(container).toHaveAttribute('aria-invalid', 'true'); + }); + it.each` Name ${'NumberField'} @@ -383,6 +406,38 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', '1'); }); + it.each` + Name + ${'NumberField'} + `('$Name will allow typing of a number greater than the max when value snapping is disabled', async () => { + let { + container, + textField + } = renderNumberField({onChange: onChangeSpy, maxValue: 1, defaultValue: 0, commitBehavior: 'validate'}); + + expect(container).not.toHaveAttribute('aria-invalid'); + + act(() => {textField.focus();}); + await user.keyboard('2'); + expect(onChangeSpy).not.toHaveBeenCalled(); + act(() => {textField.blur();}); + expect(onChangeSpy).toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenCalledWith(2); + expect(textField).toHaveAttribute('value', '2'); + + expect(container).toHaveAttribute('aria-invalid', 'true'); + + onChangeSpy.mockReset(); + act(() => {textField.focus();}); + await user.keyboard('2'); + expect(onChangeSpy).not.toHaveBeenCalled(); + act(() => {textField.blur();}); + expect(onChangeSpy).toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenCalledWith(22); + expect(textField).toHaveAttribute('value', '22'); + expect(container).toHaveAttribute('aria-invalid', 'true'); + }); + it.each` Name ${'NumberField'} @@ -772,6 +827,21 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('value', result); }); + it.each` + Name | value + ${'NumberField down positive'} | ${'6'} + ${'NumberField up positive'} | ${'8'} + ${'NumberField down negative'} | ${'-8'} + ${'NumberField up negative'} | ${'-6'} + `('$Name does not round to step on commit when value snapping is disabled', async ({value}) => { + let {textField} = renderNumberField({onChange: onChangeSpy, step: 5, commitBehavior: 'validate'}); + act(() => {textField.focus();}); + await user.keyboard(value); + act(() => {textField.blur();}); + expect(textField).toHaveAttribute('value', value); + expect(textField).toHaveAttribute('aria-invalid', 'true'); + }); + it.each` Name | value | result ${'NumberField down positive'} | ${'6'} | ${'5'} diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts index ad71558b3c9..de227c9162b 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -90,27 +90,26 @@ export function useNumberFieldState( onChange, locale, isDisabled, - isReadOnly + isReadOnly, + commitBehavior = 'snap' } = props; if (value === null) { value = NaN; } - if (value !== undefined && !isNaN(value)) { - if (step !== undefined && !isNaN(step)) { - value = snapValueToStep(value, minValue, maxValue, step); - } else { - value = clamp(value, minValue, maxValue); - } + let snapValue = useCallback(value => { + return step === undefined || isNaN(step) + ? clamp(value, minValue, maxValue) + : snapValueToStep(value, minValue, maxValue, step); + }, [step, minValue, maxValue]); + + if (value !== undefined && !isNaN(value) && commitBehavior === 'snap') { + value = snapValue(value); } - if (!isNaN(defaultValue)) { - if (step !== undefined && !isNaN(step)) { - defaultValue = snapValueToStep(defaultValue, minValue, maxValue, step); - } else { - defaultValue = clamp(defaultValue, minValue, maxValue); - } + if (!isNaN(defaultValue) && commitBehavior === 'snap') { + defaultValue = snapValue(defaultValue); } let [numberValue, setNumberValue] = useControlledState(value, isNaN(defaultValue) ? NaN : defaultValue, onChange); @@ -167,13 +166,7 @@ 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(newParsedValue, minValue, maxValue); - } else { - clampedValue = snapValueToStep(newParsedValue, minValue, maxValue, step); - } - + let clampedValue = commitBehavior === 'snap' ? snapValue(newParsedValue) : newParsedValue; clampedValue = numberParser.parse(format(clampedValue)); let shouldValidate = clampedValue !== numberValue; setNumberValue(clampedValue); diff --git a/packages/@react-types/numberfield/src/index.d.ts b/packages/@react-types/numberfield/src/index.d.ts index ab27493aa60..384faa5cbc1 100644 --- a/packages/@react-types/numberfield/src/index.d.ts +++ b/packages/@react-types/numberfield/src/index.d.ts @@ -29,7 +29,14 @@ export interface NumberFieldProps extends InputBase, Validation, Focusab * Formatting options for the value displayed in the number field. * This also affects what characters are allowed to be typed by the user. */ - formatOptions?: Intl.NumberFormatOptions + formatOptions?: Intl.NumberFormatOptions, + /** + * Controls the behavior of the number field when the user blurs the field after editing. + * 'snap' will clamp the value to the min/max values, and snap to the nearest step value. + * 'validate' will not clamp the value, and will validate that the value is within the min/max range and on a valid step. + * @default 'snap' + */ + commitBehavior?: 'snap' | 'validate' } export interface AriaNumberFieldProps extends NumberFieldProps, DOMProps, AriaLabelingProps, TextInputDOMEvents { diff --git a/packages/dev/s2-docs/pages/react-aria/NumberField.mdx b/packages/dev/s2-docs/pages/react-aria/NumberField.mdx index 2c5bc3ce0a6..d156a5664ac 100644 --- a/packages/dev/s2-docs/pages/react-aria/NumberField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/NumberField.mdx @@ -87,7 +87,7 @@ Use the `minValue`, `maxValue`, and `step` props to set the allowed values. Step component={VanillaNumberField} docs={docs.exports.NumberField} links={docs.links} - props={['minValue', 'maxValue', 'step']} + props={['minValue', 'maxValue', 'step', 'commitBehavior']} initialProps={{ label: 'Amount', minValue: 0, diff --git a/packages/dev/s2-docs/pages/s2/NumberField.mdx b/packages/dev/s2-docs/pages/s2/NumberField.mdx index 50d5824dc5e..fe268091d77 100644 --- a/packages/dev/s2-docs/pages/s2/NumberField.mdx +++ b/packages/dev/s2-docs/pages/s2/NumberField.mdx @@ -80,7 +80,7 @@ Use the `minValue`, `maxValue`, and `step` props to set the allowed values. Step component={NumberField} docs={docs.exports.NumberField} links={docs.links} - props={['minValue', 'maxValue', 'step']} + props={['minValue', 'maxValue', 'step', 'commitBehavior']} initialProps={{ label: 'Amount', placeholder: 'Enter a number', diff --git a/packages/react-aria-components/stories/NumberField.stories.tsx b/packages/react-aria-components/stories/NumberField.stories.tsx index 9f7cf521fc5..1139db3641b 100644 --- a/packages/react-aria-components/stories/NumberField.stories.tsx +++ b/packages/react-aria-components/stories/NumberField.stories.tsx @@ -29,10 +29,12 @@ export const NumberFieldExample: NumberFieldStory = { maxValue: 100, step: 1, formatOptions: {style: 'currency', currency: 'USD'}, - isWheelDisabled: false + isWheelDisabled: false, + isRequired: false, + commitBehavior: 'snap' }, render: (args) => ( - (v & 1 ? 'Invalid value' : null)}> + diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index f72d76bacbd..1da1aafcc0a 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -406,4 +406,86 @@ describe('NumberField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby').split(' ')[0])).toHaveTextContent('This field has an error.'); }); + + it('should not change the edited input value when value snapping is disabled', async () => { + let {getByRole, getByTestId} = render( +
+ + + + + + + + + +
+ ); + let input = getByRole('textbox'); + expect(input.validity.valid).toBe(true); + + // Over max + await user.tab(); + await user.clear(input); + await user.keyboard('1024'); + await user.tab(); + expect(input).toHaveValue('1,024'); + expect(announce).toHaveBeenLastCalledWith('1,024', 'assertive'); + expect(input.closest('.react-aria-NumberField')).toHaveAttribute('data-invalid', 'true'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input.validity.valid).toBe(false); + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + + act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); + + // Valid + await user.clear(input); + await user.keyboard('30'); + await user.tab(); + expect(input).toHaveValue('30'); + expect(announce).toHaveBeenLastCalledWith('30', 'assertive'); + expect(input.validity.valid).toBe(true); + expect(input).not.toHaveAttribute('aria-describedby'); + + // Under min + await user.clear(input); + await user.keyboard('2'); + await user.tab(); + expect(input).toHaveValue('2'); + expect(announce).toHaveBeenLastCalledWith('2', 'assertive'); + expect(input.validity.valid).toBe(false); + expect(input).toHaveAttribute('aria-describedby'); + + act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); + + // Not on step + await user.clear(input); + await user.keyboard('31'); + await user.tab(); + expect(input).toHaveValue('31'); + expect(announce).toHaveBeenLastCalledWith('31', 'assertive'); + expect(input.validity.valid).toBe(false); + expect(input).toHaveAttribute('aria-describedby'); + + act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); + + // Required + await user.clear(input); + await user.tab(); + expect(input).toHaveValue(''); + expect(input.validity.valid).toBe(false); + expect(input).toHaveAttribute('aria-describedby'); + + // Valid + await user.clear(input); + await user.keyboard('30'); + await user.tab(); + expect(input).toHaveValue('30'); + expect(input.validity.valid).toBe(true); + expect(input).not.toHaveAttribute('aria-describedby'); + }); });