Replace shortNumberFormat with <ShortNumber> (#14061)
This commit introduces new utility component - ShortNumber. It should work almost the same way as original shortNumberFormat function, though it also localizes units and accepts one more prop - renderer. Renderer is a function that takes rendered short formatted number and also ready-to-pluralize number to format display result accordingly. Ready-to-pluralize number allows to correctly select plural for compactly notated numbers, respecting thousands and other units. Issue #12451 accurately describes the issue with using raw numbers when replacing counter with short version. In short, it doesn't work with languages such as Russian, that require different plurals, according to the unit number was compacted to. All previous usages of shortNumberFormat were replaced with new function, and as it became unused, it was removed to avoid misleading.master
parent
c4f47f59cf
commit
cb2adaaf9d
@ -0,0 +1,62 @@ |
||||
// @ts-check
|
||||
import React from 'react'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
/** |
||||
* Returns custom renderer for one of the common counter types |
||||
* |
||||
* @param {"statuses" | "following" | "followers"} counterType |
||||
* Type of the counter |
||||
* @param {boolean} isBold Whether display number must be displayed in bold |
||||
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} |
||||
* Renderer function |
||||
* @throws If counterType is not covered by this function |
||||
*/ |
||||
export function counterRenderer(counterType, isBold = true) { |
||||
/** |
||||
* @type {(displayNumber: JSX.Element) => JSX.Element} |
||||
*/ |
||||
const renderCounter = isBold |
||||
? (displayNumber) => <strong>{displayNumber}</strong> |
||||
: (displayNumber) => displayNumber; |
||||
|
||||
switch (counterType) { |
||||
case 'statuses': { |
||||
return (displayNumber, pluralReady) => ( |
||||
<FormattedMessage |
||||
id='account.statuses_counter' |
||||
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}' |
||||
values={{ |
||||
count: pluralReady, |
||||
counter: renderCounter(displayNumber), |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
case 'following': { |
||||
return (displayNumber, pluralReady) => ( |
||||
<FormattedMessage |
||||
id='account.following_counter' |
||||
defaultMessage='{count, plural, other {{counter} Following}}' |
||||
values={{ |
||||
count: pluralReady, |
||||
counter: renderCounter(displayNumber), |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
case 'followers': { |
||||
return (displayNumber, pluralReady) => ( |
||||
<FormattedMessage |
||||
id='account.followers_counter' |
||||
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}' |
||||
values={{ |
||||
count: pluralReady, |
||||
counter: renderCounter(displayNumber), |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); |
||||
} |
||||
} |
@ -0,0 +1,117 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers'; |
||||
import { FormattedMessage, FormattedNumber } from 'react-intl'; |
||||
// @ts-check
|
||||
|
||||
/** |
||||
* @callback ShortNumberRenderer |
||||
* @param {JSX.Element} displayNumber Number to display |
||||
* @param {number} pluralReady Number used for pluralization |
||||
* @returns {JSX.Element} Final render of number |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {object} ShortNumberProps |
||||
* @property {number} value Number to display in short variant |
||||
* @property {ShortNumberRenderer} [renderer] |
||||
* Custom renderer for numbers, provided as a prop. If another renderer |
||||
* passed as a child of this component, this prop won't be used. |
||||
* @property {ShortNumberRenderer} [children] |
||||
* Custom renderer for numbers, provided as a child. If another renderer |
||||
* passed as a prop of this component, this one will be used instead. |
||||
*/ |
||||
|
||||
/** |
||||
* Component that renders short big number to a shorter version |
||||
* |
||||
* @param {ShortNumberProps} param0 Props for the component |
||||
* @returns {JSX.Element} Rendered number |
||||
*/ |
||||
function ShortNumber({ value, renderer, children }) { |
||||
const shortNumber = toShortNumber(value); |
||||
const [, division] = shortNumber; |
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (children != null && renderer != null) { |
||||
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); |
||||
} |
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const customRenderer = children != null ? children : renderer; |
||||
|
||||
const displayNumber = <ShortNumberCounter value={shortNumber} />; |
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return customRenderer != null |
||||
? customRenderer(displayNumber, pluralReady(value, division)) |
||||
: displayNumber; |
||||
} |
||||
|
||||
ShortNumber.propTypes = { |
||||
value: PropTypes.number.isRequired, |
||||
renderer: PropTypes.func, |
||||
children: PropTypes.func, |
||||
}; |
||||
|
||||
/** |
||||
* @typedef {object} ShortNumberCounterProps |
||||
* @property {import('../utils/number').ShortNumber} value Short number |
||||
*/ |
||||
|
||||
/** |
||||
* Renders short number into corresponding localizable react fragment |
||||
* |
||||
* @param {ShortNumberCounterProps} param0 Props for the component |
||||
* @returns {JSX.Element} FormattedMessage ready to be embedded in code |
||||
*/ |
||||
function ShortNumberCounter({ value }) { |
||||
const [rawNumber, unit, maxFractionDigits = 0] = value; |
||||
|
||||
const count = ( |
||||
<FormattedNumber |
||||
value={rawNumber} |
||||
maximumFractionDigits={maxFractionDigits} |
||||
/> |
||||
); |
||||
|
||||
let values = { count, rawNumber }; |
||||
|
||||
switch (unit) { |
||||
case DECIMAL_UNITS.THOUSAND: { |
||||
return ( |
||||
<FormattedMessage |
||||
id='units.short.thousand' |
||||
defaultMessage='{count}K' |
||||
values={values} |
||||
/> |
||||
); |
||||
} |
||||
case DECIMAL_UNITS.MILLION: { |
||||
return ( |
||||
<FormattedMessage |
||||
id='units.short.million' |
||||
defaultMessage='{count}M' |
||||
values={values} |
||||
/> |
||||
); |
||||
} |
||||
case DECIMAL_UNITS.BILLION: { |
||||
return ( |
||||
<FormattedMessage |
||||
id='units.short.billion' |
||||
defaultMessage='{count}B' |
||||
values={values} |
||||
/> |
||||
); |
||||
} |
||||
// Not sure if we should go farther - @Sasha-Sorokin
|
||||
default: return count; |
||||
} |
||||
} |
||||
|
||||
ShortNumberCounter.propTypes = { |
||||
value: PropTypes.arrayOf(PropTypes.number), |
||||
}; |
||||
|
||||
export default React.memo(ShortNumber); |
@ -1,16 +1,71 @@ |
||||
import React, { Fragment } from 'react'; |
||||
import { FormattedNumber } from 'react-intl'; |
||||
|
||||
export const shortNumberFormat = number => { |
||||
if (number < 1000) { |
||||
return <FormattedNumber value={number} />; |
||||
} else if (number < 10000) { |
||||
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; |
||||
} else if (number < 1000000) { |
||||
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={0} />K</Fragment>; |
||||
} else if (number < 10000000) { |
||||
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>; |
||||
} else { |
||||
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={0} />M</Fragment>; |
||||
// @ts-check
|
||||
|
||||
export const DECIMAL_UNITS = Object.freeze({ |
||||
ONE: 1, |
||||
TEN: 10, |
||||
HUNDRED: Math.pow(10, 2), |
||||
THOUSAND: Math.pow(10, 3), |
||||
MILLION: Math.pow(10, 6), |
||||
BILLION: Math.pow(10, 9), |
||||
TRILLION: Math.pow(10, 12), |
||||
}); |
||||
|
||||
const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10; |
||||
const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10; |
||||
|
||||
/** |
||||
* @typedef {[number, number, number]} ShortNumber |
||||
* Array of: shorten number, unit of shorten number and maximum fraction digits |
||||
*/ |
||||
|
||||
/** |
||||
* @param {number} sourceNumber Number to convert to short number |
||||
* @returns {ShortNumber} Calculated short number |
||||
* @example |
||||
* shortNumber(5936); |
||||
* // => [5.936, 1000, 1]
|
||||
*/ |
||||
export function toShortNumber(sourceNumber) { |
||||
if (sourceNumber < DECIMAL_UNITS.THOUSAND) { |
||||
return [sourceNumber, DECIMAL_UNITS.ONE, 0]; |
||||
} else if (sourceNumber < DECIMAL_UNITS.MILLION) { |
||||
return [ |
||||
sourceNumber / DECIMAL_UNITS.THOUSAND, |
||||
DECIMAL_UNITS.THOUSAND, |
||||
sourceNumber < TEN_THOUSAND ? 1 : 0, |
||||
]; |
||||
} else if (sourceNumber < DECIMAL_UNITS.BILLION) { |
||||
return [ |
||||
sourceNumber / DECIMAL_UNITS.MILLION, |
||||
DECIMAL_UNITS.MILLION, |
||||
sourceNumber < TEN_MILLIONS ? 1 : 0, |
||||
]; |
||||
} else if (sourceNumber < DECIMAL_UNITS.TRILLION) { |
||||
return [ |
||||
sourceNumber / DECIMAL_UNITS.BILLION, |
||||
DECIMAL_UNITS.BILLION, |
||||
0, |
||||
]; |
||||
} |
||||
}; |
||||
|
||||
return [sourceNumber, DECIMAL_UNITS.ONE, 0]; |
||||
} |
||||
|
||||
/** |
||||
* @param {number} sourceNumber Original number that is shortened |
||||
* @param {number} division The scale in which short number is displayed |
||||
* @returns {number} Number that can be used for plurals when short form used |
||||
* @example |
||||
* pluralReady(1793, DECIMAL_UNITS.THOUSAND) |
||||
* // => 1790
|
||||
*/ |
||||
export function pluralReady(sourceNumber, division) { |
||||
// eslint-disable-next-line eqeqeq
|
||||
if (division == null || division < DECIMAL_UNITS.HUNDRED) { |
||||
return sourceNumber; |
||||
} |
||||
|
||||
let closestScale = division / DECIMAL_UNITS.TEN; |
||||
|
||||
return Math.trunc(sourceNumber / closestScale) * closestScale; |
||||
} |
||||
|
Loading…
Reference in new issue