@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
549 lines (545 loc) • 17.9 kB
JavaScript
/*!
* 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
*/
import { w as containsCrossShadowBoundary, e as closestElementCrossShadowBoundary } from './dom.js';
import { n as numberKeys } from './key.js';
import { c as createObserver } from './observers.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 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 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 = createObserver("mutation", (records) => {
records.forEach((record) => {
const el = record.target;
connectedComponents.forEach((component) => {
const inUnrelatedSubtree = !containsCrossShadowBoundary(el, component.el);
if (inUnrelatedSubtree) {
return;
}
const closestLangEl = 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 ||
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;
}
export { BigDecimal as B, getDateTimeFormat as a, defaultNumberingSystem as b, connectLocalized as c, disconnectLocalized as d, getSupportedNumberingSystem as e, getSupportedLocale as g, isValidNumber as i, numberStringFormatter as n, parseNumberString as p, sanitizeNumberString as s };