Skip to content

Commit f187e3b

Browse files
will-stonesnowystingerdevongovettLFDanLu
authored
feat: add commitBehavior to NumberField (#9679)
* feat: add isValueSnappingDisabled to NumberField * Add more info to the test and change prop name * Implement native validation * rename story --------- Co-authored-by: Robert Snow <rsnow@adobe.com> Co-authored-by: Robert Snow <snowystinger@gmail.com> Co-authored-by: Devon Govett <devongovett@gmail.com> Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent ca9e92b commit f187e3b

9 files changed

Lines changed: 253 additions & 26 deletions

File tree

packages/@react-aria/numberfield/src/useNumberField.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {announce} from '@react-aria/live-announcer';
1414
import {AriaButtonProps} from '@react-types/button';
1515
import {AriaNumberFieldProps} from '@react-types/numberfield';
16-
import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils';
16+
import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId, useLayoutEffect} from '@react-aria/utils';
1717
import {
1818
type ClipboardEvent,
1919
type ClipboardEventHandler,
@@ -262,6 +262,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
262262
}, state, inputRef);
263263

264264
useFormReset(inputRef, state.defaultNumberValue, state.setNumberValue);
265+
useNativeValidation(state, props.validationBehavior, props.commitBehavior, inputRef, state.minValue, state.maxValue, props.step, state.numberValue);
265266

266267
let inputProps: InputHTMLAttributes<HTMLInputElement> = mergeProps(
267268
spinButtonProps,
@@ -363,3 +364,69 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
363364
validationDetails
364365
};
365366
}
367+
368+
let numberInput: HTMLInputElement | null = null;
369+
370+
function useNativeValidation(
371+
state: NumberFieldState,
372+
validationBehavior: 'native' | 'aria' | undefined,
373+
commitBehavior: 'snap' | 'validate' | undefined,
374+
inputRef: RefObject<HTMLInputElement | null>,
375+
min: number | undefined,
376+
max: number | undefined,
377+
step: number | undefined,
378+
value: number | undefined
379+
) {
380+
useLayoutEffect(() => {
381+
let input = inputRef.current;
382+
if (commitBehavior !== 'validate' || state.realtimeValidation.isInvalid || !input || input.disabled) {
383+
return;
384+
}
385+
386+
// Create a native number input and use it to implement validation of min/max/step.
387+
// This lets us get the native validation message provided by the browser instead of needing our own translations.
388+
if (!numberInput && typeof document !== 'undefined') {
389+
numberInput = document.createElement('input');
390+
numberInput.type = 'number';
391+
}
392+
393+
if (!numberInput) {
394+
// For TypeScript.
395+
return;
396+
}
397+
398+
numberInput.min = min != null && !isNaN(min) ? String(min) : '';
399+
numberInput.max = max != null && !isNaN(max) ? String(max) : '';
400+
numberInput.step = step != null && !isNaN(step) ? String(step) : '';
401+
numberInput.value = value != null && !isNaN(value) ? String(value) : '';
402+
403+
// Merge validity with the visible text input (for other validations like required).
404+
let valid = input.validity.valid && numberInput.validity.valid;
405+
let validationMessage = input.validationMessage || numberInput.validationMessage;
406+
let validity = {
407+
isInvalid: !valid,
408+
validationErrors: validationMessage ? [validationMessage] : [],
409+
validationDetails: {
410+
badInput: input.validity.badInput,
411+
customError: input.validity.customError,
412+
patternMismatch: input.validity.patternMismatch,
413+
rangeOverflow: numberInput.validity.rangeOverflow,
414+
rangeUnderflow: numberInput.validity.rangeUnderflow,
415+
stepMismatch: numberInput.validity.stepMismatch,
416+
tooLong: input.validity.tooLong,
417+
tooShort: input.validity.tooShort,
418+
typeMismatch: input.validity.typeMismatch,
419+
valueMissing: input.validity.valueMissing,
420+
valid
421+
}
422+
};
423+
424+
state.updateValidation(validity);
425+
426+
// Block form submission if validation behavior is native.
427+
// This won't overwrite any user-defined validation message because we checked realtimeValidation above.
428+
if (validationBehavior === 'native' && !numberInput.validity.valid) {
429+
input.setCustomValidity(numberInput.validationMessage);
430+
}
431+
});
432+
}

