UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

206 lines (194 loc) 7.21 kB
import { getFormatter } from "../../utils/formatNumber.js"; export const HAN_NUMERALS = ['零', '〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; // Map Han numeral characters to ASCII digits. Includes both forms of zero. export const HAN_NUMERAL_TO_DIGIT = { 零: '0', 〇: '0', 一: '1', 二: '2', 三: '3', 四: '4', 五: '5', 六: '6', 七: '7', 八: '8', 九: '9' }; export const ARABIC_NUMERALS = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩']; export const PERSIAN_NUMERALS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; export const FULLWIDTH_NUMERALS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; export const PERCENTAGES = ['%', '٪', '%', '﹪']; export const PERMILLE = ['‰', '؉']; export const UNICODE_MINUS_SIGNS = ['−', '-', '‒', '–', '—', '﹣']; export const UNICODE_PLUS_SIGNS = ['+', '﹢']; // Fullwidth punctuation common in CJK inputs export const FULLWIDTH_DECIMAL = '.'; // U+FF0E export const FULLWIDTH_GROUP = ','; // U+FF0C export const ARABIC_RE = new RegExp(`[${ARABIC_NUMERALS.join('')}]`, 'g'); export const PERSIAN_RE = new RegExp(`[${PERSIAN_NUMERALS.join('')}]`, 'g'); export const FULLWIDTH_RE = new RegExp(`[${FULLWIDTH_NUMERALS.join('')}]`, 'g'); export const HAN_RE = new RegExp(`[${HAN_NUMERALS.join('')}]`, 'g'); export const PERCENT_RE = new RegExp(`[${PERCENTAGES.join('')}]`); export const PERMILLE_RE = new RegExp(`[${PERMILLE.join('')}]`); // Detection regexes (non-global to avoid lastIndex side effects) export const ARABIC_DETECT_RE = /[٠١٢٣٤٥٦٧٨٩]/; export const PERSIAN_DETECT_RE = /[۰۱۲۳۴۵۶۷۸۹]/; export const HAN_DETECT_RE = /[零〇一二三四五六七八九]/; export const FULLWIDTH_DETECT_RE = new RegExp(`[${FULLWIDTH_NUMERALS.join('')}]`); export const BASE_NON_NUMERIC_SYMBOLS = ['.', ',', FULLWIDTH_DECIMAL, FULLWIDTH_GROUP, '٫', '٬']; export const SPACE_SEPARATOR_RE = /\p{Zs}/u; export const PLUS_SIGNS_WITH_ASCII = ['+', ...UNICODE_PLUS_SIGNS]; export const MINUS_SIGNS_WITH_ASCII = ['-', ...UNICODE_MINUS_SIGNS]; const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapeClassChar = s => s.replace(/[-\\\]^]/g, m => `\\${m}`); // escape for use inside [...] const charClassFrom = chars => `[${chars.map(escapeClassChar).join('')}]`; const ANY_MINUS_CLASS = charClassFrom(['-'].concat(UNICODE_MINUS_SIGNS)); const ANY_PLUS_CLASS = charClassFrom(['+'].concat(UNICODE_PLUS_SIGNS)); export const ANY_MINUS_RE = new RegExp(ANY_MINUS_CLASS, 'gu'); export const ANY_PLUS_RE = new RegExp(ANY_PLUS_CLASS, 'gu'); export const ANY_MINUS_DETECT_RE = new RegExp(ANY_MINUS_CLASS); export const ANY_PLUS_DETECT_RE = new RegExp(ANY_PLUS_CLASS); export function getNumberLocaleDetails(locale, options) { const parts = getFormatter(locale, options).formatToParts(11111.1); const result = {}; parts.forEach(part => { result[part.type] = part.value; }); // The formatting options may result in not returning a decimal. getFormatter(locale).formatToParts(0.1).forEach(part => { if (part.type === 'decimal') { result[part.type] = part.value; } }); return result; } export function parseNumber(formattedNumber, locale, options) { if (formattedNumber == null) { return null; } // Normalize control characters and whitespace; remove bidi/format controls let input = String(formattedNumber).replace(/\p{Cf}/gu, '').trim(); // Normalize unicode minus/plus to ASCII, handle leading/trailing signs input = input.replace(ANY_MINUS_RE, '-').replace(ANY_PLUS_RE, '+'); let isNegative = false; // Trailing sign, e.g. "1234-" / "1234+" const trailing = input.match(/([+-])\s*$/); if (trailing) { if (trailing[1] === '-') { isNegative = true; } input = input.replace(/([+-])\s*$/, ''); } // Leading sign const leading = input.match(/^\s*([+-])/); if (leading) { if (leading[1] === '-') { isNegative = true; } input = input.replace(/^\s*[+-]/, ''); } // Heuristic locale detection let computedLocale = locale; if (computedLocale === undefined) { if (ARABIC_DETECT_RE.test(input) || PERSIAN_DETECT_RE.test(input)) { computedLocale = 'ar'; } else if (HAN_DETECT_RE.test(input)) { computedLocale = 'zh'; } } const { group, decimal, currency } = getNumberLocaleDetails(computedLocale, options); // Build robust unit regex from all unit parts (such as "km/h") const unitParts = getFormatter(computedLocale, options).formatToParts(1).filter(p => p.type === 'unit').map(p => escapeRegExp(p.value)); const unitRegex = unitParts.length ? new RegExp(unitParts.join('|'), 'g') : null; let groupRegex = null; if (group) { // Check if the group separator is a space-like character. // If so, we'll replace all such characters with an empty string. groupRegex = /\p{Zs}/u.test(group) ? /\p{Zs}/gu : new RegExp(escapeRegExp(group), 'g'); } const replacements = [{ regex: group ? groupRegex : null, replacement: '' }, { regex: decimal ? new RegExp(escapeRegExp(decimal), 'g') : null, replacement: '.' }, // Fullwidth punctuation { regex: /./g, replacement: '.' }, // FULLWIDTH_DECIMAL { regex: /,/g, replacement: '' }, // FULLWIDTH_GROUP // Arabic punctuation { regex: /٫/g, replacement: '.' }, // ARABIC DECIMAL SEPARATOR (U+066B) { regex: /٬/g, replacement: '' }, // ARABIC THOUSANDS SEPARATOR (U+066C) // Currency & unit labels { regex: currency ? new RegExp(escapeRegExp(currency), 'g') : null, replacement: '' }, { regex: unitRegex, replacement: '' }, // Numeral systems to ASCII digits { regex: ARABIC_RE, replacement: ch => String(ARABIC_NUMERALS.indexOf(ch)) }, { regex: PERSIAN_RE, replacement: ch => String(PERSIAN_NUMERALS.indexOf(ch)) }, { regex: FULLWIDTH_RE, replacement: ch => String(FULLWIDTH_NUMERALS.indexOf(ch)) }, { regex: HAN_RE, replacement: ch => HAN_NUMERAL_TO_DIGIT[ch] }]; let unformatted = replacements.reduce((acc, { regex, replacement }) => { return regex ? acc.replace(regex, replacement) : acc; }, input); // Mixed-locale safety: keep only the last '.' as decimal const lastDot = unformatted.lastIndexOf('.'); if (lastDot !== -1) { unformatted = `${unformatted.slice(0, lastDot).replace(/\./g, '')}.${unformatted.slice(lastDot + 1).replace(/\./g, '')}`; } // Guard against Infinity inputs (ASCII and symbol) if (/^[-+]?Infinity$/i.test(input) || /[∞]/.test(input)) { return null; } const parseTarget = (isNegative ? '-' : '') + unformatted; let num = parseFloat(parseTarget); const style = options?.style; const isUnitPercent = style === 'unit' && options?.unit === 'percent'; const hasPercentSymbol = PERCENT_RE.test(formattedNumber) || style === 'percent'; const hasPermilleSymbol = PERMILLE_RE.test(formattedNumber); if (hasPermilleSymbol) { num /= 1000; } else if (!isUnitPercent && hasPercentSymbol) { num /= 100; } if (Number.isNaN(num)) { return null; } return num; }