diff --git a/packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx b/packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx index 0703e37be51..ba19054c3a1 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, interactOutsideBehavior: 'none'}); + +InteractOutsideBehaviorNone.story = { + name: 'interactOutsideBehavior = none' +}; + 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 17395b00e83..3661f657229 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -335,6 +335,30 @@ 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, interactOutsideBehavior: 'none'}); + + 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).not.toHaveAttribute('aria-invalid'); + }); + it.each` Name ${'NumberField'} @@ -383,6 +407,37 @@ 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, interactOutsideBehavior: 'none'}); + + 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).not.toHaveAttribute('aria-invalid'); + + 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'); + }); + it.each` Name ${'NumberField'} @@ -772,6 +827,20 @@ 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, interactOutsideBehavior: 'none'}); + act(() => {textField.focus();}); + await user.keyboard(value); + act(() => {textField.blur();}); + expect(textField).toHaveAttribute('value', value); + }); + 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 5bc374a4313..e8a7d9d476e 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -90,7 +90,8 @@ export function useNumberFieldState( onChange, locale, isDisabled, - isReadOnly + isReadOnly, + interactOutsideBehavior = 'clamp' } = props; if (value === null) { @@ -168,7 +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)) { + if (interactOutsideBehavior === 'none') { + clampedValue = newParsedValue; + } else if (step === undefined || isNaN(step)) { clampedValue = clamp(newParsedValue, minValue, maxValue); } else { clampedValue = snapValueToStep(newParsedValue, minValue, maxValue, step); diff --git a/packages/@react-types/numberfield/src/index.d.ts b/packages/@react-types/numberfield/src/index.d.ts index ab27493aa60..dd79bc092da 100644 --- a/packages/@react-types/numberfield/src/index.d.ts +++ b/packages/@react-types/numberfield/src/index.d.ts @@ -29,7 +29,15 @@ 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 interacts outside of the field after editing. + * 'clamp' will clamp the value to the min/max values. + * 'none' will not clamp the value and will allow the value to be outside of the min/max values. + * No native validation around min/max. Provide your own validation function via the `validate` prop. + * @default 'clamp' + */ + interactOutsideBehavior?: 'clamp' | 'none' } export interface AriaNumberFieldProps extends NumberFieldProps, DOMProps, AriaLabelingProps, TextInputDOMEvents { diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index ae7fb7c4130..9c0d61e11f1 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -250,4 +250,31 @@ describe('NumberField', () => { await user.keyboard('{Enter}'); expect(input).toHaveValue('200'); }); + + it('should not change the edited input value when value snapping is disabled', async () => { + let minValue = 10; + let maxValue = 50; + // Note, we cannot rely on native validation around min/max because we use an input type="text" + // rather than type="number". This is so we can have a formatted value like $1,024.00 or a completely + // different numbering system. + // The native type="number" input would not allow know what to do with a formatted value. + let validate = (value) => { + if (value < minValue) { + return `Value must be at least ${minValue}`; + } + if (value > maxValue) { + return `Value must be at most ${maxValue}`; + } + }; + let {getByRole} = render(); + let input = getByRole('textbox'); + 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'); + }); });