@@ -67,6 +67,26 @@ type ExcludedAttributes =
6767export 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
281373const 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
10181125interface 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 }
0 commit comments