From ea03f2e173174dbbe86060aa31301e60b9b28047 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 4 May 2026 12:28:50 +0300 Subject: [PATCH 1/3] fix(number-field): handle decimal input and sync controlled value correctly #596 --- .../form/number-field/number-field.spec.tsx | 28 ++++++++++- .../form/number-field/number-field.tsx | 49 +++++++++++++++---- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/tedi/components/form/number-field/number-field.spec.tsx b/src/tedi/components/form/number-field/number-field.spec.tsx index b2350576a..076c0ec1a 100644 --- a/src/tedi/components/form/number-field/number-field.spec.tsx +++ b/src/tedi/components/form/number-field/number-field.spec.tsx @@ -63,7 +63,7 @@ describe('NumberField component', () => { fireEvent.click(decrementButton); const input = screen.getByRole('spinbutton'); - expect(input).toHaveValue(0); + expect(input).toHaveValue('0'); }); it('does not increment above the maximum value', () => { @@ -72,7 +72,31 @@ describe('NumberField component', () => { fireEvent.click(incrementButton); const input = screen.getByRole('spinbutton'); - expect(input).toHaveValue(10); + expect(input).toHaveValue('10'); + }); + + it('parses a comma as a decimal separator', () => { + const handleChange = jest.fn(); + render(); + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '1,5' } }); + expect(handleChange).toHaveBeenCalledWith(1.5); + }); + + it('does not fire onChange for partial entries like a lone minus sign', () => { + const handleChange = jest.fn(); + render(); + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '-' } }); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('fires onChange with 0 when the input is cleared', () => { + const handleChange = jest.fn(); + render(); + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '' } }); + expect(handleChange).toHaveBeenCalledWith(0); }); it('renders helper text when provided', () => { diff --git a/src/tedi/components/form/number-field/number-field.tsx b/src/tedi/components/form/number-field/number-field.tsx index a9778b38c..c6166161a 100644 --- a/src/tedi/components/form/number-field/number-field.tsx +++ b/src/tedi/components/form/number-field/number-field.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react'; +import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; @@ -104,15 +104,25 @@ export const NumberField = (props: NumberFieldProps) => { const { getLabel } = useLabels(); const inputRef = useRef(null); + const isFocusedRef = useRef(false); const [inputUpdated, setInputUpdated] = useState(''); const [inputInnerValue, setInputInnerValue] = useState(defaultValue ?? 0); + const [displayValue, setDisplayValue] = useState(() => + value !== undefined ? String(value) : String(defaultValue ?? 0) + ); const getCurrentValue = useMemo( (): number => (onChange && typeof value !== 'undefined' ? value : inputInnerValue), [onChange, value, inputInnerValue] ); + useEffect(() => { + if (!isFocusedRef.current && typeof value !== 'undefined') { + setDisplayValue(String(value)); + } + }, [value]); + const helperId = helper ? `${id}-helper` : undefined; const isInvalid = useCallback( @@ -157,11 +167,29 @@ export const NumberField = (props: NumberFieldProps) => { updateValueUpdatedLabel(returnValue); onChange?.(returnValue); setInputInnerValue(returnValue); + setDisplayValue(String(returnValue)); + }; + + const handleInputChange = ({ currentTarget: { value: rawValue } }: ChangeEvent) => { + setDisplayValue(rawValue); + + const normalized = rawValue.replace(',', '.'); + const parsed = rawValue === '' ? 0 : parseFloat(normalized); + + if (!Number.isNaN(parsed)) { + const clamped = forceToLimits(parsed); + onChange?.(clamped); + setInputInnerValue(clamped); + } + }; + + const handleFocus = () => { + isFocusedRef.current = true; }; - const handleInputChange = ({ currentTarget: { value } }: ChangeEvent) => { - onChange?.(forceToLimits(+value)); - setInputInnerValue(forceToLimits(+value)); + const handleBlur = () => { + isFocusedRef.current = false; + setDisplayValue(String(getCurrentValue)); }; const renderButton = (direction: TDirection) => { @@ -205,17 +233,20 @@ export const NumberField = (props: NumberFieldProps) => { From 1676aee90316aa0665b9d8312f192875d369076f Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 7 May 2026 12:35:55 +0300 Subject: [PATCH 2/3] feat(number-field): add decimalSeparator prop #596 --- .../form/number-field/number-field.spec.tsx | 20 ++++++++++++ .../form/number-field/number-field.tsx | 32 ++++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/tedi/components/form/number-field/number-field.spec.tsx b/src/tedi/components/form/number-field/number-field.spec.tsx index 076c0ec1a..f28fd5d94 100644 --- a/src/tedi/components/form/number-field/number-field.spec.tsx +++ b/src/tedi/components/form/number-field/number-field.spec.tsx @@ -142,4 +142,24 @@ describe('NumberField component', () => { const container = document.querySelector('.tedi-number-field'); expect(container).toHaveClass('tedi-number-field--small'); }); + + it('formats the displayed value with the configured decimal separator', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveValue('1,5'); + }); + + it('reformats the display to the configured decimal separator on blur', () => { + render(); + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '2.5' } }); + fireEvent.blur(input); + expect(input).toHaveValue('2,5'); + }); + + it('uses inputMode="decimal" when decimalSeparator is a comma', () => { + render(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('inputmode', 'decimal'); + }); }); diff --git a/src/tedi/components/form/number-field/number-field.tsx b/src/tedi/components/form/number-field/number-field.tsx index c6166161a..ac3985033 100644 --- a/src/tedi/components/form/number-field/number-field.tsx +++ b/src/tedi/components/form/number-field/number-field.tsx @@ -51,13 +51,20 @@ export interface NumberFieldProps extends BreakpointSupport void; /** * Specifies the input mode for the field (e.g., numeric or decimal). - * @default numeric + * Defaults to `'decimal'` when `decimalPlaces > 0` or `decimalSeparator === ','`, + * otherwise numeric. */ inputMode?: 'numeric' | 'decimal'; /** * Number of decimal places for rounding calculations. */ decimalPlaces?: number; + /** + * Character used as the decimal separator when displaying the value. + * Both `.` and `,` are always accepted as input regardless of this setting. + * @default . + */ + decimalSeparator?: '.' | ','; /** * Minimum allowed value. Disables decrementing below this value and restricts manual input. */ @@ -85,8 +92,9 @@ export const NumberField = (props: NumberFieldProps) => { required, className, size, - inputMode = 'numeric', + inputMode, decimalPlaces, + decimalSeparator = '.', min, max, step = 1, @@ -101,6 +109,14 @@ export const NumberField = (props: NumberFieldProps) => { input, } = getCurrentBreakpointProps(props); + const resolvedInputMode = + inputMode ?? ((decimalPlaces && decimalPlaces > 0) || decimalSeparator === ',' ? 'decimal' : 'numeric'); + + const formatNumber = useCallback( + (num: number): string => (decimalSeparator === ',' ? String(num).replace('.', ',') : String(num)), + [decimalSeparator] + ); + const { getLabel } = useLabels(); const inputRef = useRef(null); @@ -109,7 +125,7 @@ export const NumberField = (props: NumberFieldProps) => { const [inputUpdated, setInputUpdated] = useState(''); const [inputInnerValue, setInputInnerValue] = useState(defaultValue ?? 0); const [displayValue, setDisplayValue] = useState(() => - value !== undefined ? String(value) : String(defaultValue ?? 0) + formatNumber(value !== undefined ? value : defaultValue ?? 0) ); const getCurrentValue = useMemo( @@ -119,9 +135,9 @@ export const NumberField = (props: NumberFieldProps) => { useEffect(() => { if (!isFocusedRef.current && typeof value !== 'undefined') { - setDisplayValue(String(value)); + setDisplayValue(formatNumber(value)); } - }, [value]); + }, [value, formatNumber]); const helperId = helper ? `${id}-helper` : undefined; @@ -167,7 +183,7 @@ export const NumberField = (props: NumberFieldProps) => { updateValueUpdatedLabel(returnValue); onChange?.(returnValue); setInputInnerValue(returnValue); - setDisplayValue(String(returnValue)); + setDisplayValue(formatNumber(returnValue)); }; const handleInputChange = ({ currentTarget: { value: rawValue } }: ChangeEvent) => { @@ -189,7 +205,7 @@ export const NumberField = (props: NumberFieldProps) => { const handleBlur = () => { isFocusedRef.current = false; - setDisplayValue(String(getCurrentValue)); + setDisplayValue(formatNumber(getCurrentValue)); }; const renderButton = (direction: TDirection) => { @@ -240,7 +256,7 @@ export const NumberField = (props: NumberFieldProps) => { aria-describedby={helperId} aria-invalid={isInvalid(getCurrentValue) ? 'true' : 'false'} type="text" - inputMode={inputMode} + inputMode={resolvedInputMode} value={displayValue} required={required} disabled={disabled} From 8ed66cce5c31981ad7c9b93ed9d06ae6ede0be58 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 8 May 2026 08:14:44 +0300 Subject: [PATCH 3/3] fix(number-field): cr fix #596 --- src/tedi/components/form/number-field/number-field.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tedi/components/form/number-field/number-field.tsx b/src/tedi/components/form/number-field/number-field.tsx index ac3985033..21e0e4316 100644 --- a/src/tedi/components/form/number-field/number-field.tsx +++ b/src/tedi/components/form/number-field/number-field.tsx @@ -194,8 +194,9 @@ export const NumberField = (props: NumberFieldProps) => { if (!Number.isNaN(parsed)) { const clamped = forceToLimits(parsed); - onChange?.(clamped); - setInputInnerValue(clamped); + const rounded = roundValue(clamped); + onChange?.(rounded); + setInputInnerValue(rounded); } };