Skip to content

Commit 5ca9fc8

Browse files
committed
Handle external changes
1 parent 41c8f7e commit 5ca9fc8

File tree

5 files changed

+161
-25
lines changed

5 files changed

+161
-25
lines changed

packages/react/src/number-field/decrement/NumberFieldDecrement.test.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ describe('<NumberField.Decrement />', () => {
5151
expect(screen.getByRole('textbox')).to.have.value('-1');
5252
});
5353

54+
it('first increment after external controlled update', async () => {
55+
function Controlled(props: { value: number | null }) {
56+
return (
57+
<NumberField.Root value={props.value}>
58+
<NumberField.Decrement />
59+
<NumberField.Input />
60+
</NumberField.Root>
61+
);
62+
}
63+
64+
const { setProps } = await render(<Controlled value={null} />);
65+
const input = screen.getByRole('textbox');
66+
const button = screen.getByRole('button');
67+
68+
await act(async () => {
69+
setProps({ value: 1.23456 });
70+
});
71+
72+
expect(input).to.have.value('1.23456');
73+
74+
fireEvent.click(button);
75+
// does not preserving full precision
76+
expect(input).to.have.value('0.235');
77+
});
78+
5479
describe('press and hold', () => {
5580
clock.withFakeTimers();
5681

packages/react/src/number-field/increment/NumberFieldIncrement.test.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ describe('<NumberField.Increment />', () => {
5151
expect(screen.getByRole('textbox')).to.have.value('1');
5252
});
5353

54+
it('first increment after external controlled update', async () => {
55+
function Controlled(props: { value: number | null }) {
56+
return (
57+
<NumberField.Root value={props.value}>
58+
<NumberField.Increment />
59+
<NumberField.Input />
60+
</NumberField.Root>
61+
);
62+
}
63+
64+
const { setProps } = await render(<Controlled value={null} />);
65+
const input = screen.getByRole('textbox');
66+
const button = screen.getByRole('button');
67+
68+
await act(async () => {
69+
setProps({ value: 1.23456 });
70+
});
71+
72+
expect(input).to.have.value('1.23456');
73+
74+
fireEvent.click(button);
75+
// does not preserving full precision
76+
expect(input).to.have.value('2.235');
77+
});
78+
5479
describe('press and hold', () => {
5580
clock.withFakeTimers();
5681

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

+30
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react';
22
import { expect } from 'chai';
33
import { act, screen, fireEvent } from '@mui/internal-test-utils';
4+
import { spy } from 'sinon';
45
import { NumberField } from '@base-ui-components/react/number-field';
56
import { createRenderer, describeConformance } from '#test-utils';
67

@@ -176,4 +177,33 @@ describe('<NumberField.Input />', () => {
176177
fireEvent.blur(input);
177178
expect(input).to.have.value('3');
178179
});
180+
181+
it('should preserve full precision on first blur after external value change', async () => {
182+
const onValueChange = spy();
183+
184+
function Controlled(props: { value: number | null }) {
185+
return (
186+
<NumberField.Root value={props.value} onValueChange={onValueChange}>
187+
<NumberField.Input />
188+
</NumberField.Root>
189+
);
190+
}
191+
192+
const { setProps } = await render(<Controlled value={null} />);
193+
const input = screen.getByRole('textbox');
194+
195+
await act(async () => {
196+
setProps({ value: 1.23456 });
197+
});
198+
199+
expect(input).to.have.value('1.23456');
200+
201+
await act(async () => {
202+
input.focus();
203+
input.blur();
204+
});
205+
206+
expect(input).to.have.value('1.23456');
207+
expect(onValueChange.callCount).to.equal(0);
208+
});
179209
});

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

+35-21
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
5555
locale,
5656
inputRef,
5757
value,
58+
externalUpdateRef,
59+
isControlled,
5860
} = useNumberFieldRootContext();
5961

6062
const { clearErrors } = useFormContext();
@@ -167,21 +169,31 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
167169
return;
168170
}
169171

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

