@carbon/react 1.99.0-rc.0 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -207,6 +207,15 @@ export interface NumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInp
207
207
  */
208
208
  warnText?: ReactNode;
209
209
  }
210
+ /**
211
+ * Converts a string with any Unicode numeral system to a JavaScript number.
212
+ * Handles all numeral systems supported by Intl.NumberFormat.
213
+ *
214
+ * @param {string} input - The input string with numerals in any Unicode system
215
+ * @param {string} locale - The locale for parsing separators
216
+ * @returns {number} The parsed number, or NaN if invalid
217
+ */
218
+ export declare const parseNumberWithLocale: (input: string, locale: string) => number;
210
219
  export declare const validateNumberSeparators: (input: string, locale: string) => boolean;
211
220
  declare const NumberInput: React.ForwardRefExoticComponent<NumberInputProps & React.RefAttributes<HTMLInputElement>>;
212
221
  export { NumberInput };
@@ -43,8 +43,41 @@ const getSeparators = locale => {
43
43
  const numberWithGroupAndDecimal = 1234567.89;
44
44
  const formatted = new Intl.NumberFormat(locale).format(numberWithGroupAndDecimal);
45
45
 
46
- // Extract separators using regex
47
- const match = formatted.match(/(\D+)\d{3}(\D+)\d{2}$/);
46
+ // Comprehensive Unicode digit pattern that includes all common numeral systems
47
+ // supported by Intl.NumberFormat across different locales
48
+ const digitPattern = '[' + '\\u0030-\\u0039' +
49
+ // Western
50
+ '\\u0660-\\u0669' +
51
+ // Eastern Arabic
52
+ '\\u0966-\\u096F' +
53
+ // Devanagari
54
+ '\\u09E6-\\u09EF' +
55
+ // Bengali
56
+ '\\uFF10-\\uFF19' +
57
+ // Fullwidth Japanese 0-9
58
+ '一二三四五六七八九〇零' +
59
+ // Kanji digits
60
+ ']';
61
+
62
+ // Non-digit pattern that excludes ALL digit types (not just ASCII 0-9)
63
+ const nonDigitPattern = '[^' + '\\u0030-\\u0039' +
64
+ // Western
65
+ '\\u0660-\\u0669' +
66
+ // Eastern Arabic
67
+ '\\u0966-\\u096F' +
68
+ // Devanagari
69
+ '\\u09E6-\\u09EF' +
70
+ // Bengali
71
+ '\\uFF10-\\uFF19' +
72
+ // Fullwidth Japanese 0-9
73
+ '一二三四五六七八九〇零' +
74
+ // Kanji digits
75
+ ']+';
76
+
77
+ // Extract separators using regex that handles all numeral systems
78
+ // Use nonDigitPattern instead of \D+ to correctly identify separators
79
+ const regex = new RegExp(`(${nonDigitPattern})${digitPattern}{3}(${nonDigitPattern})${digitPattern}{2}$`);
80
+ const match = formatted.match(regex);
48
81
  if (match) {
49
82
  const groupSeparator = match[1];
50
83
  const decimalSeparator = match[2];
@@ -59,11 +92,116 @@ const getSeparators = locale => {
59
92
  };
60
93
  }
61
94
  };
