Skip to content

Commit ee72b42

Browse files
authored
feat(NumberInput): add support locale format validation (#20520)
* feat(NumberInput): add support locale format validation * chore: updated locale formatting validation check * chore: updated locale formatting validation same as Manage * chore: updated code logic * dummy commit * chore: updated validation * dummy commit * chore: added warntext which was removed unintentionally
1 parent c014f1c commit ee72b42

File tree

4 files changed

+227
-8
lines changed

4 files changed

+227
-8
lines changed

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7321,6 +7321,9 @@ Map {
73217321
],
73227322
"type": "oneOf",
73237323
},
7324+
"validate": {
7325+
"type": "func",
7326+
},
73247327
"value": {
73257328
"args": [
73267329
[

packages/react/src/components/NumberInput/NumberInput.stories.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import React, { useState } from 'react';
99
import { NumberInput } from './';
1010
import NumberInputSkeleton from './NumberInput.Skeleton';
11+
import { validateNumberSeparators } from './NumberInput';
1112
import Button from '../Button';
1213
import { AILabel, AILabelContent, AILabelActions } from '../AILabel';
1314
import { IconButton } from '../IconButton';
@@ -250,6 +251,55 @@ WithTypeOfTextControlled.argTypes = {
250251
...sharedArgTypes,
251252
};
252253

254+
export const WithTypeOfCustomValidation = (args) => {
255+
const locale = useDocumentLang();
256+
const [value, setValue] = useState(NaN);
257+
258+
return (
259+
<>
260+
<NumberInput
261+
id="default-number-input"
262+
type="text"
263+
inputMode="decimal"
264+
label="NumberInput label"
265+
helperText="Optional helper text."
266+
validate={validateNumberSeparators}
267+
{...args}
268+
locale={locale}
269+
value={value}
270+
allowEmpty
271+
onChange={(event, state) => {
272+
setValue(state.value);
273+
}}
274+
/>
275+
<button
276+
type="button"
277+
onClick={() => {
278+
setValue(1000);
279+
}}>
280+
set to 1000
281+
</button>
282+
</>
283+
);
284+
};
285+
WithTypeOfCustomValidation.args = {
286+
step: 1,
287+
disabled: false,
288+
invalid: false,
289+
invalidText: `Number is not valid. Must be between ${reusableProps.min} and ${reusableProps.max}`,
290+
helperText: 'Optional helper text.',
291+
warn: false,
292+
warnText:
293+
'Warning message that is really long can wrap to more lines but should not be excessively long.',
294+
size: 'md',
295+
type: 'text',
296+
};
297+
WithTypeOfCustomValidation.argTypes = {
298+
locale: { control: { type: 'text' } },
299+
formatOptions: { control: { type: 'object' } },
300+
...sharedArgTypes,
301+
};
302+
253303
export const Skeleton = () => {
254304
return <NumberInputSkeleton />;
255305
};

packages/react/src/components/NumberInput/NumberInput.tsx

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ type ExcludedAttributes =
6767
export interface NumberInputProps
6868
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, ExcludedAttributes>,
6969
TranslateWithId<TranslationKey> {
70+
/**
71+
* Optional validation function that is called with the input value and locale.
72+
* This is called before other validations, giving consumers the ability
73+
* to short-circuit or extend validation without replacing built-in rules
74+
* @example
75+
* // Using the built-in separator validation
76+
* <NumberInput validate={validateNumberSeparators} />
77+
*
78+
* // Combining with custom validation
79+
* <NumberInput
80+
* validate={(value, locale) => {
81+
* return validateNumberSeparators(value, locale) && customValidation(value)
82+
* }}
83+
* />
84+
* - Return `false` to immediately fail validation.
85+
* - Return `true` to pass this validation, but still run other checks (min, max, required, etc.).
86+
* - Return `undefined` to defer entirely to built-in validation logic.
87+
*
88+
*/
89+
validate?: (value: string, locale: string) => boolean | undefined;
7090
/**
7191
* `true` to allow empty string.
7292
*/
@@ -277,6 +297,78 @@ export interface NumberInputProps
277297
warnText?: ReactNode;
278298
}
279299

300+
const getSeparators = (locale: string) => {
301+
const numberWithGroupAndDecimal = 1234567.89;
302+
303+
const formatted = new Intl.NumberFormat(locale).format(
304+
numberWithGroupAndDecimal
305+
);
306+
307+
// Extract separators using regex
308+
const match = formatted.match(/(\D+)\d{3}(\D+)\d{2}$/);
309+
310+
if (match) {
311+
const groupSeparator = match[1];
312+
const decimalSeparator = match[2];
313+
return { groupSeparator, decimalSeparator };
314+
} else {
315+
return { groupSeparator: null, decimalSeparator: null };
316+
}
317+
};
318+
319+
export const validateNumberSeparators = (
320+
input: string,
321+
locale: string
322+
): boolean => {
323+
// allow empty string
324+
if (input === '' || Number.isNaN(input)) {
325+
return true;
326+
}
327+
const { groupSeparator, decimalSeparator } = getSeparators(locale);
328+
329+
if (!decimalSeparator) {
330+
return !isNaN(Number(input));
331+
}
332+
333+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
334+
335+
let group = '';
336+
if (groupSeparator) {
337+
if (groupSeparator.trim() === '') {
338+
group = '[\\u00A0\\u202F\\s]'; // handle NBSP, narrow NBSP, space
339+
} else {
340+
group = esc(groupSeparator);
341+
}
342+
}
343+
344+
const decimal = esc(decimalSeparator);
345+
346+
// Regex for:
347+
// - integers (with/without grouping)
348+
// - optional decimal with 0+ digits after separator
349+
const regex = new RegExp(
350+
`^-?\\d{1,3}(${group}\\d{3})*(${decimal}\\d*)?$|^-?\\d+(${decimal}\\d*)?$`
351+
);
352+
353+
if (!regex.test(input)) {
354+
return false;
355+
}
356+
357+
// Normalize
358+
let normalized = input;
359+
if (groupSeparator) {
360+
if (groupSeparator.trim() === '') {
361+
normalized = normalized?.replace(/[\u00A0\u202F\s]/g, '');
362+
} else {
363+
normalized = normalized?.split(groupSeparator).join('');
364+
}
365+
}
366+
367+
normalized = normalized?.replace(decimalSeparator, '.');
368+
369+
return !isNaN(Number(normalized));
370+
};
371+
280372
// eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20071
281373
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
282374
(props: NumberInputProps, forwardRef) => {
@@ -312,6 +404,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
312404
translateWithId: t = (id) => defaultTranslations[id],
313405
type = 'number',
314406
defaultValue = type === 'number' ? 0 : NaN,
407+
validate,
315408
warn = false,
316409
warnText = '',
317410
stepStartValue = 0,
@@ -367,7 +460,6 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
367460
* Only used when type="text"
368461
*/
369462
const [previousNumberValue, setPreviousNumberValue] = useState(numberValue);
370-
371463
/**
372464
* The current text value of the input.
373465
* Only used when type=text
@@ -418,9 +510,11 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
418510
const isInputValid = getInputValidity({
419511
allowEmpty,
420512
invalid,
421-
value: type === 'number' ? value : numberValue,
513+
value: validate ? inputValue : type === 'number' ? value : numberValue,
422514
max,
423515
min,
516+
validate,
517+
locale,
424518
});
425519
const normalizedProps = normalize({
426520
id,
@@ -492,7 +586,6 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
492586
const _value =
493587
allowEmpty && event.target.value === '' ? '' : event.target.value;
494588

495-
// When isControlled, setNumberValue will not update numberValue in useControllableState.
496589
setNumberValue(numberParser.parse(_value));
497590
setInputValue(_value);
498591
// The onChange prop isn't called here because it will be called on blur
@@ -559,7 +652,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
559652
getDecimalPlaces(currentValue),
560653
getDecimalPlaces(step)
561654
);
562-
const floatValue = parseFloat(rawValue.toFixed(precision));
655+
const floatValue = parseFloat(Number(rawValue).toFixed(precision));
563656
const newValue = clamp(floatValue, min ?? -Infinity, max ?? Infinity);
564657

565658
const state = {
@@ -693,15 +786,17 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
693786
const formattedValue = isNaN(_numberValue)
694787
? ''
695788
: format(_numberValue);
696-
setInputValue(formattedValue);
697-
789+
const rawValue = e.target.value;
790+
// Validate raw input
791+
const isValid = validate ? validate(rawValue, locale) : true;
792+
setInputValue(isValid ? formattedValue : rawValue);
698793
// Calling format() can alter the number (such as rounding it)
699794
// causing the _numberValue to mismatch the formatted value in
700795
// the input. To avoid this, formattedValue is re-parsed.
701796
const parsedFormattedNewValue =
702797
numberParser.parse(formattedValue);
703798

704-
if (onChange) {
799+
if (onChange && isValid) {
705800
const state = {
706801
value: parsedFormattedNewValue,
707802
direction:
@@ -1013,6 +1108,18 @@ NumberInput.propTypes = {
10131108
* Provide the text that is displayed when the control is in warning state
10141109
*/
10151110
warnText: PropTypes.node,
1111+
1112+
/**
1113+
* Optional validation function that is called with the input value and locale.
1114+
*
1115+
* - Return `false` to immediately fail validation.
1116+
* - Return `true` to pass this validation, but still run other checks (min, max, required, etc.).
1117+
* - Return `undefined` to defer entirely to built-in validation logic.
1118+
*
1119+
* This is called before other validations, giving consumers the ability
1120+
* to short-circuit or extend validation without replacing built-in rules.
1121+
*/
1122+
validate: PropTypes.func,
10161123
};
10171124

10181125
interface LabelProps {
@@ -1073,9 +1180,27 @@ const HelperText = ({ disabled, description, id }: HelperTextProps) => {
10731180
* @param {number} config.value
10741181
* @param {number} config.max
10751182
* @param {number} config.min
1183+
* @param {Function} config.validate
1184+
* @param {string} config.locale
10761185
* @returns {boolean}
10771186
*/
1078-
function getInputValidity({ allowEmpty, invalid, value, max, min }) {
1187+
function getInputValidity({
1188+
allowEmpty,
1189+
invalid,
1190+
value,
1191+
max,
1192+
min,
1193+
validate,
1194+
locale,
1195+
}) {
1196+
if (typeof validate === 'function') {
1197+
const result = validate(value, locale);
1198+
if (result === false) {
1199+
return false; // immediate invalid
1200+
}
1201+
// If true or undefined, continue to further validations
1202+
}
1203+
10791204
if (invalid) {
10801205
return false;
10811206
}

packages/react/src/components/NumberInput/__tests__/NumberInput-test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { render, screen } from '@testing-library/react';
1111
import userEvent from '@testing-library/user-event';
1212
import React, { useState } from 'react';
1313
import { NumberInput } from '../NumberInput';
14+
import { validateNumberSeparators } from '../NumberInput';
1415
import { AILabel } from '../../AILabel';
1516

1617
function translateWithId(id) {
@@ -1470,6 +1471,46 @@ describe('NumberInput', () => {
14701471
await userEvent.click(screen.getByLabelText('increment'));
14711472
expect(input).toHaveValue('20%');
14721473
});
1474+
it('should throw an error if group seperator is in wrong position', async () => {
1475+
render(
1476+
<NumberInput
1477+
type="text"
1478+
label="NumberInput label"
1479+
id="number-input"
1480+
value=""
1481+
step={1}
1482+
validate={validateNumberSeparators}
1483+
translateWithId={translateWithId}
1484+
invalidText="test-invalid-text"
1485+
/>
1486+
);
1487+
const input = screen.getByLabelText('NumberInput label');
1488+
await userEvent.type(input, '1,1');
1489+
await userEvent.tab();
1490+
expect(screen.getByText('test-invalid-text')).toBeInTheDocument();
1491+
expect(screen.getByRole('textbox')).toHaveAttribute('data-invalid');
1492+
});
1493+
1494+
it('should throw an error if group seperator is in wrong position for given locale', async () => {
1495+
render(
1496+
<NumberInput
1497+
type="text"
1498+
label="NumberInput label"
1499+
id="number-input"
1500+
locale="DE"
1501+
value=""
1502+
step={1}
1503+
validate={validateNumberSeparators}
1504+
translateWithId={translateWithId}
1505+
invalidText="test-invalid-text"
1506+
/>
1507+
);
1508+
const input = screen.getByLabelText('NumberInput label');
1509+
await userEvent.type(input, '1.1');
1510+
await userEvent.tab();
1511+
expect(screen.getByText('test-invalid-text')).toBeInTheDocument();
1512+
expect(screen.getByRole('textbox')).toHaveAttribute('data-invalid');
1513+
});
14731514
});
14741515
});
14751516
});

0 commit comments

Comments
 (0)