Merge pull request #834 from ing-bank/feat/type-localize

Feat/type-localize
This commit is contained in:
Joren Broekema 2020-08-03 17:03:07 +02:00 committed by GitHub
commit d5517f2dee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1119 additions and 446 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/localize': minor
---
Add types for localize package. JSDocs types in the .js files, TSC generates type definition files. Mixins type definition files are hand-typed.

View file

@ -0,0 +1,5 @@
---
'singleton-manager': patch
---
Added basic JSDocs types to SingletonManager, in order for localize to be able to be typed correctly.

View file

@ -45,4 +45,4 @@ jobs:
publish: yarn release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -2,22 +2,36 @@ import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js';
import { LionSingleton } from '@lion/core';
import isLocalizeESModule from './isLocalizeESModule.js';
/**
* @typedef {import('../types/LocalizeMixinTypes').NamespaceObject} NamespaceObject
*/
/**
* `LocalizeManager` manages your translations (includes loading)
*/
export class LocalizeManager extends LionSingleton {
// eslint-disable-line no-unused-vars
constructor(params = {}) {
super(params);
this._fakeExtendsEventTarget();
constructor({ autoLoadOnLocaleChange = false, fallbackLocale = '' } = {}) {
super();
this.__delegationTarget = document.createDocumentFragment();
this._autoLoadOnLocaleChange = !!autoLoadOnLocaleChange;
this._fallbackLocale = fallbackLocale;
this._autoLoadOnLocaleChange = !!params.autoLoadOnLocaleChange;
this._fallbackLocale = params.fallbackLocale;
/** @type {Object.<string, Object.<string, Object>>} */
this.__storage = {};
/** @type {Map.<RegExp|string, function>} */
this.__namespacePatternsMap = new Map();
/** @type {Object.<string, function|null>} */
this.__namespaceLoadersCache = {};
/** @type {Object.<string, Object.<string, Promise.<Object>>>} */
this.__namespaceLoaderPromisesCache = {};
this.formatNumberOptions = { returnIfNaN: '' };
this.formatNumberOptions = {
returnIfNaN: '',
};
/**
* Via html[data-localize-lang], developers are allowed to set the initial locale, without
@ -73,18 +87,24 @@ export class LocalizeManager extends LionSingleton {
this._teardownHtmlLangAttributeObserver();
}
// eslint-disable-next-line class-methods-use-this
/**
* @returns {string}
*/
get locale() {
if (this._supportExternalTranslationTools) {
return this.__locale;
return this.__locale || '';
}
return document.documentElement.lang;
}
/**
* @param {string} value
*/
set locale(value) {
/** @type {string} */
let oldLocale;
if (this._supportExternalTranslationTools) {
oldLocale = this.__locale;
oldLocale = /** @type {string} */ (this.__locale);
this.__locale = value;
if (this._langAttrSetByTranslationTool === null) {
this._setHtmlLangAttribute(value);
@ -101,12 +121,19 @@ export class LocalizeManager extends LionSingleton {
this._onLocaleChanged(value, oldLocale);
}
/**
* @param {string} locale
*/
_setHtmlLangAttribute(locale) {
this._teardownHtmlLangAttributeObserver();
document.documentElement.lang = locale;
this._setupHtmlLangAttributeObserver();
}
/**
* @param {string} value
* @throws {Error} Language only locales are not allowed(Use 'en-GB' instead of 'en')
*/
// eslint-disable-next-line class-methods-use-this
__handleLanguageOnly(value) {
throw new Error(`
@ -116,6 +143,9 @@ export class LocalizeManager extends LionSingleton {
`);
}
/**
* @returns {Promise.<Object>}
*/
get loadingComplete() {
return Promise.all(Object.values(this.__namespaceLoaderPromisesCache[this.locale]));
}
@ -127,6 +157,12 @@ export class LocalizeManager extends LionSingleton {
this.__namespaceLoaderPromisesCache = {};
}
/**
* @param {string} locale
* @param {string} namespace
* @param {object} data
* @throws {Error} Namespace can be added only once, for a given locale
*/
addData(locale, namespace, data) {
if (this._isNamespaceInCache(locale, namespace)) {
throw new Error(
@ -138,18 +174,41 @@ export class LocalizeManager extends LionSingleton {
this.__storage[locale][namespace] = data;
}
/**
* @param {RegExp|string} pattern
* @param {function} loader
*/
setupNamespaceLoader(pattern, loader) {
this.__namespacePatternsMap.set(pattern, loader);
}
/**
* @param {NamespaceObject[]} namespaces
* @param {Object} [options]
* @param {string} [options.locale]
* @returns {Promise.<Object>}
*/
loadNamespaces(namespaces, { locale } = {}) {
return Promise.all(namespaces.map(namespace => this.loadNamespace(namespace, { locale })));
return Promise.all(
namespaces.map(
/** @param {NamespaceObject} namespace */
namespace => this.loadNamespace(namespace, { locale }),
),
);
}
/**
* @param {NamespaceObject} namespaceObj
* @param {Object} [options]
* @param {string} [options.locale]
* @returns {Promise.<Object|void>}
*/
loadNamespace(namespaceObj, { locale = this.locale } = { locale: this.locale }) {
const isDynamicImport = typeof namespaceObj === 'object';
const namespace = isDynamicImport ? Object.keys(namespaceObj)[0] : namespaceObj;
const namespace = /** @type {string} */ (isDynamicImport
? Object.keys(namespaceObj)[0]
: namespaceObj);
if (this._isNamespaceInCache(locale, namespace)) {
return Promise.resolve();
@ -163,6 +222,13 @@ export class LocalizeManager extends LionSingleton {
return this._loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace);
}
/**
* @param {string | string[]} keys
* @param {Object.<string,?>} [vars]
* @param {Object} [opts]
* @param {string} [opts.locale]
* @returns {string}
*/
msg(keys, vars, opts = {}) {
const locale = opts.locale ? opts.locale : this.locale;
const message = this._getMessageForKeys(keys, locale);
@ -186,7 +252,7 @@ export class LocalizeManager extends LionSingleton {
this._langAttrSetByTranslationTool = document.documentElement.lang;
}
} else {
this._onLocaleChanged(document.documentElement.lang, mutation.oldValue);
this._onLocaleChanged(document.documentElement.lang, mutation.oldValue || '');
}
});
});
@ -199,13 +265,23 @@ export class LocalizeManager extends LionSingleton {
}
_teardownHtmlLangAttributeObserver() {
this._htmlLangAttributeObserver.disconnect();
if (this._htmlLangAttributeObserver) {
this._htmlLangAttributeObserver.disconnect();
}
}
/**
* @param {string} locale
* @param {string} namespace
*/
_isNamespaceInCache(locale, namespace) {
return !!(this.__storage[locale] && this.__storage[locale][namespace]);
}
/**
* @param {string} locale
* @param {string} namespace
*/
_getCachedNamespaceLoaderPromise(locale, namespace) {
if (this.__namespaceLoaderPromisesCache[locale]) {
return this.__namespaceLoaderPromisesCache[locale][namespace];
@ -213,22 +289,41 @@ export class LocalizeManager extends LionSingleton {
return null;
}
/**
* @param {string} locale
* @param {NamespaceObject} namespaceObj
* @param {boolean} isDynamicImport
* @param {string} namespace
* @returns {Promise.<Object|void>}
*/
_loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace) {
const loader = this._getNamespaceLoader(namespaceObj, isDynamicImport, namespace);
const loaderPromise = this._getNamespaceLoaderPromise(loader, locale, namespace);
this._cacheNamespaceLoaderPromise(locale, namespace, loaderPromise);
return loaderPromise.then(obj => {
const data = isLocalizeESModule(obj) ? obj.default : obj;
this.addData(locale, namespace, data);
});
return loaderPromise.then(
/**
* @param {Object} obj
* @param {Object} obj.default
*/
obj => {
const data = isLocalizeESModule(obj) ? obj.default : obj;
this.addData(locale, namespace, data);
},
);
}
/**
* @param {NamespaceObject} namespaceObj
* @param {boolean} isDynamicImport
* @param {string} namespace
* @throws {Error} Namespace shall setup properly. Check loader!
*/
_getNamespaceLoader(namespaceObj, isDynamicImport, namespace) {
let loader = this.__namespaceLoadersCache[namespace];
if (!loader) {
if (isDynamicImport) {
loader = namespaceObj[namespace];
const _namespaceObj = /** @type {Object.<string,function>} */ (namespaceObj);
loader = _namespaceObj[namespace];
this.__namespaceLoadersCache[namespace] = loader;
} else {
loader = this._lookupNamespaceLoader(namespace);
@ -245,12 +340,20 @@ export class LocalizeManager extends LionSingleton {
return loader;
}
/**
* @param {function} loader
* @param {string} locale
* @param {string} namespace
* @param {string} [fallbackLocale]
* @returns {Promise.<any>}
* @throws {Error} Data for namespace and (locale or fallback locale) could not be loaded.
*/
_getNamespaceLoaderPromise(loader, locale, namespace, fallbackLocale = this._fallbackLocale) {
return loader(locale, namespace).catch(() => {
const lang = this._getLangFromLocale(locale);
return loader(lang, namespace).catch(() => {
if (fallbackLocale) {
return this._getNamespaceLoaderPromise(loader, fallbackLocale, namespace, false).catch(
return this._getNamespaceLoaderPromise(loader, fallbackLocale, namespace, '').catch(
() => {
const fallbackLang = this._getLangFromLocale(fallbackLocale);
throw new Error(
@ -268,6 +371,11 @@ export class LocalizeManager extends LionSingleton {
});
}
/**
* @param {string} locale
* @param {string} namespace
* @param {Promise.<Object>} promise
*/
_cacheNamespaceLoaderPromise(locale, namespace, promise) {
if (!this.__namespaceLoaderPromisesCache[locale]) {
this.__namespaceLoaderPromisesCache[locale] = {};
@ -275,6 +383,10 @@ export class LocalizeManager extends LionSingleton {
this.__namespaceLoaderPromisesCache[locale][namespace] = promise;
}
/**
* @param {string} namespace
* @returns {function|null}
*/
_lookupNamespaceLoader(namespace) {
/* eslint-disable no-restricted-syntax */
for (const [key, value] of this.__namespacePatternsMap) {
@ -289,18 +401,45 @@ export class LocalizeManager extends LionSingleton {
/* eslint-enable no-restricted-syntax */
}
/**
* @param {string} locale
* @returns {string}
*/
// eslint-disable-next-line class-methods-use-this
_getLangFromLocale(locale) {
return locale.substring(0, 2);
}
_fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
this[funcName] = (...args) => delegate[funcName](...args);
});
/**
* @param {string} type
* @param {EventListener} listener
* @param {...Object} options
*/
addEventListener(type, listener, ...options) {
this.__delegationTarget.addEventListener(type, listener, ...options);
}
/**
* @param {string} type
* @param {EventListener} listener
* @param {...Object} options
*/
removeEventListener(type, listener, ...options) {
this.__delegationTarget.removeEventListener(type, listener, ...options);
}
/**
* @param {CustomEvent} event
*/
dispatchEvent(event) {
this.__delegationTarget.dispatchEvent(event);
}
/**
* @param {string} newLocale
* @param {string} oldLocale
* @returns {undefined}
*/
_onLocaleChanged(newLocale, oldLocale) {
if (newLocale === oldLocale) {
return;
@ -311,19 +450,34 @@ export class LocalizeManager extends LionSingleton {
this.dispatchEvent(new CustomEvent('localeChanged', { detail: { newLocale, oldLocale } }));
}
/**
* @param {string} newLocale
* @param {string} oldLocale
* @returns {Promise.<Object>}
*/
_loadAllMissing(newLocale, oldLocale) {
const oldLocaleNamespaces = this.__storage[oldLocale] || {};
const newLocaleNamespaces = this.__storage[newLocale] || {};
/** @type {Promise<Object|void>[]} */
const promises = [];
Object.keys(oldLocaleNamespaces).forEach(namespace => {
const newNamespaceData = newLocaleNamespaces[namespace];
if (!newNamespaceData) {
promises.push(this.loadNamespace(namespace, { locale: newLocale }));
promises.push(
this.loadNamespace(namespace, {
locale: newLocale,
}),
);
}
});
return Promise.all(promises);
}
/**
* @param {string | string[]} keys
* @param {string} locale
* @returns {string | undefined}
*/
_getMessageForKeys(keys, locale) {
if (typeof keys === 'string') {
return this._getMessageForKey(keys, locale);
@ -341,16 +495,33 @@ export class LocalizeManager extends LionSingleton {
return undefined;
}
/**
* @param {string | undefined} key
* @param {string} locale
* @returns {string}
* @throws {Error} `key`is missing namespace. The format for `key` is "namespace:name"
*
*/
_getMessageForKey(key, locale) {
if (key.indexOf(':') === -1) {
if (!key || key.indexOf(':') === -1) {
throw new Error(
`Namespace is missing in the key "${key}". The format for keys is "namespace:name".`,
);
}
const [ns, namesString] = key.split(':');
const namespaces = this.__storage[locale];
const messages = namespaces ? namespaces[ns] : null;
const messages = namespaces ? namespaces[ns] : {};
const names = namesString.split('.');
return names.reduce((message, n) => (message ? message[n] : null), messages);
const result = names.reduce(
/**
* @param {Object.<string, any> | string} message
* @param {string} name
* @returns {string}
*/
(message, name) => (typeof message === 'object' ? message[name] : message),
messages,
);
return String(result || '');
}
}

View file

@ -2,111 +2,154 @@ import { dedupeMixin, until, nothing } from '@lion/core';
import { localize } from './localize.js';
/**
* # LocalizeMixin - for self managed templates
*
* @polymerMixin
* @mixinFunction
* @typedef {import('../types/LocalizeMixinTypes').LocalizeMixin} LocalizeMixin
*/
export const LocalizeMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class LocalizeMixin extends superclass {
static get localizeNamespaces() {
return [];
}
static get waitForLocalizeNamespaces() {
return true;
}
/**
* # LocalizeMixin - for self managed templates
* @type {LocalizeMixin}
*/
const LocalizeMixinImplementation = superclass =>
// eslint-disable-next-line
class LocalizeMixin extends superclass {
/**
* @returns {Object.<string,function>[]}
*/
static get localizeNamespaces() {
return [];
}
constructor() {
super();
/**
* @returns {boolean}
*/
static get waitForLocalizeNamespaces() {
return true;
}
this.__boundLocalizeOnLocaleChanged = (...args) => this.__localizeOnLocaleChanged(...args);
constructor() {
super();
// should be loaded in advance
this.__localizeStartLoadingNamespaces();
this.__boundLocalizeOnLocaleChanged =
/** @param {...Object} args */
(...args) => {
const event = /** @type {CustomEvent} */ (Array.from(args)[0]);
this.__localizeOnLocaleChanged(event);
};
// should be loaded in advance
this.__localizeStartLoadingNamespaces();
if (this.localizeNamespacesLoaded) {
this.localizeNamespacesLoaded.then(() => {
this.__localizeMessageSync = true;
});
}
}
/**
* hook into LitElement to only render once all translations are loaded
*/
async performUpdate() {
if (this.constructor.waitForLocalizeNamespaces) {
await this.localizeNamespacesLoaded;
}
super.performUpdate();
/**
* hook into LitElement to only render once all translations are loaded
* @returns {Promise.<void>}
*/
async performUpdate() {
if (Object.getPrototypeOf(this).constructor.waitForLocalizeNamespaces) {
await this.localizeNamespacesLoaded;
}
super.performUpdate();
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
if (this.localizeNamespacesLoaded) {
this.localizeNamespacesLoaded.then(() => this.onLocaleReady());
this.__localizeAddLocaleChangedListener();
}
this.__localizeAddLocaleChangedListener();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this.__localizeRemoveLocaleChangedListener();
}
this.__localizeRemoveLocaleChangedListener();
/**
* @param {string | string[]} keys
* @param {Object.<string,?>} variables
* @param {Object} [options]
* @param {string} [options.locale]
* @return {string | function}
*/
msgLit(keys, variables, options) {
if (this.__localizeMessageSync) {
return localize.msg(keys, variables, options);
}
msgLit(...args) {
if (this.__localizeMessageSync) {
return localize.msg(...args);
}
return until(
this.localizeNamespacesLoaded.then(() => localize.msg(...args)),
nothing,
);
if (!this.localizeNamespacesLoaded) {
return '';
}
__getUniqueNamespaces() {
const uniqueNamespaces = [];
return until(
this.localizeNamespacesLoaded.then(() => localize.msg(keys, variables, options)),
nothing,
);
}
// IE11 does not support iterable in the constructor
const s = new Set();
this.constructor.localizeNamespaces.forEach(s.add.bind(s));
s.forEach(uniqueNamespace => {
uniqueNamespaces.push(uniqueNamespace);
});
return uniqueNamespaces;
}
/**
* @returns {string[]}
*/
__getUniqueNamespaces() {
/** @type {string[]} */
const uniqueNamespaces = [];
__localizeStartLoadingNamespaces() {
this.localizeNamespacesLoaded = localize.loadNamespaces(this.__getUniqueNamespaces());
}
// IE11 does not support iterable in the constructor
const s = new Set();
Object.getPrototypeOf(this).constructor.localizeNamespaces.forEach(s.add.bind(s));
s.forEach(uniqueNamespace => {
uniqueNamespaces.push(uniqueNamespace);
});
return uniqueNamespaces;
}
__localizeAddLocaleChangedListener() {
localize.addEventListener('localeChanged', this.__boundLocalizeOnLocaleChanged);
}
__localizeStartLoadingNamespaces() {
this.localizeNamespacesLoaded = localize.loadNamespaces(this.__getUniqueNamespaces());
}
__localizeRemoveLocaleChangedListener() {
localize.removeEventListener('localeChanged', this.__boundLocalizeOnLocaleChanged);
}
__localizeAddLocaleChangedListener() {
localize.addEventListener('localeChanged', this.__boundLocalizeOnLocaleChanged);
}
__localizeOnLocaleChanged(event) {
this.onLocaleChanged(event.detail.newLocale, event.detail.oldLocale);
}
__localizeRemoveLocaleChangedListener() {
localize.removeEventListener('localeChanged', this.__boundLocalizeOnLocaleChanged);
}
onLocaleReady() {
this.onLocaleUpdated();
}
/**
* @param {CustomEvent} event
*/
__localizeOnLocaleChanged(event) {
this.onLocaleChanged(event.detail.newLocale, event.detail.oldLocale);
}
onLocaleChanged() {
this.localizeNamespacesLoaded = localize.loadNamespaces(this.__getUniqueNamespaces());
this.onLocaleUpdated();
this.requestUpdate();
}
onLocaleReady() {
this.onLocaleUpdated();
}
// eslint-disable-next-line class-methods-use-this
onLocaleUpdated() {}
},
);
/**
* @param {string} newLocale
* @param {string} oldLocale
*/
// eslint-disable-next-line no-unused-vars
onLocaleChanged(newLocale, oldLocale) {
this.__localizeStartLoadingNamespaces();
this.onLocaleUpdated();
this.requestUpdate();
}
// eslint-disable-next-line class-methods-use-this
onLocaleUpdated() {}
};
export const LocalizeMixin = dedupeMixin(LocalizeMixinImplementation);

View file

@ -4,8 +4,8 @@ import { pad } from './pad.js';
/**
* To add a leading zero to a single number
*
* @param dateString
* @returns {*}
* @param {string} dateString
* @returns {string}
*/
export function addLeadingZero(dateString) {
const dateParts = splitDate(dateString);

View file

@ -3,8 +3,8 @@ import { trim } from './trim.js';
/**
* To clean date from added characters from IE
*
* @param dateAsString
* @returns {string|XML}
* @param {string} dateAsString
* @returns {string}
*/
export function clean(dateAsString) {
// list of separators is from wikipedia https://www.wikiwand.com/en/Date_format_by_country

View file

@ -4,14 +4,31 @@ import { normalizeIntlDate } from './normalizeIntlDate.js';
/**
* Formats date based on locale and options
*
* @param date
* @param options
* @returns {*}
* @param {Date} date
* @param {Object} [options] Intl options are available
* @param {string} [options.locale]
* @param {string} [options.localeMatcher]
* @param {string} [options.formatMatcher]
* @param {boolean}[options.hour12]
* @param {string} [options.numberingSystem]
* @param {string} [options.calendar]
* @param {string} [options.timeZone]
* @param {string} [options.timeZoneName]
* @param {string} [options.weekday]
* @param {string} [options.era]
* @param {string} [options.year]
* @param {string} [options.month]
* @param {string} [options.day]
* @param {string} [options.hour]
* @param {string} [options.minute]
* @param {string} [options.second]
* @returns {string}
*/
export function formatDate(date, options) {
if (!(date instanceof Date)) {
return '';
}
/** @type {options} */
const formatOptions = options || {};
/**
* Set smart defaults if:

View file

@ -3,13 +3,28 @@ import { splitDate } from './splitDate.js';
/**
* To compute the localized date format
*
* @returns {string}
*/
export function getDateFormatBasedOnLocale() {
/**
*
* @param {ArrayLike.<string>} dateParts
* @returns {string[]}
*/
function computePositions(dateParts) {
/**
* @param {number} index
* @returns {string}
*/
function getPartByIndex(index) {
return { 2012: 'year', 12: 'month', 20: 'day' }[dateParts[index]];
/** @type {Object.<string,string>} */
const template = {
'2012': 'year',
'12': 'month',
'20': 'day',
};
const key = dateParts[index];
return template[key];
}
return [1, 3, 5].map(getPartByIndex);
@ -28,6 +43,8 @@ export function getDateFormatBasedOnLocale() {
const dateParts = splitDate(formattedDate);
const dateFormat = {};
dateFormat.positions = computePositions(dateParts);
if (dateParts) {
dateFormat.positions = computePositions(dateParts);
}
return `${dateFormat.positions[0]}-${dateFormat.positions[1]}-${dateFormat.positions[2]}`;
}

View file

@ -1,12 +1,14 @@
import { normalizeIntlDate } from './normalizeIntlDate.js';
/** @type {Object.<string, Object.<string,string[]>>} */
const monthsLocaleCache = {};
/**
* @desc Returns month names for locale
* @param {string} options.locale locale
* @param {Object} [options]
* @param {string} [options.locale] locale
* @param {string} [options.style=long] long, short or narrow
* @returns {Array} like: ['January', 'February', ...etc].
* @returns {string[]} like: ['January', 'February', ...etc].
*/
export function getMonthNames({ locale, style = 'long' } = {}) {
let months = monthsLocaleCache[locale] && monthsLocaleCache[locale][style];

View file

@ -1,24 +1,32 @@
import { normalizeIntlDate } from './normalizeIntlDate.js';
/** @type {Object.<string, Object.<string,string[]>>} */
const weekdayNamesCache = {};
/**
* @desc Return cached weekday names for locale for all styles ('long', 'short', 'narrow')
* @param {string} locale locale
* @returns {Object} like { long: ['Sunday', 'Monday'...], short: ['Sun', ...], narrow: ['S', ...] }
* @returns {Object.<string,string[]>} - like { long: ['Sunday', 'Monday'...], short: ['Sun', ...], narrow: ['S', ...] }
*/
function getCachedWeekdayNames(locale) {
let weekdays = weekdayNamesCache[locale];
const cachedWeekdayNames = weekdayNamesCache[locale];
let weekdays;
if (weekdays) {
return weekdays;
if (cachedWeekdayNames) {
return cachedWeekdayNames;
}
weekdayNamesCache[locale] = { long: [], short: [], narrow: [] };
weekdayNamesCache[locale] = {
long: [],
short: [],
narrow: [],
};
['long', 'short', 'narrow'].forEach(style => {
weekdays = weekdayNamesCache[locale][style];
const formatter = new Intl.DateTimeFormat(locale, { weekday: style });
const formatter = new Intl.DateTimeFormat(locale, {
weekday: style,
});
const date = new Date('2019/04/07'); // start from Sunday
for (let i = 0; i < 7; i += 1) {
@ -34,10 +42,11 @@ function getCachedWeekdayNames(locale) {
/**
* @desc Returns weekday names for locale
* @param {string} options.locale locale
* @param {Object} [options]
* @param {string} [options.locale] locale
* @param {string} [options.style=long] long, short or narrow
* @param {number} [options.firstDayOfWeek=0] 0 (Sunday), 1 (Monday), etc...
* @returns {Array} like: ['Sunday', 'Monday', 'Tuesday', ...etc].
* @returns {string[]} like: ['Sunday', 'Monday', 'Tuesday', ...etc].
*/
export function getWeekdayNames({ locale, style = 'long', firstDayOfWeek = 0 } = {}) {
const weekdays = getCachedWeekdayNames(locale)[style];

View file

@ -1,8 +1,8 @@
/**
* @desc Makes suitable for date comparisons
* @param {Date} d
* @param {Date} date
* @returns {Date}
*/
export function normalizeDateTime(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
export function normalizeDateTime(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

View file

@ -1,7 +1,7 @@
/**
* To filter out some added characters in IE
*
* @param str
* @param {string} str
* @returns {string}
*/
export function normalizeIntlDate(str) {

View file

@ -1,12 +1,11 @@
/**
* To get the absolute value of a number.
*
* @param n
* @param {string} n - number in string format
* @returns {string}
*/
export function pad(n) {
const digitRegex = /^\d+$/;
const v = digitRegex.test(n) ? Math.abs(n) : n;
const v = digitRegex.test(String(n)) ? Math.abs(Number(n)) : n;
return String(v < 10 ? `0${v}` : v);
}

View file

@ -2,9 +2,14 @@ import { localize } from '../localize.js';
import { getDateFormatBasedOnLocale } from './getDateFormatBasedOnLocale.js';
import { addLeadingZero } from './addLeadingZero.js';
/**
* @param {function} fn
*/
const memoize = fn => {
/** @type {Object.<any, any>} */
const cache = {};
return parm => {
return /** @param {any} parm */ parm => {
const n = parm;
if (n in cache) {
return cache[n];
@ -20,11 +25,11 @@ const memoizedGetDateFormatBasedOnLocale = memoize(getDateFormatBasedOnLocale);
/**
* To parse a date into the right format
*
* @param date
* @returns {Date}
* @param {string} dateString
* @returns {Date | undefined}
*/
export function parseDate(date) {
const stringToParse = addLeadingZero(date);
export function parseDate(dateString) {
const stringToParse = addLeadingZero(dateString);
let parsedString;
switch (memoizedGetDateFormatBasedOnLocale(localize.locale)) {
case 'day-month-year':
@ -51,7 +56,7 @@ export function parseDate(date) {
const parsedDate = new Date(parsedString);
// Check if parsedDate is not `Invalid Date`
// eslint-disable-next-line no-restricted-globals
if (!isNaN(parsedDate)) {
if (!isNaN(parsedDate.getTime())) {
return parsedDate;
}
return undefined;

View file

@ -4,8 +4,8 @@ import { clean } from './clean.js';
/**
* To sanitize a date from IE11 handling
*
* @param date
* @returns {string|XML}
* @param {Date} date
* @returns {string}
*/
export function sanitizedDateTimeFormat(date) {
const fDate = formatDate(date);

View file

@ -1,9 +1,9 @@
/**
* To split a date into days, months, years, etc
*
* @param date
* @returns {Array|{index: number, input: string}|*}
* @param {string} dateAsString
* @returns {ArrayLike.<string> | null}
*/
export function splitDate(date) {
return date.match(/(\d{1,4})([^\d]+)(\d{1,4})([^\d]+)(\d{1,4})/);
export function splitDate(dateAsString) {
return dateAsString.match(/(\d{1,4})([^\d]+)(\d{1,4})([^\d]+)(\d{1,4})/);
}

View file

@ -1,8 +1,8 @@
/**
* To trim the date
*
* @param dateAsString
* @returns {string|XML}
* @param {string} dateAsString
* @returns {string}
*/
export function trim(dateAsString) {
return dateAsString.replace(/^[^\d]*/g, '').replace(/[^\d]*$/g, '');

View file

@ -1,3 +1,7 @@
/**
* @param {Object.<string, Object>} obj
* @returns {boolean}
*/
export default function isLocalizeESModule(obj) {
return !!(obj && obj.default && typeof obj.default === 'object' && Object.keys(obj).length === 1);
}

View file

@ -1,6 +1,7 @@
import { singletonManager } from 'singleton-manager';
import { LocalizeManager } from './LocalizeManager.js';
/** @type {LocalizeManager} */
// eslint-disable-next-line import/no-mutable-exports
export let localize =
singletonManager.get('@lion/localize::localize::0.10.x') ||
@ -9,6 +10,9 @@ export let localize =
fallbackLocale: 'en-GB',
});
/**
* @param {LocalizeManager} newLocalize
*/
export function setLocalize(newLocalize) {
localize.teardown();
localize = newLocalize;

View file

@ -4,9 +4,8 @@ import { localize } from '../localize.js';
* When number is NaN we should return an empty string or returnIfNaN param
*
* @param {string} returnIfNaN
* @returns {*}
* @returns {string}
*/
export function emptyStringWhenNumberNan(returnIfNaN) {
const stringToReturn = returnIfNaN || localize.formatNumberOptions.returnIfNaN;
return stringToReturn;
return returnIfNaN || localize.formatNumberOptions.returnIfNaN;
}

View file

@ -1,11 +1,13 @@
/**
* Add separators when they are not present
*
* @param {Array} formattedParts
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @param {string} groupSeparator
* @returns {Array}
* @returns {FormatNumberPart[]}
*/
export function forceAddGroupSeparators(formattedParts, groupSeparator) {
/** @type {FormatNumberPart[]} */
let concatArray = [];
let firstPart;
let integerPart;
@ -29,24 +31,41 @@ export function forceAddGroupSeparators(formattedParts, groupSeparator) {
numberPart += integerPart[0].value[i];
// Create first grouping which is < 3
if (numberPart.length === mod3 && firstGroup === false) {
numberArray.push({ type: 'integer', value: numberPart });
numberArray.push({
type: 'integer',
value: numberPart,
});
if (numberOfDigits > 3) {
numberArray.push({ type: 'group', value: groupSeparator });
numberArray.push({
type: 'group',
value: groupSeparator,
});
}
numberPart = '';
firstGroup = true;
// Create groupings of 3
} else if (numberPart.length === 3 && i < numberOfDigits - 1) {
numberOfGroups += 1;
numberArray.push({ type: 'integer', value: numberPart });
numberArray.push({
type: 'integer',
value: numberPart,
});
if (numberOfGroups !== groups) {
numberArray.push({ type: 'group', value: groupSeparator });
numberArray.push({
type: 'group',
value: groupSeparator,
});
}
numberPart = '';
}
}
numberArray.push({ type: 'integer', value: numberPart });
concatArray = firstPart.concat(numberArray, formattedParts);
numberArray.push({
type: 'integer',
value: numberPart,
});
if (firstPart) {
concatArray = firstPart.concat(numberArray, formattedParts);
}
}
return concatArray;
}

View file

@ -1,8 +1,9 @@
/**
* For Dutch and Belgian amounts the currency should be at the end of the string
*
* @param {Array} formattedParts
* @returns {Array}
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @returns {FormatNumberPart[]}
*/
export function forceCurrencyToEnd(formattedParts) {
if (formattedParts[0].type === 'currency') {

View file

@ -1,19 +1,25 @@
export function forceENAUSymbols(formattedParts, options) {
/** @type {Object.<string,string>} */
const CURRENCY_CODE_SYMBOL_MAP = {
EUR: '€',
USD: '$',
JPY: '¥',
};
/**
* Change the symbols for locale 'en-AU', due to bug in Chrome
*
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @param {Object} [options]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @returns {FormatNumberPart[]}
*/
export function forceENAUSymbols(formattedParts, { currency, currencyDisplay } = {}) {
const result = formattedParts;
const numberOfParts = result.length;
// Change the symbols for locale 'en-AU', due to bug in Chrome
if (numberOfParts > 1 && options && options.currencyDisplay === 'symbol') {
switch (options.currency) {
case 'EUR':
result[0].value = '€';
break;
case 'USD':
result[0].value = '$';
break;
case 'JPY':
result[0].value = '¥';
break;
/* no default */
if (formattedParts.length > 1 && currencyDisplay === 'symbol') {
if (Object.keys(CURRENCY_CODE_SYMBOL_MAP).includes(currency)) {
result[0].value = CURRENCY_CODE_SYMBOL_MAP[currency];
}
result[1].value = '';
}

View file

@ -1,10 +1,14 @@
import { normalSpaces } from './normalSpaces.js';
/**
* @param {Array} formattedParts
* @return {Array} parts with forced "normal" spaces
* Parts with forced "normal" spaces
*
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @returns {FormatNumberPart[]}
*/
export function forceNormalSpaces(formattedParts) {
/** @type {FormatNumberPart[]} */
const result = [];
formattedParts.forEach(part => {
result.push({

View file

@ -1,14 +1,20 @@
/**
* When in some locales there is no space between currency and amount it is added
*
* @param {Array} formattedParts
* @param {Object} options
* @returns {*}
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @param {Object} [options]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @returns {FormatNumberPart[]}
*/
export function forceSpaceBetweenCurrencyCodeAndNumber(formattedParts, options) {
export function forceSpaceBetweenCurrencyCodeAndNumber(
formattedParts,
{ currency, currencyDisplay } = {},
) {
const numberOfParts = formattedParts.length;
const literalObject = { type: 'literal', value: ' ' };
if (numberOfParts > 1 && options && options.currency && options.currencyDisplay === 'code') {
if (numberOfParts > 1 && currency && currencyDisplay === 'code') {
if (formattedParts[0].type === 'currency' && formattedParts[1].type !== 'literal') {
// currency in front of a number: EUR 1.00
formattedParts.splice(1, 0, literalObject);

View file

@ -1,8 +1,10 @@
/**
* @desc Intl uses 0 as group separator for bg-BG locale.
* This should be a ' '
* @param {{type,value}[]} formattedParts
* @returns {{type,value}[]} corrected formatted parts
*
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @returns {FormatNumberPart[]} corrected formatted parts
*/
export function forceSpaceInsteadOfZeroForGroup(formattedParts) {
return formattedParts.map(p => {

View file

@ -1,7 +1,15 @@
export function forceTryCurrencyCode(formattedParts, options) {
/**
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @param {Object} [options]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @returns {FormatNumberPart[]}
*/
export function forceTryCurrencyCode(formattedParts, { currency, currencyDisplay } = {}) {
const result = formattedParts;
// Chage the currencycode from TRY to TL, for Turkey
if (options.currency === 'TRY' && options.currencyDisplay === 'code') {
// Change the currency code from TRY to TL, for Turkey
if (currency === 'TRY' && currencyDisplay === 'code') {
if (result[0].value === 'TRY') {
result[0].value = 'TL';
}

View file

@ -1,13 +1,16 @@
export function forceYenSymbol(formattedParts, options) {
/**
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @param {Object} [options]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @returns {FormatNumberPart[]}
*/
export function forceYenSymbol(formattedParts, { currency, currencyDisplay } = {}) {
const result = formattedParts;
const numberOfParts = result.length;
// Change the symbol from JPY to ¥, due to bug in Chrome
if (
numberOfParts > 1 &&
options &&
options.currency === 'JPY' &&
options.currencyDisplay === 'symbol'
) {
if (numberOfParts > 1 && currency === 'JPY' && currencyDisplay === 'symbol') {
result[numberOfParts - 1].value = '¥';
}
return result;

View file

@ -4,25 +4,41 @@ import { formatNumberToParts } from './formatNumberToParts.js';
/**
* Formats a number based on locale and options. It uses Intl for the formatting.
*
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {number} number Number to be formatted
* @param {Object} options Intl options are available extended by roundMode
* @returns {*} Formatted number
* @param {Object} [options] Intl options are available extended by roundMode and returnIfNaN
* @param {string} [options.roundMode]
* @param {string} [options.returnIfNaN]
* @param {string} [options.locale]
* @param {string} [options.localeMatcher]
* @param {string} [options.numberingSystem]
* @param {string} [options.style]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @param {boolean}[options.useGrouping]
* @param {number} [options.minimumIntegerDigits]
* @param {number} [options.minimumFractionDigits]
* @param {number} [options.maximumFractionDigits]
* @param {number} [options.minimumSignificantDigits]
* @param {number} [options.maximumSignificantDigits]
* @returns {string}
*/
export function formatNumber(number, options) {
export function formatNumber(number, options = {}) {
if (number === undefined || number === null) return '';
const formattedToParts = formatNumberToParts(number, options);
// If number is not a number
if (
formattedToParts === (options && options.returnIfNaN) ||
formattedToParts === options.returnIfNaN ||
formattedToParts === localize.formatNumberOptions.returnIfNaN
) {
return formattedToParts;
return /** @type {string} */ (formattedToParts);
}
let printNumberOfParts = '';
// update numberOfParts because there may be some parts added
const numberOfParts = formattedToParts && formattedToParts.length;
for (let i = 0; i < numberOfParts; i += 1) {
printNumberOfParts += formattedToParts[i].value;
const part = /** @type {FormatNumberPart} */ (formattedToParts[i]);
printNumberOfParts += part.value;
}
return printNumberOfParts;
}

View file

@ -9,11 +9,26 @@ import { roundNumber } from './roundNumber.js';
/**
* Splits a number up in parts for integer, fraction, group, literal, decimal and currency.
*
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {number} number Number to split up
* @param {Object} options Intl options are available extended by roundMode
* @returns {Array} Array with parts
* @param {Object} [options] Intl options are available extended by roundMode,returnIfNaN
* @param {string} [options.roundMode]
* @param {string} [options.returnIfNaN]
* @param {string} [options.locale]
* @param {string} [options.localeMatcher]
* @param {string} [options.numberingSystem]
* @param {string} [options.style]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @param {boolean}[options.useGrouping]
* @param {number} [options.minimumIntegerDigits]
* @param {number} [options.minimumFractionDigits]
* @param {number} [options.maximumFractionDigits]
* @param {number} [options.minimumSignificantDigits]
* @param {number} [options.maximumSignificantDigits]
* @returns {string | FormatNumberPart[]} Array with parts or (an empty string or returnIfNaN if not a number)
*/
export function formatNumberToParts(number, options) {
export function formatNumberToParts(number, options = {}) {
let parsedNumber = typeof number === 'string' ? parseFloat(number) : number;
const computedLocale = getLocale(options && options.locale);
// when parsedNumber is not a number we should return an empty string or returnIfNaN
@ -25,6 +40,7 @@ export function formatNumberToParts(number, options) {
parsedNumber = roundNumber(number, options.roundMode);
}
let formattedParts = [];
const formattedNumber = Intl.NumberFormat(computedLocale, options).format(parsedNumber);
const regexCurrency = /[.,\s0-9]/;
const regexMinusSign = /[-]/; // U+002D, Hyphen-Minus, &#45;
@ -95,7 +111,7 @@ export function formatNumberToParts(number, options) {
if (numberPart) {
formattedParts.push({ type: 'fraction', value: numberPart });
}
// If there are no fractions but we reached the end write the numberpart as integer
// If there are no fractions but we reached the end write the number part as integer
} else if (i === formattedNumber.length - 1 && numberPart) {
formattedParts.push({ type: 'integer', value: numberPart });
}

View file

@ -3,17 +3,18 @@ import { formatNumberToParts } from './formatNumberToParts.js';
/**
* Based on number, returns currency name like 'US dollar'
*
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {string} currencyIso iso code like USD
* @param {Object} options Intl options are available extended by roundMode
* @returns {string} currency name like 'US dollar'
*/
export function getCurrencyName(currencyIso, options) {
const parts = formatNumberToParts(1, {
const parts = /** @type {FormatNumberPart[]} */ (formatNumberToParts(1, {
...options,
style: 'currency',
currency: currencyIso,
currencyDisplay: 'name',
});
}));
const currencyName = parts
.filter(p => p.type === 'currency')
.map(o => o.value)

View file

@ -4,13 +4,13 @@ import { getLocale } from './getLocale.js';
* To get the decimal separator
*
* @param {string} locale To override the browser locale
* @returns {Object} the separator
* @returns {string} The separator
*/
export function getDecimalSeparator(locale) {
const computedLocale = getLocale(locale);
const formattedNumber = Intl.NumberFormat(computedLocale, {
style: 'decimal',
minimumFractionDigits: 1,
}).format('1');
}).format(1);
return formattedNumber[1];
}

View file

@ -4,14 +4,15 @@ import { formatNumberToParts } from './formatNumberToParts.js';
* @example
* getFractionDigits('JOD'); // return 3
*
* @param {string} currency Currency code e.g. EUR
* @return {number} fraction for the given currency
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {string} [currency="EUR"] Currency code e.g. EUR
* @returns {number} fraction for the given currency
*/
export function getFractionDigits(currency = 'EUR') {
const parts = formatNumberToParts(123, {
const parts = /** @type {FormatNumberPart[]} */ (formatNumberToParts(123, {
style: 'currency',
currency,
});
}));
const [fractionPart] = parts.filter(part => part.type === 'fraction');
return fractionPart ? fractionPart.value.length : 0;
}

View file

@ -2,16 +2,16 @@ import { getLocale } from './getLocale.js';
import { normalSpaces } from './normalSpaces.js';
/**
* To get the group separator
* Gets the group separator
*
* @param {string} locale To override the browser locale
* @returns {Object} the separator
* @param {string} [locale] To override the browser locale
* @returns {string}
*/
export function getGroupSeparator(locale) {
const computedLocale = getLocale(locale);
const formattedNumber = Intl.NumberFormat(computedLocale, {
style: 'decimal',
minimumFractionDigits: 0,
}).format('10000');
}).format(10000);
return normalSpaces(formattedNumber[2]);
}

View file

@ -3,7 +3,7 @@ import { localize } from '../localize.js';
/**
* Gets the locale to use
*
* @param {string} locale Locale to override browser locale
* @param {string} [locale] Locale to override browser locale
* @returns {string}
*/
export function getLocale(locale) {

View file

@ -1,6 +1,6 @@
/**
* @param {Array} value
* @return {Array} value with forced "normal" space
* @param {string} value
* @returns {string} value with forced "normal" space
*/
export function normalSpaces(value) {
// If non-breaking space (160) or narrow non-breaking space (8239) then return ' '

View file

@ -1,13 +1,10 @@
/**
* Function that fixes currency label with locale options
* For Turkey fixes currency label with locale options
*
* @param {String} currency
* @param {string} currency
* @param {string} locale
* @returns {string} currency
* @returns {string}
*/
export function normalizeCurrencyLabel(currency, locale) {
if (currency === 'TRY' && locale === 'tr-TR') {
return 'TL';
}
return currency;
return currency === 'TRY' && locale === 'tr-TR' ? 'TL' : currency;
}

View file

@ -11,15 +11,19 @@ import { forceENAUSymbols } from './forceENAUSymbols.js';
/**
* Function with all fixes on localize
*
* @param {Array} formattedParts
* @param {Object} options
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} formattedParts
* @param {Object} [options]
* @param {string} [options.style]
* @param {string} [options.currency]
* @param {string} [options.currencyDisplay]
* @param {string} _locale
* @returns {*}
* @returns {FormatNumberPart[]}
*/
export function normalizeIntl(formattedParts, options, _locale) {
let normalize = forceNormalSpaces(formattedParts, options);
export function normalizeIntl(formattedParts, options = {}, _locale) {
let normalize = forceNormalSpaces(formattedParts);
// Dutch and Belgian currency must be moved to end of number
if (options && options.style === 'currency') {
if (options.style === 'currency') {
if (options.currencyDisplay === 'code' && _locale.slice(0, 2) === 'nl') {
normalize = forceCurrencyToEnd(normalize);
}

View file

@ -3,7 +3,8 @@
*
* @param {number} number
* @param {string} roundMode
* @returns {*}
* @throws {Error} roundMode can only be round|floor|ceiling
* @returns {number}
*/
export function roundNumber(number, roundMode) {
switch (roundMode) {

View file

@ -1,11 +1,22 @@
/**
* @type {Object.<String, Object>}
*/
let fakeImports = {};
/**
* @param {string} path
* @param {Object} data
*/
export function setupFakeImport(path, data) {
const fakeExports = { ...data };
Object.defineProperty(fakeExports, '__esModule', { value: true });
fakeImports[path] = fakeExports;
}
/**
* @param {string[]} namespaces
* @param {string[]} locales
*/
export function setupEmptyFakeImportsFor(namespaces, locales) {
namespaces.forEach(namespace => {
locales.forEach(locale => {
@ -20,6 +31,11 @@ export function resetFakeImport() {
fakeImports = {};
}
/**
* @param {Object} result
* @param {Function} resolve
* @param {Function} reject
*/
function resolveOrReject(result, resolve, reject) {
if (result) {
resolve(result);
@ -28,6 +44,11 @@ function resolveOrReject(result, resolve, reject) {
}
}
/**
* @param {string} path
* @param {number} [ms=0]
* @returns {Promise.<Object>}
*/
export async function fakeImport(path, ms = 0) {
const result = fakeImports[path];
if (ms > 0) {

View file

@ -1,17 +1,20 @@
import { expect, oneEvent, aTimeout } from '@open-wc/testing';
import sinon from 'sinon';
// eslint-disable-next-line import/no-unresolved
import { fetchMock } from '@bundled-es-modules/fetch-mock';
import { setupFakeImport, resetFakeImport, fakeImport } from '../test-helpers.js';
import { LocalizeManager } from '../src/LocalizeManager.js';
// useful for IE11 where LTR and RTL symbols are put by Intl when rendering dates
/**
* @param {string} str
* Useful for IE11 where LTR and RTL symbols are put by Intl when rendering dates
*/
function removeLtrRtl(str) {
return str.replace(/(\u200E|\u200E)/g, '');
}
describe('LocalizeManager', () => {
/** @type {LocalizeManager} */
let manager;
beforeEach(() => {
@ -43,7 +46,10 @@ describe('LocalizeManager', () => {
it('has teardown() method removing all side effects', () => {
manager = new LocalizeManager();
const disconnectObserverSpy = sinon.spy(manager._htmlLangAttributeObserver, 'disconnect');
const disconnectObserverSpy = sinon.spy(
manager._htmlLangAttributeObserver,
/** @type {never} */ ('disconnect'),
);
manager.teardown();
expect(disconnectObserverSpy.callCount).to.equal(1);
});
@ -54,7 +60,11 @@ describe('LocalizeManager', () => {
setTimeout(() => {
manager.locale = 'en-US';
});
const event = await oneEvent(manager, 'localeChanged');
const event = await oneEvent(
/** @type {EventTarget} */ (/** @type {unknown} */ (manager)),
'localeChanged',
);
expect(event.detail.newLocale).to.equal('en-US');
expect(event.detail.oldLocale).to.equal('en-GB');
});
@ -62,6 +72,7 @@ describe('LocalizeManager', () => {
it('does not fire "localeChanged" event if it was set to the same locale', () => {
manager = new LocalizeManager();
const eventSpy = sinon.spy();
manager.addEventListener('localeChanged', eventSpy);
manager.locale = 'en-US';
manager.locale = 'en-US';
@ -127,6 +138,7 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
});
@ -145,6 +157,7 @@ describe('LocalizeManager', () => {
await manager.loadNamespace(
{
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
},
{ locale: 'nl-NL' },
@ -164,8 +177,14 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
await manager.loadNamespaces([
{ 'my-defaults': locale => fakeImport(`./my-defaults/${locale}.js`) },
{ 'my-send-button': locale => fakeImport(`./my-send-button/${locale}.js`) },
{
/** @param {string} locale */
'my-defaults': locale => fakeImport(`./my-defaults/${locale}.js`),
},
{
/** @param {string} locale */
'my-send-button': locale => fakeImport(`./my-send-button/${locale}.js`),
},
]);
expect(manager.__storage).to.deep.equal({
@ -185,8 +204,14 @@ describe('LocalizeManager', () => {
await manager.loadNamespaces(
[
{ 'my-defaults': locale => fakeImport(`./my-defaults/${locale}.js`) },
{ 'my-send-button': locale => fakeImport(`./my-send-button/${locale}.js`) },
{
/** @param {string} locale */
'my-defaults': locale => fakeImport(`./my-defaults/${locale}.js`),
},
{
/** @param {string} locale */
'my-send-button': locale => fakeImport(`./my-send-button/${locale}.js`),
},
],
{ locale: 'nl-NL' },
);
@ -205,6 +230,7 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
});
@ -220,6 +246,7 @@ describe('LocalizeManager', () => {
try {
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
});
} catch (e) {
@ -242,6 +269,7 @@ describe('LocalizeManager', () => {
setupFakeImport('./my-component/en-GB.js', { default: { greeting: 'Hello!' } });
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
});
@ -259,6 +287,7 @@ describe('LocalizeManager', () => {
setupFakeImport('./my-component/en.js', { default: { greeting: 'Hello!' } });
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
});
@ -275,6 +304,7 @@ describe('LocalizeManager', () => {
try {
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`),
});
} catch (e) {
@ -315,10 +345,14 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
manager.setupNamespaceLoader('my-component', async locale => {
const response = await fetch(`./my-component/${locale}.json`);
return response.json();
});
manager.setupNamespaceLoader(
'my-component',
/** @param {string} locale */
async locale => {
const response = await fetch(`./my-component/${locale}.json`);
return response.json();
},
);
await manager.loadNamespace('my-component');
@ -330,26 +364,43 @@ describe('LocalizeManager', () => {
});
it('loads multiple namespaces via loadNamespaces() using string routes', async () => {
fetchMock.get('./my-defaults/en-GB.json', { submit: 'Submit' });
fetchMock.get('./my-send-button/en-GB.json', { submit: 'Send' });
fetchMock.get('./my-defaults/en-GB.json', {
submit: 'Submit',
});
fetchMock.get('./my-send-button/en-GB.json', {
submit: 'Send',
});
manager = new LocalizeManager();
manager.setupNamespaceLoader('my-defaults', async locale => {
const response = await fetch(`./my-defaults/${locale}.json`);
return response.json();
});
manager.setupNamespaceLoader('my-send-button', async locale => {
const response = await fetch(`./my-send-button/${locale}.json`);
return response.json();
});
manager.setupNamespaceLoader(
'my-defaults',
/** @param {string} locale */
async locale => {
const response = await fetch(`./my-defaults/${locale}.json`);
return response.json();
},
);
manager.setupNamespaceLoader(
'my-send-button',
/** @param {string} locale */
async locale => {
const response = await fetch(`./my-send-button/${locale}.json`);
return response.json();
},
);
await manager.loadNamespaces(['my-defaults', 'my-send-button']);
expect(manager.__storage).to.deep.equal({
'en-GB': {
'my-send-button': { submit: 'Send' },
'my-defaults': { submit: 'Submit' },
'my-send-button': {
submit: 'Send',
},
'my-defaults': {
submit: 'Submit',
},
},
});
});
@ -359,10 +410,17 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
manager.setupNamespaceLoader(/my-.+/, async (locale, namespace) => {
const response = await fetch(`./${namespace}/${locale}.json`);
return response.json();
});
manager.setupNamespaceLoader(
/my-.+/,
/**
* @param {string} locale
* @param {string} namespace
*/
async (locale, namespace) => {
const response = await fetch(`./${namespace}/${locale}.json`);
return response.json();
},
);
await manager.loadNamespace('my-component');
@ -379,10 +437,17 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
manager.setupNamespaceLoader(/my-.+/, async (locale, namespace) => {
const response = await fetch(`./${namespace}/${locale}.json`);
return response.json();
});
manager.setupNamespaceLoader(
/my-.+/,
/**
* @param {string} locale
* @param {string} namespace
*/
async (locale, namespace) => {
const response = await fetch(`./${namespace}/${locale}.json`);
return response.json();
},
);
await manager.loadNamespaces(['my-defaults', 'my-send-button']);
@ -403,6 +468,7 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager({ autoLoadOnLocaleChange: true });
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`, 25),
});
@ -426,6 +492,7 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`, 25),
});
expect(manager.__storage).to.deep.equal({});
@ -468,6 +535,7 @@ describe('LocalizeManager', () => {
let called = 0;
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => {
called += 1;
return fakeImport(`./my-component/${locale}.js`);
@ -527,7 +595,7 @@ describe('LocalizeManager', () => {
manager = new LocalizeManager();
manager.addData('en-GB', 'my-ns', { greeting: 'Hi!' });
manager.addData('nl-NL', 'my-ns', { greeting: 'Hey!' });
expect(manager.msg('my-ns:greeting', null, { locale: 'nl-NL' })).to.equal('Hey!');
expect(manager.msg('my-ns:greeting', undefined, { locale: 'nl-NL' })).to.equal('Hey!');
manager.reset();
manager.addData('en-GB', 'my-ns', { greeting: 'Hi {name}!' });
manager.addData('nl-NL', 'my-ns', { greeting: 'Hey {name}!' });
@ -561,6 +629,7 @@ describe('When supporting external translation tools like Google Translate', ()
let manager;
const originalLang = document.documentElement.lang;
/** @param {string} lang */
async function simulateGoogleTranslateOn(lang) {
document.documentElement.lang = lang;
}
@ -569,6 +638,10 @@ describe('When supporting external translation tools like Google Translate', ()
document.documentElement.lang = 'auto';
}
/**
* @param {...*} [cfg]
* @returns {LocalizeManager}
*/
function getInstance(cfg) {
LocalizeManager.resetInstance();
return LocalizeManager.getInstance(cfg || {});
@ -660,6 +733,7 @@ describe('When supporting external translation tools like Google Translate', ()
});
describe('[deprecated] When not supporting external translation tools like Google Translate', () => {
/** @type {LocalizeManager} */
let manager;
beforeEach(() => {
@ -677,16 +751,20 @@ describe('[deprecated] When not supporting external translation tools like Googl
});
it('initializes locale from <html> by default', () => {
manager = new LocalizeManager({ supportExternalTranslationTools: false });
manager = new LocalizeManager({});
expect(manager.locale).to.equal('en-GB');
});
it('fires "localeChanged" event if locale was changed via <html lang> attribute', async () => {
manager = new LocalizeManager({ supportExternalTranslationTools: false });
manager = new LocalizeManager({});
setTimeout(() => {
document.documentElement.lang = 'en-US';
});
const event = await oneEvent(manager, 'localeChanged');
const event = await oneEvent(
/** @type {EventTarget} */ (/** @type {unknown} */ (manager)),
'localeChanged',
);
expect(event.detail.newLocale).to.equal('en-US');
expect(event.detail.oldLocale).to.equal('en-GB');
});
@ -696,11 +774,11 @@ describe('[deprecated] When not supporting external translation tools like Googl
setupFakeImport('./my-component/nl-NL.js', { default: { greeting: 'Hallo!' } });
manager = new LocalizeManager({
supportExternalTranslationTools: false,
autoLoadOnLocaleChange: true,
});
await manager.loadNamespace({
/** @param {string} locale */
'my-component': locale => fakeImport(`./my-component/${locale}.js`, 25),
});
@ -709,7 +787,7 @@ describe('[deprecated] When not supporting external translation tools like Googl
});
document.documentElement.lang = 'nl-NL';
await aTimeout(); // wait for mutation observer to be called
await aTimeout(0); // wait for mutation observer to be called
await manager.loadingComplete;
expect(manager.__storage).to.deep.equal({

View file

@ -7,6 +7,7 @@ import {
fixtureSync,
html,
nextFrame,
unsafeStatic,
} from '@open-wc/testing';
import sinon from 'sinon';
import { localize } from '../src/localize.js';
@ -19,6 +20,10 @@ import {
setupFakeImport,
} from '../test-helpers.js';
/**
* @typedef {import('../types/LocalizeMixinTypes').LocalizeMixin} LocalizeMixinHost
*/
describe('LocalizeMixin', () => {
afterEach(() => {
resetFakeImport();
@ -26,21 +31,26 @@ describe('LocalizeMixin', () => {
});
it('loads namespaces defined in "get localizeNamespaces()" when created before attached to DOM', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
class MyElement extends LocalizeMixin(class {}) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
}
const tagString = defineCE(
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
},
);
const tag = unsafeStatic(tagString);
setupEmptyFakeImportsFor(['my-element'], ['en-GB']);
const loadNamespaceSpy = sinon.spy(localize, 'loadNamespace');
await fixture(html`<${tag}></${tag}>`);
new MyElement(); // eslint-disable-line no-new
expect(loadNamespaceSpy.callCount).to.equal(1);
expect(loadNamespaceSpy.calledWith(myElementNs)).to.be.true;
@ -48,27 +58,41 @@ describe('LocalizeMixin', () => {
});
it('ignores duplicates in "get localizeNamespaces()" chain', async () => {
const defaultNs = { default: loc => fakeImport(`./default/${loc}.js`) };
const parentElementNs = { 'parent-element': loc => fakeImport(`./parent-element/${loc}.js`) };
const childElementNs = { 'child-element': loc => fakeImport(`./child-element/${loc}.js`) };
const defaultNs = {
/** @param {string} loc */
default: loc => fakeImport(`./default/${loc}.js`),
};
const parentElementNs = {
/** @param {string} loc */
'parent-element': loc => fakeImport(`./parent-element/${loc}.js`),
};
const childElementNs = {
/** @param {string} loc */
'child-element': loc => fakeImport(`./child-element/${loc}.js`),
};
class ParentElement extends LocalizeMixin(class {}) {
// @ts-ignore
class ParentElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [parentElementNs, defaultNs, ...super.localizeNamespaces];
}
}
class ChildElement extends LocalizeMixin(ParentElement) {
static get localizeNamespaces() {
return [childElementNs, defaultNs, ...super.localizeNamespaces];
}
}
const tagString = defineCE(
// @ts-ignore
class ChildElement extends LocalizeMixin(ParentElement) {
static get localizeNamespaces() {
return [childElementNs, defaultNs, ...super.localizeNamespaces];
}
},
);
const tag = unsafeStatic(tagString);
setupEmptyFakeImportsFor(['default', 'parent-element', 'child-element'], ['en-GB']);
const loadNamespaceSpy = sinon.spy(localize, 'loadNamespace');
new ChildElement(); // eslint-disable-line no-new
await fixture(html`<${tag}></${tag}>`);
expect(loadNamespaceSpy.callCount).to.equal(3);
expect(loadNamespaceSpy.calledWith(childElementNs)).to.be.true;
expect(loadNamespaceSpy.calledWith(defaultNs)).to.be.true;
@ -78,49 +102,56 @@ describe('LocalizeMixin', () => {
});
it('calls "onLocaleReady()" after namespaces were loaded for the first time (only if attached to DOM)', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
onLocaleReady() {}
}
const tagString = defineCE(MyElement);
setupEmptyFakeImportsFor(['my-element'], ['en-GB']);
const element = new MyElement();
const onLocaleReadySpy = sinon.spy(element, 'onLocaleReady');
const el = /** @type {MyElement} */ (document.createElement(tagString));
const wrapper = await fixture('<div></div>');
const onLocaleReadySpy = sinon.spy(el, 'onLocaleReady');
await localize.loadingComplete;
expect(onLocaleReadySpy.callCount).to.equal(0);
element.connectedCallback();
wrapper.appendChild(el);
await localize.loadingComplete;
expect(onLocaleReadySpy.callCount).to.equal(1);
});
it('calls "onLocaleChanged(newLocale, oldLocale)" after locale was changed (only if attached to DOM)', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyOtherElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
onLocaleChanged() {}
}
const tagString = defineCE(MyOtherElement);
setupEmptyFakeImportsFor(['my-element'], ['en-GB', 'nl-NL', 'ru-RU']);
const element = new MyElement();
const onLocaleChangedSpy = sinon.spy(element, 'onLocaleChanged');
const el = /** @type {MyOtherElement} */ (document.createElement(tagString));
const wrapper = await fixture('<div></div>');
const onLocaleChangedSpy = sinon.spy(el, 'onLocaleChanged');
await localize.loadingComplete;
@ -128,33 +159,40 @@ describe('LocalizeMixin', () => {
await localize.loadingComplete;
expect(onLocaleChangedSpy.callCount).to.equal(0);
element.connectedCallback();
wrapper.appendChild(el);
localize.locale = 'ru-RU';
await localize.loadingComplete;
expect(onLocaleChangedSpy.callCount).to.equal(1);
expect(onLocaleChangedSpy.calledWith('ru-RU', 'nl-NL')).to.be.true;
// FIXME: Expected 0 arguments, but got 2. ts(2554) --> not sure why this sinon type is not working
// @ts-ignore
expect(onLocaleChangedSpy.calledWithExactly('ru-RU', 'nl-NL')).to.be.true;
});
it('calls "onLocaleUpdated()" after both "onLocaleReady()" and "onLocaleChanged()"', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
onLocaleUpdated() {}
}
const tagString = defineCE(MyElement);
setupEmptyFakeImportsFor(['my-element'], ['en-GB', 'nl-NL']);
const el = new MyElement();
const el = /** @type {MyElement} */ (document.createElement(tagString));
const wrapper = await fixture('<div></div>');
const onLocaleUpdatedSpy = sinon.spy(el, 'onLocaleUpdated');
el.connectedCallback();
wrapper.appendChild(el);
await el.localizeNamespacesLoaded;
expect(onLocaleUpdatedSpy.callCount).to.equal(1);
@ -162,8 +200,11 @@ describe('LocalizeMixin', () => {
expect(onLocaleUpdatedSpy.callCount).to.equal(2);
});
it('should have the localizeNamespacesLoaded avaliable within "onLocaleUpdated()"', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
it('should have the localizeNamespacesLoaded available within "onLocaleUpdated()"', async () => {
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
setupFakeImport('./my-element/en-GB.js', {
default: {
label: 'one',
@ -174,13 +215,13 @@ describe('LocalizeMixin', () => {
label: 'two',
},
});
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
async onLocaleUpdated() {
super.onLocaleUpdated();
await this.localizeNamespacesLoaded;
@ -188,7 +229,8 @@ describe('LocalizeMixin', () => {
}
}
const el = new MyElement();
const tagString = defineCE(MyElement);
const el = /** @type {MyElement} */ (document.createElement(tagString));
el.connectedCallback();
await el.localizeNamespacesLoaded;
@ -201,19 +243,22 @@ describe('LocalizeMixin', () => {
});
it('calls "requestUpdate()" after locale was changed', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
}
setupEmptyFakeImportsFor(['my-element'], ['en-GB']);
const el = new MyElement();
const tagString = defineCE(MyElement);
const el = /** @type {MyElement} */ (document.createElement(tagString));
const updateSpy = sinon.spy(el, 'requestUpdate');
el.connectedCallback();
@ -224,23 +269,29 @@ describe('LocalizeMixin', () => {
});
it('has msgLit() which integrates with lit-html', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`),
};
setupFakeImport('./my-element/en-GB.js', {
default: {
greeting: 'Hi!',
},
});
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
}
const element = new MyElement();
const tagString = defineCE(MyElement);
const el = /** @type {MyElement} */ (document.createElement(tagString));
el.connectedCallback();
const lionLocalizeMessageSpy = sinon.spy(localize, 'msg');
const messageDirective = element.msgLit('my-element:greeting');
const messageDirective = el.msgLit('my-element:greeting');
expect(lionLocalizeMessageSpy.callCount).to.equal(0);
expect(isDirective(messageDirective)).to.be.true;
@ -250,7 +301,7 @@ describe('LocalizeMixin', () => {
expect(lionLocalizeMessageSpy.callCount).to.equal(1);
expect(lionLocalizeMessageSpy.calledWith('my-element:greeting')).to.be.true;
const message = element.msgLit('my-element:greeting');
const message = el.msgLit('my-element:greeting');
expect(message).to.equal('Hi!');
expect(typeof message).to.equal('string');
expect(lionLocalizeMessageSpy.callCount).to.equal(2);
@ -259,22 +310,25 @@ describe('LocalizeMixin', () => {
lionLocalizeMessageSpy.restore();
});
it('has a Promise "localizeNamespacesLoaded" which resolves once tranlations are available', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`, 25) };
it('has a Promise "localizeNamespacesLoaded" which resolves once translations are available', async () => {
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`, 25),
};
setupFakeImport('./my-element/en-GB.js', {
default: {
greeting: 'Hi!',
},
});
class MyElement extends LocalizeMixin(class {}) {
// @ts-ignore
class MyElement extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
requestUpdate() {}
}
const el = new MyElement();
const tagString = defineCE(MyElement);
const el = /** @type {MyElement} */ (document.createElement(tagString));
const messageDirective = el.msgLit('my-element:greeting');
expect(isDirective(messageDirective)).to.be.true;
@ -284,33 +338,46 @@ describe('LocalizeMixin', () => {
});
it('renders only once all translations have been loaded (if BaseElement supports it)', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`, 25) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`, 25),
};
setupFakeImport('./my-element/en-GB.js', {
default: {
greeting: 'Hi!',
},
});
const tag = defineCE(
class extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
// @ts-ignore
class MyLocalizedClass extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
render() {
return html`<p>${this.msgLit('my-element:greeting')}</p>`;
}
},
);
render() {
return html`<p>${this.msgLit('my-element:greeting')}</p>`;
}
}
const el = await fixtureSync(`<${tag}></${tag}>`);
expect(el.shadowRoot.children.length).to.equal(0);
await el.updateComplete;
expect(el.shadowRoot.querySelector('p').innerText).to.equal('Hi!');
const tag = defineCE(MyLocalizedClass);
const el = /** @type {MyLocalizedClass} */ (await fixtureSync(`<${tag}></${tag}>`));
expect(el.shadowRoot).to.exist;
if (el.shadowRoot) {
expect(el.shadowRoot.children.length).to.equal(0);
await el.updateComplete;
const pTag = el.shadowRoot.querySelector('p');
expect(pTag).to.exist;
if (pTag) {
expect(pTag.innerText).to.equal('Hi!');
}
}
});
it('rerender on locale change once all translations are loaded (if BaseElement supports it)', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`, 25) };
it('re-render on locale change once all translations are loaded (if BaseElement supports it)', async () => {
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`, 25),
};
setupFakeImport('./my-element/en-GB.js', {
default: {
greeting: 'Hi!',
@ -322,57 +389,69 @@ describe('LocalizeMixin', () => {
},
});
const tag = defineCE(
class TestPromise extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
// @ts-ignore
class MyLocalizedClass extends LocalizeMixin(LitElement) {
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
render() {
return html`<p>${this.msgLit('my-element:greeting')}</p>`;
}
},
);
render() {
return html`<p>${this.msgLit('my-element:greeting')}</p>`;
}
}
const el = await fixture(`<${tag}></${tag}>`);
const tag = defineCE(MyLocalizedClass);
const el = /** @type {MyLocalizedClass} */ (await fixture(`<${tag}></${tag}>`));
await el.updateComplete;
expect(el.shadowRoot.querySelector('p').innerText).to.equal('Hi!');
localize.locale = 'en-US';
expect(el.shadowRoot.querySelector('p').innerText).to.equal('Hi!');
await el.updateComplete;
expect(el.shadowRoot.querySelector('p').innerText).to.equal('Howdy!');
expect(el.shadowRoot).to.exist;
if (el.shadowRoot) {
const p = /** @type {HTMLParagraphElement} */ (el.shadowRoot.querySelector('p'));
expect(p.innerText).to.equal('Hi!');
localize.locale = 'en-US';
expect(p.innerText).to.equal('Hi!');
await el.updateComplete;
expect(p.innerText).to.equal('Howdy!');
}
});
it('it can still render async by setting "static get waitForLocalizeNamespaces() { return false; }" (if BaseElement supports it)', async () => {
const myElementNs = { 'my-element': locale => fakeImport(`./my-element/${locale}.js`, 50) };
const myElementNs = {
/** @param {string} locale */
'my-element': locale => fakeImport(`./my-element/${locale}.js`, 50),
};
setupFakeImport('./my-element/en-GB.js', {
default: {
greeting: 'Hi!',
},
});
const tag = defineCE(
class extends LocalizeMixin(LitElement) {
static get waitForLocalizeNamespaces() {
return false;
}
// @ts-ignore
class MyLocalizedClass extends LocalizeMixin(LitElement) {
static get waitForLocalizeNamespaces() {
return false;
}
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
static get localizeNamespaces() {
return [myElementNs, ...super.localizeNamespaces];
}
render() {
return html`<p>${this.msgLit('my-element:greeting')}</p>`;
}
},
);
render() {
return html`<p>${this.msgLit('my-element:greeting')}</p>`;
}
}
const el = await fixture(`<${tag}></${tag}>`);
const tag = defineCE(MyLocalizedClass);
const el = /** @type {MyLocalizedClass} */ (await fixture(`<${tag}></${tag}>`));
await el.updateComplete;
expect(el.shadowRoot.querySelector('p').innerText).to.equal('');
await el.localizeNamespacesLoaded;
await el.updateComplete;
expect(el.shadowRoot.querySelector('p').innerText).to.equal('Hi!');
expect(el.shadowRoot).to.exist;
if (el.shadowRoot) {
const p = /** @type {HTMLParagraphElement} */ (el.shadowRoot.querySelector('p'));
expect(p.innerText).to.equal('');
await el.localizeNamespacesLoaded;
await el.updateComplete;
expect(p.innerText).to.equal('Hi!');
}
});
});

View file

@ -57,10 +57,10 @@ describe('formatDate', () => {
locale: 'en-US',
};
localize.locale = 'hu-HU';
let date = parseDate('2018-5-28');
let date = /** @type {Date} */ (parseDate('2018-5-28'));
expect(formatDate(date)).to.equal('2018. 05. 28.');
date = parseDate('1970-11-3');
date = /** @type {Date} */ (parseDate('1970-11-3'));
expect(formatDate(date, options)).to.equal('Tuesday, November 03, 1970');
});
@ -73,13 +73,13 @@ describe('formatDate', () => {
locale: 'en-US',
};
localize.locale = 'bg-BG';
let date = parseDate('29-12-2017');
let date = /** @type {Date} */ (parseDate('29-12-2017'));
expect(formatDate(date)).to.equal('29.12.2017 г.');
date = parseDate('13-1-1940');
date = /** @type {Date} */ (parseDate('13-1-1940'));
expect(formatDate(date)).to.equal('13.01.1940 г.');
date = parseDate('3-11-1970');
date = /** @type {Date} */ (parseDate('3-11-1970'));
expect(formatDate(date, options)).to.equal('Tuesday, November 03, 1970');
});
@ -92,13 +92,13 @@ describe('formatDate', () => {
locale: 'en-US',
};
localize.locale = 'en-US';
let date = parseDate('12-29-1940');
let date = /** @type {Date} */ (parseDate('12-29-1940'));
expect(formatDate(date)).to.equal('12/29/1940');
date = parseDate('1-13-1940');
date = /** @type {Date} */ (parseDate('1-13-1940'));
expect(formatDate(date)).to.equal('01/13/1940');
date = parseDate('11-3-1970');
date = /** @type {Date} */ (parseDate('11-3-1970'));
expect(formatDate(date, options)).to.equal('Tuesday, November 03, 1970');
});
@ -110,10 +110,10 @@ describe('formatDate', () => {
day: '2-digit',
locale: 'en-US',
};
let parsedDate = parseDate('05.11.2017');
let parsedDate = /** @type {Date} */ (parseDate('05.11.2017'));
expect(formatDate(parsedDate, options)).to.equal('Sunday, November 05, 2017');
parsedDate = parseDate('01-01-1940');
parsedDate = /** @type {Date} */ (parseDate('01-01-1940'));
options = {
weekday: 'long',
year: 'numeric',
@ -130,7 +130,7 @@ describe('formatDate', () => {
month: 'long',
day: '2-digit',
};
const parsedDate = parseDate('12.10.2019');
const parsedDate = /** @type {Date} */ (parseDate('12.10.2019'));
expect(formatDate(parsedDate, options)).to.equal('Saturday, 12 October');
});
@ -140,7 +140,7 @@ describe('formatDate', () => {
year: 'numeric',
day: '2-digit',
};
const parsedDate = parseDate('12.10.2019');
const parsedDate = /** @type {Date} */ (parseDate('12.10.2019'));
expect(formatDate(parsedDate, options)).to.equal('Saturday 12 2019');
});
@ -150,12 +150,13 @@ describe('formatDate', () => {
year: 'numeric',
month: 'long',
};
const parsedDate = parseDate('12.10.2019');
const parsedDate = /** @type {Date} */ (parseDate('12.10.2019'));
expect(formatDate(parsedDate, options)).to.equal('October 2019 Saturday');
});
it('returns empty string when input is not a Date object', async () => {
const date = '1-1-2016';
// @ts-ignore tests what happens if you use a wrong type
expect(formatDate(date)).to.equal('');
});
});

View file

@ -2,6 +2,9 @@ import { expect } from '@open-wc/testing';
import { getMonthNames } from '../../src/date/getMonthNames.js';
/**
* @param {TemplateStringsArray} strings
*/
function s(strings) {
return strings[0].split(' ');
}

View file

@ -2,6 +2,9 @@ import { expect } from '@open-wc/testing';
import { getWeekdayNames } from '../../src/date/getWeekdayNames.js';
/**
* @param {TemplateStringsArray} strings
*/
function s(strings) {
return strings[0].split(' ');
}

View file

@ -3,9 +3,15 @@ import { localizeTearDown } from '../../test-helpers.js';
import { parseDate } from '../../src/date/parseDate.js';
/**
*
* @param {Date | undefined} value
* @param {Date} date
*/
function equalsDate(value, date) {
return (
Object.prototype.toString.call(value) === '[object Date]' && // is Date Object
value &&
value.getDate() === date.getDate() && // day
value.getMonth() === date.getMonth() && // month
value.getFullYear() === date.getFullYear() // year

View file

@ -27,6 +27,7 @@ describe('isLocalizeESModule', () => {
});
it('ignores if not an object', () => {
// @ts-ignore passing a non-object is not allowed by ts, but we still want to test the outcome
expect(isLocalizeESModule(undefined)).to.equal(false);
});
});

View file

@ -7,7 +7,7 @@ import { LocalizeManager } from '../src/LocalizeManager.js';
import { localize, setLocalize } from '../src/localize.js';
describe('localize', () => {
// this is an importan mindset:
// this is an important mindset:
// we don't test the singleton
// we check that it is an instance of the right class
// we test newly created instances of this class separately
@ -26,9 +26,11 @@ describe('localize', () => {
const oldLocalizeTeardown = localize.teardown;
localize.teardown = sinon.spy();
const newLocalize = { teardown: () => {} };
const newLocalize = /** @type {LocalizeManager} */ ({ teardown: () => {} });
setLocalize(newLocalize);
expect(localize).to.equal(newLocalize);
// @ts-ignore since we're testing another reference to the same global instance
expect(oldLocalize.teardown.callCount).to.equal(1);
setLocalize(oldLocalize);

View file

@ -3,14 +3,22 @@ import { localize } from '../../src/localize.js';
import { formatNumber } from '../../src/number/formatNumber.js';
import { localizeTearDown } from '../../test-helpers.js';
const currencyCode = currency => ({ style: 'currency', currencyDisplay: 'code', currency });
const currencySymbol = currency => ({ style: 'currency', currencyDisplay: 'symbol', currency });
const currencyCode = /** @param {string} currency */ currency => ({
style: 'currency',
currencyDisplay: 'code',
currency,
});
const currencySymbol = /** @param {string} currency */ currency => ({
style: 'currency',
currencyDisplay: 'symbol',
currency,
});
describe('formatNumber', () => {
afterEach(localizeTearDown);
it('displays the appropriate amount of decimal places based on currencies spec http://www.currency-iso.org/en/home/tables/table-a1.html', () => {
const clean = str => str.replace(/[a-zA-Z]+/g, '').trim();
const clean = /** @param {string} str */ str => str.replace(/[a-zA-Z]+/g, '').trim();
expect(clean(formatNumber(123456.789, currencyCode('JPY')))).to.equal('123,457');
expect(clean(formatNumber(123456.789, currencyCode('EUR')))).to.equal('123,456.79');
expect(clean(formatNumber(123456.789, currencyCode('BHD')))).to.equal('123,456.789');
@ -54,11 +62,10 @@ describe('formatNumber', () => {
expect(formatNumber(-12.6, { roundMode: 'floor' })).to.equal('13');
});
it('returns empty string when NaN', () => {
it('returns empty string when passing wrong type', () => {
// @ts-ignore tests what happens if you pass wrong type
expect(formatNumber('foo')).to.equal('');
});
it('returns empty string when number is undefined', () => {
// @ts-ignore tests what happens if you pass wrong type
expect(formatNumber(undefined)).to.equal('');
});
@ -66,12 +73,14 @@ describe('formatNumber', () => {
const savedReturnIfNaN = localize.formatNumberOptions.returnIfNaN;
localize.formatNumberOptions.returnIfNaN = '-';
// @ts-ignore
expect(formatNumber('foo')).to.equal('-');
localize.formatNumberOptions.returnIfNaN = savedReturnIfNaN;
});
it("can set what to returns when NaN via `returnIfNaN: 'foo'`", () => {
// @ts-ignore
expect(formatNumber('foo', { returnIfNaN: '-' })).to.equal('-');
});

View file

@ -1,18 +1,26 @@
import { expect } from '@open-wc/testing';
import { localize } from '../../src/localize.js';
import { localizeTearDown } from '../../test-helpers.js';
import { formatNumberToParts } from '../../src/number/formatNumberToParts.js';
const c = v => ({ type: 'currency', value: v });
const d = v => ({ type: 'decimal', value: v });
const i = v => ({ type: 'integer', value: v });
const f = v => ({ type: 'fraction', value: v });
const g = v => ({ type: 'group', value: v });
const l = v => ({ type: 'literal', value: v });
const c = /** @param {string} v */ v => ({
type: 'currency',
value: v,
});
const d = /** @param {string} v */ v => ({ type: 'decimal', value: v });
const i = /** @param {string} v */ v => ({ type: 'integer', value: v });
const f = /** @param {string} v */ v => ({ type: 'fraction', value: v });
const g = /** @param {string} v */ v => ({ type: 'group', value: v });
const l = /** @param {string} v */ v => ({ type: 'literal', value: v });
const m = { type: 'minusSign', value: '' };
const stringifyParts = parts => parts.map(part => part.value).join('');
const stringifyParts =
/**
* @typedef {import('../../types/LocalizeMixinTypes').FormatNumberPart} FormatNumberPart
* @param {FormatNumberPart[]} parts
* @returns {string}
*/
parts => parts.map(part => part.value).join('');
describe('formatNumberToParts', () => {
afterEach(localizeTearDown);
@ -32,12 +40,14 @@ describe('formatNumberToParts', () => {
];
specs.forEach(([locale, currency, amount, expectedResult]) => {
it(`formats ${locale} ${currency} ${amount} as "${stringifyParts(expectedResult)}"`, () => {
it(`formats ${locale} ${currency} ${amount} as "${stringifyParts(
/** @type {FormatNumberPart[]} */ (expectedResult),
)}"`, () => {
expect(
formatNumberToParts(amount, {
locale,
formatNumberToParts(Number(amount), {
style: 'currency',
currency,
locale: String(locale),
currency: String(currency),
}),
).to.deep.equal(expectedResult);
});
@ -59,13 +69,15 @@ describe('formatNumberToParts', () => {
];
specs.forEach(([locale, currency, amount, expectedResult]) => {
it(`formats ${locale} ${currency} ${amount} as "${stringifyParts(expectedResult)}"`, () => {
it(`formats ${locale} ${currency} ${amount} as "${stringifyParts(
/** @type {FormatNumberPart[]} */ (expectedResult),
)}"`, () => {
expect(
formatNumberToParts(amount, {
locale,
formatNumberToParts(Number(amount), {
style: 'currency',
currencyDisplay: 'code',
currency,
locale: String(locale),
currency: String(currency),
}),
).to.deep.equal(expectedResult);
});
@ -74,6 +86,7 @@ describe('formatNumberToParts', () => {
describe("style: 'decimal'", () => {
describe('no minimumFractionDigits', () => {
/** @type {Array.<Array.<?>>} */
const specs = [
['en-GB', 3500, [i('3'), g(','), i('500')]],
['en-GB', -3500, [m, i('3'), g(','), i('500')]],
@ -88,10 +101,12 @@ describe('formatNumberToParts', () => {
];
specs.forEach(([locale, amount, expectedResult]) => {
it(`formats ${locale} ${amount} as "${stringifyParts(expectedResult)}"`, () => {
it(`formats ${locale} ${amount} as "${stringifyParts(
/** @type {FormatNumberPart[]} */ (expectedResult),
)}"`, () => {
localize.locale = locale;
expect(
formatNumberToParts(amount, {
formatNumberToParts(Number(amount), {
style: 'decimal',
}),
).to.deep.equal(expectedResult);
@ -100,6 +115,7 @@ describe('formatNumberToParts', () => {
});
describe('minimumFractionDigits: 2', () => {
/** @type {Array.<Array.<?>>} */
const specs = [
['en-GB', 3500, [i('3'), g(','), i('500'), d('.'), f('00')]],
['en-GB', -3500, [m, i('3'), g(','), i('500'), d('.'), f('00')]],
@ -114,10 +130,12 @@ describe('formatNumberToParts', () => {
];
specs.forEach(([locale, amount, expectedResult]) => {
it(`formats ${locale} ${amount} as "${stringifyParts(expectedResult)}"`, () => {
it(`formats ${locale} ${amount} as "${stringifyParts(
/** @type {FormatNumberPart[]} */ (expectedResult),
)}"`, () => {
localize.locale = locale;
expect(
formatNumberToParts(amount, {
formatNumberToParts(Number(amount), {
style: 'decimal',
minimumFractionDigits: 2,
}),
@ -142,12 +160,14 @@ describe('formatNumberToParts', () => {
];
specs.forEach(([locale, amount, expectedResult]) => {
it(`formats ${locale} ${amount} as "${stringifyParts(expectedResult)}"`, () => {
it(`formats ${locale} ${amount} as "${stringifyParts(
/** @type {FormatNumberPart[]} */ (expectedResult),
)}"`, () => {
expect(
formatNumberToParts(amount / 100, {
locale,
formatNumberToParts(Number(amount) / 100, {
style: 'percent',
minimumFractionDigits: 2,
locale: String(locale),
}),
).to.deep.equal(expectedResult);
});

View file

@ -0,0 +1,52 @@
import { Constructor } from '@open-wc/dedupe-mixin';
export interface FormatNumberPart {
type: string;
value: string;
}
interface StringToFunctionMap {
[key: string]: Function;
}
export type NamespaceObject = StringToFunctionMap | string;
interface msgVariables {
[key: string]: unknown;
}
interface msgOptions {
locale?: string;
}
declare class LocalizeMixinHost {
// FIXME: return value type check doesn't seem to be `working!
static get localizeNamespaces(): StringToFunctionMap[];
static get waitForLocalizeNamespaces(): boolean;
public localizeNamespacesLoaded(): Promise<Object>;
/**
* Hook into LitElement to only render once all translations are loaded
*/
public performUpdate(): Promise<void>;
public onLocaleReady(): void;
public onLocaleChanged(): void;
public onLocaleUpdated(): void;
public connectedCallback(): void;
public disconnectedCallback(): void;
public msgLit(keys: string | string[], variables?: msgVariables, options?: msgOptions): void;
private __getUniqueNamespaces(): void;
private __localizeAddLocaleChangedListener(): void;
private __localizeRemoveLocaleChangedListener(): void;
private __localizeOnLocaleChanged(event: CustomEvent): void;
}
declare function LocalizeMixinImplementation<T extends Constructor<HTMLElement>>(
superclass: T,
): T & Constructor<LocalizeMixinHost> & typeof LocalizeMixinHost;
export type LocalizeMixin = typeof LocalizeMixinImplementation;

8
packages/localize/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
declare module '@bundled-es-modules/message-format/MessageFormat.js' {
let main: any;
export = main;
}
declare module '@bundled-es-modules/fetch-mock' {
export const fetchMock: any;
}

View file

@ -3,6 +3,11 @@ export class SingletonManagerClass {
this._map = new Map();
}
/**
* @param {string} key
* @param {any} value
* @throws {Error} Will throw if the key is already defined
*/
set(key, value) {
if (this.has(key)) {
throw new Error(`The key "${key}" is already defined and can not be overridden.`);
@ -10,10 +15,17 @@ export class SingletonManagerClass {
this._map.set(key, value);
}
/**
* @param {string} key
* @returns
*/
get(key) {
return this._map.get(key);
}
/**
* @param {string} key
*/
has(key) {
return this._map.has(key);
}

View file

@ -10,15 +10,24 @@
"strict": true,
"noImplicitThis": true,
"alwaysStrict": true,
"types": ["node", "mocha"],
"types": ["node", "mocha", "sinon"],
"esModuleInterop": true
},
"include": ["packages/core/**/*.js", "packages/tabs/**/*.js"],
"include": [
"packages/core/**/*.js",
"packages/tabs/**/*.js",
"packages/singleton-manager/**/*.js",
"packages/localize/**/*.js",
"packages/localize/**/*.ts"
],
"exclude": [
"node_modules",
"**/node_modules/*",
"**/coverage/*",
"**/dist/**/*",
"packages/**/test-helpers"
"packages/**/test-helpers",
// ignore test/demos for singleton manager until overlays are typed as it's used in there
"packages/singleton-manager/demo/",
"packages/singleton-manager/test/"
]
}

View file

@ -20,11 +20,13 @@ module.exports = {
sessionStartTimeout: 60000,
concurrency: 1,
coverageConfig: {
report: true,
reportDir: 'coverage',
threshold: {
statements: 80,
statements: 90,
branches: 70,
functions: 70,
lines: 80,
functions: 80,
lines: 90,
},
},
browsers: [

View file

@ -6,11 +6,13 @@ module.exports = {
sessionStartTimeout: 30000,
concurrency: 5,
coverageConfig: {
report: true,
reportDir: 'coverage',
threshold: {
statements: 80,
branches: 70,
functions: 70,
lines: 80,
statements: 90,
branches: 65,
functions: 80,
lines: 90,
},
},
};