diff --git a/aio/content/examples/i18n/src/app/app.locale_data.ts b/aio/content/examples/i18n/src/app/app.locale_data.ts new file mode 100644 index 00000000000000..9129a68200eee2 --- /dev/null +++ b/aio/content/examples/i18n/src/app/app.locale_data.ts @@ -0,0 +1,6 @@ +// #docregion import-locale +import { registerLocaleData } from '@angular/common'; +import localeFr from '@angular/common/i18n_data/locale_fr'; + +registerLocaleData(localeFr); +// #enddocregion import-locale diff --git a/aio/content/examples/i18n/src/app/app.locale_data_extra.ts b/aio/content/examples/i18n/src/app/app.locale_data_extra.ts new file mode 100644 index 00000000000000..312c73feecb055 --- /dev/null +++ b/aio/content/examples/i18n/src/app/app.locale_data_extra.ts @@ -0,0 +1,7 @@ +// #docregion import-locale-extra +import { registerLocaleData } from '@angular/common'; +import localeEnGB from '@angular/common/i18n_data/locale_en-GB'; +import localeEnGBExtra from '@angular/common/i18n_data/extra/locale_en-GB'; + +registerLocaleData(localeEnGB, localeEnGBExtra); +// #enddocregion import-locale-extra diff --git a/aio/content/examples/pipes/e2e-spec.ts b/aio/content/examples/pipes/e2e-spec.ts index 0aa5f09a5785a2..9675b66367cc2f 100644 --- a/aio/content/examples/pipes/e2e-spec.ts +++ b/aio/content/examples/pipes/e2e-spec.ts @@ -28,7 +28,7 @@ describe('Pipes', function () { it('should be able to toggle birthday formats', function () { let birthDayEle = element(by.css('hero-birthday2 > p')); - expect(birthDayEle.getText()).toEqual(`The hero's birthday is 4/15/1988`); + expect(birthDayEle.getText()).toEqual(`The hero's birthday is 4/15/88`); let buttonEle = element(by.cssContainingText('hero-birthday2 > button', 'Toggle Format')); expect(buttonEle.isDisplayed()).toBe(true); buttonEle.click().then(function() { diff --git a/aio/content/guide/browser-support.md b/aio/content/guide/browser-support.md index e5e20cfaff00f0..8d796ff576d98b 100644 --- a/aio/content/guide/browser-support.md +++ b/aio/content/guide/browser-support.md @@ -347,7 +347,7 @@ Here are the features which may require additional polyfills: - [Date](api/common/DatePipe), [currency](api/common/CurrencyPipe), [decimal](api/common/DecimalPipe) and [percent](api/common/PercentPipe) pipes + If you use the following deprecated i18n pipes: [date](api/common/DeprecatedDatePipe), [currency](api/common/DeprecatedCurrencyPipe), [decimal](api/common/DeprecatedDecimalPipe) and [percent](api/common/DeprecatedPercentPipe) diff --git a/aio/content/guide/i18n.md b/aio/content/guide/i18n.md index 19da44d0053e47..810bae6bdccfeb 100644 --- a/aio/content/guide/i18n.md +++ b/aio/content/guide/i18n.md @@ -40,6 +40,28 @@ You need to build and deploy a separate version of the application for each supp {@a i18n-attribute} +## i18n pipes + +Angular pipes can help you with internationalization: the `DatePipe`, `CurrencyPipe`, `DecimalPipe` +and `PercentPipe` use locale data to format your data based on your `LOCALE_ID`. + +By default Angular only contains locale data for the language `en-US`, if you set the value of +`LOCALE_ID` to another locale, you will have to import new locale data for this language: + + + + +
+ +Note that the files in `@angular/common/i18n_data` contain most of the locale data that you will +need, but some advanced formatting options might only be available in the extra dataset that you can +import from `@angular/common/i18n_data/extra`: + + + + +
+ ## Mark text with the _i18n_ attribute The Angular `i18n` attribute is a marker for translatable content. diff --git a/aio/content/guide/pipes.md b/aio/content/guide/pipes.md index 4c2a3db8ef2c6b..0445b942f0f7f7 100644 --- a/aio/content/guide/pipes.md +++ b/aio/content/guide/pipes.md @@ -46,24 +46,6 @@ Inside the interpolation expression, you flow the component's `birthday` value t function on the right. All pipes work this way. -
- - - -The `Date` and `Currency` pipes need the *ECMAScript Internationalization API*. -Safari and other older browsers don't support it. You can add support with a polyfill. - - - - <script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=Intl.~locale.en"></script> - - - - - -
- - ## Built-in pipes diff --git a/build.sh b/build.sh index 71b476bb8d2bfb..293bcf729ee4a8 100755 --- a/build.sh +++ b/build.sh @@ -86,7 +86,7 @@ done ####################################### isIgnoredDirectory() { name=$(basename ${1}) - if [[ -f "${1}" || "${name}" == "src" || "${name}" == "test" || "${name}" == "integrationtest" ]]; then + if [[ -f "${1}" || "${name}" == "src" || "${name}" == "test" || "${name}" == "integrationtest" || "${name}" == "i18n_data" ]]; then return 0 else return 1 @@ -470,6 +470,11 @@ do minify ${BUNDLES_DIR} ) 2>&1 | grep -v "as external dependency" + + if [[ ${PACKAGE} == "common" ]]; then + echo "====== Copy i18n locale data" + rsync -a --exclude=*.d.ts --exclude=*.metadata.json ${OUT_DIR}/i18n_data/ ${NPM_DIR}/i18n_data + fi else echo "====== Copy ${PACKAGE} node tool" rsync -a ${OUT_DIR}/ ${NPM_DIR} diff --git a/packages/common/BUILD.bazel b/packages/common/BUILD.bazel index cee67edccd871b..f1dcaea6c68717 100644 --- a/packages/common/BUILD.bazel +++ b/packages/common/BUILD.bazel @@ -5,6 +5,7 @@ ts_library( name = "common", srcs = glob(["**/*.ts"], exclude=[ "http/**", + "i18n/**", "test/**", "testing/**", ]), diff --git a/packages/common/i18n_data/tsconfig-build.json b/packages/common/i18n_data/tsconfig-build.json new file mode 100644 index 00000000000000..273fbb9b6e5583 --- /dev/null +++ b/packages/common/i18n_data/tsconfig-build.json @@ -0,0 +1,20 @@ +{ +"compilerOptions": { + "baseUrl": ".", + "declaration": true, + "stripInternal": true, + "experimentalDecorators": true, + "module": "es2015", + "moduleResolution": "node", + "outDir": "../../../dist/packages/common/i18n_data", + "paths": { + "@angular/common": ["../../../dist/packages/common"], + "@angular/core": ["../../../dist/packages/core"] + }, + "sourceMap": true, + "inlineSources": true, + "target": "es5", + "skipLibCheck": true, + "lib": ["es2015", "dom"] + } +} diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 5e79a49ad185be..d6f7158680b9f7 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -12,11 +12,16 @@ * Entry point for all public APIs of the common package. */ export * from './location/index'; -export {NgLocaleLocalization, NgLocalization} from './localization'; +export {NgLocaleLocalization, NgLocalization} from './i18n/localization'; +export {Plural, LOCALE_DATA} from './i18n/locale_data'; +export {findLocaleData, registerLocaleData, NumberFormatStyle, FormStyle, Time, TranslationWidth, FormatWidth, NumberSymbol, WeekDay, getLocaleDayPeriods, getLocaleDayNames, getLocaleMonthNames, getLocaleId, getLocaleEraNames, getLocaleWeekEndRange, getLocaleFirstDayOfWeek, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocalePluralCase, getLocaleTimeFormat, getLocaleNumberSymbol, getLocaleNumberFormat, getLocaleCurrencyName, getLocaleCurrencySymbol} from './i18n/locale_data_api'; +export {AVAILABLE_LOCALES} from './i18n/available_locales'; +export {CURRENCIES} from './i18n/currencies'; export {parseCookieValue as ɵparseCookieValue} from './cookie'; export {CommonModule, DeprecatedI18NPipesModule} from './common_module'; export {NgClass, NgFor, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index'; export {DOCUMENT} from './dom_tokens'; export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCasePipe, CurrencyPipe, DecimalPipe, PercentPipe, SlicePipe, UpperCasePipe, TitleCasePipe} from './pipes/index'; +export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index'; export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id'; export {VERSION} from './version'; diff --git a/packages/common/src/common_module.ts b/packages/common/src/common_module.ts index 4a75bb4f3911bb..dd7750f86fa68c 100644 --- a/packages/common/src/common_module.ts +++ b/packages/common/src/common_module.ts @@ -8,8 +8,9 @@ import {NgModule} from '@angular/core'; -import {COMMON_DEPRECATED_DIRECTIVES, COMMON_DIRECTIVES} from './directives/index'; -import {NgLocaleLocalization, NgLocalization} from './localization'; +import {COMMON_DIRECTIVES} from './directives/index'; +import {NgLocaleLocalization, NgLocalization} from './i18n/localization'; +import {COMMON_DEPRECATED_I18N_PIPES} from './pipes/deprecated/index'; import {COMMON_PIPES} from './pipes/index'; @@ -31,17 +32,10 @@ export class CommonModule { } /** - * I18N pipes are being changed to move away from using the JS Intl API. - * - * The former pipes relying on the Intl API will be moved to this module while the `CommonModule` - * will contain the new pipes that do not rely on Intl. - * - * As a first step this module is created empty to ease the migration. - * - * see https://github.com/angular/angular/pull/18284 + * A module that contains the deprecated i18n pipes. * * @deprecated from v5 */ -@NgModule({declarations: [], exports: []}) +@NgModule({declarations: [COMMON_DEPRECATED_I18N_PIPES], exports: [COMMON_DEPRECATED_I18N_PIPES]}) export class DeprecatedI18NPipesModule { } diff --git a/packages/common/src/directives/ng_plural.ts b/packages/common/src/directives/ng_plural.ts index e014a5e9d22f81..f82479aa5f4b0c 100644 --- a/packages/common/src/directives/ng_plural.ts +++ b/packages/common/src/directives/ng_plural.ts @@ -8,7 +8,7 @@ import {Attribute, Directive, Host, Input, TemplateRef, ViewContainerRef} from '@angular/core'; -import {NgLocalization, getPluralCategory} from '../localization'; +import {NgLocalization, getPluralCategory} from '../i18n/localization'; import {SwitchView} from './ng_switch'; diff --git a/packages/common/src/i18n/format_date.ts b/packages/common/src/i18n/format_date.ts new file mode 100644 index 00000000000000..df2294a339ad83 --- /dev/null +++ b/packages/common/src/i18n/format_date.ts @@ -0,0 +1,602 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {FormStyle, FormatWidth, NumberSymbol, Time, TranslationWidth, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleDayNames, getLocaleDayPeriods, getLocaleEraNames, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocaleId, getLocaleMonthNames, getLocaleNumberSymbol, getLocaleTimeFormat} from './locale_data_api'; + +const NAMED_FORMATS: {[localeId: string]: {[format: string]: string}} = {}; +const DATE_FORMATS_SPLIT = + /((?:[^GyMLwWdEabBhHmsSzZO']+)|(?:'(?:[^']|'')*')|(?:G{1,5}|y{1,4}|M{1,5}|L{1,5}|w{1,2}|W{1}|d{1,2}|E{1,6}|a{1,5}|b{1,5}|B{1,5}|h{1,2}|H{1,2}|m{1,2}|s{1,2}|S{1,3}|z{1,4}|Z{1,5}|O{1,4}))([\s\S]*)/; + +enum ZoneWidth { + Short, + ShortGMT, + Long, + Extended +} + +enum DateType { + FullYear, + Month, + Date, + Hours, + Minutes, + Seconds, + Milliseconds, + Day +} + +enum TranslationType { + DayPeriods, + Days, + Months, + Eras +} + +/** + * Transforms a date to a locale string based on a pattern and a timezone + * + * @internal + */ +export function formatDate(date: Date, format: string, locale: string, timezone?: string): string { + const namedFormat = getNamedFormat(locale, format); + format = namedFormat || format; + + let parts: string[] = []; + let match; + while (format) { + match = DATE_FORMATS_SPLIT.exec(format); + if (match) { + parts = parts.concat(match.slice(1)); + const part = parts.pop(); + if (!part) { + break; + } + format = part; + } else { + parts.push(format); + break; + } + } + + let dateTimezoneOffset = date.getTimezoneOffset(); + if (timezone) { + dateTimezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + date = convertTimezoneToLocal(date, timezone, true); + } + + let text = ''; + parts.forEach(value => { + const dateFormatter = getDateFormatter(value); + text += dateFormatter ? + dateFormatter(date, locale, dateTimezoneOffset) : + value === '\'\'' ? '\'' : value.replace(/(^'|'$)/g, '').replace(/''/g, '\''); + }); + + return text; +} + +function getNamedFormat(locale: string, format: string): string { + const localeId = getLocaleId(locale); + NAMED_FORMATS[localeId] = NAMED_FORMATS[localeId] || {}; + + if (NAMED_FORMATS[localeId][format]) { + return NAMED_FORMATS[localeId][format]; + } + + let formatValue = ''; + switch (format) { + case 'shortDate': + formatValue = getLocaleDateFormat(locale, FormatWidth.Short); + break; + case 'mediumDate': + formatValue = getLocaleDateFormat(locale, FormatWidth.Medium); + break; + case 'longDate': + formatValue = getLocaleDateFormat(locale, FormatWidth.Long); + break; + case 'fullDate': + formatValue = getLocaleDateFormat(locale, FormatWidth.Full); + break; + case 'shortTime': + formatValue = getLocaleTimeFormat(locale, FormatWidth.Short); + break; + case 'mediumTime': + formatValue = getLocaleTimeFormat(locale, FormatWidth.Medium); + break; + case 'longTime': + formatValue = getLocaleTimeFormat(locale, FormatWidth.Long); + break; + case 'fullTime': + formatValue = getLocaleTimeFormat(locale, FormatWidth.Full); + break; + case 'short': + const shortTime = getNamedFormat(locale, 'shortTime'); + const shortDate = getNamedFormat(locale, 'shortDate'); + formatValue = formatDateTime( + getLocaleDateTimeFormat(locale, FormatWidth.Short), [shortTime, shortDate]); + break; + case 'medium': + const mediumTime = getNamedFormat(locale, 'mediumTime'); + const mediumDate = getNamedFormat(locale, 'mediumDate'); + formatValue = formatDateTime( + getLocaleDateTimeFormat(locale, FormatWidth.Medium), [mediumTime, mediumDate]); + break; + case 'long': + const longTime = getNamedFormat(locale, 'longTime'); + const longDate = getNamedFormat(locale, 'longDate'); + formatValue = + formatDateTime(getLocaleDateTimeFormat(locale, FormatWidth.Long), [longTime, longDate]); + break; + case 'full': + const fullTime = getNamedFormat(locale, 'fullTime'); + const fullDate = getNamedFormat(locale, 'fullDate'); + formatValue = + formatDateTime(getLocaleDateTimeFormat(locale, FormatWidth.Full), [fullTime, fullDate]); + break; + } + if (formatValue) { + NAMED_FORMATS[localeId][format] = formatValue; + } + return formatValue; +} + +function formatDateTime(str: string, opt_values: string[]) { + if (opt_values) { + str = str.replace(/\{([^}]+)}/g, function(match, key) { + return (opt_values != null && key in opt_values) ? opt_values[key] : match; + }); + } + return str; +} + +function padNumber( + num: number, digits: number, minusSign = '-', trim?: boolean, negWrap?: boolean): string { + let neg = ''; + if (num < 0 || (negWrap && num <= 0)) { + if (negWrap) { + num = -num + 1; + } else { + num = -num; + neg = minusSign; + } + } + let strNum = '' + num; + while (strNum.length < digits) strNum = '0' + strNum; + if (trim) { + strNum = strNum.substr(strNum.length - digits); + } + return neg + strNum; +} + +/** + * Returns a date formatter that transforms a date into its locale digit representation + */ +function dateGetter( + name: DateType, size: number, offset: number = 0, trim = false, + negWrap = false): DateFormatter { + return function(date: Date, locale: string): string { + let part = getDatePart(name, date, size); + if (offset > 0 || part > -offset) { + part += offset; + } + if (name === DateType.Hours && part === 0 && offset === -12) { + part = 12; + } + return padNumber( + part, size, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign), trim, negWrap); + }; +} + +function getDatePart(name: DateType, date: Date, size: number): number { + switch (name) { + case DateType.FullYear: + return date.getFullYear(); + case DateType.Month: + return date.getMonth(); + case DateType.Date: + return date.getDate(); + case DateType.Hours: + return date.getHours(); + case DateType.Minutes: + return date.getMinutes(); + case DateType.Seconds: + return date.getSeconds(); + case DateType.Milliseconds: + const div = size === 1 ? 100 : (size === 2 ? 10 : 1); + return Math.round(date.getMilliseconds() / div); + case DateType.Day: + return date.getDay(); + default: + throw new Error(`Unknown DateType value "${name}".`); + } +} + +/** + * Returns a date formatter that transforms a date into its locale string representation + */ +function dateStrGetter( + name: TranslationType, width: TranslationWidth, form: FormStyle = FormStyle.Format, + extended = false): DateFormatter { + return function(date: Date, locale: string): string { + return getDateTranslation(date, locale, name, width, form, extended); + }; +} + +/** + * Returns the locale translation of a date for a given form, type and width + */ +function getDateTranslation( + date: Date, locale: string, name: TranslationType, width: TranslationWidth, form: FormStyle, + extended: boolean) { + switch (name) { + case TranslationType.Months: + return getLocaleMonthNames(locale, form, width)[date.getMonth()]; + case TranslationType.Days: + return getLocaleDayNames(locale, form, width)[date.getDay()]; + case TranslationType.DayPeriods: + const currentHours = date.getHours(); + const currentMinutes = date.getMinutes(); + if (extended) { + const rules = getLocaleExtraDayPeriodRules(locale); + const dayPeriods = getLocaleExtraDayPeriods(locale, form, width); + let result; + rules.forEach((rule: Time | [Time, Time], index: number) => { + if (Array.isArray(rule)) { + // morning, afternoon, evening, night + const {hours: hoursFrom, minutes: minutesFrom} = rule[0]; + const {hours: hoursTo, minutes: minutesTo} = rule[1]; + if (currentHours >= hoursFrom && currentMinutes >= minutesFrom && + (currentHours < hoursTo || + (currentHours === hoursTo && currentMinutes < minutesTo))) { + result = dayPeriods[index]; + } + } else { // noon or midnight + const {hours, minutes} = rule; + if (hours === currentHours && minutes === currentMinutes) { + result = dayPeriods[index]; + } + } + }); + if (result) { + return result; + } + } + // if no rules for the day periods, we use am/pm by default + return getLocaleDayPeriods(locale, form, width)[currentHours < 12 ? 0 : 1]; + case TranslationType.Eras: + return getLocaleEraNames(locale, width)[date.getFullYear() <= 0 ? 0 : 1]; + } +} + +/** + * Returns a date formatter that transforms a date and an offset into a timezone with ISO8601 or + * GMT format depending on the width (eg: short = +0430, short:GMT = GMT+4, long = GMT+04:30, + * extended = +04:30) + */ +function timeZoneGetter(width: ZoneWidth): DateFormatter { + return function(date: Date, locale: string, offset: number) { + const zone = -1 * offset; + const minusSign = getLocaleNumberSymbol(locale, NumberSymbol.MinusSign); + const hours = zone > 0 ? Math.floor(zone / 60) : Math.ceil(zone / 60); + switch (width) { + case ZoneWidth.Short: + return ((zone >= 0) ? '+' : '') + padNumber(hours, 2, minusSign) + + padNumber(Math.abs(zone % 60), 2, minusSign); + case ZoneWidth.ShortGMT: + return 'GMT' + ((zone >= 0) ? '+' : '') + padNumber(hours, 1, minusSign); + case ZoneWidth.Long: + return 'GMT' + ((zone >= 0) ? '+' : '') + padNumber(hours, 2, minusSign) + ':' + + padNumber(Math.abs(zone % 60), 2, minusSign); + case ZoneWidth.Extended: + if (offset === 0) { + return 'Z'; + } else { + return ((zone >= 0) ? '+' : '') + padNumber(hours, 2, minusSign) + ':' + + padNumber(Math.abs(zone % 60), 2, minusSign); + } + default: + throw new Error(`Unknown zone width "${width}"`); + } + }; +} + +const JANUARY = 0; +const THURSDAY = 4; +function getFirstThursdayOfYear(year: number) { + const firstDayOfYear = (new Date(year, JANUARY, 1)).getDay(); + return new Date( + year, 0, 1 + ((firstDayOfYear <= THURSDAY) ? THURSDAY : THURSDAY + 7) - firstDayOfYear); +} + +function getThursdayThisWeek(datetime: Date) { + return new Date( + datetime.getFullYear(), datetime.getMonth(), + datetime.getDate() + (THURSDAY - datetime.getDay())); +} + +function weekGetter(size: number, monthBased = false): DateFormatter { + return function(date: Date, locale: string) { + let result; + if (monthBased) { + const nbDaysBefore1stDayOfMonth = + new Date(date.getFullYear(), date.getMonth(), 1).getDay() - 1; + const today = date.getDate(); + result = 1 + Math.floor((today + nbDaysBefore1stDayOfMonth) / 7); + } else { + const firstThurs = getFirstThursdayOfYear(date.getFullYear()); + const thisThurs = getThursdayThisWeek(date); + const diff = thisThurs.getTime() - firstThurs.getTime(); + result = 1 + Math.round(diff / 6.048e8); // 6.048e8 ms per week + } + + return padNumber(result, size, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign)); + }; +} + +type DateFormatter = (date: Date, locale: string, offset?: number) => string; + +const DATE_FORMATS: {[format: string]: DateFormatter} = {}; + +// Based on CLDR formats: +// See complete list: http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table +// See also explanations: http://cldr.unicode.org/translation/date-time +// TODO(ocombe): support all missing cldr formats: Y, U, Q, D, F, e, c, j, J, C, A, v, V, X, x +function getDateFormatter(format: string): DateFormatter|null { + if (DATE_FORMATS[format]) { + return DATE_FORMATS[format]; + } + let formatter; + switch (format) { + // Era name (AD/BC) + case 'G': + case 'GG': + case 'GGG': + formatter = dateStrGetter(TranslationType.Eras, TranslationWidth.Abbreviated); + break; + case 'GGGG': + formatter = dateStrGetter(TranslationType.Eras, TranslationWidth.Wide); + break; + case 'GGGGG': + formatter = dateStrGetter(TranslationType.Eras, TranslationWidth.Narrow); + break; + + // 1 digit representation of the year, e.g. (AD 1 => 1, AD 199 => 199) + case 'y': + formatter = dateGetter(DateType.FullYear, 1, 0, false, true); + break; + // 2 digit representation of the year, padded (00-99). (e.g. AD 2001 => 01, AD 2010 => 10) + case 'yy': + formatter = dateGetter(DateType.FullYear, 2, 0, true, true); + break; + // 3 digit representation of the year, padded (000-999). (e.g. AD 2001 => 01, AD 2010 => 10) + case 'yyy': + formatter = dateGetter(DateType.FullYear, 3, 0, false, true); + break; + // 4 digit representation of the year (e.g. AD 1 => 0001, AD 2010 => 2010) + case 'yyyy': + formatter = dateGetter(DateType.FullYear, 4, 0, false, true); + break; + + // Month of the year (1-12), numeric + case 'M': + case 'L': + formatter = dateGetter(DateType.Month, 1, 1); + break; + case 'MM': + case 'LL': + formatter = dateGetter(DateType.Month, 2, 1); + break; + + // Month of the year (January, ...), string, format + case 'MMM': + formatter = dateStrGetter(TranslationType.Months, TranslationWidth.Abbreviated); + break; + case 'MMMM': + formatter = dateStrGetter(TranslationType.Months, TranslationWidth.Wide); + break; + case 'MMMMM': + formatter = dateStrGetter(TranslationType.Months, TranslationWidth.Narrow); + break; + + // Month of the year (January, ...), string, standalone + case 'LLL': + formatter = + dateStrGetter(TranslationType.Months, TranslationWidth.Abbreviated, FormStyle.Standalone); + break; + case 'LLLL': + formatter = + dateStrGetter(TranslationType.Months, TranslationWidth.Wide, FormStyle.Standalone); + break; + case 'LLLLL': + formatter = + dateStrGetter(TranslationType.Months, TranslationWidth.Narrow, FormStyle.Standalone); + break; + + // Week of the year (1, ... 52) + case 'w': + formatter = weekGetter(1); + break; + case 'ww': + formatter = weekGetter(2); + break; + + // Week of the month (1, ...) + case 'W': + formatter = weekGetter(1, true); + break; + + // Day of the month (1-31) + case 'd': + formatter = dateGetter(DateType.Date, 1); + break; + case 'dd': + formatter = dateGetter(DateType.Date, 2); + break; + + // Day of the Week + case 'E': + case 'EE': + case 'EEE': + formatter = dateStrGetter(TranslationType.Days, TranslationWidth.Abbreviated); + break; + case 'EEEE': + formatter = dateStrGetter(TranslationType.Days, TranslationWidth.Wide); + break; + case 'EEEEE': + formatter = dateStrGetter(TranslationType.Days, TranslationWidth.Narrow); + break; + case 'EEEEEE': + formatter = dateStrGetter(TranslationType.Days, TranslationWidth.Short); + break; + + // Generic period of the day (am-pm) + case 'a': + case 'aa': + case 'aaa': + formatter = dateStrGetter(TranslationType.DayPeriods, TranslationWidth.Abbreviated); + break; + case 'aaaa': + formatter = dateStrGetter(TranslationType.DayPeriods, TranslationWidth.Wide); + break; + case 'aaaaa': + formatter = dateStrGetter(TranslationType.DayPeriods, TranslationWidth.Narrow); + break; + + // Extended period of the day (midnight, at night, ...), standalone + case 'b': + case 'bb': + case 'bbb': + formatter = dateStrGetter( + TranslationType.DayPeriods, TranslationWidth.Abbreviated, FormStyle.Standalone, true); + break; + case 'bbbb': + formatter = dateStrGetter( + TranslationType.DayPeriods, TranslationWidth.Wide, FormStyle.Standalone, true); + break; + case 'bbbbb': + formatter = dateStrGetter( + TranslationType.DayPeriods, TranslationWidth.Narrow, FormStyle.Standalone, true); + break; + + // Extended period of the day (midnight, night, ...), standalone + case 'B': + case 'BB': + case 'BBB': + formatter = dateStrGetter( + TranslationType.DayPeriods, TranslationWidth.Abbreviated, FormStyle.Format, true); + break; + case 'BBBB': + formatter = + dateStrGetter(TranslationType.DayPeriods, TranslationWidth.Wide, FormStyle.Format, true); + break; + case 'BBBBB': + formatter = dateStrGetter( + TranslationType.DayPeriods, TranslationWidth.Narrow, FormStyle.Format, true); + break; + + // Hour in AM/PM, (1-12) + case 'h': + formatter = dateGetter(DateType.Hours, 1, -12); + break; + case 'hh': + formatter = dateGetter(DateType.Hours, 2, -12); + break; + + // Hour of the day (0-23) + case 'H': + formatter = dateGetter(DateType.Hours, 1); + break; + // Hour in day, padded (00-23) + case 'HH': + formatter = dateGetter(DateType.Hours, 2); + break; + + // Minute of the hour (0-59) + case 'm': + formatter = dateGetter(DateType.Minutes, 1); + break; + case 'mm': + formatter = dateGetter(DateType.Minutes, 2); + break; + + // Second of the minute (0-59) + case 's': + formatter = dateGetter(DateType.Seconds, 1); + break; + case 'ss': + formatter = dateGetter(DateType.Seconds, 2); + break; + + // Fractional second padded (0-9) + case 'S': + formatter = dateGetter(DateType.Milliseconds, 1); + break; + case 'SS': + formatter = dateGetter(DateType.Milliseconds, 2); + break; + // = millisecond + case 'SSS': + formatter = dateGetter(DateType.Milliseconds, 3); + break; + + + // Timezone ISO8601 short format (-0430) + case 'Z': + case 'ZZ': + case 'ZZZ': + formatter = timeZoneGetter(ZoneWidth.Short); + break; + // Timezone ISO8601 extended format (-04:30) + case 'ZZZZZ': + formatter = timeZoneGetter(ZoneWidth.Extended); + break; + + // Timezone GMT short format (GMT+4) + case 'O': + case 'OO': + case 'OOO': + // Should be location, but fallback to format O instead because we don't have the data yet + case 'z': + case 'zz': + case 'zzz': + formatter = timeZoneGetter(ZoneWidth.ShortGMT); + break; + // Timezone GMT long format (GMT+0430) + case 'OOOO': + case 'ZZZZ': + // Should be location, but fallback to format O instead because we don't have the data yet + case 'zzzz': + formatter = timeZoneGetter(ZoneWidth.Long); + break; + default: + return null; + } + DATE_FORMATS[format] = formatter; + return formatter; +} + +function timezoneToOffset(timezone: string, fallback: number): number { + // Support: IE 9-11 only, Edge 13-15+ + // IE/Edge do not "understand" colon (`:`) in timezone + timezone = timezone.replace(/:/g, ''); + const requestedTimezoneOffset = Date.parse('Jan 01, 1970 00:00:00 ' + timezone) / 60000; + return isNaN(requestedTimezoneOffset) ? fallback : requestedTimezoneOffset; +} + +function addDateMinutes(date: Date, minutes: number) { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + minutes); + return date; +} + +function convertTimezoneToLocal(date: Date, timezone: string, reverse: boolean): Date { + const reverseValue = reverse ? -1 : 1; + const dateTimezoneOffset = date.getTimezoneOffset(); + const timezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + return addDateMinutes(date, reverseValue * (timezoneOffset - dateTimezoneOffset)); +} diff --git a/packages/common/src/i18n/format_number.ts b/packages/common/src/i18n/format_number.ts new file mode 100644 index 00000000000000..4666397ee06565 --- /dev/null +++ b/packages/common/src/i18n/format_number.ts @@ -0,0 +1,376 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NumberFormatStyle, NumberSymbol, getLocaleNumberFormat, getLocaleNumberSymbol} from './locale_data_api'; + +export const NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/; +const MAX_DIGITS = 22; +const DECIMAL_SEP = '.'; +const ZERO_CHAR = '0'; +const PATTERN_SEP = ';'; +const GROUP_SEP = ','; +const DIGIT_CHAR = '#'; +const CURRENCY_CHAR = '¤'; +const PERCENT_CHAR = '%'; + +/** @internal */ +export type FormatNumberRes = { + str: string | null, + error?: string +}; + +/** + * Transform a number to a locale string based on a style and a format + * + * @internal + */ +export function formatNumber( + value: number | string, locale: string, style: NumberFormatStyle, digitsInfo?: string | null, + currency: string | null = null): FormatNumberRes { + const res: FormatNumberRes = {str: null}; + const format = getLocaleNumberFormat(locale, style); + let num; + + // Convert strings to numbers + if (typeof value === 'string' && !isNaN(+value - parseFloat(value))) { + num = +value; + } else if (typeof value !== 'number') { + res.error = `${value} is not a number`; + return res; + } else { + num = value; + } + + if (style === NumberFormatStyle.Percent) { + num = num * 100; + } + + const numStr = Math.abs(num) + ''; + const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign)); + let formattedText = ''; + let isZero = false; + + if (!isFinite(num)) { + formattedText = getLocaleNumberSymbol(locale, NumberSymbol.Infinity); + } else { + const parsedNumber = parseNumber(numStr); + + let minInt = pattern.minInt; + let minFraction = pattern.minFrac; + let maxFraction = pattern.maxFrac; + + if (digitsInfo) { + const parts = digitsInfo.match(NUMBER_FORMAT_REGEXP); + if (parts === null) { + res.error = `${digitsInfo} is not a valid digit info`; + return res; + } + const minIntPart = parts[1]; + const minFractionPart = parts[3]; + const maxFractionPart = parts[5]; + if (minIntPart != null) { + minInt = parseIntAutoRadix(minIntPart); + } + if (minFractionPart != null) { + minFraction = parseIntAutoRadix(minFractionPart); + } + if (maxFractionPart != null) { + maxFraction = parseIntAutoRadix(maxFractionPart); + } else if (minFractionPart != null && minFraction > maxFraction) { + maxFraction = minFraction; + } + } + + roundNumber(parsedNumber, minFraction, maxFraction); + + let digits = parsedNumber.digits; + let integerLen = parsedNumber.integerLen; + const exponent = parsedNumber.exponent; + let decimals = []; + isZero = digits.every(d => !d); + + // pad zeros for small numbers + for (; integerLen < minInt; integerLen++) { + digits.unshift(0); + } + + // pad zeros for small numbers + for (; integerLen < 0; integerLen++) { + digits.unshift(0); + } + + // extract decimals digits + if (integerLen > 0) { + decimals = digits.splice(integerLen, digits.length); + } else { + decimals = digits; + digits = [0]; + } + + // format the integer digits with grouping separators + const groups = []; + if (digits.length >= pattern.lgSize) { + groups.unshift(digits.splice(-pattern.lgSize, digits.length).join('')); + } + + while (digits.length > pattern.gSize) { + groups.unshift(digits.splice(-pattern.gSize, digits.length).join('')); + } + + if (digits.length) { + groups.unshift(digits.join('')); + } + + const groupSymbol = currency ? NumberSymbol.CurrencyGroup : NumberSymbol.Group; + formattedText = groups.join(getLocaleNumberSymbol(locale, groupSymbol)); + + // append the decimal digits + if (decimals.length) { + const decimalSymbol = currency ? NumberSymbol.CurrencyDecimal : NumberSymbol.Decimal; + formattedText += getLocaleNumberSymbol(locale, decimalSymbol) + decimals.join(''); + } + + if (exponent) { + formattedText += getLocaleNumberSymbol(locale, NumberSymbol.Exponential) + '+' + exponent; + } + } + + if (num < 0 && !isZero) { + formattedText = pattern.negPre + formattedText + pattern.negSuf; + } else { + formattedText = pattern.posPre + formattedText + pattern.posSuf; + } + + if (style === NumberFormatStyle.Currency && currency !== null) { + res.str = formattedText.replace(new RegExp(CURRENCY_CHAR, 'g'), currency); + return res; + } + + if (style === NumberFormatStyle.Percent) { + res.str = formattedText.replace( + new RegExp(PERCENT_CHAR, 'g'), getLocaleNumberSymbol(locale, NumberSymbol.PercentSign)); + return res; + } + + res.str = formattedText; + return res; +} + +interface ParsedNumberFormat { + minInt: number; + // the minimum number of digits required in the fraction part of the number + minFrac: number; + // the maximum number of digits required in the fraction part of the number + maxFrac: number; + // the prefix for a positive number + posPre: string; + // the suffix for a positive number + posSuf: string; + // the prefix for a negative number (e.g. `-` or `(`)) + negPre: string; + // the suffix for a negative number (e.g. `)`) + negSuf: string; + // number of digits in each group of separated digits + gSize: number; + // number of digits in the last group of digits before the decimal separator + lgSize: number; +} + +function parseNumberFormat(format: string, minusSign = '-'): ParsedNumberFormat { + const p = { + minInt: 1, + minFrac: 0, + maxFrac: 0, + posPre: '', + posSuf: '', + negPre: '', + negSuf: '', + gSize: 0, + lgSize: 0 + }; + + const patternParts = format.split(PATTERN_SEP); + const positive = patternParts[0]; + const negative = patternParts[1]; + + const positiveParts = positive.indexOf(DECIMAL_SEP) !== -1 ? + positive.split(DECIMAL_SEP) : + [ + positive.substring(0, positive.lastIndexOf(ZERO_CHAR) + 1), + positive.substring(positive.lastIndexOf(ZERO_CHAR) + 1) + ], + integer = positiveParts[0], fraction = positiveParts[1] || ''; + + p.posPre = integer.substr(0, integer.indexOf(DIGIT_CHAR)); + + for (let i = 0; i < fraction.length; i++) { + const ch = fraction.charAt(i); + if (ch === ZERO_CHAR) { + p.minFrac = p.maxFrac = i + 1; + } else if (ch === DIGIT_CHAR) { + p.maxFrac = i + 1; + } else { + p.posSuf += ch; + } + } + + const groups = integer.split(GROUP_SEP); + p.gSize = groups[1] ? groups[1].length : 0; + p.lgSize = (groups[2] || groups[1]) ? (groups[2] || groups[1]).length : 0; + + if (negative) { + const trunkLen = positive.length - p.posPre.length - p.posSuf.length, + pos = negative.indexOf(DIGIT_CHAR); + + p.negPre = negative.substr(0, pos).replace(/'/g, ''); + p.negSuf = negative.substr(pos + trunkLen).replace(/'/g, ''); + } else { + p.negPre = minusSign + p.posPre; + p.negSuf = p.posSuf; + } + + return p; +} + +interface ParsedNumber { + // an array of digits containing leading zeros as necessary + digits: number[]; + // the exponent for numbers that would need more than `MAX_DIGITS` digits in `d` + exponent: number; + // the number of the digits in `d` that are to the left of the decimal point + integerLen: number; +} + +/** + * Parse a number (as a string) + * Significant bits of this parse algorithm came from https://github.com/MikeMcl/big.js/ + */ +function parseNumber(numStr: string): ParsedNumber { + let exponent = 0, digits, integerLen; + let i, j, zeros; + + // Decimal point? + if ((integerLen = numStr.indexOf(DECIMAL_SEP)) > -1) { + numStr = numStr.replace(DECIMAL_SEP, ''); + } + + // Exponential form? + if ((i = numStr.search(/e/i)) > 0) { + // Work out the exponent. + if (integerLen < 0) integerLen = i; + integerLen += +numStr.slice(i + 1); + numStr = numStr.substring(0, i); + } else if (integerLen < 0) { + // There was no decimal point or exponent so it is an integer. + integerLen = numStr.length; + } + + // Count the number of leading zeros. + for (i = 0; numStr.charAt(i) === ZERO_CHAR; i++) { /* empty */ + } + + if (i === (zeros = numStr.length)) { + // The digits are all zero. + digits = [0]; + integerLen = 1; + } else { + // Count the number of trailing zeros + zeros--; + while (numStr.charAt(zeros) === ZERO_CHAR) zeros--; + + // Trailing zeros are insignificant so ignore them + integerLen -= i; + digits = []; + // Convert string to array of digits without leading/trailing zeros. + for (j = 0; i <= zeros; i++, j++) { + digits[j] = +numStr.charAt(i); + } + } + + // If the number overflows the maximum allowed digits then use an exponent. + if (integerLen > MAX_DIGITS) { + digits = digits.splice(0, MAX_DIGITS - 1); + exponent = integerLen - 1; + integerLen = 1; + } + + return {digits, exponent, integerLen}; +} + +/** + * Round the parsed number to the specified number of decimal places + * This function changes the parsedNumber in-place + */ +function roundNumber(parsedNumber: ParsedNumber, minFrac: number, maxFrac: number) { + if (minFrac > maxFrac) { + throw new Error( + `The minimum number of digits after fraction (${minFrac}) is higher than the maximum (${maxFrac}).`); + } + + let digits = parsedNumber.digits; + let fractionLen = digits.length - parsedNumber.integerLen; + const fractionSize = Math.min(Math.max(minFrac, fractionLen), maxFrac); + + // The index of the digit to where rounding is to occur + let roundAt = fractionSize + parsedNumber.integerLen; + let digit = digits[roundAt]; + + if (roundAt > 0) { + // Drop fractional digits beyond `roundAt` + digits.splice(Math.max(parsedNumber.integerLen, roundAt)); + + // Set non-fractional digits beyond `roundAt` to 0 + for (let j = roundAt; j < digits.length; j++) { + digits[j] = 0; + } + } else { + // We rounded to zero so reset the parsedNumber + fractionLen = Math.max(0, fractionLen); + parsedNumber.integerLen = 1; + digits.length = Math.max(1, roundAt = fractionSize + 1); + digits[0] = 0; + for (let i = 1; i < roundAt; i++) digits[i] = 0; + } + + if (digit >= 5) { + if (roundAt - 1 < 0) { + for (let k = 0; k > roundAt; k--) { + digits.unshift(0); + parsedNumber.integerLen++; + } + digits.unshift(1); + parsedNumber.integerLen++; + } else { + digits[roundAt - 1]++; + } + } + + // Pad out with zeros to get the required fraction length + for (; fractionLen < Math.max(0, fractionSize); fractionLen++) digits.push(0); + + + // Do any carrying, e.g. a digit was rounded up to 10 + const carry = digits.reduceRight(function(carry, d, i, digits) { + d = d + carry; + digits[i] = d % 10; + return Math.floor(d / 10); + }, 0); + if (carry) { + digits.unshift(carry); + parsedNumber.integerLen++; + } +} + +/** @internal */ +export function parseIntAutoRadix(text: string): number { + const result: number = parseInt(text); + if (isNaN(result)) { + throw new Error('Invalid integer literal when parsing ' + text); + } + return result; +} diff --git a/packages/common/src/i18n/locale_data.ts b/packages/common/src/i18n/locale_data.ts new file mode 100644 index 00000000000000..cbcbaf1afe61d6 --- /dev/null +++ b/packages/common/src/i18n/locale_data.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** @experimental */ +export enum Plural { + Zero, + One, + Two, + Few, + Many, + Other, +} + +/** + * @experimental i18n support is experimental. + */ +export const LOCALE_DATA: {[localeId: string]: any} = {}; diff --git a/packages/common/src/i18n/locale_data_api.ts b/packages/common/src/i18n/locale_data_api.ts new file mode 100644 index 00000000000000..bf8bb9739b6b22 --- /dev/null +++ b/packages/common/src/i18n/locale_data_api.ts @@ -0,0 +1,606 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {AVAILABLE_LOCALES} from './available_locales'; +import {CURRENCIES} from './currencies'; +import localeEn from './locale_en'; +import {LOCALE_DATA, Plural} from './locale_data'; + +/** + * The different format styles that can be used to represent numbers. + * Used by the function {@link getLocaleNumberFormat}. + * + * @experimental i18n support is experimental. + */ +export enum NumberFormatStyle { + Decimal, + Percent, + Currency, + Scientific +} + +/** + * Some languages use two different forms of strings (standalone and format) depending on the + * context. + * Typically the standalone version is the nominative form of the word, and the format version is in + * the genitive. + * See [the CLDR website](http://cldr.unicode.org/translation/date-time) for more information. + * + * @experimental i18n support is experimental. + */ +export enum FormStyle { + Format, + Standalone +} + +/** + * Multiple widths are available for translations: narrow (1 character), abbreviated (3 characters), + * wide (full length), and short (2 characters, only for days). + * + * For example the day `Sunday` will be: + * - Narrow: `S` + * - Short: `Su` + * - Abbreviated: `Sun` + * - Wide: `Sunday` + * + * @experimental i18n support is experimental. + */ +export enum TranslationWidth { + Narrow, + Abbreviated, + Wide, + Short +} + +/** + * Multiple widths are available for formats: short (minimal amount of data), medium (small amount + * of data), long (complete amount of data), full (complete amount of data and extra information). + * + * For example the date-time formats for the english locale will be: + * - `'short'`: `'M/d/yy, h:mm a'` (e.g. `6/15/15, 9:03 AM`) + * - `'medium'`: `'MMM d, y, h:mm:ss a'` (e.g. `Jun 15, 2015, 9:03:01 AM`) + * - `'long'`: `'MMMM d, y, h:mm:ss a z'` (e.g. `June 15, 2015 at 9:03:01 AM GMT+1`) + * - `'full'`: `'EEEE, MMMM d, y, h:mm:ss a zzzz'` (e.g. `Monday, June 15, 2015 at + * 9:03:01 AM GMT+01:00`) + * + * @experimental i18n support is experimental. + */ +export enum FormatWidth { + Short, + Medium, + Long, + Full +} + +/** + * Number symbol that can be used to replace placeholders in number patterns. + * The placeholders are based on english values: + * + * | Name | Example for en-US | Meaning | + * |------------------------|-------------------|---------------------------------------------| + * | decimal | 2,345`.`67 | decimal separator | + * | group | 2`,`345.67 | grouping separator, typically for thousands | + * | plusSign | `+`23 | the plus sign used with numbers | + * | minusSign | `-`23 | the minus sign used with numbers | + * | percentSign | 23.4`%` | the percent sign (out of 100) | + * | perMille | 234`‰` | the permille sign (out of 1000) | + * | exponential | 1.2`E`3 | used in computers for 1.2×10³. | + * | superscriptingExponent | 1.2`×`103 | human-readable format of exponential | + * | infinity | `∞` | used in +∞ and -∞. | + * | nan | `NaN` | "not a number". | + * | timeSeparator | 10`:`52 | symbol used between time units | + * | currencyDecimal | $2,345`.`67 | decimal separator, fallback to "decimal" | + * | currencyGroup | $2`,`345.67 | grouping separator, fallback to "group" | + * + * @experimental i18n support is experimental. + */ +export enum NumberSymbol { + Decimal, + Group, + List, + PercentSign, + PlusSign, + MinusSign, + Exponential, + SuperscriptingExponent, + PerMille, + Infinity, + NaN, + TimeSeparator, + CurrencyDecimal, + CurrencyGroup +} + +/** + * The value for each day of the week, based on the en-US locale + * + * @experimental + */ +export enum WeekDay { + Sunday = 0, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday +} + +/** + * Use this enum to find the index of each type of locale data from the locale data array + */ +enum LocaleDataIndex { + LocaleId = 0, + DayPeriodsFormat, + DayPeriodsStandalone, + DaysFormat, + DaysStandalone, + MonthsFormat, + MonthsStandalone, + Eras, + FirstDayOfWeek, + WeekendRange, + DateFormat, + TimeFormat, + DateTimeFormat, + NumberSymbols, + NumberFormats, + CurrencySymbol, + CurrencyName, + PluralCase, + ExtraData +} + +/** + * Use this enum to find the index of each type of locale data from the extra locale data array + */ +enum ExtraLocaleDataIndex { + ExtraDayPeriodFormats = 0, + ExtraDayPeriodStandalone, + ExtraDayPeriodsRules +} + +/** + * The locale id for the chosen locale (e.g `en-GB`). + * + * @experimental i18n support is experimental. + */ +export function getLocaleId(locale: string): string { + return findLocaleData(locale)[LocaleDataIndex.LocaleId]; +} + +/** + * Periods of the day (e.g. `[AM, PM]` for en-US). + * + * @experimental i18n support is experimental. + */ +export function getLocaleDayPeriods( + locale: string, formStyle: FormStyle, width: TranslationWidth): [string, string] { + const data = findLocaleData(locale); + const amPmData = <[ + string, string + ][][]>[data[LocaleDataIndex.DayPeriodsFormat], data[LocaleDataIndex.DayPeriodsStandalone]]; + const amPm = getLastDefinedValue(amPmData, formStyle); + return getLastDefinedValue(amPm, width); +} + +/** + * Days of the week for the Gregorian calendar (e.g. `[Sunday, Monday, ... Saturday]` for en-US). + * + * @experimental i18n support is experimental. + */ +export function getLocaleDayNames( + locale: string, formStyle: FormStyle, width: TranslationWidth): string[] { + const data = findLocaleData(locale); + const daysData = + [data[LocaleDataIndex.DaysFormat], data[LocaleDataIndex.DaysStandalone]]; + const days = getLastDefinedValue(daysData, formStyle); + return getLastDefinedValue(days, width); +} + +/** + * Months of the year for the Gregorian calendar (e.g. `[January, February, ...]` for en-US). + * + * @experimental i18n support is experimental. + */ +export function getLocaleMonthNames( + locale: string, formStyle: FormStyle, width: TranslationWidth): string[] { + const data = findLocaleData(locale); + const monthsData = + [data[LocaleDataIndex.MonthsFormat], data[LocaleDataIndex.MonthsStandalone]]; + const months = getLastDefinedValue(monthsData, formStyle); + return getLastDefinedValue(months, width); +} + +/** + * Eras for the Gregorian calendar (e.g. AD/BC). + * + * @experimental i18n support is experimental. + */ +export function getLocaleEraNames(locale: string, width: TranslationWidth): [string, string] { + const data = findLocaleData(locale); + const erasData = <[string, string][]>data[LocaleDataIndex.Eras]; + return getLastDefinedValue(erasData, width); +} + +/** + * First day of the week for this locale, based on english days (Sunday = 0, Monday = 1, ...). + * For example in french the value would be 1 because the first day of the week is Monday. + * + * @experimental i18n support is experimental. + */ +export function getLocaleFirstDayOfWeek(locale: string): WeekDay { + const data = findLocaleData(locale); + return data[LocaleDataIndex.FirstDayOfWeek]; +} + +/** + * Range of days in the week that represent the week-end for this locale, based on english days + * (Sunday = 0, Monday = 1, ...). + * For example in english the value would be [6,0] for Saturday to Sunday. + * + * @experimental i18n support is experimental. + */ +export function getLocaleWeekEndRange(locale: string): [WeekDay, WeekDay] { + const data = findLocaleData(locale); + return data[LocaleDataIndex.WeekendRange]; +} + +/** + * Date format that depends on the locale. + * + * There are four basic date formats: + * - `full` should contain long-weekday (EEEE), year (y), long-month (MMMM), day (d). + * + * For example, English uses `EEEE, MMMM d, y`, corresponding to a date like + * "Tuesday, September 14, 1999". + * + * - `long` should contain year, long-month, day. + * + * For example, `MMMM d, y`, corresponding to a date like "September 14, 1999". + * + * - `medium` should contain year, abbreviated-month (MMM), day. + * + * For example, `MMM d, y`, corresponding to a date like "Sep 14, 1999". + * For languages that do not use abbreviated months, use the numeric month (MM/M). For example, + * `y/MM/dd`, corresponding to a date like "1999/09/14". + * + * - `short` should contain year, numeric-month (MM/M), and day. + * + * For example, `M/d/yy`, corresponding to a date like "9/14/99". + * + * @experimental i18n support is experimental. + */ +export function getLocaleDateFormat(locale: string, width: FormatWidth): string { + const data = findLocaleData(locale); + return data[LocaleDataIndex.DateFormat][width]; +} + +/** + * Time format that depends on the locale. + * + * The standard formats include four basic time formats: + * - `full` should contain hour (h/H), minute (mm), second (ss), and zone (zzzz). + * - `long` should contain hour, minute, second, and zone (z) + * - `medium` should contain hour, minute, second. + * - `short` should contain hour, minute. + * + * Note: The patterns depend on whether the main country using your language uses 12-hour time or + * not: + * - For 12-hour time, use a pattern like `hh:mm a` using h to mean a 12-hour clock cycle running + * 1 through 12 (midnight plus 1 minute is 12:01), or using K to mean a 12-hour clock cycle + * running 0 through 11 (midnight plus 1 minute is 0:01). + * - For 24-hour time, use a pattern like `HH:mm` using H to mean a 24-hour clock cycle running 0 + * through 23 (midnight plus 1 minute is 0:01), or using k to mean a 24-hour clock cycle running + * 1 through 24 (midnight plus 1 minute is 24:01). + * + * @experimental i18n support is experimental. + */ +export function getLocaleTimeFormat(locale: string, width: FormatWidth): string { + const data = findLocaleData(locale); + return data[LocaleDataIndex.TimeFormat][width]; +} + +/** + * Date-time format that depends on the locale. + * + * The date-time pattern shows how to combine separate patterns for date (represented by {1}) + * and time (represented by {0}) into a single pattern. It usually doesn't need to be changed. + * What you want to pay attention to are: + * - possibly removing a space for languages that don't use it, such as many East Asian languages + * - possibly adding a comma, other punctuation, or a combining word + * + * For example: + * - English uses `{1} 'at' {0}` or `{1}, {0}` (depending on date style), while Japanese uses + * `{1}{0}`. + * - An English formatted date-time using the combining pattern `{1}, {0}` could be + * `Dec 10, 2010, 3:59:49 PM`. Notice the comma and space between the date portion and the time + * portion. + * + * There are four formats (`full`, `long`, `medium`, `short`); the determination of which to use + * is normally based on the date style. For example, if the date has a full month and weekday + * name, the full combining pattern will be used to combine that with a time. If the date has + * numeric month, the short version of the combining pattern will be used to combine that with a + * time. English uses `{1} 'at' {0}` for full and long styles, and `{1}, {0}` for medium and short + * styles. + * + * @experimental i18n support is experimental. + */ +export function getLocaleDateTimeFormat(locale: string, width: FormatWidth): string { + const data = findLocaleData(locale); + const dateTimeFormatData = data[LocaleDataIndex.DateTimeFormat]; + return getLastDefinedValue(dateTimeFormatData, width); +} + +/** + * Number symbol that can be used to replace placeholders in number formats. + * See {@link NumberSymbol} for more information. + * + * @experimental i18n support is experimental. + */ +export function getLocaleNumberSymbol(locale: string, symbol: NumberSymbol): string { + const data = findLocaleData(locale); + const res = data[LocaleDataIndex.NumberSymbols][symbol]; + if (typeof res === 'undefined') { + if (symbol === NumberSymbol.CurrencyDecimal) { + return data[LocaleDataIndex.NumberSymbols][NumberSymbol.Decimal]; + } else if (symbol === NumberSymbol.CurrencyGroup) { + return data[LocaleDataIndex.NumberSymbols][NumberSymbol.Decimal]; + } + } + return res; +} + +/** + * Number format that depends on the locale. + * + * Numbers are formatted using patterns, like `#,###.00`. For example, the pattern `#,###.00` + * when used to format the number 12345.678 could result in "12'345,67". That would happen if the + * grouping separator for your language is an apostrophe, and the decimal separator is a comma. + * + * Important: The characters `.` `,` `0` `#` (and others below) are special placeholders; + * they stand for the decimal separator, and so on, and are NOT real characters. + * You must NOT "translate" the placeholders; for example, don't change `.` to `,` even though in + * your language the decimal point is written with a comma. The symbols should be replaced by the + * local equivalents, using the Number Symbols for your language. + * + * Here are the special characters used in number patterns: + * + * | Symbol | Meaning | + * |--------|---------| + * | . | Replaced automatically by the character used for the decimal point. | + * | , | Replaced by the "grouping" (thousands) separator. | + * | 0 | Replaced by a digit (or zero if there aren't enough digits). | + * | # | Replaced by a digit (or nothing if there aren't enough). | + * | ¤ | This will be replaced by a currency symbol, such as $ or USD. | + * | % | This marks a percent format. The % symbol may change position, but must be retained. | + * | E | This marks a scientific format. The E symbol may change position, but must be retained. | + * | ' | Special characters used as literal characters are quoted with ASCII single quotes. | + * + * You can find more information + * [on the CLDR website](http://cldr.unicode.org/translation/number-patterns) + * + * @experimental i18n support is experimental. + */ +export function getLocaleNumberFormat(locale: string, type: NumberFormatStyle): string { + const data = findLocaleData(locale); + return data[LocaleDataIndex.NumberFormats][type]; +} + +/** + * The symbol used to represent the currency for the main country using this locale (e.g. $ for + * the locale en-US). + * The symbol will be `null` if the main country cannot be determined. + * + * @experimental i18n support is experimental. + */ +export function getLocaleCurrencySymbol(locale: string): string|null { + const data = findLocaleData(locale); + return data[LocaleDataIndex.CurrencySymbol] || null; +} + +/** + * The name of the currency for the main country using this locale (e.g. USD for the locale + * en-US). + * The name will be `null` if the main country cannot be determined. + * + * @experimental i18n support is experimental. + */ +export function getLocaleCurrencyName(locale: string): string|null { + const data = findLocaleData(locale); + return data[LocaleDataIndex.CurrencyName] || null; +} + +/** + * The locale plural function used by ICU expressions to determine the plural case to use. + * See {@link NgPlural} for more information. + * + * @experimental i18n support is experimental. + */ +export function getLocalePluralCase(locale: string): (value: number) => Plural { + const data = findLocaleData(locale); + return data[LocaleDataIndex.PluralCase]; +} + +function checkFullData(data: any) { + if (!data[LocaleDataIndex.ExtraData]) { + throw new Error( + `Missing extra locale data for the locale "${data[LocaleDataIndex.LocaleId]}". Use "registerLocaleData" to load new data. See the "I18n guide" on angular.io to know more.`); + } +} + +/** + * Rules used to determine which day period to use (See `dayPeriods` below). + * The rules can either be an array or a single value. If it's an array, consider it as "from" + * and "to". If it's a single value then it means that the period is only valid at this exact + * value. + * There is always the same number of rules as the number of day periods, which means that the + * first rule is applied to the first day period and so on. + * You should fallback to AM/PM when there are no rules available. + * + * Note: this is only available if you load the full locale data. + * See the {@linkDocs guide/i18n#i18n-pipes "I18n guide"} to know how to import additional locale + * data. + * + * @experimental i18n support is experimental. + */ +export function getLocaleExtraDayPeriodRules(locale: string): (Time | [Time, Time])[] { + const data = findLocaleData(locale); + checkFullData(data); + const rules = data[LocaleDataIndex.ExtraData][ExtraLocaleDataIndex.ExtraDayPeriodsRules] || []; + return rules.map((rule: string | [string, string]) => { + if (typeof rule === 'string') { + return extractTime(rule); + } + return [extractTime(rule[0]), extractTime(rule[1])]; + }); +} + +/** + * Day Periods indicate roughly how the day is broken up in different languages (e.g. morning, + * noon, afternoon, midnight, ...). + * You should use the function {@link getLocaleExtraDayPeriodRules} to determine which period to + * use. + * You should fallback to AM/PM when there are no day periods available. + * + * Note: this is only available if you load the full locale data. + * See the {@linkDocs guide/i18n#i18n-pipes "I18n guide"} to know how to import additional locale + * data. + * + * @experimental i18n support is experimental. + */ +export function getLocaleExtraDayPeriods( + locale: string, formStyle: FormStyle, width: TranslationWidth): string[] { + const data = findLocaleData(locale); + checkFullData(data); + const dayPeriodsData = [ + data[LocaleDataIndex.ExtraData][ExtraLocaleDataIndex.ExtraDayPeriodFormats], + data[LocaleDataIndex.ExtraData][ExtraLocaleDataIndex.ExtraDayPeriodStandalone] + ]; + const dayPeriods = getLastDefinedValue(dayPeriodsData, formStyle) || []; + return getLastDefinedValue(dayPeriods, width) || []; +} + +/** + * Returns the first value that is defined in an array, going backwards. + * + * To avoid repeating the same data (e.g. when "format" and "standalone" are the same) we only + * add the first one to the locale data arrays, the other ones are only defined when different. + * We use this function to retrieve the first defined value. + * + * @experimental i18n support is experimental. + */ +function getLastDefinedValue(data: T[], index: number): T { + for (let i = index; i > -1; i--) { + if (typeof data[i] !== 'undefined') { + return data[i]; + } + } + throw new Error('Locale data API: locale data undefined'); +} + +/** + * A representation of the time with hours and minutes + * + * @experimental i18n support is experimental. + */ +export type Time = { + hours: number, + minutes: number +}; + +/** + * Extract the hours and minutes from a string like "15:45" + */ +function extractTime(time: string): Time { + const [h, m] = time.split(':'); + return {hours: +h, minutes: +m}; +} + +/** + * Finds the locale data for a locale id + * + * @experimental i18n support is experimental. + */ +export function findLocaleData(locale: string): any { + const normalizedLocale = getNormalizedLocale(locale); + + if (normalizedLocale === 'en') { + return LOCALE_DATA['en'] || localeEn; + } + + const match = LOCALE_DATA[toCamelCase(normalizedLocale)]; + if (match) { + return match; + } + + throw new Error( + `Missing locale data for the locale "${locale}". Use "registerLocaleData" to load new data. See the "I18n guide" on angular.io to know more.`); +} + +const NORMALIZED_LOCALES: any = {}; + +/** + * Returns the closest matching locale that exists or throw + * e.g.: "en-US" will return "en", and "fr_ca" will return "fr-CA" + * Rules for locale id equivalences are defined in + * http://cldr.unicode.org/index/cldr-spec/language-tag-equivalences + * and in https://tools.ietf.org/html/rfc4647#section-3.4 + */ +function getNormalizedLocale(locale: string): string { + if (NORMALIZED_LOCALES[locale]) { + return NORMALIZED_LOCALES[locale]; + } + + const normalizedLocale = locale.toLowerCase().replace(/_/g, '-'); + const match = AVAILABLE_LOCALES.find((l: string) => l.toLowerCase() === normalizedLocale); + + if (match) { + NORMALIZED_LOCALES[locale] = match; + return match; + } + + const parentLocale = normalizedLocale.split('-')[0]; + if (AVAILABLE_LOCALES.find((l: string) => l.toLowerCase() === parentLocale)) { + NORMALIZED_LOCALES[locale] = parentLocale; + return parentLocale; + } + + throw new Error( + `"${locale}" is not a valid LOCALE_ID value. See https://github.com/unicode-cldr/cldr-core/blob/master/availableLocales.json for a list of valid locales`); +} + +function toCamelCase(str: string): string { + return str.replace(/-+([a-z0-9A-Z])/g, (...m: string[]) => m[1].toUpperCase()); +} + +/** + * Return the currency symbol for a given currency code, or the code if no symbol available + * (e.g.: $, US$, or USD) + * + * @internal + */ +export function findCurrencySymbol(code: string, format: 'wide' | 'narrow') { + const currency = CURRENCIES[code] || {}; + const symbol = currency[0] || code; + return format === 'wide' ? symbol : currency[1] || symbol; +} + +/** + * Register global data to be used internally by Angular. See the + * {@linkDocs guide/i18n#i18n-pipes "I18n guide"} to know how to import additional locale data. + * + * @experimental i18n support is experimental. + */ +export function registerLocaleData(data: any, extraData?: any) { + const localeId = toCamelCase(data[LocaleDataIndex.LocaleId]); + LOCALE_DATA[localeId] = data; + if (extraData) { + LOCALE_DATA[localeId][LocaleDataIndex.ExtraData] = extraData; + } +} diff --git a/packages/common/src/i18n/localization.ts b/packages/common/src/i18n/localization.ts new file mode 100644 index 00000000000000..2ac5a1a0e1139e --- /dev/null +++ b/packages/common/src/i18n/localization.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, Injectable, LOCALE_ID} from '@angular/core'; +import {Plural} from './locale_data'; +import {getLocalePluralCase} from './locale_data_api'; + +/** + * @experimental + */ +export abstract class NgLocalization { + abstract getPluralCategory(value: any, locale?: string): string; +} + + +/** + * Returns the plural category for a given value. + * - "=value" when the case exists, + * - the plural category otherwise + * + * @internal + */ +export function getPluralCategory( + value: number, cases: string[], ngLocalization: NgLocalization, locale?: string): string { + let key = `=${value}`; + + if (cases.indexOf(key) > -1) { + return key; + } + + key = ngLocalization.getPluralCategory(value, locale); + + if (cases.indexOf(key) > -1) { + return key; + } + + if (cases.indexOf('other') > -1) { + return 'other'; + } + + throw new Error(`No plural message found for value "${value}"`); +} + +/** + * Returns the plural case based on the locale + * + * @experimental + */ +@Injectable() +export class NgLocaleLocalization extends NgLocalization { + constructor(@Inject(LOCALE_ID) protected locale: string) { super(); } + + getPluralCategory(value: any, locale?: string): string { + const plural = getLocalePluralCase(locale || this.locale)(value); + + switch (plural) { + case Plural.Zero: + return 'zero'; + case Plural.One: + return 'one'; + case Plural.Two: + return 'two'; + case Plural.Few: + return 'few'; + case Plural.Many: + return 'many'; + default: + return 'other'; + } + } +} diff --git a/packages/common/src/localization.ts b/packages/common/src/localization.ts deleted file mode 100644 index c2deb745b524c9..00000000000000 --- a/packages/common/src/localization.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {Inject, Injectable, LOCALE_ID} from '@angular/core'; - -/** - * @experimental - */ -export abstract class NgLocalization { abstract getPluralCategory(value: any): string; } - - -/** - * Returns the plural category for a given value. - * - "=value" when the case exists, - * - the plural category otherwise - * - * @internal - */ -export function getPluralCategory( - value: number, cases: string[], ngLocalization: NgLocalization): string { - let key = `=${value}`; - - if (cases.indexOf(key) > -1) { - return key; - } - - key = ngLocalization.getPluralCategory(value); - - if (cases.indexOf(key) > -1) { - return key; - } - - if (cases.indexOf('other') > -1) { - return 'other'; - } - - throw new Error(`No plural message found for value "${value}"`); -} - -/** - * Returns the plural case based on the locale - * - * @experimental - */ -@Injectable() -export class NgLocaleLocalization extends NgLocalization { - constructor(@Inject(LOCALE_ID) protected locale: string) { super(); } - - getPluralCategory(value: any): string { - const plural = getPluralCase(this.locale, value); - - switch (plural) { - case Plural.Zero: - return 'zero'; - case Plural.One: - return 'one'; - case Plural.Two: - return 'two'; - case Plural.Few: - return 'few'; - case Plural.Many: - return 'many'; - default: - return 'other'; - } - } -} - -// This is generated code DO NOT MODIFY -// see angular/script/cldr/gen_plural_rules.js - -/** @experimental */ -export enum Plural { - Zero, - One, - Two, - Few, - Many, - Other, -} - -/** - * Returns the plural case based on the locale - * - * @experimental - */ -export function getPluralCase(locale: string, nLike: number | string): Plural { - // TODO(vicb): lazy compute - if (typeof nLike === 'string') { - nLike = parseInt(nLike, 10); - } - const n: number = nLike as number; - const nDecimal = n.toString().replace(/^[^.]*\.?/, ''); - const i = Math.floor(Math.abs(n)); - const v = nDecimal.length; - const f = parseInt(nDecimal, 10); - const t = parseInt(n.toString().replace(/^[^.]*\.?|0+$/g, ''), 10) || 0; - - const lang = locale.split('-')[0].toLowerCase(); - - switch (lang) { - case 'af': - case 'asa': - case 'az': - case 'bem': - case 'bez': - case 'bg': - case 'brx': - case 'ce': - case 'cgg': - case 'chr': - case 'ckb': - case 'ee': - case 'el': - case 'eo': - case 'es': - case 'eu': - case 'fo': - case 'fur': - case 'gsw': - case 'ha': - case 'haw': - case 'hu': - case 'jgo': - case 'jmc': - case 'ka': - case 'kk': - case 'kkj': - case 'kl': - case 'ks': - case 'ksb': - case 'ky': - case 'lb': - case 'lg': - case 'mas': - case 'mgo': - case 'ml': - case 'mn': - case 'nb': - case 'nd': - case 'ne': - case 'nn': - case 'nnh': - case 'nyn': - case 'om': - case 'or': - case 'os': - case 'ps': - case 'rm': - case 'rof': - case 'rwk': - case 'saq': - case 'seh': - case 'sn': - case 'so': - case 'sq': - case 'ta': - case 'te': - case 'teo': - case 'tk': - case 'tr': - case 'ug': - case 'uz': - case 'vo': - case 'vun': - case 'wae': - case 'xog': - if (n === 1) return Plural.One; - return Plural.Other; - case 'ak': - case 'ln': - case 'mg': - case 'pa': - case 'ti': - if (n === Math.floor(n) && n >= 0 && n <= 1) return Plural.One; - return Plural.Other; - case 'am': - case 'as': - case 'bn': - case 'fa': - case 'gu': - case 'hi': - case 'kn': - case 'mr': - case 'zu': - if (i === 0 || n === 1) return Plural.One; - return Plural.Other; - case 'ar': - if (n === 0) return Plural.Zero; - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - if (n % 100 === Math.floor(n % 100) && n % 100 >= 3 && n % 100 <= 10) return Plural.Few; - if (n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 99) return Plural.Many; - return Plural.Other; - case 'ast': - case 'ca': - case 'de': - case 'en': - case 'et': - case 'fi': - case 'fy': - case 'gl': - case 'it': - case 'nl': - case 'sv': - case 'sw': - case 'ur': - case 'yi': - if (i === 1 && v === 0) return Plural.One; - return Plural.Other; - case 'be': - if (n % 10 === 1 && !(n % 100 === 11)) return Plural.One; - if (n % 10 === Math.floor(n % 10) && n % 10 >= 2 && n % 10 <= 4 && - !(n % 100 >= 12 && n % 100 <= 14)) - return Plural.Few; - if (n % 10 === 0 || n % 10 === Math.floor(n % 10) && n % 10 >= 5 && n % 10 <= 9 || - n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 14) - return Plural.Many; - return Plural.Other; - case 'br': - if (n % 10 === 1 && !(n % 100 === 11 || n % 100 === 71 || n % 100 === 91)) return Plural.One; - if (n % 10 === 2 && !(n % 100 === 12 || n % 100 === 72 || n % 100 === 92)) return Plural.Two; - if (n % 10 === Math.floor(n % 10) && (n % 10 >= 3 && n % 10 <= 4 || n % 10 === 9) && - !(n % 100 >= 10 && n % 100 <= 19 || n % 100 >= 70 && n % 100 <= 79 || - n % 100 >= 90 && n % 100 <= 99)) - return Plural.Few; - if (!(n === 0) && n % 1e6 === 0) return Plural.Many; - return Plural.Other; - case 'bs': - case 'hr': - case 'sr': - if (v === 0 && i % 10 === 1 && !(i % 100 === 11) || f % 10 === 1 && !(f % 100 === 11)) - return Plural.One; - if (v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 2 && i % 10 <= 4 && - !(i % 100 >= 12 && i % 100 <= 14) || - f % 10 === Math.floor(f % 10) && f % 10 >= 2 && f % 10 <= 4 && - !(f % 100 >= 12 && f % 100 <= 14)) - return Plural.Few; - return Plural.Other; - case 'cs': - case 'sk': - if (i === 1 && v === 0) return Plural.One; - if (i === Math.floor(i) && i >= 2 && i <= 4 && v === 0) return Plural.Few; - if (!(v === 0)) return Plural.Many; - return Plural.Other; - case 'cy': - if (n === 0) return Plural.Zero; - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - if (n === 3) return Plural.Few; - if (n === 6) return Plural.Many; - return Plural.Other; - case 'da': - if (n === 1 || !(t === 0) && (i === 0 || i === 1)) return Plural.One; - return Plural.Other; - case 'dsb': - case 'hsb': - if (v === 0 && i % 100 === 1 || f % 100 === 1) return Plural.One; - if (v === 0 && i % 100 === 2 || f % 100 === 2) return Plural.Two; - if (v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 3 && i % 100 <= 4 || - f % 100 === Math.floor(f % 100) && f % 100 >= 3 && f % 100 <= 4) - return Plural.Few; - return Plural.Other; - case 'ff': - case 'fr': - case 'hy': - case 'kab': - if (i === 0 || i === 1) return Plural.One; - return Plural.Other; - case 'fil': - if (v === 0 && (i === 1 || i === 2 || i === 3) || - v === 0 && !(i % 10 === 4 || i % 10 === 6 || i % 10 === 9) || - !(v === 0) && !(f % 10 === 4 || f % 10 === 6 || f % 10 === 9)) - return Plural.One; - return Plural.Other; - case 'ga': - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - if (n === Math.floor(n) && n >= 3 && n <= 6) return Plural.Few; - if (n === Math.floor(n) && n >= 7 && n <= 10) return Plural.Many; - return Plural.Other; - case 'gd': - if (n === 1 || n === 11) return Plural.One; - if (n === 2 || n === 12) return Plural.Two; - if (n === Math.floor(n) && (n >= 3 && n <= 10 || n >= 13 && n <= 19)) return Plural.Few; - return Plural.Other; - case 'gv': - if (v === 0 && i % 10 === 1) return Plural.One; - if (v === 0 && i % 10 === 2) return Plural.Two; - if (v === 0 && - (i % 100 === 0 || i % 100 === 20 || i % 100 === 40 || i % 100 === 60 || i % 100 === 80)) - return Plural.Few; - if (!(v === 0)) return Plural.Many; - return Plural.Other; - case 'he': - if (i === 1 && v === 0) return Plural.One; - if (i === 2 && v === 0) return Plural.Two; - if (v === 0 && !(n >= 0 && n <= 10) && n % 10 === 0) return Plural.Many; - return Plural.Other; - case 'is': - if (t === 0 && i % 10 === 1 && !(i % 100 === 11) || !(t === 0)) return Plural.One; - return Plural.Other; - case 'ksh': - if (n === 0) return Plural.Zero; - if (n === 1) return Plural.One; - return Plural.Other; - case 'kw': - case 'naq': - case 'se': - case 'smn': - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - return Plural.Other; - case 'lag': - if (n === 0) return Plural.Zero; - if ((i === 0 || i === 1) && !(n === 0)) return Plural.One; - return Plural.Other; - case 'lt': - if (n % 10 === 1 && !(n % 100 >= 11 && n % 100 <= 19)) return Plural.One; - if (n % 10 === Math.floor(n % 10) && n % 10 >= 2 && n % 10 <= 9 && - !(n % 100 >= 11 && n % 100 <= 19)) - return Plural.Few; - if (!(f === 0)) return Plural.Many; - return Plural.Other; - case 'lv': - case 'prg': - if (n % 10 === 0 || n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 19 || - v === 2 && f % 100 === Math.floor(f % 100) && f % 100 >= 11 && f % 100 <= 19) - return Plural.Zero; - if (n % 10 === 1 && !(n % 100 === 11) || v === 2 && f % 10 === 1 && !(f % 100 === 11) || - !(v === 2) && f % 10 === 1) - return Plural.One; - return Plural.Other; - case 'mk': - if (v === 0 && i % 10 === 1 || f % 10 === 1) return Plural.One; - return Plural.Other; - case 'mt': - if (n === 1) return Plural.One; - if (n === 0 || n % 100 === Math.floor(n % 100) && n % 100 >= 2 && n % 100 <= 10) - return Plural.Few; - if (n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 19) return Plural.Many; - return Plural.Other; - case 'pl': - if (i === 1 && v === 0) return Plural.One; - if (v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 2 && i % 10 <= 4 && - !(i % 100 >= 12 && i % 100 <= 14)) - return Plural.Few; - if (v === 0 && !(i === 1) && i % 10 === Math.floor(i % 10) && i % 10 >= 0 && i % 10 <= 1 || - v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 5 && i % 10 <= 9 || - v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 12 && i % 100 <= 14) - return Plural.Many; - return Plural.Other; - case 'pt': - if (n === Math.floor(n) && n >= 0 && n <= 2 && !(n === 2)) return Plural.One; - return Plural.Other; - case 'ro': - if (i === 1 && v === 0) return Plural.One; - if (!(v === 0) || n === 0 || - !(n === 1) && n % 100 === Math.floor(n % 100) && n % 100 >= 1 && n % 100 <= 19) - return Plural.Few; - return Plural.Other; - case 'ru': - case 'uk': - if (v === 0 && i % 10 === 1 && !(i % 100 === 11)) return Plural.One; - if (v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 2 && i % 10 <= 4 && - !(i % 100 >= 12 && i % 100 <= 14)) - return Plural.Few; - if (v === 0 && i % 10 === 0 || - v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 5 && i % 10 <= 9 || - v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 11 && i % 100 <= 14) - return Plural.Many; - return Plural.Other; - case 'shi': - if (i === 0 || n === 1) return Plural.One; - if (n === Math.floor(n) && n >= 2 && n <= 10) return Plural.Few; - return Plural.Other; - case 'si': - if (n === 0 || n === 1 || i === 0 && f === 1) return Plural.One; - return Plural.Other; - case 'sl': - if (v === 0 && i % 100 === 1) return Plural.One; - if (v === 0 && i % 100 === 2) return Plural.Two; - if (v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 3 && i % 100 <= 4 || !(v === 0)) - return Plural.Few; - return Plural.Other; - case 'tzm': - if (n === Math.floor(n) && n >= 0 && n <= 1 || n === Math.floor(n) && n >= 11 && n <= 99) - return Plural.One; - return Plural.Other; - // When there is no specification, the default is always "other" - // Spec: http://cldr.unicode.org/index/cldr-spec/plural-rules - // > other (required—general plural form — also used if the language only has a single form) - default: - return Plural.Other; - } -} diff --git a/packages/common/src/pipes/date_pipe.ts b/packages/common/src/pipes/date_pipe.ts index aec4cee8355e7c..2129c06d08a597 100644 --- a/packages/common/src/pipes/date_pipe.ts +++ b/packages/common/src/pipes/date_pipe.ts @@ -7,66 +7,118 @@ */ import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; -import {DateFormatter} from './intl'; +import {formatDate} from '../i18n/format_date'; import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; -import {isNumeric} from './number_pipe'; -const ISO8601_DATE_REGEX = +export const ISO8601_DATE_REGEX = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; // 1 2 3 4 5 6 7 8 9 10 11 +// clang-format off /** * @ngModule CommonModule * @whatItDoes Formats a date according to locale rules. - * @howToUse `date_expression | date[:format]` + * @howToUse `date_expression | date[:format[:timezone[:locale]]]` * @description * * Where: * - `expression` is a date object or a number (milliseconds since UTC epoch) or an ISO string * (https://www.w3.org/TR/NOTE-datetime). * - `format` indicates which date/time components to include. The format can be predefined as - * shown below or custom as shown in the table. - * - `'medium'`: equivalent to `'yMMMdjms'` (e.g. `Sep 3, 2010, 12:05:08 PM` for `en-US`) - * - `'short'`: equivalent to `'yMdjm'` (e.g. `9/3/2010, 12:05 PM` for `en-US`) - * - `'fullDate'`: equivalent to `'yMMMMEEEEd'` (e.g. `Friday, September 3, 2010` for `en-US`) - * - `'longDate'`: equivalent to `'yMMMMd'` (e.g. `September 3, 2010` for `en-US`) - * - `'mediumDate'`: equivalent to `'yMMMd'` (e.g. `Sep 3, 2010` for `en-US`) - * - `'shortDate'`: equivalent to `'yMd'` (e.g. `9/3/2010` for `en-US`) - * - `'mediumTime'`: equivalent to `'jms'` (e.g. `12:05:08 PM` for `en-US`) - * - `'shortTime'`: equivalent to `'jm'` (e.g. `12:05 PM` for `en-US`) + * shown below (all examples are given for `en-US`) or custom as shown in the table. + * - `'short'`: equivalent to `'M/d/yy, h:mm a'` (e.g. `6/15/15, 9:03 AM`) + * - `'medium'`: equivalent to `'MMM d, y, h:mm:ss a'` (e.g. `Jun 15, 2015, 9:03:01 AM`) + * - `'long'`: equivalent to `'MMMM d, y, h:mm:ss a z'` (e.g. `June 15, 2015 at 9:03:01 AM GMT+1`) + * - `'full'`: equivalent to `'EEEE, MMMM d, y, h:mm:ss a zzzz'` (e.g. `Monday, June 15, 2015 at + * 9:03:01 AM GMT+01:00`) + * - `'shortDate'`: equivalent to `'M/d/yy'` (e.g. `6/15/15`) + * - `'mediumDate'`: equivalent to `'MMM d, y'` (e.g. `Jun 15, 2015`) + * - `'longDate'`: equivalent to `'MMMM d, y'` (e.g. `June 15, 2015`) + * - `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` (e.g. `Monday, June 15, 2015`) + * - `'shortTime'`: equivalent to `'h:mm a'` (e.g. `9:03 AM`) + * - `'mediumTime'`: equivalent to `'h:mm:ss a'` (e.g. `9:03:01 AM`) + * - `'longTime'`: equivalent to `'h:mm:ss a z'` (e.g. `9:03:01 AM GMT+1`) + * - `'fullTime'`: equivalent to `'h:mm:ss a zzzz'` (e.g. `9:03:01 AM GMT+01:00`) + * - `timezone` to be used for formatting. It understands UTC/GMT and the continental US time zone + * abbreviations, but for general use, use a time zone offset, for example, + * `'+0430'` (4 hours, 30 minutes east of the Greenwich meridian) + * If not specified, the local system timezone of the end-user's browser will be used. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * - * | Component | Symbol | Narrow | Short Form | Long Form | Numeric | 2-digit | - * |-----------|:------:|--------|--------------|-------------------|-----------|-----------| - * | era | G | G (A) | GGG (AD) | GGGG (Anno Domini)| - | - | - * | year | y | - | - | - | y (2015) | yy (15) | - * | month | M | L (S) | MMM (Sep) | MMMM (September) | M (9) | MM (09) | - * | day | d | - | - | - | d (3) | dd (03) | - * | weekday | E | E (S) | EEE (Sun) | EEEE (Sunday) | - | - | - * | hour | j | - | - | - | j (1 PM) | jj (1 PM) | - * | hour12 | h | - | - | - | h (1) | hh (01) | - * | hour24 | H | - | - | - | H (13) | HH (13) | - * | minute | m | - | - | - | m (5) | mm (05) | - * | second | s | - | - | - | s (9) | ss (09) | - * | timezone | z | - | - | z (Pacific Standard Time)| - | - | - * | timezone | Z | - | Z (GMT-8:00) | - | - | - | - * | timezone | a | - | a (PM) | - | - | - | + * | Field Type | Format | Description | Example Value | + * |--------------------|-------------|---------------------------------------------------------------|------------------------------------------------------------| + * | Era | G, GG & GGG | Abbreviated | AD | + * | | GGGG | Wide | Anno Domini | + * | | GGGGG | Narrow | A | + * | Year | y | Numeric: minimum digits | 2, 20, 201, 2017, 20173 | + * | | yy | Numeric: 2 digits + zero padded | 02, 20, 01, 17, 73 | + * | | yyy | Numeric: 3 digits + zero padded | 002, 020, 201, 2017, 20173 | + * | | yyyy | Numeric: 4 digits or more + zero padded | 0002, 0020, 0201, 2017, 20173 | + * | Month | M | Numeric: 1 digit | 9, 12 | + * | | MM | Numeric: 2 digits + zero padded | 09, 12 | + * | | MMM | Abbreviated | Sep | + * | | MMMM | Wide | September | + * | | MMMMM | Narrow | S | + * | Month standalone | L | Numeric: 1 digit | 9, 12 | + * | | LL | Numeric: 2 digits + zero padded | 09, 12 | + * | | LLL | Abbreviated | Sep | + * | | LLLL | Wide | September | + * | | LLLLL | Narrow | S | + * | Week of year | w | Numeric: minimum digits | 1... 53 | + * | | ww | Numeric: 2 digits + zero padded | 01... 53 | + * | Week of month | W | Numeric: 1 digit | 1... 5 | + * | Day of month | d | Numeric: minimum digits | 1 | + * | | dd | Numeric: 2 digits + zero padded | 1 | + * | Week day | E, EE & EEE | Abbreviated | Tue | + * | | EEEE | Wide | Tuesday | + * | | EEEEE | Narrow | T | + * | | EEEEEE | Short | Tu | + * | Period | a, aa & aaa | Abbreviated | am/pm or AM/PM | + * | | aaaa | Wide (fallback to `a` when missing) | ante meridiem/post meridiem | + * | | aaaaa | Narrow | a/p | + * | Period* | B, BB & BBB | Abbreviated | mid. | + * | | BBBB | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | + * | | BBBBB | Narrow | md | + * | Period standalone* | b, bb & bbb | Abbreviated | mid. | + * | | bbbb | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | + * | | bbbbb | Narrow | md | + * | Hour 1-12 | h | Numeric: minimum digits | 1, 12 | + * | | hh | Numeric: 2 digits + zero padded | 01, 12 | + * | Hour 0-23 | H | Numeric: minimum digits | 0, 23 | + * | | HH | Numeric: 2 digits + zero padded | 00, 23 | + * | Minute | m | Numeric: minimum digits | 8, 59 | + * | | mm | Numeric: 2 digits + zero padded | 08, 59 | + * | Second | s | Numeric: minimum digits | 0... 59 | + * | | ss | Numeric: 2 digits + zero padded | 00... 59 | + * | Fractional seconds | S | Numeric: 1 digit | 0... 9 | + * | | SS | Numeric: 2 digits + zero padded | 00... 99 | + * | | SSS | Numeric: 3 digits + zero padded (= milliseconds) | 000... 999 | + * | Zone | z, zz & zzz | Short specific non location format (fallback to O) | GMT-8 | + * | | zzzz | Long specific non location format (fallback to OOOO) | GMT-08:00 | + * | | Z, ZZ & ZZZ | ISO8601 basic format | -0800 | + * | | ZZZZ | Long localized GMT format | GMT-8:00 | + * | | ZZZZZ | ISO8601 extended format + Z indicator for offset 0 (= XXXXX) | -08:00 | + * | | O, OO & OOO | Short localized GMT format | GMT-8 | + * | | OOOO | Long localized GMT format | GMT-08:00 | * - * In javascript, only the components specified will be respected (not the ordering, - * punctuations, ...) and details of the formatting will be dependent on the locale. - * - * Timezone of the formatted text will be the local system timezone of the end-user's machine. * * When the expression is a ISO string without time (e.g. 2016-09-19) the time zone offset is not * applied and the formatted text will have the same day, month and year of the expression. * * WARNINGS: + * - this pipe has only access to en-US locale data by default. If you want to localize the dates + * in another language, you will have to import data for other locales. + * See the {@linkDocs guide/i18n#i18n-pipes "I18n guide"} to know how to import additional locale + * data. + * - Fields suffixed with * are only available in the extra dataset. + * See the {@linkDocs guide/i18n#i18n-pipes "I18n guide"} to know how to import extra locale + * data. * - this pipe is marked as pure hence it will not be re-evaluated when the input is mutated. * Instead users should treat the date as an immutable object and change the reference when the * pipe needs to re-run (this is to avoid reformatting the date on every change detection run * which would be an expensive operation). - * - this pipe uses the Internationalization API. Therefore it is only reliable in Chrome and Opera - * browsers. * * ### Examples * @@ -77,41 +129,29 @@ const ISO8601_DATE_REGEX = * {{ dateObj | date }} // output is 'Jun 15, 2015' * {{ dateObj | date:'medium' }} // output is 'Jun 15, 2015, 9:43:11 PM' * {{ dateObj | date:'shortTime' }} // output is '9:43 PM' - * {{ dateObj | date:'mmss' }} // output is '43:11' + * {{ dateObj | date:'hh:mm:ss a' }} // output is '09:43:11 PM' * ``` * * {@example common/pipes/ts/date_pipe.ts region='DatePipe'} * * @stable */ +// clang-format on @Pipe({name: 'date', pure: true}) export class DatePipe implements PipeTransform { - /** @internal */ - static _ALIASES: {[key: string]: string} = { - 'medium': 'yMMMdjms', - 'short': 'yMdjm', - 'fullDate': 'yMMMMEEEEd', - 'longDate': 'yMMMMd', - 'mediumDate': 'yMMMd', - 'shortDate': 'yMd', - 'mediumTime': 'jms', - 'shortTime': 'jm' - }; - - constructor(@Inject(LOCALE_ID) private _locale: string) {} + constructor(@Inject(LOCALE_ID) private locale: string) {} - transform(value: any, pattern: string = 'mediumDate'): string|null { - let date: Date; - - if (isBlank(value) || value !== value) return null; + transform(value: any, format = 'mediumDate', timezone?: string, locale?: string): string|null { + if (value == null || value === '' || value !== value) return null; if (typeof value === 'string') { value = value.trim(); } + let date: Date; if (isDate(value)) { date = value; - } else if (isNumeric(value)) { + } else if (!isNaN(value - parseFloat(value))) { date = new Date(parseFloat(value)); } else if (typeof value === 'string' && /^(\d{4}-\d{1,2}-\d{1,2})$/.test(value)) { /** @@ -123,7 +163,7 @@ export class DatePipe implements PipeTransform { * is applied * Note: ISO months are 0 for January, 1 for February, ... */ - const [y, m, d] = value.split('-').map((val: string) => parseInt(val, 10)); + const [y, m, d] = value.split('-').map((val: string) => +val); date = new Date(y, m - 1, d); } else { date = new Date(value); @@ -138,19 +178,12 @@ export class DatePipe implements PipeTransform { } } - return DateFormatter.format(date, this._locale, DatePipe._ALIASES[pattern] || pattern); + return formatDate(date, format, locale || this.locale, timezone); } } -function isBlank(obj: any): boolean { - return obj == null || obj === ''; -} - -function isDate(obj: any): obj is Date { - return obj instanceof Date && !isNaN(obj.valueOf()); -} - -function isoStringToDate(match: RegExpMatchArray): Date { +/** @internal */ +export function isoStringToDate(match: RegExpMatchArray): Date { const date = new Date(0); let tzHour = 0; let tzMin = 0; @@ -158,18 +191,18 @@ function isoStringToDate(match: RegExpMatchArray): Date { const timeSetter = match[8] ? date.setUTCHours : date.setHours; if (match[9]) { - tzHour = toInt(match[9] + match[10]); - tzMin = toInt(match[9] + match[11]); + tzHour = +(match[9] + match[10]); + tzMin = +(match[9] + match[11]); } - dateSetter.call(date, toInt(match[1]), toInt(match[2]) - 1, toInt(match[3])); - const h = toInt(match[4] || '0') - tzHour; - const m = toInt(match[5] || '0') - tzMin; - const s = toInt(match[6] || '0'); + dateSetter.call(date, +(match[1]), +(match[2]) - 1, +(match[3])); + const h = +(match[4] || '0') - tzHour; + const m = +(match[5] || '0') - tzMin; + const s = +(match[6] || '0'); const ms = Math.round(parseFloat('0.' + (match[7] || 0)) * 1000); timeSetter.call(date, h, m, s, ms); return date; } -function toInt(str: string): number { - return parseInt(str, 10); +function isDate(value: any): value is Date { + return value instanceof Date && !isNaN(value.valueOf()); } diff --git a/packages/common/src/pipes/deprecated/date_pipe.ts b/packages/common/src/pipes/deprecated/date_pipe.ts new file mode 100644 index 00000000000000..2b500897cac5f0 --- /dev/null +++ b/packages/common/src/pipes/deprecated/date_pipe.ts @@ -0,0 +1,145 @@ +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license + */ + +import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; +import {ISO8601_DATE_REGEX, isoStringToDate} from '../date_pipe'; +import {invalidPipeArgumentError} from '../invalid_pipe_argument_error'; +import {DateFormatter} from './intl'; + +/** + * @ngModule CommonModule + * @whatItDoes Formats a date according to locale rules. + * @howToUse `date_expression | date[:format]` + * @description + * + * Where: + * - `expression` is a date object or a number (milliseconds since UTC epoch) or an ISO string + * (https://www.w3.org/TR/NOTE-datetime). + * - `format` indicates which date/time components to include. The format can be predefined as + * shown below or custom as shown in the table. + * - `'medium'`: equivalent to `'yMMMdjms'` (e.g. `Sep 3, 2010, 12:05:08 PM` for `en-US`) + * - `'short'`: equivalent to `'yMdjm'` (e.g. `9/3/2010, 12:05 PM` for `en-US`) + * - `'fullDate'`: equivalent to `'yMMMMEEEEd'` (e.g. `Friday, September 3, 2010` for `en-US`) + * - `'longDate'`: equivalent to `'yMMMMd'` (e.g. `September 3, 2010` for `en-US`) + * - `'mediumDate'`: equivalent to `'yMMMd'` (e.g. `Sep 3, 2010` for `en-US`) + * - `'shortDate'`: equivalent to `'yMd'` (e.g. `9/3/2010` for `en-US`) + * - `'mediumTime'`: equivalent to `'jms'` (e.g. `12:05:08 PM` for `en-US`) + * - `'shortTime'`: equivalent to `'jm'` (e.g. `12:05 PM` for `en-US`) + * + * + * | Component | Symbol | Narrow | Short Form | Long Form | Numeric | 2-digit | + * |-----------|:------:|--------|--------------|-------------------|-----------|-----------| + * | era | G | G (A) | GGG (AD) | GGGG (Anno Domini)| - | - | + * | year | y | - | - | - | y (2015) | yy (15) | + * | month | M | L (S) | MMM (Sep) | MMMM (September) | M (9) | MM (09) | + * | day | d | - | - | - | d (3) | dd (03) | + * | weekday | E | E (S) | EEE (Sun) | EEEE (Sunday) | - | - | + * | hour | j | - | - | - | j (13) | jj (13) | + * | hour12 | h | - | - | - | h (1 PM) | hh (01 PM)| + * | hour24 | H | - | - | - | H (13) | HH (13) | + * | minute | m | - | - | - | m (5) | mm (05) | + * | second | s | - | - | - | s (9) | ss (09) | + * | timezone | z | - | - | z (Pacific Standard Time)| - | - | + * | timezone | Z | - | Z (GMT-8:00) | - | - | - | + * | timezone | a | - | a (PM) | - | - | - | + * + * In javascript, only the components specified will be respected (not the ordering, + * punctuations, ...) and details of the formatting will be dependent on the locale. + * + * Timezone of the formatted text will be the local system timezone of the end-user's machine. + * + * When the expression is a ISO string without time (e.g. 2016-09-19) the time zone offset is not + * applied and the formatted text will have the same day, month and year of the expression. + * + * WARNINGS: + * - this pipe is marked as pure hence it will not be re-evaluated when the input is mutated. + * Instead users should treat the date as an immutable object and change the reference when the + * pipe needs to re-run (this is to avoid reformatting the date on every change detection run + * which would be an expensive operation). + * - this pipe uses the Internationalization API. Therefore it is only reliable in Chrome and Opera + * browsers. + * + * ### Examples + * + * Assuming `dateObj` is (year: 2015, month: 6, day: 15, hour: 21, minute: 43, second: 11) + * in the _local_ time and locale is 'en-US': + * + * ``` + * {{ dateObj | date }} // output is 'Jun 15, 2015' + * {{ dateObj | date:'medium' }} // output is 'Jun 15, 2015, 9:43:11 PM' + * {{ dateObj | date:'shortTime' }} // output is '9:43 PM' + * {{ dateObj | date:'mmss' }} // output is '43:11' + * ``` + * + * {@example common/pipes/ts/date_pipe.ts region='DatePipe'} + * + * @stable + */ +@Pipe({name: 'date', pure: true}) +export class DeprecatedDatePipe implements PipeTransform { + /** @internal */ + static _ALIASES: {[key: string]: string} = { + 'medium': 'yMMMdjms', + 'short': 'yMdjm', + 'fullDate': 'yMMMMEEEEd', + 'longDate': 'yMMMMd', + 'mediumDate': 'yMMMd', + 'shortDate': 'yMd', + 'mediumTime': 'jms', + 'shortTime': 'jm' + }; + + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform(value: any, pattern: string = 'mediumDate'): string|null { + if (value == null || value === '' || value !== value) return null; + + let date: Date; + + if (typeof value === 'string') { + value = value.trim(); + } + + if (isDate(value)) { + date = value; + } else if (!isNaN(value - parseFloat(value))) { + date = new Date(parseFloat(value)); + } else if (typeof value === 'string' && /^(\d{4}-\d{1,2}-\d{1,2})$/.test(value)) { + /** + * For ISO Strings without time the day, month and year must be extracted from the ISO String + * before Date creation to avoid time offset and errors in the new Date. + * If we only replace '-' with ',' in the ISO String ("2015,01,01"), and try to create a new + * date, some browsers (e.g. IE 9) will throw an invalid Date error + * If we leave the '-' ("2015-01-01") and try to create a new Date("2015-01-01") the + * timeoffset + * is applied + * Note: ISO months are 0 for January, 1 for February, ... + */ + const [y, m, d] = value.split('-').map((val: string) => parseInt(val, 10)); + date = new Date(y, m - 1, d); + } else { + date = new Date(value); + } + + if (!isDate(date)) { + let match: RegExpMatchArray|null; + if ((typeof value === 'string') && (match = value.match(ISO8601_DATE_REGEX))) { + date = isoStringToDate(match); + } else { + throw invalidPipeArgumentError(DeprecatedDatePipe, value); + } + } + + return DateFormatter.format( + date, this._locale, DeprecatedDatePipe._ALIASES[pattern] || pattern); + } +} + +function isDate(value: any): value is Date { + return value instanceof Date && !isNaN(value.valueOf()); +} diff --git a/packages/common/src/pipes/deprecated/index.ts b/packages/common/src/pipes/deprecated/index.ts new file mode 100644 index 00000000000000..935583234bcbc1 --- /dev/null +++ b/packages/common/src/pipes/deprecated/index.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Provider} from '@angular/core'; +import {DeprecatedDatePipe} from './date_pipe'; +import {DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './number_pipe'; + +export { + DeprecatedCurrencyPipe, + DeprecatedDatePipe, + DeprecatedDecimalPipe, + DeprecatedPercentPipe, +}; + + +/** + * A collection of deprecated i18n pipes that require intl api + * + * @deprecated + */ +export const COMMON_DEPRECATED_I18N_PIPES: Provider[] = + [DeprecatedDecimalPipe, DeprecatedPercentPipe, DeprecatedCurrencyPipe, DeprecatedDatePipe]; diff --git a/packages/common/src/pipes/intl.ts b/packages/common/src/pipes/deprecated/intl.ts similarity index 99% rename from packages/common/src/pipes/intl.ts rename to packages/common/src/pipes/deprecated/intl.ts index 483e89fd8c84c8..ff7fa9808a9ed5 100644 --- a/packages/common/src/pipes/intl.ts +++ b/packages/common/src/pipes/deprecated/intl.ts @@ -5,12 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ - -export enum NumberFormatStyle { - Decimal, - Percent, - Currency, -} +import {NumberFormatStyle} from '../../i18n/locale_data_api'; export class NumberFormatter { static format(num: number, locale: string, style: NumberFormatStyle, opts: { diff --git a/packages/common/src/pipes/deprecated/number_pipe.ts b/packages/common/src/pipes/deprecated/number_pipe.ts new file mode 100644 index 00000000000000..c2b2aa62a86b8d --- /dev/null +++ b/packages/common/src/pipes/deprecated/number_pipe.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, LOCALE_ID, Pipe, PipeTransform, Type} from '@angular/core'; +import {NUMBER_FORMAT_REGEXP, parseIntAutoRadix} from '../../i18n/format_number'; +import {NumberFormatStyle} from '../../i18n/locale_data_api'; +import {invalidPipeArgumentError} from '../invalid_pipe_argument_error'; +import {NumberFormatter} from './intl'; + +function formatNumber( + pipe: Type, locale: string, value: number | string, style: NumberFormatStyle, + digits?: string | null, currency: string | null = null, + currencyAsSymbol: boolean = false): string|null { + if (value == null) return null; + + // Convert strings to numbers + value = typeof value === 'string' && !isNaN(+value - parseFloat(value)) ? +value : value; + if (typeof value !== 'number') { + throw invalidPipeArgumentError(pipe, value); + } + + let minInt: number|undefined; + let minFraction: number|undefined; + let maxFraction: number|undefined; + if (style !== NumberFormatStyle.Currency) { + // rely on Intl default for currency + minInt = 1; + minFraction = 0; + maxFraction = 3; + } + + if (digits) { + const parts = digits.match(NUMBER_FORMAT_REGEXP); + if (parts === null) { + throw new Error(`${digits} is not a valid digit info for number pipes`); + } + if (parts[1] != null) { // min integer digits + minInt = parseIntAutoRadix(parts[1]); + } + if (parts[3] != null) { // min fraction digits + minFraction = parseIntAutoRadix(parts[3]); + } + if (parts[5] != null) { // max fraction digits + maxFraction = parseIntAutoRadix(parts[5]); + } + } + + return NumberFormatter.format(value as number, locale, style, { + minimumIntegerDigits: minInt, + minimumFractionDigits: minFraction, + maximumFractionDigits: maxFraction, + currency: currency, + currencyAsSymbol: currencyAsSymbol, + }); +} + +/** + * @ngModule CommonModule + * @whatItDoes Formats a number according to locale rules. + * @howToUse `number_expression | number[:digitInfo]` + * + * Formats a number as text. Group sizing and separator and other locale-specific + * configurations are based on the active locale. + * + * where `expression` is a number: + * - `digitInfo` is a `string` which has a following format:
+ * {minIntegerDigits}.{minFractionDigits}-{maxFractionDigits} + * - `minIntegerDigits` is the minimum number of integer digits to use. Defaults to `1`. + * - `minFractionDigits` is the minimum number of digits after fraction. Defaults to `0`. + * - `maxFractionDigits` is the maximum number of digits after fraction. Defaults to `3`. + * + * For more information on the acceptable range for each of these numbers and other + * details see your native internationalization library. + * + * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers + * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * + * ### Example + * + * {@example common/pipes/ts/number_pipe.ts region='NumberPipe'} + * + * @stable + */ +@Pipe({name: 'number'}) +export class DeprecatedDecimalPipe implements PipeTransform { + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform(value: any, digits?: string): string|null { + return formatNumber( + DeprecatedDecimalPipe, this._locale, value, NumberFormatStyle.Decimal, digits); + } +} + +/** + * @ngModule CommonModule + * @whatItDoes Formats a number as a percentage according to locale rules. + * @howToUse `number_expression | percent[:digitInfo]` + * + * @description + * + * Formats a number as percentage. + * + * - `digitInfo` See {@link DecimalPipe} for detailed description. + * + * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers + * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * + * ### Example + * + * {@example common/pipes/ts/number_pipe.ts region='PercentPipe'} + * + * @stable + */ +@Pipe({name: 'percent'}) +export class DeprecatedPercentPipe implements PipeTransform { + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform(value: any, digits?: string): string|null { + return formatNumber( + DeprecatedPercentPipe, this._locale, value, NumberFormatStyle.Percent, digits); + } +} + +/** + * @ngModule CommonModule + * @whatItDoes Formats a number as currency using locale rules. + * @howToUse `number_expression | currency[:currencyCode[:symbolDisplay[:digitInfo]]]` + * @description + * + * Use `currency` to format a number as currency. + * + * - `currencyCode` is the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code, such + * as `USD` for the US dollar and `EUR` for the euro. + * - `symbolDisplay` is a boolean indicating whether to use the currency symbol or code. + * - `true`: use symbol (e.g. `$`). + * - `false`(default): use code (e.g. `USD`). + * - `digitInfo` See {@link DecimalPipe} for detailed description. + * + * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers + * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * + * ### Example + * + * {@example common/pipes/ts/number_pipe.ts region='CurrencyPipe'} + * + * @stable + */ +@Pipe({name: 'currency'}) +export class DeprecatedCurrencyPipe implements PipeTransform { + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform( + value: any, currencyCode: string = 'USD', symbolDisplay: boolean = false, + digits?: string): string|null { + return formatNumber( + DeprecatedCurrencyPipe, this._locale, value, NumberFormatStyle.Currency, digits, + currencyCode, symbolDisplay); + } +} diff --git a/packages/common/src/pipes/i18n_plural_pipe.ts b/packages/common/src/pipes/i18n_plural_pipe.ts index dc0909d1a5e16e..0d3ca1b65ea065 100644 --- a/packages/common/src/pipes/i18n_plural_pipe.ts +++ b/packages/common/src/pipes/i18n_plural_pipe.ts @@ -6,8 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {Pipe, PipeTransform} from '@angular/core'; -import {NgLocalization, getPluralCategory} from '../localization'; +import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; + +import {NgLocalization, getPluralCategory} from '../i18n/localization'; + import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; const _INTERPOLATION_REGEXP: RegExp = /#/g; @@ -15,13 +17,15 @@ const _INTERPOLATION_REGEXP: RegExp = /#/g; /** * @ngModule CommonModule * @whatItDoes Maps a value to a string that pluralizes the value according to locale rules. - * @howToUse `expression | i18nPlural:mapping` + * @howToUse `expression | i18nPlural:mapping[:locale]` * @description * * Where: * - `expression` is a number. * - `mapping` is an object that mimics the ICU format, see * http://userguide.icu-project.org/formatparse/messages + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * ## Example * @@ -33,14 +37,14 @@ const _INTERPOLATION_REGEXP: RegExp = /#/g; export class I18nPluralPipe implements PipeTransform { constructor(private _localization: NgLocalization) {} - transform(value: number, pluralMap: {[count: string]: string}): string { + transform(value: number, pluralMap: {[count: string]: string}, locale?: string): string { if (value == null) return ''; if (typeof pluralMap !== 'object' || pluralMap === null) { throw invalidPipeArgumentError(I18nPluralPipe, pluralMap); } - const key = getPluralCategory(value, Object.keys(pluralMap), this._localization); + const key = getPluralCategory(value, Object.keys(pluralMap), this._localization, locale); return pluralMap[key].replace(_INTERPOLATION_REGEXP, value.toString()); } diff --git a/packages/common/src/pipes/number_pipe.ts b/packages/common/src/pipes/number_pipe.ts index ae170ddc5f6eb7..6970cf0539550e 100644 --- a/packages/common/src/pipes/number_pipe.ts +++ b/packages/common/src/pipes/number_pipe.ts @@ -6,63 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {Inject, LOCALE_ID, Pipe, PipeTransform, Type} from '@angular/core'; -import {NumberFormatStyle, NumberFormatter} from './intl'; +import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; +import {formatNumber} from '../i18n/format_number'; +import {NumberFormatStyle, findCurrencySymbol, getLocaleCurrencyName, getLocaleCurrencySymbol} from '../i18n/locale_data_api'; import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; -const _NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/; - -function formatNumber( - pipe: Type, locale: string, value: number | string, style: NumberFormatStyle, - digits?: string | null, currency: string | null = null, - currencyAsSymbol: boolean = false): string|null { - if (value == null) return null; - - // Convert strings to numbers - value = typeof value === 'string' && isNumeric(value) ? +value : value; - if (typeof value !== 'number') { - throw invalidPipeArgumentError(pipe, value); - } - - let minInt: number|undefined = undefined; - let minFraction: number|undefined = undefined; - let maxFraction: number|undefined = undefined; - if (style !== NumberFormatStyle.Currency) { - // rely on Intl default for currency - minInt = 1; - minFraction = 0; - maxFraction = 3; - } - - if (digits) { - const parts = digits.match(_NUMBER_FORMAT_REGEXP); - if (parts === null) { - throw new Error(`${digits} is not a valid digit info for number pipes`); - } - if (parts[1] != null) { // min integer digits - minInt = parseIntAutoRadix(parts[1]); - } - if (parts[3] != null) { // min fraction digits - minFraction = parseIntAutoRadix(parts[3]); - } - if (parts[5] != null) { // max fraction digits - maxFraction = parseIntAutoRadix(parts[5]); - } - } - - return NumberFormatter.format(value as number, locale, style, { - minimumIntegerDigits: minInt, - minimumFractionDigits: minFraction, - maximumFractionDigits: maxFraction, - currency: currency, - currencyAsSymbol: currencyAsSymbol, - }); -} - /** * @ngModule CommonModule * @whatItDoes Formats a number according to locale rules. - * @howToUse `number_expression | number[:digitInfo]` + * @howToUse `number_expression | number[:digitInfo[:locale]]` * * Formats a number as text. Group sizing and separator and other locale-specific * configurations are based on the active locale. @@ -73,13 +25,12 @@ function formatNumber( * - `minIntegerDigits` is the minimum number of integer digits to use. Defaults to `1`. * - `minFractionDigits` is the minimum number of digits after fraction. Defaults to `0`. * - `maxFractionDigits` is the maximum number of digits after fraction. Defaults to `3`. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * For more information on the acceptable range for each of these numbers and other * details see your native internationalization library. * - * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers - * and may require a polyfill. See [Browser Support](guide/browser-support) for details. - * * ### Example * * {@example common/pipes/ts/number_pipe.ts region='NumberPipe'} @@ -90,24 +41,33 @@ function formatNumber( export class DecimalPipe implements PipeTransform { constructor(@Inject(LOCALE_ID) private _locale: string) {} - transform(value: any, digits?: string): string|null { - return formatNumber(DecimalPipe, this._locale, value, NumberFormatStyle.Decimal, digits); + transform(value: any, digits?: string, locale?: string): string|null { + if (isEmpty(value)) return null; + + locale = locale || this._locale; + + const {str, error} = formatNumber(value, locale, NumberFormatStyle.Decimal, digits); + + if (error) { + throw invalidPipeArgumentError(CurrencyPipe, error); + } + + return str; } } /** * @ngModule CommonModule * @whatItDoes Formats a number as a percentage according to locale rules. - * @howToUse `number_expression | percent[:digitInfo]` + * @howToUse `number_expression | percent[:digitInfo[:locale]]` * * @description * * Formats a number as percentage. * * - `digitInfo` See {@link DecimalPipe} for detailed description. - * - * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers - * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * ### Example * @@ -119,28 +79,40 @@ export class DecimalPipe implements PipeTransform { export class PercentPipe implements PipeTransform { constructor(@Inject(LOCALE_ID) private _locale: string) {} - transform(value: any, digits?: string): string|null { - return formatNumber(PercentPipe, this._locale, value, NumberFormatStyle.Percent, digits); + transform(value: any, digits?: string, locale?: string): string|null { + if (isEmpty(value)) return null; + + locale = locale || this._locale; + + const {str, error} = formatNumber(value, locale, NumberFormatStyle.Percent, digits); + + if (error) { + throw invalidPipeArgumentError(CurrencyPipe, error); + } + + return str; } } /** * @ngModule CommonModule * @whatItDoes Formats a number as currency using locale rules. - * @howToUse `number_expression | currency[:currencyCode[:symbolDisplay[:digitInfo]]]` + * @howToUse `number_expression | currency[:currencyCode[:display[:digitInfo[:locale]]]]` * @description * * Use `currency` to format a number as currency. * * - `currencyCode` is the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code, such * as `USD` for the US dollar and `EUR` for the euro. - * - `symbolDisplay` is a boolean indicating whether to use the currency symbol or code. - * - `true`: use symbol (e.g. `$`). - * - `false`(default): use code (e.g. `USD`). + * - `display` indicates whether to show the currency symbol or the code. + * - `code`(default): use code (e.g. `USD`). + * - `symbol`: use symbol (e.g. `$`). + * - `symbol-narrow`: some countries have two symbols for their currency, one regular and one + * narrow (e.g. the canadian dollar CAD has the symbol `CA$` and the symbol-narrow `$`). + * If there is no narrow symbol for the chosen currency, the regular symbol will be used. * - `digitInfo` See {@link DecimalPipe} for detailed description. - * - * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers - * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * ### Example * @@ -153,22 +125,35 @@ export class CurrencyPipe implements PipeTransform { constructor(@Inject(LOCALE_ID) private _locale: string) {} transform( - value: any, currencyCode: string = 'USD', symbolDisplay: boolean = false, - digits?: string): string|null { - return formatNumber( - CurrencyPipe, this._locale, value, NumberFormatStyle.Currency, digits, currencyCode, - symbolDisplay); - } -} + value: any, currencyCode?: string, display: 'code'|'symbol'|'symbol-narrow' = 'symbol', + digits?: string, locale?: string): string|null { + if (isEmpty(value)) return null; + + locale = locale || this._locale; + + if (typeof display === 'boolean') { + if (console && console.warn) { + console.warn( + `Warning: the currency pipe has been changed in Angular v5. The symbolDisplay option (third parameter) is now a string instead of a boolean. The accepted values are "code", "symbol" or "symbol-narrow".`); + } + display = display ? 'symbol' : 'code'; + } + + let currency = currencyCode || 'USD'; + if (display !== 'code') { + currency = findCurrencySymbol(currency, display === 'symbol' ? 'wide' : 'narrow'); + } + + const {str, error} = formatNumber(value, locale, NumberFormatStyle.Currency, digits, currency); + + if (error) { + throw invalidPipeArgumentError(CurrencyPipe, error); + } -function parseIntAutoRadix(text: string): number { - const result: number = parseInt(text); - if (isNaN(result)) { - throw new Error('Invalid integer literal when parsing ' + text); + return str; } - return result; } -export function isNumeric(value: any): boolean { - return !isNaN(value - parseFloat(value)); +function isEmpty(value: any): boolean { + return value == null || value === '' || value !== value; } diff --git a/packages/common/test/i18n/locale_data_api_spec.ts b/packages/common/test/i18n/locale_data_api_spec.ts new file mode 100644 index 00000000000000..44620c022ef842 --- /dev/null +++ b/packages/common/test/i18n/locale_data_api_spec.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import localeCaESVALENCIA from '../../i18n_data/locale_ca-ES-VALENCIA'; +import localeEn from '../../i18n_data/locale_en'; +import localeFr from '../../i18n_data/locale_fr'; +import localeFrCA from '../../i18n_data/locale_fr-CA'; +import {registerLocaleData, findLocaleData} from '../../src/i18n/locale_data_api'; + +export function main() { + describe('locale data api', () => { + beforeAll(() => { + registerLocaleData(localeCaESVALENCIA); + registerLocaleData(localeEn); + registerLocaleData(localeFr); + registerLocaleData(localeFrCA); + }); + + describe('findLocaleData', () => { + it('should throw if the locale provided is not a valid LOCALE_ID', () => { + expect(() => findLocaleData('invalid')) + .toThrow(new Error( + `"invalid" is not a valid LOCALE_ID value. See https://github.com/unicode-cldr/cldr-core/blob/master/availableLocales.json for a list of valid locales`)); + }); + + it('should throw if the LOCALE_DATA for the chosen locale if not available', () => { + expect(() => findLocaleData('fr-BE')) + .toThrowError(/Missing locale data for the locale "fr-BE"/); + }); + + it('should return english data if the locale is en-US', + () => { expect(findLocaleData('en-US')).toEqual(localeEn); }); + + it('should return the exact LOCALE_DATA if it is available', + () => { expect(findLocaleData('fr-CA')).toEqual(localeFrCA); }); + + it('should return the parent LOCALE_DATA if it exists and exact locale is not available', + () => { expect(findLocaleData('fr-FR')).toEqual(localeFr); }); + + it(`should find the LOCALE_DATA even if the locale id is badly formatted`, () => { + expect(findLocaleData('ca-ES-VALENCIA')).toEqual(localeCaESVALENCIA); + expect(findLocaleData('CA_es_Valencia')).toEqual(localeCaESVALENCIA); + }); + }); + }); +} diff --git a/packages/common/test/localization_spec.ts b/packages/common/test/i18n/localization_spec.ts similarity index 93% rename from packages/common/test/localization_spec.ts rename to packages/common/test/i18n/localization_spec.ts index 08e7792e8e4777..069b963492a7b7 100644 --- a/packages/common/test/localization_spec.ts +++ b/packages/common/test/i18n/localization_spec.ts @@ -6,13 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ +import localeRo from '../../i18n_data/locale_ro'; +import localeSr from '../../i18n_data/locale_sr'; +import localeZgh from '../../i18n_data/locale_zgh'; +import localeFr from '../../i18n_data/locale_fr'; import {LOCALE_ID} from '@angular/core'; import {TestBed, inject} from '@angular/core/testing'; - -import {NgLocaleLocalization, NgLocalization, getPluralCategory} from '../src/localization'; +import {NgLocaleLocalization, NgLocalization, getPluralCategory} from '../../src/i18n/localization'; +import {registerLocaleData} from '../../src/i18n/locale_data_api'; export function main() { describe('l10n', () => { + beforeAll(() => { + registerLocaleData(localeRo); + registerLocaleData(localeSr); + registerLocaleData(localeZgh); + registerLocaleData(localeFr); + }); describe('NgLocalization', () => { describe('ro', () => { diff --git a/packages/common/test/pipes/date_pipe_spec.ts b/packages/common/test/pipes/date_pipe_spec.ts index 85a997bcc13c0c..2094d35bd8b854 100644 --- a/packages/common/test/pipes/date_pipe_spec.ts +++ b/packages/common/test/pipes/date_pipe_spec.ts @@ -6,10 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {DatePipe} from '@angular/common'; +import {DatePipe, registerLocaleData} from '@angular/common'; import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; import {JitReflector} from '@angular/platform-browser-dynamic/src/compiler_reflector'; -import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; +import localeEn from '../../i18n_data/locale_en'; +import localeEnExtra from '../../i18n_data/extra/locale_en'; +import localeDe from '../../i18n_data/locale_de'; +import localeHu from '../../i18n_data/locale_hu'; +import localeSr from '../../i18n_data/locale_sr'; +import localeTh from '../../i18n_data/locale_th'; export function main() { describe('DatePipe', () => { @@ -22,16 +27,16 @@ export function main() { expect(pipe.transform(date, pattern)).toEqual(output); } - // TODO: reactivate the disabled expectations once emulators are fixed in SauceLabs - // In some old versions of Chrome in Android emulators, time formatting returns dates in the - // timezone of the VM host, - // instead of the device timezone. Same symptoms as - // https://bugs.chromium.org/p/chromium/issues/detail?id=406382 - // This happens locally and in SauceLabs, so some checks are disabled to avoid failures. - // Tracking issue: https://github.com/angular/angular/issues/11187 + beforeAll(() => { + registerLocaleData(localeEn, localeEnExtra); + registerLocaleData(localeDe); + registerLocaleData(localeHu); + registerLocaleData(localeSr); + registerLocaleData(localeTh); + }); beforeEach(() => { - date = new Date(2015, 5, 15, 9, 3, 1); + date = new Date(2015, 5, 15, 9, 3, 1, 550); pipe = new DatePipe('en-US'); }); @@ -67,71 +72,149 @@ export function main() { describe('transform', () => { it('should format each component correctly', () => { const dateFixtures: any = { - 'y': '2015', - 'yy': '15', - 'M': '6', - 'MM': '06', - 'MMM': 'Jun', - 'MMMM': 'June', - 'd': '15', - 'dd': '15', - 'EEE': 'Mon', - 'EEEE': 'Monday' + G: 'AD', + GG: 'AD', + GGG: 'AD', + GGGG: 'Anno Domini', + GGGGG: 'A', + y: '2015', + yy: '15', + yyy: '2015', + yyyy: '2015', + M: '6', + MM: '06', + MMM: 'Jun', + MMMM: 'June', + MMMMM: 'J', + L: '6', + LL: '06', + LLL: 'Jun', + LLLL: 'June', + LLLLL: 'J', + w: '25', + ww: '25', + W: '3', + d: '15', + dd: '15', + E: 'Mon', + EE: 'Mon', + EEE: 'Mon', + EEEE: 'Monday', + EEEEEE: 'Mo', + h: '9', + hh: '09', + H: '9', + HH: '09', + m: '3', + mm: '03', + s: '1', + ss: '01', + S: '6', + SS: '55', + SSS: '550', + a: 'AM', + aa: 'AM', + aaa: 'AM', + aaaa: 'AM', + aaaaa: 'a', + b: 'morning', + bb: 'morning', + bbb: 'morning', + bbbb: 'morning', + bbbbb: 'morning', + B: 'in the morning', + BB: 'in the morning', + BBB: 'in the morning', + BBBB: 'in the morning', + BBBBB: 'in the morning', }; const isoStringWithoutTimeFixtures: any = { - 'y': '2015', - 'yy': '15', - 'M': '1', - 'MM': '01', - 'MMM': 'Jan', - 'MMMM': 'January', - 'd': '1', - 'dd': '01', - 'EEE': 'Thu', - 'EEEE': 'Thursday' + G: 'AD', + GG: 'AD', + GGG: 'AD', + GGGG: 'Anno Domini', + GGGGG: 'A', + y: '2015', + yy: '15', + yyy: '2015', + yyyy: '2015', + M: '1', + MM: '01', + MMM: 'Jan', + MMMM: 'January', + MMMMM: 'J', + L: '1', + LL: '01', + LLL: 'Jan', + LLLL: 'January', + LLLLL: 'J', + w: '1', + ww: '01', + W: '1', + d: '1', + dd: '01', + E: 'Thu', + EE: 'Thu', + EEE: 'Thu', + EEEE: 'Thursday', + EEEEE: 'T', + EEEEEE: 'Th', + h: '12', + hh: '12', + H: '0', + HH: '00', + m: '0', + mm: '00', + s: '0', + ss: '00', + S: '0', + SS: '00', + SSS: '000', + a: 'AM', + aa: 'AM', + aaa: 'AM', + aaaa: 'AM', + aaaaa: 'a', + b: 'midnight', + bb: 'midnight', + bbb: 'midnight', + bbbb: 'midnight', + bbbbb: 'midnight', + B: 'midnight', + BB: 'midnight', + BBB: 'midnight', + BBBB: 'midnight', + BBBBB: 'mi', }; - if (!browserDetection.isOldChrome) { - dateFixtures['h'] = '9'; - dateFixtures['hh'] = '09'; - dateFixtures['j'] = '9 AM'; - isoStringWithoutTimeFixtures['h'] = '12'; - isoStringWithoutTimeFixtures['hh'] = '12'; - isoStringWithoutTimeFixtures['j'] = '12 AM'; - } - - // IE and Edge can't format a date to minutes and seconds without hours - if (!browserDetection.isEdge && !browserDetection.isIE || - !browserDetection.supportsNativeIntlApi) { - if (!browserDetection.isOldChrome) { - dateFixtures['HH'] = '09'; - isoStringWithoutTimeFixtures['HH'] = '00'; - } - dateFixtures['E'] = 'M'; - dateFixtures['L'] = 'J'; - dateFixtures['m'] = '3'; - dateFixtures['s'] = '1'; - dateFixtures['mm'] = '03'; - dateFixtures['ss'] = '01'; - isoStringWithoutTimeFixtures['m'] = '0'; - isoStringWithoutTimeFixtures['s'] = '0'; - isoStringWithoutTimeFixtures['mm'] = '00'; - isoStringWithoutTimeFixtures['ss'] = '00'; - } - Object.keys(dateFixtures).forEach((pattern: string) => { expectDateFormatAs(date, pattern, dateFixtures[pattern]); }); - if (!browserDetection.isOldChrome) { - Object.keys(isoStringWithoutTimeFixtures).forEach((pattern: string) => { - expectDateFormatAs( - isoStringWithoutTime, pattern, isoStringWithoutTimeFixtures[pattern]); - }); - } + Object.keys(isoStringWithoutTimeFixtures).forEach((pattern: string) => { + expectDateFormatAs(isoStringWithoutTime, pattern, isoStringWithoutTimeFixtures[pattern]); + }); + }); - expect(pipe.transform(date, 'Z')).toBeDefined(); + it('should format with timezones', () => { + const dateFixtures: any = { + z: /GMT(\+|-)\d/, + zz: /GMT(\+|-)\d/, + zzz: /GMT(\+|-)\d/, + zzzz: /GMT(\+|-)\d{2}\:30/, + Z: /(\+|-)\d{2}30/, + ZZ: /(\+|-)\d{2}30/, + ZZZ: /(\+|-)\d{2}30/, + ZZZZ: /GMT(\+|-)\d{2}\:30/, + ZZZZZ: /(\+|-)\d{2}\:30/, + O: /GMT(\+|-)\d/, + OOOO: /GMT(\+|-)\d{2}\:30/, + }; + + Object.keys(dateFixtures).forEach((pattern: string) => { + expect(pipe.transform(date, pattern, '+0430')).toMatch(dateFixtures[pattern]); + }); }); it('should format common multi component patterns', () => { @@ -144,19 +227,13 @@ export function main() { 'yMEEEd': '20156Mon15', 'MEEEd': '6Mon15', 'MMMd': 'Jun15', - 'yMMMMEEEEd': 'Monday, June 15, 2015' + 'EEEE, MMMM d, y': 'Monday, June 15, 2015', + 'H:mm a': '9:03 AM', + 'ms': '31', + 'MM/dd/yy hh:mm': '06/15/15 09:03', + 'MM/dd/y': '06/15/2015' }; - // IE and Edge can't format a date to minutes and seconds without hours - if (!browserDetection.isEdge && !browserDetection.isIE || - !browserDetection.supportsNativeIntlApi) { - dateFixtures['ms'] = '31'; - } - - if (!browserDetection.isOldChrome) { - dateFixtures['jm'] = '9:03 AM'; - } - Object.keys(dateFixtures).forEach((pattern: string) => { expectDateFormatAs(date, pattern, dateFixtures[pattern]); }); @@ -166,33 +243,23 @@ export function main() { it('should format with pattern aliases', () => { const dateFixtures: any = { 'MM/dd/yyyy': '06/15/2015', - 'fullDate': 'Monday, June 15, 2015', - 'longDate': 'June 15, 2015', - 'mediumDate': 'Jun 15, 2015', - 'shortDate': '6/15/2015' + shortDate: '6/15/15', + mediumDate: 'Jun 15, 2015', + longDate: 'June 15, 2015', + fullDate: 'Monday, June 15, 2015', + short: '6/15/15, 9:03 AM', + medium: 'Jun 15, 2015, 9:03:01 AM', + long: /June 15, 2015 at 9:03:01 AM GMT(\+|-)\d/, + full: /Monday, June 15, 2015 at 9:03:01 AM GMT(\+|-)\d{2}:\d{2}/, + shortTime: '9:03 AM', + mediumTime: '9:03:01 AM', + longTime: /9:03:01 AM GMT(\+|-)\d/, + fullTime: /9:03:01 AM GMT(\+|-)\d{2}:\d{2}/, }; - if (!browserDetection.isOldChrome) { - // IE and Edge do not add a coma after the year in these 2 cases - if ((browserDetection.isEdge || browserDetection.isIE) && - browserDetection.supportsNativeIntlApi) { - dateFixtures['medium'] = 'Jun 15, 2015 9:03:01 AM'; - dateFixtures['short'] = '6/15/2015 9:03 AM'; - } else { - dateFixtures['medium'] = 'Jun 15, 2015, 9:03:01 AM'; - dateFixtures['short'] = '6/15/2015, 9:03 AM'; - } - } - - if (!browserDetection.isOldChrome) { - dateFixtures['mediumTime'] = '9:03:01 AM'; - dateFixtures['shortTime'] = '9:03 AM'; - } - Object.keys(dateFixtures).forEach((pattern: string) => { - expectDateFormatAs(date, pattern, dateFixtures[pattern]); + expect(pipe.transform(date, pattern)).toMatch(dateFixtures[pattern]); }); - }); it('should format invalid in IE ISO date', @@ -201,8 +268,39 @@ export function main() { it('should format invalid in Safari ISO date', () => expect(pipe.transform('2017-01-20T19:00:00+0000')).toEqual('Jan 20, 2017')); + // test for the following bugs: + // https://github.com/angular/angular/issues/9524 + // https://github.com/angular/angular/issues/9524 + it('should format correctly with iso strings that contain time', + () => expect(pipe.transform('2017-05-07T22:14:39', 'dd-MM-yyyy HH:mm')) + .toMatch(/07-05-2017 \d{2}:\d{2}/)); + + // test for the following bugs: + // https://github.com/angular/angular/issues/16624 + // https://github.com/angular/angular/issues/17478 + it('should show the correct time when the timezone is fixed', () => { + expect(pipe.transform('2017-06-13T10:14:39+0000', 'shortTime', '+0000')) + .toEqual('10:14 AM'); + expect(pipe.transform('2017-06-13T10:14:39+0000', 'h:mm a', '+0000')).toEqual('10:14 AM'); + }); + it('should remove bidi control characters', () => expect(pipe.transform(date, 'MM/dd/yyyy') !.length).toEqual(10)); + + it(`should format the date correctly in various locales`, () => { + expect(new DatePipe('de').transform(date, 'short')).toEqual('15.06.15, 09:03'); + expect(new DatePipe('th').transform(date, 'dd-MM-yy')).toEqual('15-06-15'); + expect(new DatePipe('hu').transform(date, 'a')).toEqual('de.'); + expect(new DatePipe('sr').transform(date, 'a')).toEqual('пре подне'); + + // TODO(ocombe): activate this test when we support local numbers + // expect(new DatePipe('mr', [localeMr]).transform(date, 'hh')).toEqual('०९'); + }); + + it('should throw if we use getExtraDayPeriods without loading extra locale data', () => { + expect(() => new DatePipe('de').transform(date, 'b')) + .toThrowError(/Missing extra locale data for the locale "de"/); + }); }); }); } diff --git a/packages/common/test/pipes/deprecated/date_pipe_spec.ts b/packages/common/test/pipes/deprecated/date_pipe_spec.ts new file mode 100644 index 00000000000000..d1bbc586ac7238 --- /dev/null +++ b/packages/common/test/pipes/deprecated/date_pipe_spec.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DeprecatedDatePipe} from '@angular/common'; +import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; +import {JitReflector} from '@angular/platform-browser-dynamic/src/compiler_reflector'; +import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; + +export function main() { + describe('DeprecatedDatePipe', () => { + let date: Date; + const isoStringWithoutTime = '2015-01-01'; + let pipe: DeprecatedDatePipe; + + // Check the transformation of a date into a pattern + function expectDateFormatAs(date: Date | string, pattern: any, output: string): void { + expect(pipe.transform(date, pattern)).toEqual(output); + } + + // TODO: reactivate the disabled expectations once emulators are fixed in SauceLabs + // In some old versions of Chrome in Android emulators, time formatting returns dates in the + // timezone of the VM host, + // instead of the device timezone. Same symptoms as + // https://bugs.chromium.org/p/chromium/issues/detail?id=406382 + // This happens locally and in SauceLabs, so some checks are disabled to avoid failures. + // Tracking issue: https://github.com/angular/angular/issues/11187 + + beforeEach(() => { + date = new Date(2015, 5, 15, 9, 3, 1); + pipe = new DeprecatedDatePipe('en-US'); + }); + + it('should be marked as pure', () => { + expect(new PipeResolver(new JitReflector()).resolve(DeprecatedDatePipe) !.pure).toEqual(true); + }); + + describe('supports', () => { + it('should support date', () => { expect(() => pipe.transform(date)).not.toThrow(); }); + + it('should support int', () => { expect(() => pipe.transform(123456789)).not.toThrow(); }); + + it('should support numeric strings', + () => { expect(() => pipe.transform('123456789')).not.toThrow(); }); + + it('should support decimal strings', + () => { expect(() => pipe.transform('123456789.11')).not.toThrow(); }); + + it('should support ISO string', + () => expect(() => pipe.transform('2015-06-15T21:43:11Z')).not.toThrow()); + + it('should return null for empty string', () => expect(pipe.transform('')).toEqual(null)); + + it('should return null for NaN', () => expect(pipe.transform(Number.NaN)).toEqual(null)); + + it('should support ISO string without time', + () => { expect(() => pipe.transform(isoStringWithoutTime)).not.toThrow(); }); + + it('should not support other objects', + () => expect(() => pipe.transform({})).toThrowError(/InvalidPipeArgument/)); + }); + + describe('transform', () => { + it('should format each component correctly', () => { + const dateFixtures: any = { + 'y': '2015', + 'yy': '15', + 'M': '6', + 'MM': '06', + 'MMM': 'Jun', + 'MMMM': 'June', + 'd': '15', + 'dd': '15', + 'EEE': 'Mon', + 'EEEE': 'Monday' + }; + + const isoStringWithoutTimeFixtures: any = { + 'y': '2015', + 'yy': '15', + 'M': '1', + 'MM': '01', + 'MMM': 'Jan', + 'MMMM': 'January', + 'd': '1', + 'dd': '01', + 'EEE': 'Thu', + 'EEEE': 'Thursday' + }; + + if (!browserDetection.isOldChrome) { + dateFixtures['h'] = '9'; + dateFixtures['hh'] = '09'; + dateFixtures['j'] = '9 AM'; + isoStringWithoutTimeFixtures['h'] = '12'; + isoStringWithoutTimeFixtures['hh'] = '12'; + isoStringWithoutTimeFixtures['j'] = '12 AM'; + } + + // IE and Edge can't format a date to minutes and seconds without hours + if (!browserDetection.isEdge && !browserDetection.isIE || + !browserDetection.supportsNativeIntlApi) { + if (!browserDetection.isOldChrome) { + dateFixtures['HH'] = '09'; + isoStringWithoutTimeFixtures['HH'] = '00'; + } + dateFixtures['E'] = 'M'; + dateFixtures['L'] = 'J'; + dateFixtures['m'] = '3'; + dateFixtures['s'] = '1'; + dateFixtures['mm'] = '03'; + dateFixtures['ss'] = '01'; + isoStringWithoutTimeFixtures['m'] = '0'; + isoStringWithoutTimeFixtures['s'] = '0'; + isoStringWithoutTimeFixtures['mm'] = '00'; + isoStringWithoutTimeFixtures['ss'] = '00'; + } + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + if (!browserDetection.isOldChrome) { + Object.keys(isoStringWithoutTimeFixtures).forEach((pattern: string) => { + expectDateFormatAs( + isoStringWithoutTime, pattern, isoStringWithoutTimeFixtures[pattern]); + }); + } + + expect(pipe.transform(date, 'Z')).toBeDefined(); + }); + + it('should format common multi component patterns', () => { + const dateFixtures: any = { + 'EEE, M/d/y': 'Mon, 6/15/2015', + 'EEE, M/d': 'Mon, 6/15', + 'MMM d': 'Jun 15', + 'dd/MM/yyyy': '15/06/2015', + 'MM/dd/yyyy': '06/15/2015', + 'yMEEEd': '20156Mon15', + 'MEEEd': '6Mon15', + 'MMMd': 'Jun15', + 'yMMMMEEEEd': 'Monday, June 15, 2015' + }; + + // IE and Edge can't format a date to minutes and seconds without hours + if (!browserDetection.isEdge && !browserDetection.isIE || + !browserDetection.supportsNativeIntlApi) { + dateFixtures['ms'] = '31'; + } + + if (!browserDetection.isOldChrome) { + dateFixtures['jm'] = '9:03 AM'; + } + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + }); + + it('should format with pattern aliases', () => { + const dateFixtures: any = { + 'MM/dd/yyyy': '06/15/2015', + 'fullDate': 'Monday, June 15, 2015', + 'longDate': 'June 15, 2015', + 'mediumDate': 'Jun 15, 2015', + 'shortDate': '6/15/2015' + }; + + if (!browserDetection.isOldChrome) { + // IE and Edge do not add a coma after the year in these 2 cases + if ((browserDetection.isEdge || browserDetection.isIE) && + browserDetection.supportsNativeIntlApi) { + dateFixtures['medium'] = 'Jun 15, 2015 9:03:01 AM'; + dateFixtures['short'] = '6/15/2015 9:03 AM'; + } else { + dateFixtures['medium'] = 'Jun 15, 2015, 9:03:01 AM'; + dateFixtures['short'] = '6/15/2015, 9:03 AM'; + } + } + + if (!browserDetection.isOldChrome) { + dateFixtures['mediumTime'] = '9:03:01 AM'; + dateFixtures['shortTime'] = '9:03 AM'; + } + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + }); + + it('should format invalid in IE ISO date', + () => expect(pipe.transform('2017-01-11T09:25:14.014-0500')).toEqual('Jan 11, 2017')); + + it('should format invalid in Safari ISO date', + () => expect(pipe.transform('2017-01-20T19:00:00+0000')).toEqual('Jan 20, 2017')); + + it('should remove bidi control characters', + () => expect(pipe.transform(date, 'MM/dd/yyyy') !.length).toEqual(10)); + }); + }); +} diff --git a/packages/common/test/pipes/deprecated/number_pipe_spec.ts b/packages/common/test/pipes/deprecated/number_pipe_spec.ts new file mode 100644 index 00000000000000..a0fb5edfc7cad1 --- /dev/null +++ b/packages/common/test/pipes/deprecated/number_pipe_spec.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from '@angular/common'; +import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testing_internal'; +import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; + +export function main() { + function isNumeric(value: any): boolean { return !isNaN(value - parseFloat(value)); } + + // Between the symbol and the number, Edge adds a no breaking space and IE11 adds a standard space + function normalize(s: string): string { return s.replace(/\u00A0| /g, ''); } + + describe('Number pipes', () => { + describe('DeprecatedDecimalPipe', () => { + let pipe: DeprecatedDecimalPipe; + + beforeEach(() => { pipe = new DeprecatedDecimalPipe('en-US'); }); + + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(pipe.transform(12345)).toEqual('12,345'); + expect(pipe.transform(123, '.2')).toEqual('123.00'); + expect(pipe.transform(1, '3.')).toEqual('001'); + expect(pipe.transform(1.1, '3.4-5')).toEqual('001.1000'); + expect(pipe.transform(1.123456, '3.4-5')).toEqual('001.12346'); + expect(pipe.transform(1.1234)).toEqual('1.123'); + }); + + it('should support strings', () => { + expect(pipe.transform('12345')).toEqual('12,345'); + expect(pipe.transform('123', '.2')).toEqual('123.00'); + expect(pipe.transform('1', '3.')).toEqual('001'); + expect(pipe.transform('1.1', '3.4-5')).toEqual('001.1000'); + expect(pipe.transform('1.123456', '3.4-5')).toEqual('001.12346'); + expect(pipe.transform('1.1234')).toEqual('1.123'); + }); + + it('should not support other objects', () => { + expect(() => pipe.transform(new Object())).toThrowError(); + expect(() => pipe.transform('123abc')).toThrowError(); + }); + }); + }); + + describe('DeprecatedPercentPipe', () => { + let pipe: DeprecatedPercentPipe; + + beforeEach(() => { pipe = new DeprecatedPercentPipe('en-US'); }); + + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(normalize(pipe.transform(1.23) !)).toEqual('123%'); + expect(normalize(pipe.transform(1.2, '.2') !)).toEqual('120.00%'); + }); + + it('should not support other objects', + () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + }); + }); + + describe('DeprecatedCurrencyPipe', () => { + let pipe: DeprecatedCurrencyPipe; + + beforeEach(() => { pipe = new DeprecatedCurrencyPipe('en-US'); }); + + describe('transform', () => { + it('should return correct value for numbers', () => { + // In old Chrome, default formatiing for USD is different + if (browserDetection.isOldChrome) { + expect(normalize(pipe.transform(123) !)).toEqual('USD123'); + } else { + expect(normalize(pipe.transform(123) !)).toEqual('USD123.00'); + } + expect(normalize(pipe.transform(12, 'EUR', false, '.1') !)).toEqual('EUR12.0'); + expect(normalize(pipe.transform(5.1234, 'USD', false, '.0-3') !)).toEqual('USD5.123'); + }); + + it('should not support other objects', + () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + }); + }); + + describe('isNumeric', () => { + it('should return true when passing correct numeric string', + () => { expect(isNumeric('2')).toBe(true); }); + + it('should return true when passing correct double string', + () => { expect(isNumeric('1.123')).toBe(true); }); + + it('should return true when passing correct negative string', + () => { expect(isNumeric('-2')).toBe(true); }); + + it('should return true when passing correct scientific notation string', + () => { expect(isNumeric('1e5')).toBe(true); }); + + it('should return false when passing incorrect numeric', + () => { expect(isNumeric('a')).toBe(false); }); + + it('should return false when passing parseable but non numeric', + () => { expect(isNumeric('2a')).toBe(false); }); + }); + }); +} diff --git a/packages/common/test/pipes/number_pipe_spec.ts b/packages/common/test/pipes/number_pipe_spec.ts index 85cd2515278c23..9721613b5eb3e3 100644 --- a/packages/common/test/pipes/number_pipe_spec.ts +++ b/packages/common/test/pipes/number_pipe_spec.ts @@ -6,19 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ -import {CurrencyPipe, DecimalPipe, PercentPipe} from '@angular/common'; -import {isNumeric} from '@angular/common/src/pipes/number_pipe'; +import localeEn from '../../i18n_data/locale_en'; +import localeEsUS from '../../i18n_data/locale_es-US'; +import {registerLocaleData, CurrencyPipe, DecimalPipe, PercentPipe} from '@angular/common'; import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testing_internal'; -import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; export function main() { describe('Number pipes', () => { - describe('DecimalPipe', () => { - let pipe: DecimalPipe; + beforeAll(() => { + registerLocaleData(localeEn); + registerLocaleData(localeEsUS); + }); - beforeEach(() => { pipe = new DecimalPipe('en-US'); }); + function isNumeric(value: any): boolean { return !isNaN(value - parseFloat(value)); } + describe('DecimalPipe', () => { describe('transform', () => { + let pipe: DecimalPipe; + beforeEach(() => { pipe = new DecimalPipe('en-US'); }); + it('should return correct value for numbers', () => { expect(pipe.transform(12345)).toEqual('12,345'); expect(pipe.transform(123, '.2')).toEqual('123.00'); @@ -26,6 +32,8 @@ export function main() { expect(pipe.transform(1.1, '3.4-5')).toEqual('001.1000'); expect(pipe.transform(1.123456, '3.4-5')).toEqual('001.12346'); expect(pipe.transform(1.1234)).toEqual('1.123'); + expect(pipe.transform(1.123456, '.2')).toEqual('1.123'); + expect(pipe.transform(1.123456, '.4')).toEqual('1.1235'); }); it('should support strings', () => { @@ -38,9 +46,20 @@ export function main() { }); it('should not support other objects', () => { - expect(() => pipe.transform(new Object())).toThrowError(); + expect(() => pipe.transform({})).toThrowError(); expect(() => pipe.transform('123abc')).toThrowError(); }); + + it('should throw if minFractionDigits is explicitly higher than maxFractionDigits', () => { + expect(() => pipe.transform('1.1', '3.4-2')).toThrowError(/is higher than the maximum/); + }); + }); + + describe('transform with custom locales', () => { + it('should return the correct format for es-US in IE11', () => { + const pipe = new DecimalPipe('es-US'); + expect(pipe.transform('9999999.99', '1.2-2')).toEqual('9,999,999.99'); + }); }); }); @@ -51,12 +70,12 @@ export function main() { describe('transform', () => { it('should return correct value for numbers', () => { - expect(normalize(pipe.transform(1.23) !)).toEqual('123%'); - expect(normalize(pipe.transform(1.2, '.2') !)).toEqual('120.00%'); + expect(pipe.transform(1.23)).toEqual('123%'); + expect(pipe.transform(1.2, '.2')).toEqual('120.00%'); }); it('should not support other objects', - () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + () => { expect(() => pipe.transform({})).toThrowError(); }); }); }); @@ -67,18 +86,24 @@ export function main() { describe('transform', () => { it('should return correct value for numbers', () => { - // In old Chrome, default formatiing for USD is different - if (browserDetection.isOldChrome) { - expect(normalize(pipe.transform(123) !)).toEqual('USD123'); - } else { - expect(normalize(pipe.transform(123) !)).toEqual('USD123.00'); - } - expect(normalize(pipe.transform(12, 'EUR', false, '.1') !)).toEqual('EUR12.0'); - expect(normalize(pipe.transform(5.1234, 'USD', false, '.0-3') !)).toEqual('USD5.123'); + expect(pipe.transform(123)).toEqual('$123.00'); + expect(pipe.transform(12, 'EUR', 'code', '.1')).toEqual('EUR12.0'); + expect(pipe.transform(5.1234, 'USD', 'code', '.0-3')).toEqual('USD5.123'); + expect(pipe.transform(5.1234, 'USD', 'code')).toEqual('USD5.12'); + expect(pipe.transform(5.1234, 'USD', 'symbol')).toEqual('$5.12'); + expect(pipe.transform(5.1234, 'CAD', 'symbol')).toEqual('CA$5.12'); + expect(pipe.transform(5.1234, 'CAD', 'symbol-narrow')).toEqual('$5.12'); }); it('should not support other objects', - () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + () => { expect(() => pipe.transform({})).toThrowError(); }); + + it('should warn if you are using the v4 signature', () => { + const warnSpy = spyOn(console, 'warn'); + pipe.transform(123, 'USD', true); + expect(warnSpy).toHaveBeenCalledWith( + `Warning: the currency pipe has been changed in Angular v5. The symbolDisplay option (third parameter) is now a string instead of a boolean. The accepted values are "code", "symbol" or "symbol-narrow".`); + }); }); }); @@ -103,8 +128,3 @@ export function main() { }); }); } - -// Between the symbol and the number, Edge adds a no breaking space and IE11 adds a standard space -function normalize(s: string): string { - return s.replace(/\u00A0| /g, ''); -} diff --git a/packages/examples/common/pipes/ts/date_pipe.ts b/packages/examples/common/pipes/ts/date_pipe.ts index 5b518524ce58c8..d88b6da9a1c5a3 100644 --- a/packages/examples/common/pipes/ts/date_pipe.ts +++ b/packages/examples/common/pipes/ts/date_pipe.ts @@ -14,7 +14,8 @@ import {Component} from '@angular/core'; template: `

