UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

379 lines (378 loc) 10.7 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 */ import { closestElementCrossShadowBoundary, containsCrossShadowBoundary } from "./dom"; import { BigDecimal, isValidNumber, sanitizeExponentialNumberString } from "./number"; import { createObserver } from "./observers"; export const defaultLocale = "en"; export 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" ]; export 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" ]; export const numberingSystems = [ "arab", "arabext", "bali", "beng", "deva", "fullwide", "gujr", "guru", "hanidec", "khmr", "knda", "laoo", "latn", "limb", "mlym", "mong", "mymr", "orya", "tamldec", "telu", "thai", "tibt" ]; export const supportedLocales = [...new Set([...t9nLocales, ...locales])]; const isNumberingSystemSupported = (numberingSystem) => numberingSystems.includes(numberingSystem); const browserNumberingSystem = new Intl.NumberFormat().resolvedOptions().numberingSystem; export const defaultNumberingSystem = browserNumberingSystem === "arab" || !isNumberingSystemSupported(browserNumberingSystem) ? "latn" : browserNumberingSystem; export 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) */ export 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 */ export 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 */ export function updateEffectiveLocale(component) { component.effectiveLocale = getLocale(component); } /** * This utility tears down internals for messages support. * * It needs to be called in `disconnectedCallback` * * @param component */ export 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 */ export 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); } } export const numberStringFormatter = new NumberStringFormat(); /** * Exported for testing purposes only. * * @internal */ export 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 */ export 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; }