/** * Ported from * `packages/react/src/number-field/utils/parse.ts`. Locale-aware numeric * parsing for every numeral system / decimal-group separator / currency / * unit combination `Intl.NumberFormat` can produce. * * Pure helpers — no Solid reactivity. Safe to call in render and event * handlers alike. */ const FORMATTER_CACHE = new Map(); function getFormatter( locale?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions, ): Intl.NumberFormat { const key = JSON.stringify({ locale, options }); const cached = FORMATTER_CACHE.get(key); if (cached) return cached; const formatter = new Intl.NumberFormat(locale, options); FORMATTER_CACHE.set(key, formatter); return formatter; } const HAN_NUMERALS = [ "零", "〇", "一", "二", "三", "四", "五", "六", "七", "八", "九", ]; const HAN_NUMERAL_TO_DIGIT: Record = { 零: "0", 〇: "0", 一: "1", 二: "2", 三: "3", 四: "4", 五: "5", 六: "6", 七: "7", 八: "8", 九: "9", }; const ARABIC_NUMERALS = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"]; const PERSIAN_NUMERALS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; const FULLWIDTH_NUMERALS = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ]; export const PERCENTAGES = ["%", "٪", "%", "﹪"]; export const PERMILLE = ["‰", "؉"]; const UNICODE_MINUS_SIGNS = ["−", "-", "‒", "–", "—", "﹣"]; const UNICODE_PLUS_SIGNS = ["+", "﹢"]; const FULLWIDTH_DECIMAL = "."; const FULLWIDTH_GROUP = ","; const ARABIC_RE = new RegExp(`[${ARABIC_NUMERALS.join("")}]`, "g"); const PERSIAN_RE = new RegExp(`[${PERSIAN_NUMERALS.join("")}]`, "g"); const FULLWIDTH_RE = new RegExp(`[${FULLWIDTH_NUMERALS.join("")}]`, "g"); const HAN_RE = new RegExp(`[${HAN_NUMERALS.join("")}]`, "g"); const PERCENT_RE = new RegExp(`[${PERCENTAGES.join("")}]`); const PERMILLE_RE = new RegExp(`[${PERMILLE.join("")}]`); 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, "٫", "٬", ] as const; 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: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapeClassChar = (s: string): string => s.replace(/[-\\\]^]/g, (m) => `\\${m}`); const charClassFrom = (chars: string[]): string => `[${chars.map(escapeClassChar).join("")}]`; const ANY_MINUS_CLASS = charClassFrom(["-", ...UNICODE_MINUS_SIGNS]); const ANY_PLUS_CLASS = charClassFrom(["+", ...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?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions, ): Partial> { const parts = getFormatter(locale, options).formatToParts(11111.1); const result: Partial< Record > = {}; for (const part of parts) { result[part.type] = part.value; } // The formatting options may result in not returning a decimal. for (const part of getFormatter(locale).formatToParts(0.1)) { if (part.type === "decimal") { result[part.type] = part.value; } } return result; } export function parseNumber( formattedNumber: string, locale?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions, ): number | null { if (formattedNumber == null) { return null; } let input = String(formattedNumber) .replace(/\p{Cf}/gu, "") .trim(); input = input.replace(ANY_MINUS_RE, "-").replace(ANY_PLUS_RE, "+"); let isNegative = false; const trailing = input.match(/([+-])\s*$/); if (trailing) { if (trailing[1] === "-") { isNegative = true; } input = input.replace(/([+-])\s*$/, ""); } const leading = input.match(/^\s*([+-])/); if (leading) { if (leading[1] === "-") { isNegative = true; } input = input.replace(/^\s*[+-]/, ""); } 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, ); 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: RegExp | null = null; if (group) { const isSpaceGroup = /\p{Zs}/u.test(group); const isApostropheGroup = group === "'" || group === "\u2019"; if (isSpaceGroup) { groupRegex = /\p{Zs}/gu; } else if (isApostropheGroup) { groupRegex = /['\u2019]/g; } else { groupRegex = new RegExp(escapeRegExp(group), "g"); } } const replacements: Array<{ regex: RegExp | null; replacement: string | ((m: string) => string); }> = [ { regex: group ? groupRegex : null, replacement: "" }, { regex: decimal ? new RegExp(escapeRegExp(decimal), "g") : null, replacement: ".", }, { regex: /./g, replacement: "." }, { regex: /,/g, replacement: "" }, { regex: /٫/g, replacement: "." }, { regex: /٬/g, replacement: "" }, { regex: currency ? new RegExp(escapeRegExp(currency), "g") : null, replacement: "", }, { regex: unitRegex, replacement: "" }, { 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] ?? ch }, ]; let unformatted = replacements.reduce((acc, { regex, replacement }) => { return regex ? acc.replace(regex, replacement as (m: string) => string) : acc; }, input); const lastDot = unformatted.lastIndexOf("."); if (lastDot !== -1) { unformatted = `${unformatted.slice(0, lastDot).replace(/\./g, "")}.${unformatted.slice(lastDot + 1).replace(/\./g, "")}`; } if (/^[-+]?Infinity$/i.test(input) || /[∞]/.test(input)) { return null; } const parseTarget = (isNegative ? "-" : "") + unformatted; let num = Number.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; }