UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

561 lines (556 loc) 18.1 kB
/*! * All material copyright ESRI, All Rights Reserved, unless otherwise specified. * See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details. * v1.5.0-next.4 */ 'use strict'; const dom = require('./dom-18ca68ff.js'); const key = require('./key-2ce02f02.js'); const observers = require('./observers-83b3999d.js'); const unnecessaryDecimal = new RegExp(`\\${"."}(0+)?$`); const trailingZeros = new RegExp("0+$"); // adopted from https://stackoverflow.com/a/66939244 class BigDecimal { constructor(input) { if (input instanceof BigDecimal) { return input; } const [integers, decimals] = expandExponentialNumberString(input).split(".").concat(""); this.value = BigInt(integers + decimals.padEnd(BigDecimal.DECIMALS, "0").slice(0, BigDecimal.DECIMALS)) + BigInt(BigDecimal.ROUNDED && decimals[BigDecimal.DECIMALS] >= "5"); this.isNegative = input.charAt(0) === "-"; } getIntegersAndDecimals() { const s = this.value .toString() .replace("-", "") .padStart(BigDecimal.DECIMALS + 1, "0"); const integers = s.slice(0, -BigDecimal.DECIMALS); const decimals = s.slice(-BigDecimal.DECIMALS).replace(trailingZeros, ""); return { integers, decimals }; } toString() { const { integers, decimals } = this.getIntegersAndDecimals(); return `${this.isNegative ? "-" : ""}${integers}${decimals.length ? "." + decimals : ""}`; } formatToParts(formatter) { const { integers, decimals } = this.getIntegersAndDecimals(); const parts = formatter.numberFormatter.formatToParts(BigInt(integers)); this.isNegative && parts.unshift({ type: "minusSign", value: formatter.minusSign }); if (decimals.length) { parts.push({ type: "decimal", value: formatter.decimal }); decimals.split("").forEach((char) => parts.push({ type: "fraction", value: char })); } return parts; } format(formatter) { const { integers, decimals } = this.getIntegersAndDecimals(); const integersFormatted = `${this.isNegative ? formatter.minusSign : ""}${formatter.numberFormatter.format(BigInt(integers))}`; const decimalsFormatted = decimals.length ? `${formatter.decimal}${decimals .split("") .map((char) => formatter.numberFormatter.format(Number(char))) .join("")}` : ""; return `${integersFormatted}${decimalsFormatted}`; } add(n) { return BigDecimal.fromBigInt(this.value + new BigDecimal(n).value); } subtract(n) { return BigDecimal.fromBigInt(this.value - new BigDecimal(n).value); } multiply(n) { return BigDecimal._divRound(this.value * new BigDecimal(n).value, BigDecimal.SHIFT); } divide(n) { return BigDecimal._divRound(this.value * BigDecimal.SHIFT, new BigDecimal(n).value); } } // Configuration: constants BigDecimal.DECIMALS = 100; // number of decimals on all instances BigDecimal.ROUNDED = true; // numbers are truncated (false) or rounded (true) BigDecimal.SHIFT = BigInt("1" + "0".repeat(BigDecimal.DECIMALS)); // derived constant BigDecimal._divRound = (dividend, divisor) => BigDecimal.fromBigInt(dividend / divisor + (BigDecimal.ROUNDED ? ((dividend * BigInt(2)) / divisor) % BigInt(2) : BigInt(0))); BigDecimal.fromBigInt = (bigint) => Object.assign(Object.create(BigDecimal.prototype), { value: bigint, isNegative: bigint < BigInt(0) }); function isValidNumber(numberString) { return !(!numberString || isNaN(Number(numberString))); } function parseNumberString(numberString) { if (!numberString || !stringContainsNumbers(numberString)) { return ""; } return sanitizeExponentialNumberString(numberString, (nonExpoNumString) => { let containsDecimal = false; const result = nonExpoNumString .split("") .filter((value, i) => { if (value.match(/\./g) && !containsDecimal) { containsDecimal = true; return true; } if (value.match(/\-/g) && i === 0) { return true; } return key.numberKeys.includes(value); }) .reduce((string, part) => string + part); return isValidNumber(result) ? new BigDecimal(result).toString() : ""; }); } // regex for number sanitization const allLeadingZerosOptionallyNegative = /^([-0])0+(?=\d)/; const decimalOnlyAtEndOfString = /(?!^\.)\.$/; const allHyphensExceptTheStart = /(?!^-)-/g; const isNegativeDecimalOnlyZeros = /^-\b0\b\.?0*$/; const sanitizeNumberString = (numberString) => sanitizeExponentialNumberString(numberString, (nonExpoNumString) => { const sanitizedValue = nonExpoNumString .replace(allHyphensExceptTheStart, "") .replace(decimalOnlyAtEndOfString, "") .replace(allLeadingZerosOptionallyNegative, "$1"); return isValidNumber(sanitizedValue) ? isNegativeDecimalOnlyZeros.test(sanitizedValue) ? sanitizedValue : new BigDecimal(sanitizedValue).toString() : nonExpoNumString; }); function sanitizeExponentialNumberString(numberString, func) { if (!numberString) { return numberString; } const firstE = numberString.toLowerCase().indexOf("e") + 1; if (!firstE) { return func(numberString); } return numberString .replace(/[eE]*$/g, "") .substring(0, firstE) .concat(numberString.slice(firstE).replace(/[eE]/g, "")) .split(/[eE]/) .map((section, i) => (i === 1 ? func(section.replace(/\./g, "")) : func(section))) .join("e") .replace(/^e/, "1e"); } /** * Converts an exponential notation numberString into decimal notation. * BigInt doesn't support exponential notation, so this is required to maintain precision * * @param {string} numberString - pre-validated exponential or decimal number * @returns {string} numberString in decimal notation */ function expandExponentialNumberString(numberString) { const exponentialParts = numberString.split(/[eE]/); if (exponentialParts.length === 1) { return numberString; } const number = +numberString; if (Number.isSafeInteger(number)) { return `${number}`; } const isNegative = numberString.charAt(0) === "-"; const magnitude = +exponentialParts[1]; const decimalParts = exponentialParts[0].split("."); const integers = (isNegative ? decimalParts[0].substring(1) : decimalParts[0]) || ""; const decimals = decimalParts[1] || ""; const shiftDecimalLeft = (integers, magnitude) => { const magnitudeDelta = Math.abs(magnitude) - integers.length; const leftPaddedZeros = magnitudeDelta > 0 ? `${"0".repeat(magnitudeDelta)}${integers}` : integers; const shiftedDecimal = `${leftPaddedZeros.slice(0, magnitude)}${"."}${leftPaddedZeros.slice(magnitude)}`; return shiftedDecimal; }; const shiftDecimalRight = (decimals, magnitude) => { const rightPaddedZeros = magnitude > decimals.length ? `${decimals}${"0".repeat(magnitude - decimals.length)}` : decimals; const shiftedDecimal = `${rightPaddedZeros.slice(0, magnitude)}${"."}${rightPaddedZeros.slice(magnitude)}`; return shiftedDecimal; }; const expandedNumberString = magnitude > 0 ? `${integers}${shiftDecimalRight(decimals, magnitude)}` : `${shiftDecimalLeft(integers, magnitude)}${decimals}`; return `${isNegative ? "-" : ""}${expandedNumberString.charAt(0) === "." ? "0" : ""}${expandedNumberString .replace(unnecessaryDecimal, "") .replace(allLeadingZerosOptionallyNegative, "")}`; } function stringContainsNumbers(string) { return key.numberKeys.some((number) => string.includes(number)); } const defaultLocale = "en"; const t9nLocales = [ "ar", "bg", "bs", "ca", "cs", "da", "de", "el", defaultLocale, "es", "et", "fi", "fr", "he", "hr", "hu", "id", "it", "ja", "ko", "lt", "lv", "no", "nl", "pl", "pt-BR", "pt-PT", "ro", "ru", "sk", "sl", "sr", "sv", "th", "tr", "uk", "vi", "zh-CN", "zh-HK", "zh-TW" ]; const locales = [ "ar", "bg", "bs", "ca", "cs", "da", "de", "de-AT", "de-CH", "el", defaultLocale, "en-AU", "en-CA", "en-GB", "es", "es-MX", "et", "fi", "fr", "fr-CH", "he", "hi", "hr", "hu", "id", "it", "it-CH", "ja", "ko", "lt", "lv", "mk", "no", "nl", "pl", "pt", "pt-PT", "ro", "ru", "sk", "sl", "sr", "sv", "th", "tr", "uk", "vi", "zh-CN", "zh-HK", "zh-TW" ]; const numberingSystems = [ "arab", "arabext", "bali", "beng", "deva", "fullwide", "gujr", "guru", "hanidec", "khmr", "knda", "laoo", "latn", "limb", "mlym", "mong", "mymr", "orya", "tamldec", "telu", "thai", "tibt" ]; const isNumberingSystemSupported = (numberingSystem) => numberingSystems.includes(numberingSystem); const browserNumberingSystem = new Intl.NumberFormat().resolvedOptions().numberingSystem; const defaultNumberingSystem = browserNumberingSystem === "arab" || !isNumberingSystemSupported(browserNumberingSystem) ? "latn" : browserNumberingSystem; const getSupportedNumberingSystem = (numberingSystem) => isNumberingSystemSupported(numberingSystem) ? numberingSystem : defaultNumberingSystem; /** * Gets the locale that best matches the context. * * @param locale – the BCP 47 locale code * @param context - specifies whether the locale code should match in the context of CLDR or T9N (translation) */ function getSupportedLocale(locale, context = "cldr") { const contextualLocales = context === "cldr" ? locales : t9nLocales; if (!locale) { return defaultLocale; } if (contextualLocales.includes(locale)) { return locale; } locale = locale.toLowerCase(); // we support both 'nb' and 'no' (BCP 47) for Norwegian but only `no` has corresponding bundle if (locale === "nb") { return "no"; } // we use `pt-BR` as it will have the same translations as `pt`, which has no corresponding bundle if (context === "t9n" && locale === "pt") { return "pt-BR"; } if (locale.includes("-")) { locale = locale.replace(/(\w+)-(\w+)/, (_match, language, region) => `${language}-${region.toUpperCase()}`); if (!contextualLocales.includes(locale)) { locale = locale.split("-")[0]; } } // we can `zh-CN` as base translation for chinese locales which has no corresponding bundle. if (locale === "zh") { return "zh-CN"; } if (!contextualLocales.includes(locale)) { console.warn(`Translations for the "${locale}" locale are not available and will fall back to the default, English (en).`); return defaultLocale; } return locale; } const connectedComponents = new Set(); /** * This utility sets up internals for messages support. * * It needs to be called in `connectedCallback` before any logic that depends on locale * * @param component */ function connectLocalized(component) { updateEffectiveLocale(component); if (connectedComponents.size === 0) { mutationObserver?.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"], subtree: true }); } connectedComponents.add(component); } /** * This is only exported for components that implemented the now deprecated `locale` prop. * * Do not use this utils for new components. * * @param component */ function updateEffectiveLocale(component) { component.effectiveLocale = getLocale(component); } /** * This utility tears down internals for messages support. * * It needs to be called in `disconnectedCallback` * * @param component */ function disconnectLocalized(component) { connectedComponents.delete(component); if (connectedComponents.size === 0) { mutationObserver.disconnect(); } } const mutationObserver = observers.createObserver("mutation", (records) => { records.forEach((record) => { const el = record.target; connectedComponents.forEach((component) => { const inUnrelatedSubtree = !dom.containsCrossShadowBoundary(el, component.el); if (inUnrelatedSubtree) { return; } const closestLangEl = dom.closestElementCrossShadowBoundary(component.el, "[lang]"); if (!closestLangEl) { component.effectiveLocale = defaultLocale; return; } const closestLang = closestLangEl.lang; component.effectiveLocale = // user set lang="" means unknown language, so we use default closestLangEl.hasAttribute("lang") && closestLang === "" ? defaultLocale : closestLang; }); }); }); /** * This util helps resolve a component's locale. * It will also fall back on the deprecated `locale` if a component implemented this previously. * * @param component */ function getLocale(component) { return (component.el.lang || dom.closestElementCrossShadowBoundary(component.el, "[lang]")?.lang || document.documentElement.lang || defaultLocale); } /** * This util formats and parses numbers for localization */ class NumberStringFormat { constructor() { this.delocalize = (numberString) => // For performance, (de)localization is skipped if the formatter isn't initialized. // In order to localize/delocalize, e.g. when lang/numberingSystem props are not default values, // `numberFormatOptions` must be set in a component to create and cache the formatter. this._numberFormatOptions ? sanitizeExponentialNumberString(numberString, (nonExpoNumString) => nonExpoNumString .replace(new RegExp(`[${this._minusSign}]`, "g"), "-") .replace(new RegExp(`[${this._group}]`, "g"), "") .replace(new RegExp(`[${this._decimal}]`, "g"), ".") .replace(new RegExp(`[${this._digits.join("")}]`, "g"), this._getDigitIndex)) : numberString; this.localize = (numberString) => this._numberFormatOptions ? sanitizeExponentialNumberString(numberString, (nonExpoNumString) => isValidNumber(nonExpoNumString.trim()) ? new BigDecimal(nonExpoNumString.trim()) .format(this) .replace(new RegExp(`[${this._actualGroup}]`, "g"), this._group) : nonExpoNumString) : numberString; } get group() { return this._group; } get decimal() { return this._decimal; } get minusSign() { return this._minusSign; } get digits() { return this._digits; } get numberFormatter() { return this._numberFormatter; } get numberFormatOptions() { return this._numberFormatOptions; } /** * numberFormatOptions needs to be set before localize/delocalize is called to ensure the options are up to date */ set numberFormatOptions(options) { options.locale = getSupportedLocale(options?.locale); options.numberingSystem = getSupportedNumberingSystem(options?.numberingSystem); if ( // No need to create the formatter if `locale` and `numberingSystem` // are the default values and `numberFormatOptions` has not been set (!this._numberFormatOptions && options.locale === defaultLocale && options.numberingSystem === defaultNumberingSystem && // don't skip initialization if any options besides locale/numberingSystem are set Object.keys(options).length === 2) || // cache formatter by only recreating when options change JSON.stringify(this._numberFormatOptions) === JSON.stringify(options)) { return; } this._numberFormatOptions = options; this._numberFormatter = new Intl.NumberFormat(this._numberFormatOptions.locale, this._numberFormatOptions); this._digits = [ ...new Intl.NumberFormat(this._numberFormatOptions.locale, { useGrouping: false, numberingSystem: this._numberFormatOptions.numberingSystem }).format(9876543210) ].reverse(); const index = new Map(this._digits.map((d, i) => [d, i])); // numberingSystem is parsed to return consistent decimal separator across browsers. const parts = new Intl.NumberFormat(this._numberFormatOptions.locale, { numberingSystem: this._numberFormatOptions.numberingSystem }).formatToParts(-12345678.9); this._actualGroup = parts.find((d) => d.type === "group").value; // change whitespace group characters that don't render correctly this._group = this._actualGroup.trim().length === 0 ? " " : this._actualGroup; this._decimal = parts.find((d) => d.type === "decimal").value; this._minusSign = parts.find((d) => d.type === "minusSign").value; this._getDigitIndex = (d) => index.get(d); } } const numberStringFormatter = new NumberStringFormat(); /** * Exported for testing purposes only. * * @internal */ let dateTimeFormatCache; /** * Used to ensure all cached formats are for the same locale. * * @internal */ let previousLocaleUsedForCaching; /** * Generates a cache key for date time format lookups. * * @internal */ function buildDateTimeFormatCacheKey(options = {}) { return Object.entries(options) .sort(([key1], [key2]) => key1.localeCompare(key2)) .map((keyValue) => `${keyValue[0]}-${keyValue[1]}`) .flat() .join(":"); } /** * Returns an instance of Intl.DateTimeFormat and reuses it if requested with the same locale and options. * * **Note**: the cache will be cleared if a different locale is provided * * @internal */ function getDateTimeFormat(locale, options) { locale = getSupportedLocale(locale); if (!dateTimeFormatCache) { dateTimeFormatCache = new Map(); } if (previousLocaleUsedForCaching !== locale) { dateTimeFormatCache.clear(); previousLocaleUsedForCaching = locale; } const key = buildDateTimeFormatCacheKey(options); const cached = dateTimeFormatCache.get(key); if (cached) { return cached; } const format = new Intl.DateTimeFormat(locale, options); dateTimeFormatCache.set(key, format); return format; } exports.BigDecimal = BigDecimal; exports.connectLocalized = connectLocalized; exports.defaultNumberingSystem = defaultNumberingSystem; exports.disconnectLocalized = disconnectLocalized; exports.getDateTimeFormat = getDateTimeFormat; exports.getSupportedLocale = getSupportedLocale; exports.getSupportedNumberingSystem = getSupportedNumberingSystem; exports.isValidNumber = isValidNumber; exports.numberStringFormatter = numberStringFormatter; exports.parseNumberString = parseNumberString; exports.sanitizeNumberString = sanitizeNumberString;