Skip to content

Commit 4180912

Browse files
ocombevicb
authored andcommitted
feat(common): export functions to format numbers, percents, currencies & dates (#22423)
The utility functions `formatNumber`, `formatPercent`, `formatCurrency`, and `formatDate` used by the number, percent, currency and date pipes are now available for developers who want to use them outside of templates. Fixes #20536 PR Close #22423
1 parent 094666d commit 4180912

File tree

13 files changed

+702
-483
lines changed

13 files changed

+702
-483
lines changed

packages/common/src/common.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
* Entry point for all public APIs of the common package.
1313
*/
1414
export * from './location/index';
15+
export {formatDate} from './i18n/format_date';
16+
export {formatCurrency, formatNumber, formatPercent} from './i18n/format_number';
1517
export {NgLocaleLocalization, NgLocalization} from './i18n/localization';
1618
export {registerLocaleData} from './i18n/locale_data';
17-
export {Plural, NumberFormatStyle, FormStyle, Time, TranslationWidth, FormatWidth, NumberSymbol, WeekDay, getNbOfCurrencyDigits, getCurrencySymbol, getLocaleDayPeriods, getLocaleDayNames, getLocaleMonthNames, getLocaleId, getLocaleEraNames, getLocaleWeekEndRange, getLocaleFirstDayOfWeek, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocalePluralCase, getLocaleTimeFormat, getLocaleNumberSymbol, getLocaleNumberFormat, getLocaleCurrencyName, getLocaleCurrencySymbol} from './i18n/locale_data_api';
19+
export {Plural, NumberFormatStyle, FormStyle, Time, TranslationWidth, FormatWidth, NumberSymbol, WeekDay, getNumberOfCurrencyDigits, getCurrencySymbol, getLocaleDayPeriods, getLocaleDayNames, getLocaleMonthNames, getLocaleId, getLocaleEraNames, getLocaleWeekEndRange, getLocaleFirstDayOfWeek, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocalePluralCase, getLocaleTimeFormat, getLocaleNumberSymbol, getLocaleNumberFormat, getLocaleCurrencyName, getLocaleCurrencySymbol} from './i18n/locale_data_api';
1820
export {parseCookieValue as ɵparseCookieValue} from './cookie';
1921
export {CommonModule, DeprecatedI18NPipesModule} from './common_module';
2022
export {NgClass, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index';

packages/common/src/i18n/format_date.ts

+113-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
import {FormStyle, FormatWidth, NumberSymbol, Time, TranslationWidth, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleDayNames, getLocaleDayPeriods, getLocaleEraNames, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocaleId, getLocaleMonthNames, getLocaleNumberSymbol, getLocaleTimeFormat} from './locale_data_api';
1010

11+
export const ISO8601_DATE_REGEX =
12+
/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;
13+
// 1 2 3 4 5 6 7 8 9 10 11
1114
const NAMED_FORMATS: {[localeId: string]: {[format: string]: string}} = {};
1215
const DATE_FORMATS_SPLIT =
1316
/((?:[^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]*)/;
@@ -38,11 +41,27 @@ enum TranslationType {
3841
}
3942

4043
/**
41-
* Transforms a date to a locale string based on a pattern and a timezone
44+
* @ngModule CommonModule
45+
* @whatItDoes Formats a date according to locale rules.
46+
* @description
4247
*
43-
* @internal
48+
* Where:
49+
* - `value` is a Date, a number (milliseconds since UTC epoch) or an ISO string
50+
* (https://www.w3.org/TR/NOTE-datetime).
51+
* - `format` indicates which date/time components to include. See {@link DatePipe} for more
52+
* details.
53+
* - `locale` is a `string` defining the locale to use.
54+
* - `timezone` to be used for formatting. It understands UTC/GMT and the continental US time zone
55+
* abbreviations, but for general use, use a time zone offset (e.g. `'+0430'`).
56+
* If not specified, host system settings are used.
57+
*
58+
* See {@link DatePipe} for more details.
59+
*
60+
* @stable
4461
*/
45-
export function formatDate(date: Date, format: string, locale: string, timezone?: string): string {
62+
export function formatDate(
63+
value: string | number | Date, format: string, locale: string, timezone?: string): string {
64+
let date = toDate(value);
4665
const namedFormat = getNamedFormat(locale, format);
4766
format = namedFormat || format;
4867

@@ -165,8 +184,10 @@ function padNumber(
165184
neg = minusSign;
166185
}
167186
}
168-
let strNum = '' + num;
169-
while (strNum.length < digits) strNum = '0' + strNum;
187+
let strNum = String(num);
188+
while (strNum.length < digits) {
189+
strNum = '0' + strNum;
190+
}
170191
if (trim) {
171192
strNum = strNum.substr(strNum.length - digits);
172193
}
@@ -607,3 +628,90 @@ function convertTimezoneToLocal(date: Date, timezone: string, reverse: boolean):
607628
const timezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset);
608629
return addDateMinutes(date, reverseValue * (timezoneOffset - dateTimezoneOffset));
609630
}
631+
632+
/**
633+
* Converts a value to date.
634+
*
635+
* Supported input formats:
636+
* - `Date`
637+
* - number: timestamp
638+
* - string: numeric (e.g. "1234"), ISO and date strings in a format supported by
639+
* [Date.parse()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse).
640+
* Note: ISO strings without time return a date without timeoffset.
641+
*
642+
* Throws if unable to convert to a date.
643+
*/
644+
export function toDate(value: string | number | Date): Date {
645+
if (isDate(value)) {
646+
return value;
647+
}
648+
649+
if (typeof value === 'number' && !isNaN(value)) {
650+
return new Date(value);
651+
}
652+
653+
if (typeof value === 'string') {
654+
value = value.trim();
655+
656+
const parsedNb = parseFloat(value);
657+
658+
// any string that only contains numbers, like "1234" but not like "1234hello"
659+
if (!isNaN(value as any - parsedNb)) {
660+
return new Date(parsedNb);
661+
}
662+
663+
if (/^(\d{4}-\d{1,2}-\d{1,2})$/.test(value)) {
664+
/* For ISO Strings without time the day, month and year must be extracted from the ISO String
665+
before Date creation to avoid time offset and errors in the new Date.
666+
If we only replace '-' with ',' in the ISO String ("2015,01,01"), and try to create a new
667+
date, some browsers (e.g. IE 9) will throw an invalid Date error.
668+
If we leave the '-' ("2015-01-01") and try to create a new Date("2015-01-01") the timeoffset
669+
is applied.
670+
Note: ISO months are 0 for January, 1 for February, ... */
671+
const [y, m, d] = value.split('-').map((val: string) => +val);
672+
return new Date(y, m - 1, d);
673+
}
674+
675+
let match: RegExpMatchArray|null;
676+
if (match = value.match(ISO8601_DATE_REGEX)) {
677+
return isoStringToDate(match);
678+
}
679+
}
680+
681+
const date = new Date(value as any);
682+
if (!isDate(date)) {
683+
throw new Error(`Unable to convert "${value}" into a date`);
684+
}
685+
return date;
686+
}
687+
688+
/**
689+
* Converts a date in ISO8601 to a Date.
690+
* Used instead of `Date.parse` because of browser discrepancies.
691+
*/
692+
export function isoStringToDate(match: RegExpMatchArray): Date {
693+
const date = new Date(0);
694+
let tzHour = 0;
695+
let tzMin = 0;
696+
697+
// match[8] means that the string contains "Z" (UTC) or a timezone like "+01:00" or "+0100"
698+
const dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear;
699+
const timeSetter = match[8] ? date.setUTCHours : date.setHours;
700+
701+
// if there is a timezone defined like "+01:00" or "+0100"
702+
if (match[9]) {
703+
tzHour = Number(match[9] + match[10]);
704+
tzMin = Number(match[9] + match[11]);
705+
}
706+
dateSetter.call(date, Number(match[1]), Number(match[2]) - 1, Number(match[3]));
707+
const h = Number(match[4] || 0) - tzHour;
708+
const m = Number(match[5] || 0) - tzMin;
709+
const s = Number(match[6] || 0);
710+
const ms = Math.round(parseFloat('0.' + (match[7] || 0)) * 1000);
711+
timeSetter.call(date, h, m, s, ms);
712+
return date;
713+
}
714+
715+
export function isDate(value: any): value is Date {
716+
return value instanceof Date && !isNaN(value.valueOf());
717+
}

packages/common/src/i18n/format_number.ts

+52-36
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {NumberFormatStyle, NumberSymbol, getLocaleNumberFormat, getLocaleNumberSymbol, getNbOfCurrencyDigits} from './locale_data_api';
9+
import {NumberFormatStyle, NumberSymbol, getLocaleNumberFormat, getLocaleNumberSymbol, getNumberOfCurrencyDigits} from './locale_data_api';
1010

1111
export const NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/;
1212
const MAX_DIGITS = 22;
@@ -18,34 +18,19 @@ const DIGIT_CHAR = '#';
1818
const CURRENCY_CHAR = '¤';
1919
const PERCENT_CHAR = '%';
2020

21-
/**
22-
* Transforms a string into a number (if needed)
23-
*/
24-
function strToNumber(value: number | string): number {
25-
// Convert strings to numbers
26-
if (typeof value === 'string' && !isNaN(+value - parseFloat(value))) {
27-
return +value;
28-
}
29-
if (typeof value !== 'number') {
30-
throw new Error(`${value} is not a number`);
31-
}
32-
return value;
33-
}
34-
3521
/**
3622
* Transforms a number to a locale string based on a style and a format
3723
*/
38-
function formatNumber(
39-
value: number | string, pattern: ParsedNumberFormat, locale: string, groupSymbol: NumberSymbol,
24+
function formatNumberToLocaleString(
25+
value: number, pattern: ParsedNumberFormat, locale: string, groupSymbol: NumberSymbol,
4026
decimalSymbol: NumberSymbol, digitsInfo?: string, isPercent = false): string {
4127
let formattedText = '';
4228
let isZero = false;
43-
const num = strToNumber(value);
4429

45-
if (!isFinite(num)) {
30+
if (!isFinite(value)) {
4631
formattedText = getLocaleNumberSymbol(locale, NumberSymbol.Infinity);
4732
} else {
48-
let parsedNumber = parseNumber(num);
33+
let parsedNumber = parseNumber(value);
4934

5035
if (isPercent) {
5136
parsedNumber = toPercent(parsedNumber);
@@ -128,7 +113,7 @@ function formatNumber(
128113
}
129114
}
130115

131-
if (num < 0 && !isZero) {
116+
if (value < 0 && !isZero) {
132117
formattedText = pattern.negPre + formattedText + pattern.negSuf;
133118
} else {
134119
formattedText = pattern.posPre + formattedText + pattern.posSuf;
@@ -138,20 +123,32 @@ function formatNumber(
138123
}
139124

140125
/**
141-
* Formats a currency to a locale string
126+
* @ngModule CommonModule
127+
* @whatItDoes Formats a number as currency using locale rules.
128+
* @description
142129
*
143-
* @internal
130+
* Use `currency` to format a number as currency.
131+
*
132+
* Where:
133+
* - `value` is a number.
134+
* - `locale` is a `string` defining the locale to use.
135+
* - `currency` is the string that represents the currency, it can be its symbol or its name.
136+
* - `currencyCode` is the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code, such
137+
* as `USD` for the US dollar and `EUR` for the euro.
138+
* - `digitInfo` See {@link DecimalPipe} for more details.
139+
*
140+
* @stable
144141
*/
145142
export function formatCurrency(
146-
value: number | string, locale: string, currency: string, currencyCode?: string,
143+
value: number, locale: string, currency: string, currencyCode?: string,
147144
digitsInfo?: string): string {
148145
const format = getLocaleNumberFormat(locale, NumberFormatStyle.Currency);
149146
const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign));
150147

151-
pattern.minFrac = getNbOfCurrencyDigits(currencyCode !);
148+
pattern.minFrac = getNumberOfCurrencyDigits(currencyCode !);
152149
pattern.maxFrac = pattern.minFrac;
153150

154-
const res = formatNumber(
151+
const res = formatNumberToLocaleString(
155152
value, pattern, locale, NumberSymbol.CurrencyGroup, NumberSymbol.CurrencyDecimal, digitsInfo);
156153
return res
157154
.replace(CURRENCY_CHAR, currency)
@@ -160,28 +157,48 @@ export function formatCurrency(
160157
}
161158

162159
/**
163-
* Formats a percentage to a locale string
160+
* @ngModule CommonModule
161+
* @whatItDoes Formats a number as a percentage according to locale rules.
162+
* @description
163+
*
164+
* Formats a number as percentage.
164165
*
165-
* @internal
166+
* Where:
167+
* - `value` is a number.
168+
* - `locale` is a `string` defining the locale to use.
169+
* - `digitInfo` See {@link DecimalPipe} for more details.
170+
*
171+
* @stable
166172
*/
167-
export function formatPercent(value: number | string, locale: string, digitsInfo?: string): string {
173+
export function formatPercent(value: number, locale: string, digitsInfo?: string): string {
168174
const format = getLocaleNumberFormat(locale, NumberFormatStyle.Percent);
169175
const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign));
170-
const res = formatNumber(
176+
const res = formatNumberToLocaleString(
171177
value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo, true);
172178
return res.replace(
173179
new RegExp(PERCENT_CHAR, 'g'), getLocaleNumberSymbol(locale, NumberSymbol.PercentSign));
174180
}
175181

176182
/**
177-
* Formats a number to a locale string
183+
* @ngModule CommonModule
184+
* @whatItDoes Formats a number according to locale rules.
185+
* @description
186+
*
187+
* Formats a number as text. Group sizing and separator and other locale-specific
188+
* configurations are based on the locale.
189+
*
190+
* Where:
191+
* - `value` is a number.
192+
* - `locale` is a `string` defining the locale to use.
193+
* - `digitInfo` See {@link DecimalPipe} for more details.
178194
*
179-
* @internal
195+
* @stable
180196
*/
181-
export function formatDecimal(value: number | string, locale: string, digitsInfo?: string): string {
197+
export function formatNumber(value: number, locale: string, digitsInfo?: string): string {
182198
const format = getLocaleNumberFormat(locale, NumberFormatStyle.Decimal);
183199
const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign));
184-
return formatNumber(value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo);
200+
return formatNumberToLocaleString(
201+
value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo);
185202
}
186203

187204
interface ParsedNumberFormat {
@@ -335,7 +352,7 @@ function parseNumber(num: number): ParsedNumber {
335352
digits = [];
336353
// Convert string to array of digits without leading/trailing zeros.
337354
for (j = 0; i <= zeros; i++, j++) {
338-
digits[j] = +numStr.charAt(i);
355+
digits[j] = Number(numStr.charAt(i));
339356
}
340357
}
341358

@@ -424,7 +441,6 @@ function roundNumber(parsedNumber: ParsedNumber, minFrac: number, maxFrac: numbe
424441
}
425442
}
426443

427-
/** @internal */
428444
export function parseIntAutoRadix(text: string): number {
429445
const result: number = parseInt(text);
430446
if (isNaN(result)) {

packages/common/src/i18n/locale_data_api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ const DEFAULT_NB_OF_CURRENCY_DIGITS = 2;
560560
*
561561
* @experimental i18n support is experimental.
562562
*/
563-
export function getNbOfCurrencyDigits(code: string): number {
563+
export function getNumberOfCurrencyDigits(code: string): number {
564564
let digits;
565565
const currency = CURRENCIES_EN[code];
566566
if (currency) {

0 commit comments

Comments
 (0)