locale.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /*!
  2. * All material copyright ESRI, All Rights Reserved, unless otherwise specified.
  3. * See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
  4. * v1.0.0-beta.97
  5. */
  6. import { BigDecimal, isValidNumber, sanitizeExponentialNumberString } from "./number";
  7. import { createObserver } from "./observers";
  8. import { closestElementCrossShadowBoundary, containsCrossShadowBoundary } from "./dom";
  9. export const defaultLocale = "en";
  10. export const locales = [
  11. "ar",
  12. "bg",
  13. "bs",
  14. "ca",
  15. "cs",
  16. "da",
  17. "de",
  18. "de-CH",
  19. "el",
  20. defaultLocale,
  21. "en-AU",
  22. "en-CA",
  23. "en-GB",
  24. "es",
  25. "es-MX",
  26. "et",
  27. "fi",
  28. "fr",
  29. "fr-CH",
  30. "he",
  31. "hi",
  32. "hr",
  33. "hu",
  34. "id",
  35. "it",
  36. "it-CH",
  37. "ja",
  38. "ko",
  39. "lt",
  40. "lv",
  41. "mk",
  42. "nb",
  43. "nl",
  44. "pl",
  45. "pt",
  46. "pt-PT",
  47. "ro",
  48. "ru",
  49. "sk",
  50. "sl",
  51. "sr",
  52. "sv",
  53. "th",
  54. "tr",
  55. "uk",
  56. "vi",
  57. "zh-CN",
  58. "zh-HK",
  59. "zh-TW"
  60. ];
  61. export const numberingSystems = [
  62. "arab",
  63. "arabext",
  64. "bali",
  65. "beng",
  66. "deva",
  67. "fullwide",
  68. "gujr",
  69. "guru",
  70. "hanidec",
  71. "khmr",
  72. "knda",
  73. "laoo",
  74. "latn",
  75. "limb",
  76. "mlym",
  77. "mong",
  78. "mymr",
  79. "orya",
  80. "tamldec",
  81. "telu",
  82. "thai",
  83. "tibt"
  84. ];
  85. const isNumberingSystemSupported = (numberingSystem) => numberingSystems.includes(numberingSystem);
  86. const browserNumberingSystem = new Intl.NumberFormat().resolvedOptions().numberingSystem;
  87. export const defaultNumberingSystem = browserNumberingSystem === "arab" || !isNumberingSystemSupported(browserNumberingSystem)
  88. ? "latn"
  89. : browserNumberingSystem;
  90. export const getSupportedNumberingSystem = (numberingSystem) => isNumberingSystemSupported(numberingSystem) ? numberingSystem : defaultNumberingSystem;
  91. export function getSupportedLocale(locale) {
  92. if (locales.indexOf(locale) > -1) {
  93. return locale;
  94. }
  95. if (!locale) {
  96. return defaultLocale;
  97. }
  98. locale = locale.toLowerCase();
  99. // we support both 'nb' and 'no' (BCP 47) for Norwegian
  100. if (locale === "nb") {
  101. return "no";
  102. }
  103. if (locale.includes("-")) {
  104. locale = locale.replace(/(\w+)-(\w+)/, (_match, language, region) => `${language}-${region.toUpperCase()}`);
  105. if (!locales.includes(locale)) {
  106. locale = locale.split("-")[0];
  107. }
  108. }
  109. return locales.includes(locale) ? locale : defaultLocale;
  110. }
  111. const connectedComponents = new Set();
  112. /**
  113. * This utility sets up internals for messages support.
  114. *
  115. * It needs to be called in `connectedCallback` before any logic that depends on locale
  116. *
  117. * @param component
  118. */
  119. export function connectLocalized(component) {
  120. updateEffectiveLocale(component);
  121. if (connectedComponents.size === 0) {
  122. mutationObserver.observe(document.documentElement, {
  123. attributes: true,
  124. attributeFilter: ["lang"],
  125. subtree: true
  126. });
  127. }
  128. connectedComponents.add(component);
  129. }
  130. /**
  131. * This is only exported for components that implemented the now deprecated `locale` prop.
  132. *
  133. * Do not use this utils for new components.
  134. *
  135. * @param component
  136. */
  137. export function updateEffectiveLocale(component) {
  138. component.effectiveLocale = getLocale(component);
  139. }
  140. /**
  141. * This utility tears down internals for messages support.
  142. *
  143. * It needs to be called in `disconnectedCallback`
  144. *
  145. * @param component
  146. */
  147. export function disconnectLocalized(component) {
  148. connectedComponents.delete(component);
  149. if (connectedComponents.size === 0) {
  150. mutationObserver.disconnect();
  151. }
  152. }
  153. const mutationObserver = createObserver("mutation", (records) => {
  154. records.forEach((record) => {
  155. const el = record.target;
  156. connectedComponents.forEach((component) => {
  157. const hasOverridingLocale = !!(component.locale && !component.el.lang);
  158. const inUnrelatedSubtree = !containsCrossShadowBoundary(el, component.el);
  159. if (hasOverridingLocale || inUnrelatedSubtree) {
  160. return;
  161. }
  162. const closestLangEl = closestElementCrossShadowBoundary(component.el, "[lang]");
  163. if (!closestLangEl) {
  164. component.effectiveLocale = defaultLocale;
  165. return;
  166. }
  167. const closestLang = closestLangEl.lang;
  168. component.effectiveLocale =
  169. // user set lang="" means unknown language, so we use default
  170. closestLangEl.hasAttribute("lang") && closestLang === "" ? defaultLocale : closestLang;
  171. });
  172. });
  173. });
  174. /**
  175. * This util helps resolve a component's locale.
  176. * It will also fall back on the deprecated `locale` if a component implemented this previously.
  177. *
  178. * @param component
  179. */
  180. function getLocale(component) {
  181. var _a;
  182. return (component.el.lang ||
  183. component.locale ||
  184. ((_a = closestElementCrossShadowBoundary(component.el, "[lang]")) === null || _a === void 0 ? void 0 : _a.lang) ||
  185. document.documentElement.lang ||
  186. defaultLocale);
  187. }
  188. /**
  189. * This util formats and parses numbers for localization
  190. */
  191. class NumberStringFormat {
  192. constructor() {
  193. this.delocalize = (numberString) =>
  194. // For performance, (de)localization is skipped if the formatter isn't initialized.
  195. // In order to localize/delocalize, e.g. when lang/numberingSystem props are not default values,
  196. // `numberFormatOptions` must be set in a component to create and cache the formatter.
  197. this._numberFormatOptions
  198. ? sanitizeExponentialNumberString(numberString, (nonExpoNumString) => nonExpoNumString
  199. .trim()
  200. .replace(new RegExp(`[${this._minusSign}]`, "g"), "-")
  201. .replace(new RegExp(`[${this._group}]`, "g"), "")
  202. .replace(new RegExp(`[${this._decimal}]`, "g"), ".")
  203. .replace(new RegExp(`[${this._digits.join("")}]`, "g"), this._getDigitIndex))
  204. : numberString;
  205. this.localize = (numberString) => this._numberFormatOptions
  206. ? sanitizeExponentialNumberString(numberString, (nonExpoNumString) => isValidNumber(nonExpoNumString.trim())
  207. ? new BigDecimal(nonExpoNumString.trim())
  208. .format(this._numberFormatter)
  209. .replace(new RegExp(`[${this._actualGroup}]`, "g"), this._group)
  210. : nonExpoNumString)
  211. : numberString;
  212. }
  213. get group() {
  214. return this._group;
  215. }
  216. get decimal() {
  217. return this._decimal;
  218. }
  219. get minusSign() {
  220. return this._minusSign;
  221. }
  222. get digits() {
  223. return this._digits;
  224. }
  225. get numberFormatter() {
  226. return this._numberFormatter;
  227. }
  228. get numberFormatOptions() {
  229. return this._numberFormatOptions;
  230. }
  231. /**
  232. * numberFormatOptions needs to be set before localize/delocalize is called to ensure the options are up to date
  233. */
  234. set numberFormatOptions(options) {
  235. options.locale = getSupportedLocale(options === null || options === void 0 ? void 0 : options.locale);
  236. options.numberingSystem = getSupportedNumberingSystem(options === null || options === void 0 ? void 0 : options.numberingSystem);
  237. if (
  238. // No need to create the formatter if `locale` and `numberingSystem`
  239. // are the default values and `numberFormatOptions` has not been set
  240. (!this._numberFormatOptions &&
  241. options.locale === defaultLocale &&
  242. options.numberingSystem === defaultNumberingSystem &&
  243. // don't skip initialization if any options besides locale/numberingSystem are set
  244. Object.keys(options).length === 2) ||
  245. // cache formatter by only recreating when options change
  246. JSON.stringify(this._numberFormatOptions) === JSON.stringify(options)) {
  247. return;
  248. }
  249. this._numberFormatOptions = options;
  250. this._numberFormatter = new Intl.NumberFormat(this._numberFormatOptions.locale, this._numberFormatOptions);
  251. this._digits = [
  252. ...new Intl.NumberFormat(this._numberFormatOptions.locale, {
  253. useGrouping: false,
  254. numberingSystem: this._numberFormatOptions.numberingSystem
  255. }).format(9876543210)
  256. ].reverse();
  257. const index = new Map(this._digits.map((d, i) => [d, i]));
  258. const parts = new Intl.NumberFormat(this._numberFormatOptions.locale).formatToParts(-12345678.9);
  259. this._actualGroup = parts.find((d) => d.type === "group").value;
  260. // change whitespace group characters that don't render correctly
  261. this._group = this._actualGroup.trim().length === 0 ? " " : this._actualGroup;
  262. this._decimal = parts.find((d) => d.type === "decimal").value;
  263. this._minusSign = parts.find((d) => d.type === "minusSign").value;
  264. this._getDigitIndex = (d) => index.get(d);
  265. }
  266. }
  267. export const numberStringFormatter = new NumberStringFormat();