95
+
96
+ // Normalizes all Unicode minus variants to ASCII hyphen-minus (-)
97
+ const normalizeMinus = value => value.replace(/[\u2212\u2012\u2013\u2014\uFE63\uFF0D]/g, '-');
98
+ const normalizeNumericInput = value => value
99
+ // Remove bidi / direction control characters (Arabic keyboards)
100
+ .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '')
101
+ // Normalize Unicode minus variants to ASCII "-"
102
+ .replace(/[\u2212\u2012\u2013\u2014\uFE63\uFF0D]/g, '-');
103
+ /**
104
+ * Converts a string with any Unicode numeral system to a JavaScript number.
105
+ * Handles all numeral systems supported by Intl.NumberFormat.
106
+ *
107
+ * @param {string} input - The input string with numerals in any Unicode system
108
+ * @param {string} locale - The locale for parsing separators
109
+ * @returns {number} The parsed number, or NaN if invalid
110
+ */
111
+ const parseNumberWithLocale = (input, locale) => {
112
+ // Handle empty, null, or undefined inputs
113
+ if (input === '' || input === undefined || input === null) {
114
+ return NaN;
115
+ }
116
+ input = normalizeNumericInput(input);
117
+ const {
118
+ groupSeparator,
119
+ decimalSeparator
120
+ } = getSeparators(locale);
121
+
122
+ // Kanji digit map
123
+ const kanjiMap = {
124
+ 零: '0',
125
+ 〇: '0',
126
+ 一: '1',
127
+ 二: '2',
128
+ 三: '3',
129
+ 四: '4',
130
+ 五: '5',
131
+ 六: '6',
132
+ 七: '7',
133
+ 八: '8',
134
+ 九: '9'
135
+ };
136
+ const digitRanges = [{
137
+ start: 0x0030,
138
+ end: 0x0039,
139
+ base: 0x0030
140
+ }, {
141
+ start: 0x0660,
142
+ end: 0x0669,
143
+ base: 0x0660
144
+ }, {
145
+ start: 0x0966,
146
+ end: 0x096f,
147
+ base: 0x0966
148
+ }, {
149
+ start: 0x09e6,
150
+ end: 0x09ef,
151
+ base: 0x09e6
152
+ }, {
153
+ start: 0xff10,
154
+ end: 0xff19,
155
+ base: 0xff10
156
+ }];
157
+ let normalized = Array.from(input).map(char => {
158
+ // Preserve scientific notation characters
159
+ if (char === 'e' || char === 'E' || char === '+' || char === '-') {
160
+ return char;
161
+ }
162
+
163
+ // Check Kanji first
164
+ if (kanjiMap[char] !== undefined) {
165
+ return kanjiMap[char];
166
+ }
167
+ const code = char.charCodeAt(0);
168
+ for (const range of digitRanges) {
169
+ if (code >= range.start && code <= range.end) {
170
+ return String(code - range.start);
171
+ }
172
+ }
173
+ return char;
174
+ }).join('');
175
+
176
+ // Remove grouping separators
177
+ if (groupSeparator) {
178
+ if (groupSeparator?.trim() === '') {
179
+ normalized = normalized?.replace(/[\u00A0\u202F\s]/g, '');
180
+ } else {
181
+ if (decimalSeparator !== ',' && decimalSeparator !== '٬') {
182
+ normalized = normalized?.replace(/[,٬]/g, '');
183
+ }
184
+ if (groupSeparator !== ',' && groupSeparator !== '٬') {
185
+ const escaped = groupSeparator?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
186
+ normalized = normalized?.replace(new RegExp(escaped, 'g'), '');
187
+ }
188
+ }
189
+ }
190
+ normalized = normalized.replace(/٫/g, '.');
191
+ if (decimalSeparator && decimalSeparator !== '.' && decimalSeparator !== '٫') {
192
+ const escaped = decimalSeparator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
193
+ normalized = normalized.replace(new RegExp(escaped, 'g'), '.');
194
+ }
195
+ normalized = normalizeMinus(normalized);
196
+ return Number(normalized);
197
+ };
62
198
  const validateNumberSeparators = (input, locale) => {
63
- // allow empty string
64
- if (input === '' || Number.isNaN(input)) {
199
+ if (input === '') {
65
200
  return true;
66
201
  }
202
+
203
+ // Normalize bidi marks + minus signs FIRST
204
+ input = normalizeNumericInput(input);
67
205
  const {
68
206
  groupSeparator,
69
207
  decimalSeparator
@@ -72,35 +210,54 @@ const validateNumberSeparators = (input, locale) => {
72
210
  return !isNaN(Number(input));
73
211
  }
74
212
  const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
213
+ const digit = '[' + '\\u0030-\\u0039' + '\\u0660-\\u0669' + '\\u0966-\\u096F' + '\\u09E6-\\u09EF' + '\\uFF10-\\uFF19' + '一二三四五六七八九〇零' + ']';
214
+
215
+ // Group separator regex
75
216
  let group = '';
76
217
  if (groupSeparator) {
77
- if (groupSeparator.trim() === '') {
78
- group = '[\\u00A0\\u202F\\s]'; // handle NBSP, narrow NBSP, space
218
+ if (groupSeparator?.trim() === '') {
219
+ group = '[\\u00A0\\u202F\\s]';
220
+ } else if (groupSeparator === ',' || groupSeparator === '٬') {
221
+ group = '[,٬]';
79
222
  } else {
80
223
  group = esc(groupSeparator);
81
224
  }
82
225
  }
83
- const decimal = esc(decimalSeparator);
84
226
 
85
- // Regex for:
86
- // - integers (with/without grouping)
87
- // - optional decimal with 0+ digits after separator
88
- const regex = new RegExp(`^-?\\d{1,3}(${group}\\d{3})*(${decimal}\\d*)?$|^-?\\d+(${decimal}\\d*)?$`);
89
- if (!regex.test(input)) {
90
- return false;
227
+ // Decimal separator regex
228
+ let decimal = esc(decimalSeparator);
229
+ if (decimalSeparator === '.' || decimalSeparator === '٫') {
230
+ decimal = '[.٫]';
91
231
  }
232
+ const sign = '[\\-\\u2212]?';
233
+ const scientific = `([eE][+-]?${digit}+)?`;
92
234
 
93
- // Normalize
94
- let normalized = input;
95
- if (groupSeparator) {
96
- if (groupSeparator.trim() === '') {
97
- normalized = normalized?.replace(/[\u00A0\u202F\s]/g, '');
98
- } else {
99
- normalized = normalized?.split(groupSeparator).join('');
100
- }
235
+ // Detect if grouping is used AT ALL
236
+ const usesGrouping = group && (groupSeparator?.trim() === '' ? /[\u00A0\u202F\s]/.test(input) : groupSeparator === ',' || groupSeparator === '٬' ? /[,٬]/.test(input) : groupSeparator ? input.includes(groupSeparator) : false);
237
+ const scientificMatch = input?.match(/^([^eE]+)([eE][+-]?.*)?$/);
238
+ const baseNumber = scientificMatch ? scientificMatch[1] : input;
239
+
240
+ // Split integer part from the base number - handle both decimal separator variants
241
+ let integerPart;
242
+ if (decimalSeparator === '.' || decimalSeparator === '٫') {
243
+ // Split by either . or ٫
244
+ integerPart = baseNumber?.split(/[.,]/)[0];
245
+ } else {
246
+ integerPart = baseNumber?.split(decimalSeparator)[0];
101
247
  }
102
- normalized = normalized?.replace(decimalSeparator, '.');
103
- return !isNaN(Number(normalized));
248
+
249
+ // STEP 1: strict integer validation
250
+ // When grouping is used, we need to handle two cases:
251
+ // 1. Numbers with 1-3 digits (no separator required): 1, 12, 123
252
+ // 2. Numbers with 4+ digits (separator required): 1,234 or 12,345 or 123,456
253
+ const integerRegex = usesGrouping ? new RegExp(`^${sign}(${digit}{1,3}|${digit}{1,3}(${group}${digit}{3})+)$`) : new RegExp(`^${sign}${digit}+$`);
254
+ if (!integerRegex.test(integerPart)) {
255
+ return false;
256
+ }
257
+
258
+ // STEP 2: full number validation
259
+ const fullRegex = new RegExp(`^${sign}${digit}+` + (usesGrouping ? `(${group}${digit}{3})*` : '') + `(${decimal}${digit}+)?${scientific}$`);
260
+ return fullRegex.test(input);
104
261
  };
105
262
 
106
263
  // eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452
@@ -292,8 +449,9 @@ const NumberInput = /*#__PURE__*/React.forwardRef((props, forwardRef) => {
292
449
  }
293
450
  };
294
451
  const outerElementClasses = cx(`${prefix}--form-item`, {
295
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
296
- [customClassName]: !!customClassName,
452
+ ...(customClassName ? {
453
+ [customClassName]: true
454
+ } : {}),
297
455
  [`${prefix}--number-input--fluid--invalid`]: isFluid && normalizedProps.invalid,
298
456
  [`${prefix}--number-input--fluid--focus`]: isFluid && isFocused,
299
457
  [`${prefix}--number-input--fluid--disabled`]: isFluid && disabled
@@ -764,23 +922,33 @@ function getInputValidity({
764
922
  validate,
765
923
  locale
766
924
  }) {
767
- if (typeof validate === 'function') {
768
- const result = validate(value, locale);
769
- if (result === false) {
770
- return false; // immediate invalid
771
- }
772
- // If true or undefined, continue to further validations
773
- }
774
925
  if (invalid) {
775
926
  return false;
776
927
  }
777
- if (value === '') {
778
- return allowEmpty;
928
+
929
+ // Skip validation if value is empty and allowEmpty
930
+ if (value === '') return allowEmpty;
931
+
932
+ // Normalize the value
933
+ let numericValue;
934
+ if (typeof value === 'string') {
935
+ numericValue = parseNumberWithLocale(value, locale); // safe: handles Arabic, Kanji, etc.
936
+ } else {
937
+ numericValue = value;
779
938
  }
780
- if (value > max || value < min) {
781
- return false;
939
+
940
+ // Use custom validate ONLY for formatting, not numeric comparison
941
+ if (validate && typeof value === 'string') {
942
+ const isFormatValid = validate(value, locale);
943
+ if (isFormatValid === false) {
944
+ return false; // invalid format
945
+ }
782
946
  }
783
- return true;
947
+
948
+ // Check min/max bounds
949
+ if (max !== undefined && numericValue > max) return false;
950
+ if (min !== undefined && numericValue < min) return false;
951
+ return true; // valid
784
952
  }
785
953
 
786
954
  /**
@@ -801,4 +969,4 @@ function disableWheel(e) {
801
969
  e.preventDefault();
802
970
  }
803
971
 
804
- export { NumberInput, validateNumberSeparators };
972
+ export { NumberInput, parseNumberWithLocale, validateNumberSeparators };
@@ -207,6 +207,15 @@ export interface NumberInputProps extends Omit<React.InputHTMLAttributes<HTMLInp
207
207
  */
208
208
  warnText?: ReactNode;
209
209
  }
210
+ /**
211
+ * Converts a string with any Unicode numeral system to a JavaScript number.
212
+ * Handles all numeral systems supported by Intl.NumberFormat.
213
+ *
214
+ * @param {string} input - The input string with numerals in any Unicode system
215
+ * @param {string} locale - The locale for parsing separators
216
+ * @returns {number} The parsed number, or NaN if invalid
217
+ */
218
+ export declare const parseNumberWithLocale: (input: string, locale: string) => number;
210
219
  export declare const validateNumberSeparators: (input: string, locale: string) => boolean;
211
220
  declare const NumberInput: React.ForwardRefExoticComponent<NumberInputProps & React.RefAttributes<HTMLInputElement>>;
212
221
  export { NumberInput };
@@ -45,8 +45,41 @@ const getSeparators = locale => {
45
45
  const numberWithGroupAndDecimal = 1234567.89;
46
46
  const formatted = new Intl.NumberFormat(locale).format(numberWithGroupAndDecimal);
47
47
 
48
- // Extract separators using regex
49
- const match = formatted.match(/(\D+)\d{3}(\D+)\d{2}$/);
48
+ // Comprehensive Unicode digit pattern that includes all common numeral systems
49
+ // supported by Intl.NumberFormat across different locales
50
+ const digitPattern = '[' + '\\u0030-\\u0039' +
51
+ // Western
52
+ '\\u0660-\\u0669' +
53
+ // Eastern Arabic
54
+ '\\u0966-\\u096F' +
55
+ // Devanagari
56
+ '\\u09E6-\\u09EF' +
57
+ // Bengali
58
+ '\\uFF10-\\uFF19' +
59
+ // Fullwidth Japanese 0-9
60
+ '一二三四五六七八九〇零' +
61
+ // Kanji digits
62
+ ']';
63
+
64
+ // Non-digit pattern that excludes ALL digit types (not just ASCII 0-9)
65
+ const nonDigitPattern = '[^' + '\\u0030-\\u0039' +
66
+ // Western
67
+ '\\u0660-\\u0669' +
68
+ // Eastern Arabic
69
+ '\\u0966-\\u096F' +
70
+ // Devanagari
71
+ '\\u09E6-\\u09EF' +
72
+ // Bengali
73
+ '\\uFF10-\\uFF19' +
74
+ // Fullwidth Japanese 0-9
75
+ '一二三四五六七八九〇零' +
76
+ // Kanji digits
77
+ ']+';
78
+
79
+ // Extract separators using regex that handles all numeral systems
80
+ // Use nonDigitPattern instead of \D+ to correctly identify separators
81
+ const regex = new RegExp(`(${nonDigitPattern})${digitPattern}{3}(${nonDigitPattern})${digitPattern}{2}$`);
82
+ const match = formatted.match(regex);
50
83
  if (match) {
51
84
  const groupSeparator = match[1];
52
85
  const decimalSeparator = match[2];
@@ -61,11 +94,116 @@ const getSeparators = locale => {
61
94
  };
62
95
  }
63
96
  };
97
+
98
+ // Normalizes all Unicode minus variants to ASCII hyphen-minus (-)
99
+ const normalizeMinus = value => value.replace(/[\u2212\u2012\u2013\u2014\uFE63\uFF0D]/g, '-');
100
+ const normalizeNumericInput = value => value
101
+ // Remove bidi / direction control characters (Arabic keyboards)
102
+ .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '')
103
+ // Normalize Unicode minus variants to ASCII "-"
104
+ .replace(/[\u2212\u2012\u2013\u2014\uFE63\uFF0D]/g, '-');
105
+ /**
106
+ * Converts a string with any Unicode numeral system to a JavaScript number.
107
+ * Handles all numeral systems supported by Intl.NumberFormat.
108
+ *
109
+ * @param {string} input - The input string with numerals in any Unicode system
110
+ * @param {string} locale - The locale for parsing separators
111
+ * @returns {number} The parsed number, or NaN if invalid
112
+ */
113
+ const parseNumberWithLocale = (input, locale) => {
114
+ // Handle empty, null, or undefined inputs
115
+ if (input === '' || input === undefined || input === null) {
116
+ return NaN;
117
+ }
118
+ input = normalizeNumericInput(input);
119
+ const {
120
+ groupSeparator,
121
+ decimalSeparator
122
+ } = getSeparators(locale);
123
+
124
+ // Kanji digit map
125
+ const kanjiMap = {
126
+ 零: '0',
127
+ 〇: '0',
128
+ 一: '1',
129
+ 二: '2',
130
+ 三: '3',
131
+ 四: '4',
132
+ 五: '5',
133
+ 六: '6',
134
+ 七: '7',
135
+ 八: '8',
136
+ 九: '9'
137
+ };
138
+ const digitRanges = [{
139
+ start: 0x0030,
140
+ end: 0x0039,
141
+ base: 0x0030
142
+ }, {
143
+ start: 0x0660,
144
+ end: 0x0669,
145
+ base: 0x0660
146
+ }, {
147
+ start: 0x0966,
148
+ end: 0x096f,
149
+ base: 0x0966
150
+ }, {
151
+ start: 0x09e6,
152
+ end: 0x09ef,
153
+ base: 0x09e6
154
+ }, {
155
+ start: 0xff10,
156
+ end: 0xff19,
157
+ base: 0xff10
158
+ }];
159
+ let normalized = Array.from(input).map(char => {
160
+ // Preserve scientific notation characters
161
+ if (char === 'e' || char === 'E' || char === '+' || char === '-') {
162
+ return char;
163
+ }
164
+
165
+ // Check Kanji first
166
+ if (kanjiMap[char] !== undefined) {
167
+ return kanjiMap[char];
168
+ }
169
+ const code = char.charCodeAt(0);
170
+ for (const range of digitRanges) {
171
+ if (code >= range.start && code <= range.end) {
172
+ return String(code - range.start);
173
+ }
174
+ }
175
+ return char;
176
+ }).join('');
177
+
178
+ // Remove grouping separators
179
+ if (groupSeparator) {
180
+ if (groupSeparator?.trim() === '') {
181
+ normalized = normalized?.replace(/[\u00A0\u202F\s]/g, '');
182
+ } else {
183
+ if (decimalSeparator !== ',' && decimalSeparator !== '٬') {
184
+ normalized = normalized?.replace(/[,٬]/g, '');
185
+ }
186
+ if (groupSeparator !== ',' && groupSeparator !== '٬') {
187
+ const escaped = groupSeparator?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
+ normalized = normalized?.replace(new RegExp(escaped, 'g'), '');
189
+ }
190
+ }
191
+ }
192
+ normalized = normalized.replace(/٫/g, '.');
193
+ if (decimalSeparator && decimalSeparator !== '.' && decimalSeparator !== '٫') {
194
+ const escaped = decimalSeparator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195
+ normalized = normalized.replace(new RegExp(escaped, 'g'), '.');
196
+ }
197
+ normalized = normalizeMinus(normalized);
198
+ return Number(normalized);
199
+ };
64
200
  const validateNumberSeparators = (input, locale) => {
65
- // allow empty string
66
- if (input === '' || Number.isNaN(input)) {
201
+ if (input === '') {
67
202
  return true;
68
203
  }
204
+
205
+ // Normalize bidi marks + minus signs FIRST
206
+ input = normalizeNumericInput(input);
69
207
  const {
70
208
  groupSeparator,
71
209
  decimalSeparator
@@ -74,35 +212,54 @@ const validateNumberSeparators = (input, locale) => {
74
212
  return !isNaN(Number(input));
75
213
  }
76
214
  const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
215
+ const digit = '[' + '\\u0030-\\u0039' + '\\u0660-\\u0669' + '\\u0966-\\u096F' + '\\u09E6-\\u09EF' + '\\uFF10-\\uFF19' + '一二三四五六七八九〇零' + ']';
216
+
217
+ // Group separator regex
77
218
  let group = '';
78
219
  if (groupSeparator) {
79
- if (groupSeparator.trim() === '') {
80
- group = '[\\u00A0\\u202F\\s]'; // handle NBSP, narrow NBSP, space
220
+ if (groupSeparator?.trim() === '') {
221
+ group = '[\\u00A0\\u202F\\s]';
222
+ } else if (groupSeparator === ',' || groupSeparator === '٬') {
223
+ group = '[,٬]';
81
224
  } else {
82
225
  group = esc(groupSeparator);
83
226
  }
84
227
  }
85
- const decimal = esc(decimalSeparator);
86
228
 
87
- // Regex for:
88
- // - integers (with/without grouping)
89
- // - optional decimal with 0+ digits after separator
90
- const regex = new RegExp(`^-?\\d{1,3}(${group}\\d{3})*(${decimal}\\d*)?$|^-?\\d+(${decimal}\\d*)?$`);
91
- if (!regex.test(input)) {
92
- return false;
229
+ // Decimal separator regex
230
+ let decimal = esc(decimalSeparator);
231
+ if (decimalSeparator === '.' || decimalSeparator === '٫') {
232
+ decimal = '[.٫]';
93
233
  }
234
+ const sign = '[\\-\\u2212]?';
235
+ const scientific = `([eE][+-]?${digit}+)?`;
94
236
 
95
- // Normalize
96
- let normalized = input;
97
- if (groupSeparator) {
98
- if (groupSeparator.trim() === '') {
99
- normalized = normalized?.replace(/[\u00A0\u202F\s]/g, '');
100
- } else {
101
- normalized = normalized?.split(groupSeparator).join('');
102
- }
237
+ // Detect if grouping is used AT ALL
238
+ const usesGrouping = group && (groupSeparator?.trim() === '' ? /[\u00A0\u202F\s]/.test(input) : groupSeparator === ',' || groupSeparator === '٬' ? /[,٬]/.test(input) : groupSeparator ? input.includes(groupSeparator) : false);
239
+ const scientificMatch = input?.match(/^([^eE]+)([eE][+-]?.*)?$/);
240
+ const baseNumber = scientificMatch ? scientificMatch[1] : input;
241
+
242
+ // Split integer part from the base number - handle both decimal separator variants
243
+ let integerPart;
244
+ if (decimalSeparator === '.' || decimalSeparator === '٫') {
245
+ // Split by either . or ٫
246
+ integerPart = baseNumber?.split(/[.,]/)[0];
247
+ } else {
248
+ integerPart = baseNumber?.split(decimalSeparator)[0];
103
249
  }
104
- normalized = normalized?.replace(decimalSeparator, '.');
105
- return !isNaN(Number(normalized));
250
+
251
+ // STEP 1: strict integer validation
252
+ // When grouping is used, we need to handle two cases:
253
+ // 1. Numbers with 1-3 digits (no separator required): 1, 12, 123
254
+ // 2. Numbers with 4+ digits (separator required): 1,234 or 12,345 or 123,456
255
+ const integerRegex = usesGrouping ? new RegExp(`^${sign}(${digit}{1,3}|${digit}{1,3}(${group}${digit}{3})+)$`) : new RegExp(`^${sign}${digit}+$`);
256
+ if (!integerRegex.test(integerPart)) {
257
+ return false;
258
+ }
259
+
260
+ // STEP 2: full number validation
261
+ const fullRegex = new RegExp(`^${sign}${digit}+` + (usesGrouping ? `(${group}${digit}{3})*` : '') + `(${decimal}${digit}+)?${scientific}$`);
262
+ return fullRegex.test(input);
106
263
  };
107
264
 
108
265
  // eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452
@@ -294,8 +451,9 @@ const NumberInput = /*#__PURE__*/React.forwardRef((props, forwardRef) => {
294
451
  }
295
452
  };
296
453
  const outerElementClasses = cx(`${prefix}--form-item`, {
297
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
298
- [customClassName]: !!customClassName,
454
+ ...(customClassName ? {
455
+ [customClassName]: true
456
+ } : {}),
299
457
  [`${prefix}--number-input--fluid--invalid`]: isFluid && normalizedProps.invalid,
300
458
  [`${prefix}--number-input--fluid--focus`]: isFluid && isFocused,
301
459
  [`${prefix}--number-input--fluid--disabled`]: isFluid && disabled
@@ -766,23 +924,33 @@ function getInputValidity({
766
924
  validate,
767
925
  locale
768
926
  }) {
769
- if (typeof validate === 'function') {
770
- const result = validate(value, locale);
771
- if (result === false) {
772
- return false; // immediate invalid
773
- }
774
- // If true or undefined, continue to further validations
775
- }
776
927
  if (invalid) {
777
928
  return false;
778
929
  }
779
- if (value === '') {
780
- return allowEmpty;
930
+
931
+ // Skip validation if value is empty and allowEmpty
932
+ if (value === '') return allowEmpty;
933
+
934
+ // Normalize the value
935
+ let numericValue;
936
+ if (typeof value === 'string') {
937
+ numericValue = parseNumberWithLocale(value, locale); // safe: handles Arabic, Kanji, etc.
938
+ } else {
939
+ numericValue = value;
781
940
  }
782
- if (value > max || value < min) {
783
- return false;
941
+
942
+ // Use custom validate ONLY for formatting, not numeric comparison
943
+ if (validate && typeof value === 'string') {
944
+ const isFormatValid = validate(value, locale);
945
+ if (isFormatValid === false) {
946
+ return false; // invalid format
947
+ }
784
948
  }
785
- return true;
949
+
950
+ // Check min/max bounds
951
+ if (max !== undefined && numericValue > max) return false;
952
+ if (min !== undefined && numericValue < min) return false;
953
+ return true; // valid
786
954
  }
787
955
 
788
956
  /**
@@ -804,4 +972,5 @@ function disableWheel(e) {
804
972
  }
805
973
 
806
974
  exports.NumberInput = NumberInput;
975
+ exports.parseNumberWithLocale = parseNumberWithLocale;
807
976
  exports.validateNumberSeparators = validateNumberSeparators;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@carbon/react",
3
3
  "description": "React components for the Carbon Design System",
4
- "version": "1.99.0-rc.0",
4
+ "version": "1.99.0",
5
5
  "license": "Apache-2.0",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -53,9 +53,9 @@
53
53
  "dependencies": {
54
54
  "@babel/runtime": "^7.27.3",
55
55
  "@carbon/feature-flags": ">=0.32.0",
56
- "@carbon/icons-react": "^11.73.0-rc.0",
57
- "@carbon/layout": "^11.46.0-rc.0",
58
- "@carbon/styles": "^1.98.0-rc.0",
56
+ "@carbon/icons-react": "^11.73.0",
57
+ "@carbon/layout": "^11.46.0",
58
+ "@carbon/styles": "^1.98.0",
59
59
  "@carbon/utilities": "^0.14.0",
60
60
  "@floating-ui/react": "^0.27.4",
61
61
  "@ibm/telemetry-js": "^1.5.0",
@@ -78,8 +78,8 @@
78
78
  "@babel/preset-env": "^7.27.2",
79
79
  "@babel/preset-react": "^7.27.1",
80
80
  "@babel/preset-typescript": "^7.27.1",
81
- "@carbon/test-utils": "^10.39.0-rc.0",
82
- "@carbon/themes": "^11.66.0-rc.0",
81
+ "@carbon/test-utils": "^10.39.0",
82
+ "@carbon/themes": "^11.66.0",
83
83
  "@figma/code-connect": "^1.3.5",
84
84
  "@rollup/plugin-babel": "^6.0.0",
85
85
  "@rollup/plugin-commonjs": "^28.0.3",
@@ -131,5 +131,5 @@
131
131
  "**/*.scss",
132
132
  "**/*.css"
133
133
  ],
134
- "gitHead": "8263c72357fc43c00e66c9030698f759ef7977ce"
134
+ "gitHead": "ce7846aab8a3a1afe9b03d2d07d267af6cdb6ac2"
135
135
  }