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);
}
};