Skip to content

Commit

Permalink
Add Intl.NumberFormat support, and cache NumberFormat objects whe…
Browse files Browse the repository at this point in the history
…n reused (#122)

* Give feature tests a callback so they can be reused
* Implement use of Intl.NumberFormat where available
* Implement naïve caching of Intl.NumberFormat instances
* Factor out `Intl.NumberFormat` instance generation and cache
* Only test locale support when testing toLocaleString support
* Handle the unlikely case that `Intl.NumberFormat` works but `toLocaleString` doesn't
* Update README with Intl.NumberFormat usage info

Thank you for the PR and ping... again... @ticky. Sorry it took so long to get back to this!
  • Loading branch information
ticky authored and jsmreese committed Jun 3, 2019
1 parent 0d6cfe1 commit befbf17
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 41 deletions.
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ This plugin does not have any dependencies beyond Moment.js itself, and may be u

## Formatting Numbers and Testing

Where it is available and functional, this plugin uses `Number#toLocaleString` to render formatted numerical output. Unfortunately, many environments do not fully implement the full suite of options in the `toLocaleString` spec, and some provide a buggy implementation.
Where it is available and functional, this plugin uses either `Intl.NumberFormat#format` or `Number#toLocaleString` to render formatted numerical output. Unfortunately, many environments do not fully implement the full suite of options in their respective specs, and some provide a buggy implementation.

This plugin runs a feature test for `toLocaleString`, and will revert to a fallback function to render formatted numerical output if the feature test fails. To force this plugin to always use the fallback number format function, set `useToLocaleString` to `false`. The fallback number format function output can be localized using options detailed at the bottom of this page. You should, in general, specify the fallback number formatting options if the default `"en"` locale formatting would be unacceptable on some devices or in some environments.
This plugin runs a feature test for each formatter, and will revert to a fallback function to render formatted numerical output if the feature test fails. To force this plugin to always use the fallback number format function, set `useToLocaleString` to `false`. The fallback number format function output can be localized using options detailed at the bottom of this page. You should, in general, specify the fallback number formatting options if the default `"en"` locale formatting would be unacceptable on some devices or in some environments.

This plugin is tested using BrowserStack on a range of Android devices with OS versions from 2.2 to 7, and on a range of iOS devices with OS versions from 4.3 to 11. Also tested on Chrome, Firefox, IE 8-11, and Edge browsers.

Expand Down Expand Up @@ -42,9 +42,9 @@ The ideas below are logged as issues and tagged with the [3.0.0 milestone](https

- The fallback number formatting localization options should be included with the Moment Locale object extensions this plugin already adds for localizing duration unit labels. This would put all of the localization configuration in one place.

- moment-duration-format and its fallback number formatting function do not follow the same API as `Number#toLocaleString` for significant digits and faction digits. The fallback function should be updated to use the `toLocaleString` API, and the plugin should expose the `toLocaleString` API options directly rather than hiding some of the options and masking them behind `precision` and `useSignificantDigits` options.
- moment-duration-format and its fallback number formatting function do not follow the same API as `Number#toLocaleString` for significant digits and faction digits. The fallback function should be updated to use the `toLocaleString` API, and the plugin should expose the API options directly rather than hiding some of the options and masking them behind `precision` and `useSignificantDigits` options.

- Exposing the fallback number formatting function as well as the `toLocaleString` feature test function would facilitate testing and allow them to be used outside of the context of formatting durations.
- Exposing the fallback number formatting function as well as the formatter feature test function would facilitate testing and allow them to be used outside of the context of formatting durations.

---

Expand Down Expand Up @@ -843,7 +843,7 @@ moment.duration(12.55, "hours").format("h:mm", {

### Localization

Formatted numerical output is rendered using [`toLocaleString`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString) if that built-in function is available and passes a feature test on plugin initialization. If the feature test fails, a fallback format function is used. See below for details on localizing output from the fallback format function.
Formatted numerical output is rendered using [`Intl.NumberFormat#format`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/format) or [`toLocaleString`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString) where available, as long as they pass a feature test on plugin initialization. If the feature tests fail, a fallback format function is used. See below for details on localizing output from the fallback format function.

Unit labels are automatically localized and pluralized. Unit labels are detected using the [locale set in moment.js](https://momentjs.com/docs/#/i18n/), which can be different from the locale of user's environment. This plugin uses custom extensions to the moment.js locale object, which can be easily added for any locale (see below).

Expand Down Expand Up @@ -1135,9 +1135,9 @@ You can (and likely should) set the localization options for the fallback number

#### `useToLocaleString`

Set this option to `false` to ignore the `toLocaleString` feature test and force the use of the `formatNumber` fallback function included in this plugin.
Set this option to `false` to ignore the `Intl.NumberFormat` and `toLocaleString` feature tests and force the use of the `formatNumber` fallback function included in this plugin.

The fallback number format options will have no effect when `toLocaleString` is used. The grouping separator, decimal separator, and integer digit grouping will be determined by the user locale.
The fallback number format options will have no effect when `Intl.NumberFormat` or `toLocaleString` are used. The grouping separator, decimal separator, and integer digit grouping will be determined by the user locale.

```javascript
moment.duration(100000.1, "seconds").format("s", {
Expand All @@ -1162,7 +1162,7 @@ The decimal separator used when using the fallback number format function. Defau
The integer digit grouping used when using the fallback number format function. Must be an array. The default value of `[3]` gives the standard 3-digit thousand/million/billion digit groupings for the "en" locale. Setting this option to `[3, 2]` would generate the thousand/lakh/crore digit groupings used in the "en-IN" locale.

```javascript
// Force the use of the fallback number format function. Do not use toLocaleString.
// Force the use of the fallback number format function. Do not use toLocaleString or Intl.NumberFormat.
// We're in some sort of strange hybrid french-indian locale...
moment.duration(100000000000, "seconds").format("m", {
useToLocaleString: false,
Expand Down
130 changes: 97 additions & 33 deletions lib/moment-duration-format.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@
// function before passing them to `toLocaleString` for final formatting.
var toLocaleStringRoundingWorks = false;

// `Intl.NumberFormat#format` is tested on plugin initialization.
// If the feature test passes, `intlNumberFormatRoundingWorks` will be set to
// `true` and the native function will be used to generate formatted output.
// If the feature test fails, either `Number#tolocaleString` (if
// `toLocaleStringWorks` is `true`), or the fallback format function internal
// to this plugin will be used.
var intlNumberFormatWorks = false;

// `Intl.NumberFormat#format` rounds incorrectly for select numbers in Microsoft
// environments (Edge, IE11, Windows Phone) and possibly other environments.
// If the rounding test fails and `Intl.NumberFormat#format` will be used for
// formatting, the plugin will "pre-round" number values using the fallback number
// format function before passing them to `Intl.NumberFormat#format` for final
// formatting.
var intlNumberFormatRoundingWorks = false;

// Token type names in order of descending magnitude.
var types = "escape years months weeks days hours minutes seconds milliseconds general".split(" ");

Expand Down Expand Up @@ -148,6 +164,33 @@
return digitsArray.reverse().join("");
}

// cachedNumberFormat
// Returns an `Intl.NumberFormat` instance for the given locale and configuration.
// On first use of a particular configuration, the instance is cached for fast
// repeat access.
function cachedNumberFormat(locale, options) {
// Create a sorted, stringified version of `options`
// for use as part of the cache key
var optionsString = map(
keys(options).sort(),
function(key) {
return key + ':' + options[key];
}
).join(',');

// Set our cache key
var cacheKey = locale + '+' + optionsString;

// If we don't have this configuration cached, configure and cache it
if (!cachedNumberFormat.cache[cacheKey]) {
cachedNumberFormat.cache[cacheKey] = Intl.NumberFormat(locale, options);
}

// Return the cached version of this configuration
return cachedNumberFormat.cache[cacheKey];
}
cachedNumberFormat.cache = {};

// formatNumber
// Formats any number greater than or equal to zero using these options:
// - userLocale
Expand All @@ -160,8 +203,8 @@
// - groupingSeparator
// - decimalSeparator
//
// `useToLocaleString` will use `toLocaleString` for formatting.
// `userLocale` option is passed through to `toLocaleString`.
// `useToLocaleString` will use `Intl.NumberFormat` or `toLocaleString` for formatting.
// `userLocale` option is passed through to the formatting function.
// `fractionDigits` is passed through to `maximumFractionDigits` and `minimumFractionDigits`
// Using `maximumSignificantDigits` will override `minimumIntegerDigits` and `fractionDigits`.
function formatNumber(number, options, userLocale) {
Expand Down Expand Up @@ -191,14 +234,25 @@
localeStringOptions.maximumSignificantDigits = maximumSignificantDigits;
}

if (!toLocaleStringRoundingWorks) {
var roundingOptions = extend({}, options);
roundingOptions.useGrouping = false;
roundingOptions.decimalSeparator = ".";
number = parseFloat(formatNumber(number, roundingOptions), 10);
}
if (intlNumberFormatWorks) {
if (!intlNumberFormatRoundingWorks) {
var roundingOptions = extend({}, options);
roundingOptions.useGrouping = false;
roundingOptions.decimalSeparator = ".";
number = parseFloat(formatNumber(number, roundingOptions), 10);
}

return number.toLocaleString(userLocale, localeStringOptions);
return cachedNumberFormat(userLocale, localeStringOptions).format(number);
} else {
if (!toLocaleStringRoundingWorks) {
var roundingOptions = extend({}, options);
roundingOptions.useGrouping = false;
roundingOptions.decimalSeparator = ".";
number = parseFloat(formatNumber(number, roundingOptions), 10);
}

return number.toLocaleString(userLocale, localeStringOptions);
}
}

var numberString;
Expand Down Expand Up @@ -614,46 +668,42 @@
return false;
}

function featureTestToLocaleStringRounding() {
return (3.55).toLocaleString("en", {
function featureTestFormatterRounding(formatter) {
return formatter(3.55, "en", {
useGrouping: false,
minimumIntegerDigits: 1,
minimumFractionDigits: 1,
maximumFractionDigits: 1
}) === "3.6";
}

function featureTestToLocaleString() {
function featureTestFormatter(formatter) {
var passed = true;

// Test locale.
passed = passed && toLocaleStringSupportsLocales();
if (!passed) { return false; }

// Test minimumIntegerDigits.
passed = passed && (1).toLocaleString("en", { minimumIntegerDigits: 1 }) === "1";
passed = passed && (1).toLocaleString("en", { minimumIntegerDigits: 2 }) === "01";
passed = passed && (1).toLocaleString("en", { minimumIntegerDigits: 3 }) === "001";
passed = passed && formatter(1, "en", { minimumIntegerDigits: 1 }) === "1";
passed = passed && formatter(1, "en", { minimumIntegerDigits: 2 }) === "01";
passed = passed && formatter(1, "en", { minimumIntegerDigits: 3 }) === "001";
if (!passed) { return false; }

// Test maximumFractionDigits and minimumFractionDigits.
passed = passed && (99.99).toLocaleString("en", { maximumFractionDigits: 0, minimumFractionDigits: 0 }) === "100";
passed = passed && (99.99).toLocaleString("en", { maximumFractionDigits: 1, minimumFractionDigits: 1 }) === "100.0";
passed = passed && (99.99).toLocaleString("en", { maximumFractionDigits: 2, minimumFractionDigits: 2 }) === "99.99";
passed = passed && (99.99).toLocaleString("en", { maximumFractionDigits: 3, minimumFractionDigits: 3 }) === "99.990";
passed = passed && formatter(99.99, "en", { maximumFractionDigits: 0, minimumFractionDigits: 0 }) === "100";
passed = passed && formatter(99.99, "en", { maximumFractionDigits: 1, minimumFractionDigits: 1 }) === "100.0";
passed = passed && formatter(99.99, "en", { maximumFractionDigits: 2, minimumFractionDigits: 2 }) === "99.99";
passed = passed && formatter(99.99, "en", { maximumFractionDigits: 3, minimumFractionDigits: 3 }) === "99.990";
if (!passed) { return false; }

// Test maximumSignificantDigits.
passed = passed && (99.99).toLocaleString("en", { maximumSignificantDigits: 1 }) === "100";
passed = passed && (99.99).toLocaleString("en", { maximumSignificantDigits: 2 }) === "100";
passed = passed && (99.99).toLocaleString("en", { maximumSignificantDigits: 3 }) === "100";
passed = passed && (99.99).toLocaleString("en", { maximumSignificantDigits: 4 }) === "99.99";
passed = passed && (99.99).toLocaleString("en", { maximumSignificantDigits: 5 }) === "99.99";
passed = passed && formatter(99.99, "en", { maximumSignificantDigits: 1 }) === "100";
passed = passed && formatter(99.99, "en", { maximumSignificantDigits: 2 }) === "100";
passed = passed && formatter(99.99, "en", { maximumSignificantDigits: 3 }) === "100";
passed = passed && formatter(99.99, "en", { maximumSignificantDigits: 4 }) === "99.99";
passed = passed && formatter(99.99, "en", { maximumSignificantDigits: 5 }) === "99.99";
if (!passed) { return false; }

// Test grouping.
passed = passed && (1000).toLocaleString("en", { useGrouping: true }) === "1,000";
passed = passed && (1000).toLocaleString("en", { useGrouping: false }) === "1000";
passed = passed && formatter(1000, "en", { useGrouping: true }) === "1,000";
passed = passed && formatter(1000, "en", { useGrouping: false }) === "1000";
if (!passed) { return false; }

return true;
Expand Down Expand Up @@ -892,7 +942,7 @@
var decimalSeparator = settings.decimalSeparator;
var grouping = settings.grouping;

useToLocaleString = useToLocaleString && toLocaleStringWorks;
useToLocaleString = useToLocaleString && (toLocaleStringWorks || intlNumberFormatWorks);

// Trim options.
var trim = settings.trim;
Expand Down Expand Up @@ -1661,8 +1711,22 @@
}

// Run feature tests for `Number#toLocaleString`.
toLocaleStringWorks = featureTestToLocaleString();
toLocaleStringRoundingWorks = toLocaleStringWorks && featureTestToLocaleStringRounding();
var toLocaleStringFormatter = function(number, locale, options) {
return number.toLocaleString(locale, options);
};

toLocaleStringWorks = toLocaleStringSupportsLocales() && featureTestFormatter(toLocaleStringFormatter);
toLocaleStringRoundingWorks = toLocaleStringWorks && featureTestFormatterRounding(toLocaleStringFormatter);

// Run feature tests for `Intl.NumberFormat#format`.
var intlNumberFormatFormatter = function(number, locale, options) {
if (window.Intl && window.Intl.NumberFormat) {
return window.Intl.NumberFormat(locale, options).format(number);
}
};

intlNumberFormatWorks = featureTestFormatter(intlNumberFormatFormatter);
intlNumberFormatRoundingWorks = intlNumberFormatWorks && featureTestFormatterRounding(intlNumberFormatFormatter);

// Initialize duration format on the global moment instance.
init(moment);
Expand Down

0 comments on commit befbf17

Please sign in to comment.