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..f28fd5d94 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', () => { @@ -118,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 a9778b38c..21e0e4316 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'; @@ -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,18 +109,36 @@ 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); + const isFocusedRef = useRef(false); const [inputUpdated, setInputUpdated] = useState(''); const [inputInnerValue, setInputInnerValue] = useState(defaultValue ?? 0); + const [displayValue, setDisplayValue] = useState(() => + formatNumber(value !== undefined ? value : defaultValue ?? 0) + ); const getCurrentValue = useMemo( (): number => (onChange && typeof value !== 'undefined' ? value : inputInnerValue), [onChange, value, inputInnerValue] ); + useEffect(() => { + if (!isFocusedRef.current && typeof value !== 'undefined') { + setDisplayValue(formatNumber(value)); + } + }, [value, formatNumber]); + const helperId = helper ? `${id}-helper` : undefined; const isInvalid = useCallback( @@ -157,11 +183,30 @@ export const NumberField = (props: NumberFieldProps) => { updateValueUpdatedLabel(returnValue); onChange?.(returnValue); setInputInnerValue(returnValue); + setDisplayValue(formatNumber(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); + const rounded = roundValue(clamped); + onChange?.(rounded); + setInputInnerValue(rounded); + } + }; + + const handleFocus = () => { + isFocusedRef.current = true; }; - const handleInputChange = ({ currentTarget: { value } }: ChangeEvent) => { - onChange?.(forceToLimits(+value)); - setInputInnerValue(forceToLimits(+value)); + const handleBlur = () => { + isFocusedRef.current = false; + setDisplayValue(formatNumber(getCurrentValue)); }; const renderButton = (direction: TDirection) => { @@ -205,17 +250,20 @@ export const NumberField = (props: NumberFieldProps) => {