177+
if (isControlled && externalUpdateRef.current) {
178+
externalUpdateRef.current = false;
179+
// we trust the prop—no re‐format
182180
if (validationMode === 'onBlur') {
183-
commitValidation(afterFormatValue);
181+
commitValidation(parsed);
184182
}
183+
return;
184+
}
185+
186+
const canonicalText = formatNumber(parsed, locale, formatOptionsRef.current);
187+
const canonical = parseNumber(canonicalText, locale, formatOptionsRef.current);
188+
189+
if (canonical !== value) {
190+
setValue(canonical, event.nativeEvent);
191+
} else {
192+
setInputValue(canonicalText);
193+
}
194+
195+
if (validationMode === 'onBlur') {
196+
commitValidation(canonical);
185197
}
186198
},
187199
onChange(event) {
@@ -323,33 +335,35 @@ export const NumberFieldInput = React.forwardRef(function NumberFieldInput(
323335
getInputValidationProps(getValidationProps(externalProps)),
324336
),
325337
[
326-
getInputValidationProps,
327-
getValidationProps,
328338
id,
329339
required,
330340
name,
331341
disabled,
332342
readOnly,
333343
inputMode,
334344
inputValue,
345+
handleInputRef,
335346
invalid,
336347
labelId,
348+
getInputValidationProps,
349+
getValidationProps,
337350
setFocused,
351+
value,
352+
locale,
353+
formatOptionsRef,
354+
allowInputSyncRef,
355+
setInputValue,
338356
setTouched,
357+
isControlled,
358+
externalUpdateRef,
339359
validationMode,
340-
formatOptionsRef,
341-
commitValidation,
342360
setValue,
361+
commitValidation,
343362
getAllowedNonNumericKeys,
344363
getStepAmount,
345364
min,
346365
max,
347366
incrementValue,
348-
setInputValue,
349-
allowInputSyncRef,
350-
locale,
351-
handleInputRef,
352-
value,
353367
],
354368
);
355369

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

+46-4
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ export function useNumberFieldRoot(
9999
const allowInputSyncRef = React.useRef(true);
100100
const unsubscribeFromGlobalContextMenuRef = React.useRef<() => void>(() => {});
101101

102+
const isControlled = externalValue !== undefined;
103+
const lastExternalValueRef = React.useRef<number | null | undefined>(externalValue);
104+
const externalUpdateRef = React.useRef(false);
105+
106+
useModernLayoutEffect(() => {
107+
if (isControlled && externalValue !== lastExternalValueRef.current) {
108+
externalUpdateRef.current = true;
109+
}
110+
lastExternalValueRef.current = externalValue;
111+
}, [externalValue, isControlled]);
112+
102113
useModernLayoutEffect(() => {
103114
if (validityData.initialValue === null && value !== validityData.initialValue) {
104115
setValidityData((prev) => ({ ...prev, initialValue: value }));
@@ -157,6 +168,22 @@ export function useNumberFieldRoot(
157168
setValueUnwrapped(validatedValue);
158169
setDirty(validatedValue !== validityData.initialValue);
159170

171+
if (dir != null) {
172+
const wasExternal = externalUpdateRef.current;
173+
externalUpdateRef.current = false;
174+
175+
const text = wasExternal
176+
? formatNumber(validatedValue, locale, {
177+
...formatOptionsRef.current,
178+
maximumFractionDigits: 20,
179+
})
180+
: formatNumber(validatedValue, locale, formatOptionsRef.current);
181+
182+
allowInputSyncRef.current = false;
183+
setInputValue(text);
184+
return;
185+
}
186+
160187
// We need to force a re-render, because while the value may be unchanged, the formatting may
161188
// be different. This forces the `useModernLayoutEffect` to run which acts as a single source of
162189
// truth to sync the input value.
@@ -246,10 +273,20 @@ export function useNumberFieldRoot(
246273
return;
247274
}
248275

249-
const nextInputValue = formatNumber(value, locale, formatOptionsRef.current);
250-
251-
if (nextInputValue !== inputValue) {
252-
setInputValue(nextInputValue);
276+
if (isControlled && externalUpdateRef.current) {
277+
// Respect precision if externally changed
278+
const fullPrecision = formatNumber(value, locale, {
279+
...formatOptionsRef.current,
280+
maximumFractionDigits: 20,
281+
});
282+
if (fullPrecision !== inputValue) {
283+
setInputValue(fullPrecision);
284+
}
285+
} else {
286+
const next = formatNumber(value, locale, formatOptionsRef.current);
287+
if (next !== inputValue) {
288+
setInputValue(next);
289+
}
253290
}
254291
});
255292

@@ -345,6 +382,8 @@ export function useNumberFieldRoot(
345382
locale,
346383
isScrubbing,
347384
setIsScrubbing,
385+
externalUpdateRef,
386+
isControlled,
348387
}),
349388
[
350389
inputRef,
@@ -376,6 +415,7 @@ export function useNumberFieldRoot(
376415
setInputValue,
377416
locale,
378417
isScrubbing,
418+
isControlled,
379419
],
380420
);
381421
}
@@ -512,5 +552,7 @@ export namespace useNumberFieldRoot {
512552
locale: Intl.LocalesArgument;
513553
isScrubbing: boolean;
514554
setIsScrubbing: React.Dispatch<React.SetStateAction<boolean>>;
555+
externalUpdateRef: React.RefObject<boolean | null>;
556+
isControlled: boolean;
515557
}
516558
}

0 commit comments

Comments
 (0)