packages/@react-spectrum/numberfield/stories/NumberField.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@ Step3WithMin2Max21.story = {
207207
name: 'step = 3 with min = 2, max = 21'
208208
};
209209

210+
export const InteractOutsideBehaviorNone: NumberFieldStory = () => render({step: 3, minValue: 2, maxValue: 21, commitBehavior: 'validate'});
211+
212+
InteractOutsideBehaviorNone.story = {
213+
name: 'commitBehavior = validate'
214+
};
215+
210216
export const AutoFocus: NumberFieldStory = () => render({autoFocus: true});
211217

212218
AutoFocus.story = {

packages/@react-spectrum/numberfield/test/NumberField.test.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,29 @@ describe('NumberField', function () {
335335
expect(container).not.toHaveAttribute('aria-invalid');
336336
});
337337

338+
it.each`
339+
Name
340+
${'NumberField'}
341+
`('$Name will allow typing of a number less than the min when value snapping is disabled', async () => {
342+
let {
343+
container,
344+
textField
345+
} = renderNumberField({onChange: onChangeSpy, minValue: 10, commitBehavior: 'validate'});
346+
347+
expect(container).not.toHaveAttribute('aria-invalid');
348+
349+
act(() => {textField.focus();});
350+
await user.clear(textField);
351+
await user.keyboard('5');
352+
expect(onChangeSpy).toHaveBeenCalledTimes(0);
353+
expect(textField).toHaveAttribute('value', '5');
354+
act(() => {textField.blur();});
355+
expect(onChangeSpy).toHaveBeenCalledTimes(1);
356+
expect(onChangeSpy).toHaveBeenCalledWith(5);
357+
expect(textField).toHaveAttribute('value', '5');
358+
expect(container).toHaveAttribute('aria-invalid', 'true');
359+
});
360+
338361
it.each`
339362
Name
340363
${'NumberField'}
@@ -383,6 +406,38 @@ describe('NumberField', function () {
383406
expect(textField).toHaveAttribute('value', '1');
384407
});
385408

409+
it.each`
410+
Name
411+
${'NumberField'}
412+
`('$Name will allow typing of a number greater than the max when value snapping is disabled', async () => {
413+
let {
414+
container,
415+
textField
416+
} = renderNumberField({onChange: onChangeSpy, maxValue: 1, defaultValue: 0, commitBehavior: 'validate'});
417+
418+
expect(container).not.toHaveAttribute('aria-invalid');
419+
420+
act(() => {textField.focus();});
421+
await user.keyboard('2');
422+
expect(onChangeSpy).not.toHaveBeenCalled();
423+
act(() => {textField.blur();});
424+
expect(onChangeSpy).toHaveBeenCalled();
425+
expect(onChangeSpy).toHaveBeenCalledWith(2);
426+
expect(textField).toHaveAttribute('value', '2');
427+
428+
expect(container).toHaveAttribute('aria-invalid', 'true');
429+
430+
onChangeSpy.mockReset();
431+
act(() => {textField.focus();});
432+
await user.keyboard('2');
433+
expect(onChangeSpy).not.toHaveBeenCalled();
434+
act(() => {textField.blur();});
435+
expect(onChangeSpy).toHaveBeenCalled();
436+
expect(onChangeSpy).toHaveBeenCalledWith(22);
437+
expect(textField).toHaveAttribute('value', '22');
438+
expect(container).toHaveAttribute('aria-invalid', 'true');
439+
});
440+
386441
it.each`
387442
Name
388443
${'NumberField'}
@@ -772,6 +827,21 @@ describe('NumberField', function () {
772827
expect(textField).toHaveAttribute('value', result);
773828
});
774829

830+
it.each`
831+
Name | value
832+
${'NumberField down positive'} | ${'6'}
833+
${'NumberField up positive'} | ${'8'}
834+
${'NumberField down negative'} | ${'-8'}
835+
${'NumberField up negative'} | ${'-6'}
836+
`('$Name does not round to step on commit when value snapping is disabled', async ({value}) => {
837+
let {textField} = renderNumberField({onChange: onChangeSpy, step: 5, commitBehavior: 'validate'});
838+
act(() => {textField.focus();});
839+
await user.keyboard(value);
840+
act(() => {textField.blur();});
841+
expect(textField).toHaveAttribute('value', value);
842+
expect(textField).toHaveAttribute('aria-invalid', 'true');
843+
});
844+
775845
it.each`
776846
Name | value | result
777847
${'NumberField down positive'} | ${'6'} | ${'5'}

packages/@react-stately/numberfield/src/useNumberFieldState.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -90,27 +90,26 @@ export function useNumberFieldState(
9090
onChange,
9191
locale,
9292
isDisabled,
93-
isReadOnly
93+
isReadOnly,
94+
commitBehavior = 'snap'
9495
} = props;
9596

9697
if (value === null) {
9798
value = NaN;
9899
}
99100

100-
if (value !== undefined && !isNaN(value)) {
101-
if (step !== undefined && !isNaN(step)) {
102-
value = snapValueToStep(value, minValue, maxValue, step);
103-
} else {
104-
value = clamp(value, minValue, maxValue);
105-
}
101+
let snapValue = useCallback(value => {
102+
return step === undefined || isNaN(step)
103+
? clamp(value, minValue, maxValue)
104+
: snapValueToStep(value, minValue, maxValue, step);
105+
}, [step, minValue, maxValue]);
106+
107+
if (value !== undefined && !isNaN(value) && commitBehavior === 'snap') {
108+
value = snapValue(value);
106109
}
107110

108-
if (!isNaN(defaultValue)) {
109-
if (step !== undefined && !isNaN(step)) {
110-
defaultValue = snapValueToStep(defaultValue, minValue, maxValue, step);
111-
} else {
112-
defaultValue = clamp(defaultValue, minValue, maxValue);
113-
}
111+
if (!isNaN(defaultValue) && commitBehavior === 'snap') {
112+
defaultValue = snapValue(defaultValue);
114113
}
115114

116115
let [numberValue, setNumberValue] = useControlledState<number>(value, isNaN(defaultValue) ? NaN : defaultValue, onChange);
@@ -167,13 +166,7 @@ export function useNumberFieldState(
167166
}
168167

169168
// Clamp to min and max, round to the nearest step, and round to specified number of digits
170-
let clampedValue: number;
171-
if (step === undefined || isNaN(step)) {
172-
clampedValue = clamp(newParsedValue, minValue, maxValue);
173-
} else {
174-
clampedValue = snapValueToStep(newParsedValue, minValue, maxValue, step);
175-
}
176-
169+
let clampedValue = commitBehavior === 'snap' ? snapValue(newParsedValue) : newParsedValue;
177170
clampedValue = numberParser.parse(format(clampedValue));
178171
let shouldValidate = clampedValue !== numberValue;
179172
setNumberValue(clampedValue);

packages/@react-types/numberfield/src/index.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ export interface NumberFieldProps extends InputBase, Validation<number>, Focusab
2929
* Formatting options for the value displayed in the number field.
3030
* This also affects what characters are allowed to be typed by the user.
3131
*/
32-
formatOptions?: Intl.NumberFormatOptions
32+
formatOptions?: Intl.NumberFormatOptions,
33+
/**
34+
* Controls the behavior of the number field when the user blurs the field after editing.
35+
* 'snap' will clamp the value to the min/max values, and snap to the nearest step value.
36+
* 'validate' will not clamp the value, and will validate that the value is within the min/max range and on a valid step.
37+
* @default 'snap'
38+
*/
39+
commitBehavior?: 'snap' | 'validate'
3340
}
3441

3542
export interface AriaNumberFieldProps extends NumberFieldProps, DOMProps, AriaLabelingProps, TextInputDOMEvents {

packages/dev/s2-docs/pages/react-aria/NumberField.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Use the `minValue`, `maxValue`, and `step` props to set the allowed values. Step
8787
component={VanillaNumberField}
8888
docs={docs.exports.NumberField}
8989
links={docs.links}
90-
props={['minValue', 'maxValue', 'step']}
90+
props={['minValue', 'maxValue', 'step', 'commitBehavior']}
9191
initialProps={{
9292
label: 'Amount',
9393
minValue: 0,

packages/dev/s2-docs/pages/s2/NumberField.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Use the `minValue`, `maxValue`, and `step` props to set the allowed values. Step
8080
component={NumberField}
8181
docs={docs.exports.NumberField}
8282
links={docs.links}
83-
props={['minValue', 'maxValue', 'step']}
83+
props={['minValue', 'maxValue', 'step', 'commitBehavior']}
8484
initialProps={{
8585
label: 'Amount',
8686
placeholder: 'Enter a number',

packages/react-aria-components/stories/NumberField.stories.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ export const NumberFieldExample: NumberFieldStory = {
2929
maxValue: 100,
3030
step: 1,
3131
formatOptions: {style: 'currency', currency: 'USD'},
32-
isWheelDisabled: false
32+
isWheelDisabled: false,
33+
isRequired: false,
34+
commitBehavior: 'snap'
3335
},
3436
render: (args) => (
35-
<NumberField {...args} validate={(v) => (v & 1 ? 'Invalid value' : null)}>
37+
<NumberField {...args}>
3638
<Label>Test</Label>
3739
<Group style={{display: 'flex'}}>
3840
<Button slot="decrement">-</Button>

packages/react-aria-components/test/NumberField.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,4 +406,86 @@ describe('NumberField', () => {
406406
expect(input).toHaveAttribute('aria-describedby');
407407
expect(document.getElementById(input.getAttribute('aria-describedby').split(' ')[0])).toHaveTextContent('This field has an error.');
408408
});
409+
410+
it('should not change the edited input value when value snapping is disabled', async () => {
411+
let {getByRole, getByTestId} = render(
412+
<form data-testid="form">
413+
<NumberField isRequired defaultValue={20} minValue={10} step={10} maxValue={50} commitBehavior="validate">
414+
<Label>Width</Label>
415+
<Group>
416+
<Button slot="decrement">-</Button>
417+
<Input />
418+
<Button slot="increment">+</Button>
419+
</Group>
420+
<FieldError />
421+
</NumberField>
422+
</form>
423+
);
424+
let input = getByRole('textbox');
425+
expect(input.validity.valid).toBe(true);
426+
427+
// Over max
428+
await user.tab();
429+
await user.clear(input);
430+
await user.keyboard('1024');
431+
await user.tab();
432+
expect(input).toHaveValue('1,024');
433+
expect(announce).toHaveBeenLastCalledWith('1,024', 'assertive');
434+
expect(input.closest('.react-aria-NumberField')).toHaveAttribute('data-invalid', 'true');
435+
expect(input).toHaveAttribute('aria-invalid', 'true');
436+
expect(input.validity.valid).toBe(false);
437+
expect(input).toHaveAttribute('aria-describedby');
438+
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied');
439+
440+
act(() => {getByTestId('form').checkValidity();});
441+
expect(document.activeElement).toBe(input);
442+
443+
// Valid
444+
await user.clear(input);
445+
await user.keyboard('30');
446+
await user.tab();
447+
expect(input).toHaveValue('30');
448+
expect(announce).toHaveBeenLastCalledWith('30', 'assertive');
449+
expect(input.validity.valid).toBe(true);
450+
expect(input).not.toHaveAttribute('aria-describedby');
451+
452+
// Under min
453+
await user.clear(input);
454+
await user.keyboard('2');
455+
await user.tab();
456+
expect(input).toHaveValue('2');
457+
expect(announce).toHaveBeenLastCalledWith('2', 'assertive');
458+
expect(input.validity.valid).toBe(false);
459+
expect(input).toHaveAttribute('aria-describedby');
460+
461+
act(() => {getByTestId('form').checkValidity();});
462+
expect(document.activeElement).toBe(input);
463+
464+
// Not on step
465+
await user.clear(input);
466+
await user.keyboard('31');
467+
await user.tab();
468+
expect(input).toHaveValue('31');
469+
expect(announce).toHaveBeenLastCalledWith('31', 'assertive');
470+
expect(input.validity.valid).toBe(false);
471+
expect(input).toHaveAttribute('aria-describedby');
472+
473+
act(() => {getByTestId('form').checkValidity();});
474+
expect(document.activeElement).toBe(input);
475+
476+
// Required
477+
await user.clear(input);
478+
await user.tab();
479+
expect(input).toHaveValue('');
480+
expect(input.validity.valid).toBe(false);
481+
expect(input).toHaveAttribute('aria-describedby');
482+
483+
// Valid
484+
await user.clear(input);
485+
await user.keyboard('30');
486+
await user.tab();
487+
expect(input).toHaveValue('30');
488+
expect(input.validity.valid).toBe(true);
489+
expect(input).not.toHaveAttribute('aria-describedby');
490+
});
409491
});

0 commit comments

Comments
 (0)