Skip to content

Commit 7a16103

Browse files
committed
[number field] Ensure onValueChange is called with parsed value as formatted
1 parent 2c7fc9a commit 7a16103

File tree

3 files changed

+60
-4
lines changed

3 files changed

+60
-4
lines changed

packages/react/src/number-field/input/NumberFieldInput.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { NumberFieldRoot } from '../root/NumberFieldRoot';
1515
import { styleHookMapping } from '../utils/styleHooks';
1616
import { useField } from '../../field/useField';
1717
import { useFormContext } from '../../form/FormContext';
18+
import { formatNumber } from '../../utils/formatNumber';
1819

1920
const customStyleHookMapping = {
2021
...fieldValidityMapping,
@@ -126,6 +127,18 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
126127
return;
127128
}
128129

130+
// Restore full-precision on focus
131+
if (value !== null) {
132+
const fullPrecision = formatNumber(value, locale, {
133+
...formatOptionsRef.current,
134+
maximumFractionDigits: 20,
135+
});
136+
allowInputSyncRef.current = false;
137+
setInputValue(fullPrecision);
138+
const len = fullPrecision.length;
139+
event.currentTarget.setSelectionRange(len, len);
140+
}
141+
129142
hasTouchedInputRef.current = true;
130143
setFocused(true);
131144

@@ -153,12 +166,20 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
153166
return;
154167
}
155168

156-
const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
169+
const beforeFormatValue = parseNumber(inputValue, locale, formatOptionsRef.current);
170+
171+
if (beforeFormatValue !== null) {
172+
const text = formatNumber(beforeFormatValue, locale, formatOptionsRef.current);
173+
const afterFormatValue = parseNumber(text, locale, formatOptionsRef.current);
174+
175+
if (afterFormatValue !== value) {
176+
setValue(afterFormatValue, event.nativeEvent);
177+
} else {
178+
setInputValue(text);
179+
}
157180

158-
if (parsedValue !== null) {
159-
setValue(parsedValue, event.nativeEvent);
160181
if (validationMode === 'onBlur') {
161-
commitValidation(parsedValue);
182+
commitValidation(afterFormatValue);
162183
}
163184
}
164185
},
@@ -327,6 +348,7 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
327348
allowInputSyncRef,
328349
locale,
329350
handleInputRef,
351+
value,
330352
],
331353
);
332354

packages/react/src/number-field/root/NumberFieldRoot.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,39 @@ describe('<NumberField />', () => {
135135
expect(onValueChange.callCount).to.equal(1);
136136
expect(onValueChange.firstCall.args[0]).to.equal(null);
137137
});
138+
139+
it('only fires once when blurring twice without edits (restores full precision on focus)', async () => {
140+
const onValueChange = spy();
141+
function App() {
142+
const [value, setValue] = React.useState<number | null>(1.23456);
143+
return (
144+
<NumberField
145+
value={value}
146+
onValueChange={(val) => {
147+
onValueChange(val);
148+
setValue(val);
149+
}}
150+
/>
151+
);
152+
}
153+
154+
await render(<App />);
155+
const input = screen.getByRole('textbox');
156+
157+
const formatted = new Intl.NumberFormat().format(1.23456);
158+
expect(input).to.have.value(formatted);
159+
160+
// First focus → blur: should round-trip and fire once
161+
await act(() => input.focus());
162+
await act(() => input.blur());
163+
expect(onValueChange.callCount).to.equal(1);
164+
expect(onValueChange.firstCall.args[0]).to.equal(parseFloat(formatted));
165+
166+
// Second focus → blur without typing: no new onValueChange
167+
await act(() => input.focus());
168+
await act(() => input.blur());
169+
expect(onValueChange.callCount).to.equal(1);
170+
});
138171
});
139172

140173
describe('prop: disabled', () => {

packages/react/src/number-field/root/useNumberFieldRoot.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ export function useNumberFieldRoot(
297297

298298
// Prevent the default behavior to avoid scrolling the page.
299299
event.preventDefault();
300+
allowInputSyncRef.current = true;
300301

301302
const amount = getStepAmount(event) ?? DEFAULT_STEP;
302303

0 commit comments

Comments
 (0)