From b33579df7763334218d96519cf4cdf6852dfcaaa Mon Sep 17 00:00:00 2001 From: Borys Ponomarenko Date: Mon, 25 Mar 2019 12:09:59 +0100 Subject: [PATCH] :star: new(number): i18n-n functional component (#541) by @bponomarenko --- decls/i18n.js | 9 + examples/number-formatting/index.html | 70 +++++ package.json | 2 +- .../interpolation.js} | 2 +- src/components/number.js | 63 ++++ src/index.js | 54 ++-- src/install.js | 6 +- src/util.js | 18 ++ test/e2e/test/number_formatting.js | 11 + test/unit/interpolation.test.js | 2 +- test/unit/number_component.test.js | 279 ++++++++++++++++++ types/index.d.ts | 9 + vuepress/api/README.md | 88 +++++- vuepress/guide/number.md | 75 ++++- 14 files changed, 655 insertions(+), 33 deletions(-) create mode 100644 examples/number-formatting/index.html rename src/{component.js => components/interpolation.js} (98%) create mode 100644 src/components/number.js create mode 100644 test/e2e/test/number_formatting.js create mode 100644 test/unit/number_component.test.js diff --git a/decls/i18n.js b/decls/i18n.js index cb4764b1b..e0cc7a9a4 100644 --- a/decls/i18n.js +++ b/decls/i18n.js @@ -50,6 +50,15 @@ declare type DateTimeFormatResult = string; declare type NumberFormatResult = string; declare type MissingHandler = (locale: Locale, key: Path, vm?: any) => string | void; +declare type FormattedNumberPartType = 'currency' | 'decimal' | 'fraction' | 'group' | 'infinity' | 'integer' | 'literal' | 'minusSign' | 'nan' | 'plusSign' | 'percentSign'; +declare type FormattedNumberPart = { + type: FormattedNumberPartType, + value: string, +}; +// This array is the same as Intl.NumberFormat.formatToParts() return value: +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/formatToParts#Return_value +declare type NumberFormatToPartsResult = Array; + declare type I18nOptions = { locale?: Locale, fallbackLocale?: Locale, diff --git a/examples/number-formatting/index.html b/examples/number-formatting/index.html new file mode 100644 index 000000000..c25e8b0ff --- /dev/null +++ b/examples/number-formatting/index.html @@ -0,0 +1,70 @@ + + + + + number custom formatting + + + + + +
+ +

+ {{ $t('money') }}: + + + {{ props.fraction }} + +

+
+ + + diff --git a/package.json b/package.json index 65ac201ff..9bd067df1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "dist/vue-i18n.min.js", "dist/vue-i18n.common.js", "dist/vue-i18n.esm.js", - "src/*.js", + "src/**/*.js", "types/*.d.ts", "decls" ], diff --git a/src/component.js b/src/components/interpolation.js similarity index 98% rename from src/component.js rename to src/components/interpolation.js index a27dec796..af7dae8c4 100644 --- a/src/component.js +++ b/src/components/interpolation.js @@ -1,6 +1,6 @@ /* @flow */ -import { warn } from './util' +import { warn } from '../util' export default { name: 'i18n', diff --git a/src/components/number.js b/src/components/number.js new file mode 100644 index 000000000..a9c98eb62 --- /dev/null +++ b/src/components/number.js @@ -0,0 +1,63 @@ +/* @flow */ + +import { warn, isObject, numberFormatKeys } from '../util' + +export default { + name: 'i18n-n', + functional: true, + props: { + tag: { + type: String, + default: 'span' + }, + value: { + type: Number, + required: true + }, + format: { + type: [String, Object] + }, + locale: { + type: String + } + }, + render (h: Function, { props, parent, data }: Object) { + const i18n = parent.$i18n + + if (!i18n) { + if (process.env.NODE_ENV !== 'production') { + warn('Cannot find VueI18n instance!') + } + return null + } + + let key: ?string = null + let options: ?NumberFormatOptions = null + + if (typeof props.format === 'string') { + key = props.format + } else if (isObject(props.format)) { + if (props.format.key) { + key = props.format.key + } + + // Filter out number format options only + options = Object.keys(props.format).reduce((acc, prop) => { + if (numberFormatKeys.includes(prop)) { + return Object.assign({}, acc, { [prop]: props.format[prop] }) + } + return acc + }, null) + } + + const locale: Locale = props.locale || i18n.locale + const parts: NumberFormatToPartsResult = i18n._ntp(props.value, locale, key, options) + + const values = parts.map((part, index) => { + const slot: ?Function = data.scopedSlots && data.scopedSlots[part.type] + return slot ? slot({ [part.type]: part.value, index, parts }) : part.value + }) + + return h(props.tag, values) + } +} diff --git a/src/index.js b/src/index.js index b0aaa77b7..7cce203bc 100644 --- a/src/index.js +++ b/src/index.js @@ -9,26 +9,14 @@ import { isObject, looseClone, remove, - merge + merge, + numberFormatKeys } from './util' import BaseFormatter from './format' import I18nPath from './path' import type { PathValue } from './path' -const numberFormatKeys = [ - 'style', - 'currency', - 'currencyDisplay', - 'useGrouping', - 'minimumIntegerDigits', - 'minimumFractionDigits', - 'maximumFractionDigits', - 'minimumSignificantDigits', - 'maximumSignificantDigits', - 'localeMatcher', - 'formatMatcher' -] const linkKeyMatcher = /(?:@(?:\.[a-z]+)?:(?:[\w\-_|.]+|\([\w\-_|.]+\)))/g const linkKeyPrefixMatcher = /^@(?:\.([a-z]+))?:/ const bracketsMatcher = /[()]/g @@ -626,14 +614,14 @@ export default class VueI18n { this._vm.$set(this._vm.numberFormats, locale, merge(this._vm.numberFormats[locale] || {}, format)) } - _localizeNumber ( + _getNumberFormatter ( value: number, locale: Locale, fallback: Locale, numberFormats: NumberFormats, key: string, options: ?NumberFormatOptions - ): ?NumberFormatResult { + ): ?Object { let _locale: Locale = locale let formats: NumberFormat = numberFormats[_locale] @@ -662,7 +650,7 @@ export default class VueI18n { formatter = this._numberFormatters[id] = new Intl.NumberFormat(_locale, format) } } - return formatter.format(value) + return formatter } } @@ -680,8 +668,8 @@ export default class VueI18n { return nf.format(value) } - const ret: ?NumberFormatResult = - this._localizeNumber(value, locale, this.fallbackLocale, this._getNumberFormats(), key, options) + const formatter: ?Object = this._getNumberFormatter(value, locale, this.fallbackLocale, this._getNumberFormats(), key, options) + const ret: ?NumberFormatResult = formatter && formatter.format(value) if (this._isFallbackRoot(ret)) { if (process.env.NODE_ENV !== 'production' && !this._silentTranslationWarn) { warn(`Fall back to number localization of root: key '${key}' .`) @@ -729,6 +717,34 @@ export default class VueI18n { return this._n(value, locale, key, options) } + + _ntp (value: number, locale: Locale, key: ?string, options: ?NumberFormatOptions): NumberFormatToPartsResult { + /* istanbul ignore if */ + if (!VueI18n.availabilities.numberFormat) { + if (process.env.NODE_ENV !== 'production') { + warn('Cannot format to parts a Number value due to not supported Intl.NumberFormat.') + } + return [] + } + + if (!key) { + const nf = !options ? new Intl.NumberFormat(locale) : new Intl.NumberFormat(locale, options) + return nf.formatToParts(value) + } + + const formatter: ?Object = this._getNumberFormatter(value, locale, this.fallbackLocale, this._getNumberFormats(), key, options) + const ret: ?NumberFormatToPartsResult = formatter && formatter.formatToParts(value) + if (this._isFallbackRoot(ret)) { + if (process.env.NODE_ENV !== 'production' && !this._silentTranslationWarn) { + warn(`Fall back to format number to parts of root: key '${key}' .`) + } + /* istanbul ignore if */ + if (!this._root) { throw Error('unexpected error') } + return this._root.$i18n._ntp(value, locale, key, options) + } else { + return ret || [] + } + } } let availabilities: IntlAvailability diff --git a/src/install.js b/src/install.js index d2c5e84e5..62edbc2db 100644 --- a/src/install.js +++ b/src/install.js @@ -1,7 +1,8 @@ import { warn } from './util' import extend from './extend' import mixin from './mixin' -import component from './component' +import interpolationComponent from './components/interpolation' +import numberComponent from './components/number' import { bind, update, unbind } from './directive' export let Vue @@ -26,7 +27,8 @@ export function install (_Vue) { extend(Vue) Vue.mixin(mixin) Vue.directive('t', { bind, update, unbind }) - Vue.component(component.name, component) + Vue.component(interpolationComponent.name, interpolationComponent) + Vue.component(numberComponent.name, numberComponent) // use simple mergeStrategies to prevent i18n instance lose '__proto__' const strats = Vue.config.optionMergeStrategies diff --git a/src/util.js b/src/util.js index 5891e6b48..e0c637994 100644 --- a/src/util.js +++ b/src/util.js @@ -1,5 +1,23 @@ /* @flow */ +/** + * constants + */ + +export const numberFormatKeys = [ + 'style', + 'currency', + 'currencyDisplay', + 'useGrouping', + 'minimumIntegerDigits', + 'minimumFractionDigits', + 'maximumFractionDigits', + 'minimumSignificantDigits', + 'maximumSignificantDigits', + 'localeMatcher', + 'formatMatcher' +] + /** * utilities */ diff --git a/test/e2e/test/number_formatting.js b/test/e2e/test/number_formatting.js new file mode 100644 index 000000000..d0e9b2010 --- /dev/null +++ b/test/e2e/test/number_formatting.js @@ -0,0 +1,11 @@ +module.exports = { + component: function (browser) { + browser + .url('http://localhost:8080/examples/number-formatting/') + .waitForElementVisible('#app', 1000) + .assert.containsText('p', 'お金: ¥1,000') + .click('select option[value=en-US]') + .assert.attributeContains('p', 'innerHTML', 'Money: $1,00000') + .end() + } +} diff --git a/test/unit/interpolation.test.js b/test/unit/interpolation.test.js index 7f93a14e4..702bb8711 100644 --- a/test/unit/interpolation.test.js +++ b/test/unit/interpolation.test.js @@ -1,4 +1,4 @@ -import Component from '../../src/component' +import Component from '../../src/components/interpolation' const messages = { en: { diff --git a/test/unit/number_component.test.js b/test/unit/number_component.test.js new file mode 100644 index 000000000..3263cea50 --- /dev/null +++ b/test/unit/number_component.test.js @@ -0,0 +1,279 @@ +import numberFormats from './fixture/number' + +const desc = VueI18n.availabilities.numberFormat ? describe : describe.skip +desc('number custom formatting', () => { + let i18n + let value + + beforeEach(() => { + i18n = new VueI18n({ + locale: 'en-US', + fallbackLocale: 'ja-JP', + numberFormats + }) + value = 10100 + }) + + describe('basic', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '10,100') + }).then(done) + }) + }) + + describe('format', () => { + describe('as string property', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: 'currency' } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '$10,100.00') + }).then(done) + }) + }) + + describe('as object property', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: { key: 'currency' } } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '$10,100.00') + }).then(done) + }) + }) + }) + + describe('locale', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: 'currency', locale: 'ja-JP' } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '¥10,100') + }).then(done) + }) + }) + + describe('tag', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, tag: 'p' } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.outerHTML, '

10,100

') + }).then(done) + }) + }) + + describe('explicit options', () => { + describe('without key', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: { style: 'currency', currency: 'JPY' } } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '¥10,100') + }).then(done) + }) + + it('should respect other number options', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: { style: 'currency', currency: 'EUR', currencyDisplay: 'code' } } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, 'EUR 10,100.00') + }).then(done) + }) + }) + + describe('with key', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: { key: 'currency', currency: 'JPY' } } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '¥10,100') + }).then(done) + }) + + it('should respect other number options', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value, format: { key: 'currency', currency: 'EUR', currencyDisplay: 'code' } } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, 'EUR 10,100.00') + }).then(done) + }) + }) + }) + + describe('partial formatting', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { + props: { value }, + scopedSlots: { + integer: props => h('span', props.integer), + group: props => h('p', props.group) + } + }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.innerHTML, '10

,

100') + }).then(done) + }) + + it('should pass part index as scoped prop', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { + props: { value: 1000000, format: 'currency' }, + scopedSlots: { + currency: props => h('span', new Array(3).fill(props.currency).join('')), + group: props => h('p', { staticClass: props.index }, props.group) + } + }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.innerHTML, '$$$1

,

000

,

000.00') + }).then(done) + }) + + it('should pass parts as scoped prop', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { + props: { value: -12 }, + scopedSlots: { + integer: props => h('span', { + staticClass: props.parts.find(part => part.type === 'minusSign') ? 'red' : '' + }, props.integer) + } + }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.innerHTML, '-12') + }).then(done) + }) + + it('should ignore non-present scoped slot', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { + props: { value }, + scopedSlots: { + currency: props => h('span', props.currency) + } + }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.innerHTML, '10,100') + }).then(done) + }) + + it('should ignore default scoped slot', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { + props: { value }, + scopedSlots: { + default: props => h('span', props.integer) + } + }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.innerHTML, '10,100') + }).then(done) + }) + }) + + describe('fallback', () => { + it('should be formatted', done => { + const vm = new Vue({ + i18n, + render (h) { + return h('i18n-n', { props: { value: 0.9, format: 'percent' } }) + }, + el: document.createElement('div') + }) + nextTick(() => { + assert.strictEqual(vm.$el.textContent, '90%') + }).then(done) + }) + }) + + describe('warnning in render', () => { + it('should be warned', () => { + const spy = sinon.spy(console, 'warn') + + new Vue({ + render (h) { + return h('i18n-n', { props: { value } }) + }, + el: document.createElement('div') + }) + assert(spy.notCalled === false) + assert(spy.callCount === 1) + + spy.restore() + }) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index fee46e76c..6a31b4619 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -69,6 +69,14 @@ declare namespace VueI18n { [lang: string]: (choice: number, choicesLength: number) => number; }; + type FormattedNumberPartType = 'currency' | 'decimal' | 'fraction' | 'group' | 'infinity' | 'integer' | 'literal' | 'minusSign' | 'nan' | 'plusSign' | 'percentSign'; + + interface FormattedNumberPart { + type: FormattedNumberPartType; + value: string; + } + interface NumberFormatToPartsResult { [index: number]: FormattedNumberPart; } + interface Formatter { interpolate(message: string, values: Values | undefined, path: string): (any[] | null); } @@ -115,6 +123,7 @@ export type NumberFormatOptions = VueI18n.NumberFormatOptions; export type NumberFormat = VueI18n.NumberFormat; export type NumberFormats = VueI18n.NumberFormats; export type NumberFormatResult = VueI18n.NumberFormatResult; +export type NumberFormatToPartsResult = VueI18n.NumberFormatToPartsResult; export type Formatter = VueI18n.Formatter; export type MissingHandler = VueI18n.MissingHandler; export type IntlAvailability = VueI18n.IntlAvailability; diff --git a/vuepress/api/README.md b/vuepress/api/README.md index 596e6ad52..0bc37610d 100644 --- a/vuepress/api/README.md +++ b/vuepress/api/README.md @@ -89,15 +89,15 @@ Note that you need to guarantee this context equal to component instance in life * **Arguments:** * `{number} value`: required - * `{Path | Object} key`: optional + * `{Path | Object} format`: optional * `{Locale} locale`: optional * **Return:** `NumberFormatResult` -Localize the number of `value` with number format of `key`. The number format of `key` need to register to `numberFormats` option of `VueI18n` class, and depend on `locale` option of `VueI18n` constructor. If you will specify `locale` argument, it will have priority over `locale` option of `VueI18n` constructor. +Localize the number of `value` with number format of `format`. The number format of `format` need to register to `numberFormats` option of `VueI18n` class, and depend on `locale` option of `VueI18n` constructor. If you will specify `locale` argument, it will have priority over `locale` option of `VueI18n` constructor. -If the number format of `key` not exist in `numberFormats` option, fallback to depend on `fallbackLocale` option of `VueI18n` constructor. +If the number format of `format` not exist in `numberFormats` option, fallback to depend on `fallbackLocale` option of `VueI18n` constructor. -If the second `key` argument specified as an object, it should have the following properties: +If the second `format` argument specified as an object, it should have the following properties: * `key {Path}`: optional, number format * `locale {Locale}`: optional, locale @@ -216,7 +216,7 @@ The number formats of localization. * **Type:** `Locale[]` * **Default:** `[]` - + * **Examples:** `["en", "ja"]` The list of available locales in `messages` in lexical order. @@ -541,14 +541,14 @@ Set the number format of locale. Merge the registered number formats with the number format of locale. -#### n( value, [key], [locale] ) +#### n( value, [format], [locale] ) > :new: 7.0+ * **Arguments:** * `{number} value`: required - * `{Path | Object} key`: optional + * `{Path | Object} format`: optional * `{Locale} locale`: optional * **Return:** `NumberFormatResult` @@ -649,6 +649,80 @@ new Vue({ [Component interpolation](../guide/interpolation.md) +### i18n-n functional component + +> :new: 8.10+ + +#### Props: + + * `value {number}`: required, number to format + * `format {strig | NumberFormatOptions}`: optional, number format name or object with explicit format options + * `locale {Locale}`: optional, locale + * `tag {string}`: optional, default `span` + +#### Usage: + +```html +
+ + + {{ slotProps.currency }} + + +
+``` +```js +var numberFormats = { + 'en-US': { + currency: { + style: 'currency', currency: 'USD' + } + }, + 'ja-JP': { + currency: { + style: 'currency', currency: 'JPY' + } + } +} + +const i18n = new VueI18n({ + locale: 'en-US', + numberFormats +}) +new Vue({ + i18n, + data: { + money: 10234, + } +}).$mount('#app') +``` + +#### Scoped slots + +`` functional component can accept a number of named scoped slots. List of supported slot names is based on [`Intl.NumberFormat.formatToParts()` output types](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat/formatToParts): + +* `currency` +* `decimal` +* `fraction` +* `group` +* `infinity` +* `integer` +* `literal` +* `minusSign` +* `nan` +* `plusSign` +* `percentSign` + +Each of these named scoped slots will accept three scope parameters: + +* `[slotName] {FormattedNumberPartType}`: parameter of the same name as actual slot name (like `integer') +* `index {Number}`: index of the specific part in the array of number parts +* `parts {Array}`: array of all formatted number parts + +#### See also: + +[Number custom formatting](../guide/number.md#custom-formatting) + ## Special Attributes ### place diff --git a/vuepress/guide/number.md b/vuepress/guide/number.md index 035f05003..dcc62bca3 100644 --- a/vuepress/guide/number.md +++ b/vuepress/guide/number.md @@ -23,9 +23,9 @@ const numberFormats = { } ``` -As the Above, You can define the number format with named (e.g. `currency`, etc), and you need to use [the options with ECMA-402 Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) +As the above, you can define the number format with name (e.g. `currency`, etc), and you need to use [the options with ECMA-402 Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) -After that like the locale messages, You need to specify the `numberFormats` option of `VueI18n` constructor: +After that like the locale messages, you need to specify the `numberFormats` option of `VueI18n` constructor: ```js const i18n = new VueI18n({ @@ -55,3 +55,74 @@ Output the below:

¥100

``` + +## Custom formatting + +:::tip Support Version +:new: 8.10+ +::: + +`$n` method returns resulting string with fully formatted number, which can only be used as a whole. In situations when you need to style some part of the formatted number (like fraction digits), `$n` is not enough. In such cases `` functional component will be of help. + +With a minimum set of properties, `` generates the same output as `$n`, wrapped into configured DOM element. + +The following template: + +```html +
+ + + +
+``` + +will produce output below: + +```html +
+ 100 + $100.00 + ¥100 +
+``` + +But real power of this component comes into play when it is used with [scoped slots](https://vuejs.org/v2/guide/components-slots.html#Scoped-Slots). + +Lets say there is requirement to render integer part of the number with a bolder font. This can be achieved by specifying `integer` scoped slot element: + +```html + + {{ slotProps.integer }} + +``` + +Template above will result in the following HTML: + +```html +$100.00 +``` + +It is possible to specify multiple scoped slots at the same time: + +```html + + {{ slotProps.integer }} + {{ slotProps.group }} + {{ slotProps.fraction }} + +``` + +(this resulting HTML was formatted for better readability) + +```html + + € + 1 + , + 234 + . + 00 + +``` + +Full list of the supported scoped slots as well as other `` properties can be found [on API page](../api/readme.md#i18n-n-functional-component).