Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
69 changes: 69 additions & 0 deletions packages/@react-spectrum/numberfield/test/NumberField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe in the v3 spectrum component this should be false, but if we write the same test in the react aria component, does it have the invalid attribute? seems like it should

});

it.each`
Name
${'NumberField'}
Expand Down Expand Up @@ -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'}
Expand Down Expand Up @@ -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'}
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps out of scope for this PR's change, but seeing as setting this snapping option would allow a user to enter a value beyond the upper limit, should we also allow users to enter invalid negative numbers? I'm not sure if there is a realistic use case, but that would also bring us closer to native behavior.

Copy link
Member

@snowystinger snowystinger Mar 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is stopped before the user can even see the negative sign reflected in the input, so shouldn't be the same problem. I think we leave it as is for now.

Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export function useNumberFieldState(
onChange,
locale,
isDisabled,
isReadOnly
isReadOnly,
interactOutsideBehavior = 'clamp'
} = props;

if (value === null) {
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion packages/@react-types/numberfield/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ export interface NumberFieldProps extends InputBase, Validation<number>, 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 {
Expand Down
27 changes: 27 additions & 0 deletions packages/react-aria-components/test/NumberField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TestNumberField defaultValue={20} minValue={minValue} step={10} maxValue={maxValue} interactOutsideBehavior="none" validate={validate} />);
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');
});
});
Loading