Today is {{today | date}}

Or if you prefer, {{today | date:'fullDate'}}

-

The time is {{today | date:'jmZ'}}

+

The time is {{today | date:'shortTime'}}

+

The custom date is {{today | date:'yyyy-mm-dd HH:mm'}}

` }) export class DatePipeComponent { diff --git a/packages/examples/common/pipes/ts/number_pipe.ts b/packages/examples/common/pipes/ts/number_pipe.ts index aa9d595db1cb10..96056b9ffdcb19 100644 --- a/packages/examples/common/pipes/ts/number_pipe.ts +++ b/packages/examples/common/pipes/ts/number_pipe.ts @@ -42,8 +42,9 @@ export class PercentPipeComponent { @Component({ selector: 'currency-pipe', template: `
-

A: {{a | currency:'USD':false}}

-

B: {{b | currency:'USD':true:'4.2-2'}}

+

A: {{a | currency:'CAD'}}

+

B: {{b | currency:'CAD':'symbol':'4.2-2'}}

+

B: {{b | currency:'CAD':'symbol-narrow':'4.2-2'}}

` }) export class CurrencyPipeComponent { diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 10ec247ee07d15..d951d649f12e3f 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -27,6 +27,7 @@ "exclude": [ "compiler-cli/integrationtest", "platform-server/integrationtest", - "tsc-wrapped" + "tsc-wrapped", + "common/i18n_data" ] } diff --git a/tools/public_api_guard/common/common.d.ts b/tools/public_api_guard/common/common.d.ts index 1c206638fc7a8c..a66302f78b223f 100644 --- a/tools/public_api_guard/common/common.d.ts +++ b/tools/public_api_guard/common/common.d.ts @@ -11,24 +11,50 @@ export declare class AsyncPipe implements OnDestroy, PipeTransform { transform(obj: null): null; } +/** @experimental */ +export declare const AVAILABLE_LOCALES: string[]; + /** @stable */ export declare class CommonModule { } +/** @experimental */ +export declare const CURRENCIES: { + [code: string]: (string | undefined)[]; +}; + /** @stable */ export declare class CurrencyPipe implements PipeTransform { constructor(_locale: string); - transform(value: any, currencyCode?: string, symbolDisplay?: boolean, digits?: string): string | null; + transform(value: any, currencyCode?: string, display?: 'code' | 'symbol' | 'symbol-narrow', digits?: string, locale?: string): string | null; } /** @stable */ export declare class DatePipe implements PipeTransform { + constructor(locale: string); + transform(value: any, format?: string, timezone?: string, locale?: string): string | null; +} + +/** @stable */ +export declare class DecimalPipe implements PipeTransform { + constructor(_locale: string); + transform(value: any, digits?: string, locale?: string): string | null; +} + +/** @stable */ +export declare class DeprecatedCurrencyPipe implements PipeTransform { + constructor(_locale: string); + transform(value: any, currencyCode?: string, symbolDisplay?: boolean, digits?: string): string | null; +} + +/** @stable */ +export declare class DeprecatedDatePipe implements PipeTransform { constructor(_locale: string); transform(value: any, pattern?: string): string | null; } /** @stable */ -export declare class DecimalPipe implements PipeTransform { +export declare class DeprecatedDecimalPipe implements PipeTransform { constructor(_locale: string); transform(value: any, digits?: string): string | null; } @@ -37,9 +63,83 @@ export declare class DecimalPipe implements PipeTransform { export declare class DeprecatedI18NPipesModule { } +/** @stable */ +export declare class DeprecatedPercentPipe implements PipeTransform { + constructor(_locale: string); + transform(value: any, digits?: string): string | null; +} + /** @stable */ export declare const DOCUMENT: InjectionToken; +/** @experimental */ +export declare function findLocaleData(locale: string): any; + +/** @experimental */ +export declare enum FormatWidth { + Short = 0, + Medium = 1, + Long = 2, + Full = 3, +} + +/** @experimental */ +export declare enum FormStyle { + Format = 0, + Standalone = 1, +} + +/** @experimental */ +export declare function getLocaleCurrencyName(locale: string): string | null; + +/** @experimental */ +export declare function getLocaleCurrencySymbol(locale: string): string | null; + +/** @experimental */ +export declare function getLocaleDateFormat(locale: string, width: FormatWidth): string; + +/** @experimental */ +export declare function getLocaleDateTimeFormat(locale: string, width: FormatWidth): string; + +/** @experimental */ +export declare function getLocaleDayNames(locale: string, formStyle: FormStyle, width: TranslationWidth): string[]; + +/** @experimental */ +export declare function getLocaleDayPeriods(locale: string, formStyle: FormStyle, width: TranslationWidth): [string, string]; + +/** @experimental */ +export declare function getLocaleEraNames(locale: string, width: TranslationWidth): [string, string]; + +/** @experimental */ +export declare function getLocaleExtraDayPeriodRules(locale: string): (Time | [Time, Time])[]; + +/** @experimental */ +export declare function getLocaleExtraDayPeriods(locale: string, formStyle: FormStyle, width: TranslationWidth): string[]; + +/** @experimental */ +export declare function getLocaleFirstDayOfWeek(locale: string): WeekDay; + +/** @experimental */ +export declare function getLocaleId(locale: string): string; + +/** @experimental */ +export declare function getLocaleMonthNames(locale: string, formStyle: FormStyle, width: TranslationWidth): string[]; + +/** @experimental */ +export declare function getLocaleNumberFormat(locale: string, type: NumberFormatStyle): string; + +/** @experimental */ +export declare function getLocaleNumberSymbol(locale: string, symbol: NumberSymbol): string; + +/** @experimental */ +export declare function getLocalePluralCase(locale: string): (value: number) => Plural; + +/** @experimental */ +export declare function getLocaleTimeFormat(locale: string, width: FormatWidth): string; + +/** @experimental */ +export declare function getLocaleWeekEndRange(locale: string): [WeekDay, WeekDay]; + /** @stable */ export declare class HashLocationStrategy extends LocationStrategy { constructor(_platformLocation: PlatformLocation, _baseHref?: string); @@ -58,7 +158,7 @@ export declare class I18nPluralPipe implements PipeTransform { constructor(_localization: NgLocalization); transform(value: number, pluralMap: { [count: string]: string; - }): string; + }, locale?: string): string; } /** @experimental */ @@ -85,6 +185,11 @@ export declare class JsonPipe implements PipeTransform { transform(value: any): string; } +/** @experimental */ +export declare const LOCALE_DATA: { + [localeId: string]: any; +}; + /** @stable */ export declare class Location { constructor(platformStrategy: LocationStrategy); @@ -197,12 +302,12 @@ export declare class NgIfContext { export declare class NgLocaleLocalization extends NgLocalization { protected locale: string; constructor(locale: string); - getPluralCategory(value: any): string; + getPluralCategory(value: any, locale?: string): string; } /** @experimental */ export declare abstract class NgLocalization { - abstract getPluralCategory(value: any): string; + abstract getPluralCategory(value: any, locale?: string): string; } /** @experimental */ @@ -252,6 +357,32 @@ export declare class NgTemplateOutlet implements OnChanges { ngOnChanges(changes: SimpleChanges): void; } +/** @experimental */ +export declare enum NumberFormatStyle { + Decimal = 0, + Percent = 1, + Currency = 2, + Scientific = 3, +} + +/** @experimental */ +export declare enum NumberSymbol { + Decimal = 0, + Group = 1, + List = 2, + PercentSign = 3, + PlusSign = 4, + MinusSign = 5, + Exponential = 6, + SuperscriptingExponent = 7, + PerMille = 8, + Infinity = 9, + NaN = 10, + TimeSeparator = 11, + CurrencyDecimal = 12, + CurrencyGroup = 13, +} + /** @stable */ export declare class PathLocationStrategy extends LocationStrategy { constructor(_platformLocation: PlatformLocation, href?: string); @@ -268,7 +399,7 @@ export declare class PathLocationStrategy extends LocationStrategy { /** @stable */ export declare class PercentPipe implements PipeTransform { constructor(_locale: string); - transform(value: any, digits?: string): string | null; + transform(value: any, digits?: string, locale?: string): string | null; } /** @stable */ @@ -285,6 +416,16 @@ export declare abstract class PlatformLocation { abstract replaceState(state: any, title: string, url: string): void; } +/** @experimental */ +export declare enum Plural { + Zero = 0, + One = 1, + Two = 2, + Few = 3, + Many = 4, + Other = 5, +} + /** @experimental */ export interface PopStateEvent { pop?: boolean; @@ -292,16 +433,33 @@ export interface PopStateEvent { url?: string; } +/** @experimental */ +export declare function registerLocaleData(data: any, extraData?: any): void; + /** @stable */ export declare class SlicePipe implements PipeTransform { transform(value: any, start: number, end?: number): any; } +/** @experimental */ +export declare type Time = { + hours: number; + minutes: number; +}; + /** @stable */ export declare class TitleCasePipe implements PipeTransform { transform(value: string): string; } +/** @experimental */ +export declare enum TranslationWidth { + Narrow = 0, + Abbreviated = 1, + Wide = 2, + Short = 3, +} + /** @stable */ export declare class UpperCasePipe implements PipeTransform { transform(value: string): string; @@ -309,3 +467,14 @@ export declare class UpperCasePipe implements PipeTransform { /** @stable */ export declare const VERSION: Version; + +/** @experimental */ +export declare enum WeekDay { + Sunday = 0, + Monday = 1, + Tuesday = 2, + Wednesday = 3, + Thursday = 4, + Friday = 5, + Saturday = 6, +}