Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions src/tedi/components/form/number-field/number-field.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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(<NumberField {...defaultProps} onChange={handleChange} min={0} max={100} />);
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(<NumberField {...defaultProps} onChange={handleChange} min={-100} max={100} />);
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(<NumberField {...defaultProps} onChange={handleChange} defaultValue={5} />);
const input = screen.getByRole('spinbutton');
fireEvent.change(input, { target: { value: '' } });
expect(handleChange).toHaveBeenCalledWith(0);
});

it('renders helper text when provided', () => {
Expand Down Expand Up @@ -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(<NumberField {...defaultProps} decimalSeparator="," defaultValue={1.5} min={-10} max={10} />);
const input = screen.getByRole('spinbutton');
expect(input).toHaveValue('1,5');
});

it('reformats the display to the configured decimal separator on blur', () => {
render(<NumberField {...defaultProps} decimalSeparator="," defaultValue={0} min={0} max={10} />);
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(<NumberField {...defaultProps} decimalSeparator="," />);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('inputmode', 'decimal');
});
});
72 changes: 60 additions & 12 deletions src/tedi/components/form/number-field/number-field.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -51,13 +51,20 @@ export interface NumberFieldProps extends BreakpointSupport<NumberFieldBreakpoin
onChange?: (value: number) => 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.
*/
Expand Down Expand Up @@ -85,8 +92,9 @@ export const NumberField = (props: NumberFieldProps) => {
required,
className,
size,
inputMode = 'numeric',
inputMode,
decimalPlaces,
decimalSeparator = '.',
min,
max,
step = 1,
Expand All @@ -101,18 +109,36 @@ export const NumberField = (props: NumberFieldProps) => {
input,
} = getCurrentBreakpointProps<NumberFieldProps>(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<HTMLInputElement>(null);
const isFocusedRef = useRef(false);

const [inputUpdated, setInputUpdated] = useState<string>('');
const [inputInnerValue, setInputInnerValue] = useState<number>(defaultValue ?? 0);
const [displayValue, setDisplayValue] = useState<string>(() =>
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(
Expand Down Expand Up @@ -157,11 +183,30 @@ export const NumberField = (props: NumberFieldProps) => {
updateValueUpdatedLabel(returnValue);
onChange?.(returnValue);
setInputInnerValue(returnValue);
setDisplayValue(formatNumber(returnValue));
};

const handleInputChange = ({ currentTarget: { value: rawValue } }: ChangeEvent<HTMLInputElement>) => {
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);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleFocus = () => {
isFocusedRef.current = true;
};

const handleInputChange = ({ currentTarget: { value } }: ChangeEvent<HTMLInputElement>) => {
onChange?.(forceToLimits(+value));
setInputInnerValue(forceToLimits(+value));
const handleBlur = () => {
isFocusedRef.current = false;
setDisplayValue(formatNumber(getCurrentValue));
};

const renderButton = (direction: TDirection) => {
Expand Down Expand Up @@ -205,17 +250,20 @@ export const NumberField = (props: NumberFieldProps) => {
<input
ref={inputRef}
id={id}
role="spinbutton"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={getCurrentValue}
aria-describedby={helperId}
aria-invalid={isInvalid(getCurrentValue) ? 'true' : 'false'}
type="number"
inputMode={inputMode}
value={getCurrentValue}
min={min}
max={max}
type="text"
inputMode={resolvedInputMode}
value={displayValue}
required={required}
step={step}
disabled={disabled}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
className={InputBEM}
{...input}
/>
Expand Down
Loading