diff --git a/.changeset/number-field-fraction-digits.md b/.changeset/number-field-fraction-digits.md new file mode 100644 index 000000000..95645c786 --- /dev/null +++ b/.changeset/number-field-fraction-digits.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Fix `fields.number` rounding values to 3 decimal places when the input is blurred (e.g. `51.98771` was saved as `51.988`) diff --git a/packages/keystatic/src/form/fields/number/ui.test.tsx b/packages/keystatic/src/form/fields/number/ui.test.tsx new file mode 100644 index 000000000..dcb717986 --- /dev/null +++ b/packages/keystatic/src/form/fields/number/ui.test.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { expect, test } from 'vitest'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { KeystarProvider } from '@keystar/ui/core'; +import { NumberFieldInput } from './ui'; + +function renderNumberField({ + step, + initialValue = null, +}: { step?: number; initialValue?: number | null } = {}) { + const user = userEvent.setup(); + const committed: (number | null)[] = []; + function Harness() { + const [value, setValue] = useState(initialValue); + return ( + { + committed.push(val); + setValue(val); + }} + autoFocus={false} + forceValidation={false} + /> + ); + } + const result = render( + + + + ); + const input = result.getByRole('textbox') as HTMLInputElement; + return { user, input, committed }; +} + +test('keeps more than three fraction digits on commit', async () => { + const { user, input, committed } = renderNumberField(); + await user.click(input); + await user.keyboard('51.98771'); + await user.tab(); + expect(committed.at(-1)).toBe(51.98771); + expect(input.value).toBe('51.98771'); +}); + +test('keeps more than three fraction digits of an existing value', async () => { + const { user, input, committed } = renderNumberField({ + initialValue: 5.8123456, + }); + expect(input.value).toBe('5.8123456'); + await user.click(input); + await user.tab(); + expect(committed).toEqual([]); + expect(input.value).toBe('5.8123456'); +}); + +test('keeps precision when step is smaller than 0.001', async () => { + const { user, input, committed } = renderNumberField({ step: 0.00001 }); + await user.click(input); + await user.keyboard('51.98771'); + await user.tab(); + expect(committed.at(-1)).toBe(51.98771); +}); + +test('integers are displayed and committed unchanged', async () => { + const { user, input, committed } = renderNumberField(); + await user.click(input); + await user.keyboard('1234567'); + await user.tab(); + expect(committed.at(-1)).toBe(1234567); + expect(input.value).toBe('1,234,567'); +}); + +test('step still rounds the committed value', async () => { + const { user, input, committed } = renderNumberField({ step: 0.01 }); + await user.click(input); + await user.keyboard('1.23456'); + await user.tab(); + expect(committed.at(-1)).toBe(1.23); +}); diff --git a/packages/keystatic/src/form/fields/number/ui.tsx b/packages/keystatic/src/form/fields/number/ui.tsx index 1be018620..4725af3de 100644 --- a/packages/keystatic/src/form/fields/number/ui.tsx +++ b/packages/keystatic/src/form/fields/number/ui.tsx @@ -3,6 +3,15 @@ import { useReducer } from 'react'; import { validateNumber } from './validateNumber'; import { FormFieldInputProps } from '../../api'; +// NumberField round-trips the committed value through its formatter, and +// Intl.NumberFormat defaults to `maximumFractionDigits: 3`, which would +// silently round stored values (e.g. 51.98771 -> 51.988). 20 is the largest +// value every supported engine accepts, and engines format the shortest +// round-trip representation, so 0.1 still displays as "0.1". Precision is +// then limited only by `step` (if given), not by display formatting. +// Hoisted so the object identity is stable across renders. +const formatOptions: Intl.NumberFormatOptions = { maximumFractionDigits: 20 }; + export function NumberFieldInput( props: FormFieldInputProps & { label: string; @@ -33,6 +42,7 @@ export function NumberFieldInput( onBlur={onBlur} autoFocus={props.autoFocus} step={props.step} + formatOptions={formatOptions} value={props.value === null ? undefined : props.value} onChange={val => { props.onChange((val === undefined ? null : val) as any);