Skip to content

Commit f5eacbf

Browse files
authored
feat: add formatList & FormattedList (#1494)
1 parent a098369 commit f5eacbf

File tree

13 files changed

+198
-10
lines changed

13 files changed

+198
-10
lines changed

docs/API.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ There are a few API layers that React Intl provides and is built on. When using
2020
- [Number Formatting APIs](#number-formatting-apis)
2121
- [`formatNumber`](#formatnumber)
2222
- [`formatPlural`](#formatplural)
23+
- [List Formatting APIs](#list-formatting-apis)
24+
- [`formatList`](#formatlist)
2325
- [String Formatting APIs](#string-formatting-apis)
2426
- [Message Syntax](#message-syntax)
2527
- [Message Descriptor](#message-descriptor)
@@ -393,6 +395,31 @@ formatPlural(4, {style: 'ordinal'}); // "other"
393395

394396
**Note:** This function should only be used in apps that only need to support one language. If your app supports multiple languages use [`formatMessage`](#formatmessage) instead.
395397

398+
### List Formatting APIs
399+
400+
**This is currently stage 3 so [polyfill](https://www.npmjs.com/package/@formatjs/intl-listformat) would be required.**
401+
402+
#### `formatList`
403+
404+
```ts
405+
type ListFormatOptions = {
406+
type?: 'disjunction' | 'conjunction' | 'unit';
407+
style?: 'long' | 'short' | 'narrow';
408+
};
409+
410+
function formatPlural(
411+
elements: (string | React.ReactNode)[],
412+
options?: Intl.ListFormatOptions
413+
): string | React.ReactNode[];
414+
```
415+
416+
This function allows you to join list of things together in an i18n-safe way. For example:
417+
418+
```tsx
419+
formatList(['Me', 'myself', 'I'], {type: 'conjunction'}); // Me, myself and I
420+
formatList(['5 hours', '3 minues'], {type: 'unit'}); // 5 hours, 3 minutes
421+
```
422+
396423
### String Formatting APIs
397424

398425
React Intl provides two functions to format strings/messages:

docs/Components.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ React Intl has a set of React components that provide a declarative way to setup
1919
- [`FormattedNumber`](#formattednumber)
2020
- [`FormattedNumberParts`](#formattednumberparts)
2121
- [`FormattedPlural`](#formattedplural)
22+
- [List Formatting Components](#list-formatting-components)
23+
- [`FormattedList`](#formattedlist)
2224
- [String Formatting Components](#string-formatting-components)
2325
- [Message Syntax](#message-syntax)
2426
- [Message Descriptor](#message-descriptor)
@@ -488,6 +490,39 @@ By default `<FormattedPlural>` will select a [plural category](http://www.unicod
488490
messages
489491
```
490492

493+
## List Formatting Components
494+
495+
### `FormattedList`
496+
497+
This component uses [`formatList`](API.md#formatlist) API and [Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ListFormat). Its props corresponds to `Intl.ListFormatOptions`.
498+
499+
**Props:**
500+
501+
```tsx
502+
props: ListFormatOptions &
503+
{
504+
children: (chunksOrString: string | React.ReactElement[]) => ReactElement,
505+
};
506+
```
507+
508+
**Example:**
509+
510+
```tsx
511+
<FormattedList type="conjunction" value={['Me', 'myself', 'I']} />
512+
```
513+
514+
```html
515+
Me, myself and I
516+
```
517+
518+
```tsx
519+
<FormattedList type="conjunction" value={['Me', <b>myself</b>, 'I']} />
520+
```
521+
522+
```html
523+
Me, <b>myself</b> and I
524+
```
525+
491526
## String Formatting Components
492527

493528
React Intl provides two components to format strings:

package-lock.json

Lines changed: 11 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"types": "./dist/index.d.ts",
3636
"sideEffects": false,
3737
"dependencies": {
38+
"@formatjs/intl-listformat": "^1.2.1",
3839
"@formatjs/intl-relativetimeformat": "^4.2.1",
3940
"@formatjs/intl-unified-numberformat": "^2.1.0",
4041
"@types/hoist-non-react-statics": "^3.3.1",
@@ -87,7 +88,7 @@
8788
"react": "^16.11.0",
8889
"react-dom": "^16.11.0",
8990
"rimraf": "^3.0.0",
90-
"rollup": "^1.25.1",
91+
"rollup": "^1.25.2",
9192
"rollup-plugin-babel": "^4.3.3",
9293
"rollup-plugin-commonjs": "^10.1.0",
9394
"rollup-plugin-node-resolve": "^5.2.0",

src/components/createFormattedComponent.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
import * as React from 'react';
22
import {invariantIntlContext} from '../utils';
3-
import {IntlShape, FormatDateOptions, FormatNumberOptions} from '../types';
3+
import {
4+
IntlShape,
5+
FormatDateOptions,
6+
FormatNumberOptions,
7+
FormatListOptions,
8+
} from '../types';
49
import {Context} from './injectIntl';
510

611
enum DisplayName {
712
formatDate = 'FormattedDate',
813
formatTime = 'FormattedTime',
914
formatNumber = 'FormattedNumber',
15+
formatList = 'FormattedList',
1016
}
1117

1218
enum DisplayNameParts {
1319
formatDate = 'FormattedDateParts',
1420
formatTime = 'FormattedTimeParts',
1521
formatNumber = 'FormattedNumberParts',
22+
formatList = 'FormattedListParts',
1623
}
1724

1825
type Formatter = {
1926
formatDate: FormatDateOptions;
2027
formatTime: FormatDateOptions;
2128
formatNumber: FormatNumberOptions;
29+
formatList: FormatListOptions;
2230
};
2331

2432
export const FormattedNumberParts: React.FC<
@@ -39,7 +47,7 @@ export const FormattedNumberParts: React.FC<
3947
FormattedNumberParts.displayName = 'FormattedNumberParts';
4048

4149
export function createFormattedDateTimePartsComponent<
42-
Name extends keyof Formatter
50+
Name extends 'formatDate' | 'formatTime'
4351
>(name: Name) {
4452
type FormatFn = IntlShape[Name];
4553
type Props = Formatter[Name] & {
@@ -80,7 +88,8 @@ export function createFormattedComponent<Name extends keyof Formatter>(
8088
{intl => {
8189
invariantIntlContext(intl);
8290
const {value, children, ...formatProps} = props;
83-
const formattedValue = intl[name](value as any, formatProps);
91+
// TODO: fix TS type definition for localeMatcher upstream
92+
const formattedValue = intl[name](value as any, formatProps as any);
8493

8594
if (typeof children === 'function') {
8695
return children(formattedValue as any);

src/components/provider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import {formatPlural} from '../formatters/plural';
2727
import {formatMessage, formatHTMLMessage} from '../formatters/message';
2828
import * as shallowEquals_ from 'shallow-equal/objects';
29+
import {formatList} from '../formatters/list';
2930
const shallowEquals: typeof shallowEquals_ =
3031
(shallowEquals_ as any).default || shallowEquals_;
3132

@@ -177,5 +178,6 @@ export function createIntl(
177178
),
178179
formatMessage: formatMessage.bind(null, resolvedConfig, formatters),
179180
formatHTMLMessage: formatHTMLMessage.bind(null, resolvedConfig, formatters),
181+
formatList: formatList.bind(null, resolvedConfig, formatters.getListFormat),
180182
};
181183
}

src/formatters/list.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as React from 'react';
2+
import {IntlConfig, Formatters, IntlFormatters} from '../types';
3+
import {filterProps, createError} from '../utils';
4+
import IntlListFormat, {IntlListFormatOptions} from '@formatjs/intl-listformat';
5+
6+
const LIST_FORMAT_OPTIONS: Array<keyof IntlListFormatOptions> = [
7+
'localeMatcher',
8+
'type',
9+
'style',
10+
];
11+
12+
const now = Date.now();
13+
14+
function generateToken(i: number) {
15+
return `${now}_${i}_${now}`;
16+
}
17+
18+
export function formatList(
19+
{locale, onError}: Pick<IntlConfig, 'locale' | 'onError'>,
20+
getListFormat: Formatters['getListFormat'],
21+
values: Parameters<IntlFormatters['formatList']>[0],
22+
options: Parameters<IntlFormatters['formatList']>[1] = {}
23+
) {
24+
const ListFormat: typeof IntlListFormat = (Intl as any).ListFormat;
25+
if (!ListFormat) {
26+
onError(
27+
createError(`Intl.ListFormat is not available in this environment.
28+
Try polyfilling it using "@formatjs/intl-listformat"
29+
`)
30+
);
31+
}
32+
let filteredOptions = filterProps(options, LIST_FORMAT_OPTIONS);
33+
34+
try {
35+
const richValues: Record<string, React.ReactNode> = {};
36+
const serializedValues = values.map((v, i) => {
37+
if (typeof v === 'object') {
38+
const id = generateToken(i);
39+
richValues[id] = v;
40+
return id;
41+
}
42+
return String(v);
43+
});
44+
if (!Object.keys(richValues).length) {
45+
return getListFormat(locale, filteredOptions).format(serializedValues);
46+
}
47+
const parts = getListFormat(locale, filteredOptions).formatToParts(
48+
serializedValues
49+
);
50+
return parts.reduce((all: Array<string | React.ReactNode>, el) => {
51+
const val = el.value;
52+
if (richValues[val]) {
53+
all.push(richValues[val]);
54+
} else if (typeof all[all.length - 1] === 'string') {
55+
all[all.length - 1] += val;
56+
} else {
57+
all.push(val);
58+
}
59+
return all;
60+
}, []);
61+
} catch (e) {
62+
onError(createError('Error formatting list.', e));
63+
}
64+
65+
return values;
66+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {default as IntlProvider, createIntl} from './components/provider';
2222
export const FormattedDate = createFormattedComponent('formatDate');
2323
export const FormattedTime = createFormattedComponent('formatTime');
2424
export const FormattedNumber = createFormattedComponent('formatNumber');
25+
export const FormattedList = createFormattedComponent('formatList');
2526
export const FormattedDateParts = createFormattedDateTimePartsComponent(
2627
'formatDate'
2728
);

src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import IntlRelativeTimeFormat, {
1414
} from '@formatjs/intl-relativetimeformat';
1515
import {MessageFormatElement} from 'intl-messageformat-parser';
1616
import {UnifiedNumberFormatOptions} from '@formatjs/intl-unified-numberformat';
17+
import IntlListFormat, {IntlListFormatOptions} from '@formatjs/intl-listformat';
1718

1819
export interface IntlConfig {
1920
locale: string;
@@ -55,6 +56,8 @@ export type FormatPluralOptions = Exclude<
5556
> &
5657
CustomFormatConfig;
5758

59+
export type FormatListOptions = Exclude<IntlListFormatOptions, 'localeMatcher'>;
60+
5861
export interface IntlFormatters {
5962
formatDate(
6063
value: Parameters<Intl.DateTimeFormat['format']>[0] | string,
@@ -104,6 +107,10 @@ export interface IntlFormatters {
104107
descriptor: MessageDescriptor,
105108
values?: Record<string, PrimitiveType>
106109
): string;
110+
formatList(
111+
values: Array<string | React.ReactNode>,
112+
opts?: FormatListOptions
113+
): string | Array<string | React.ReactNode>;
107114
}
108115

109116
export interface Formatters {
@@ -122,6 +129,9 @@ export interface Formatters {
122129
getPluralRules(
123130
...args: ConstructorParameters<typeof Intl.PluralRules>
124131
): Intl.PluralRules;
132+
getListFormat(
133+
...args: ConstructorParameters<typeof IntlListFormat>
134+
): IntlListFormat;
125135
}
126136

127137
export interface IntlShape extends IntlConfig, IntlFormatters {
@@ -134,6 +144,7 @@ export interface IntlCache {
134144
message: Record<string, IntlMessageFormat>;
135145
relativeTime: Record<string, IntlRelativeTimeFormat>;
136146
pluralRules: Record<string, Intl.PluralRules>;
147+
list: Record<string, IntlListFormat>;
137148
}
138149

139150
export interface MessageDescriptor {

src/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export function createIntlCache(): IntlCache {
103103
message: {},
104104
relativeTime: {},
105105
pluralRules: {},
106+
list: {},
106107
};
107108
}
108109

@@ -112,6 +113,7 @@ export function createIntlCache(): IntlCache {
112113
*/
113114
export function createFormatters(cache: IntlCache = createIntlCache()) {
114115
const RelativeTimeFormat = (Intl as any).RelativeTimeFormat;
116+
const ListFormat = (Intl as any).ListFormat;
115117
return {
116118
getDateTimeFormat: memoizeIntlConstructor(
117119
Intl.DateTimeFormat,
@@ -124,6 +126,7 @@ export function createFormatters(cache: IntlCache = createIntlCache()) {
124126
cache.relativeTime
125127
),
126128
getPluralRules: memoizeIntlConstructor(Intl.PluralRules, cache.pluralRules),
129+
getListFormat: memoizeIntlConstructor(ListFormat, cache.list),
127130
};
128131
}
129132

test/setup.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {configure} from 'enzyme';
22
import '@formatjs/intl-pluralrules/polyfill-locales';
33
import '@formatjs/intl-relativetimeformat/polyfill-locales';
4+
import '@formatjs/intl-listformat/polyfill-locales';
45
import * as Adapter from 'enzyme-adapter-react-16';
56

67
configure({adapter: new Adapter()});

test/unit/components/relative.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe('<FormattedRelativeTime>', () => {
7878
expect(rendered.text()).toBe('0');
7979
expect(console.error).toHaveBeenCalledWith(
8080
expect.stringMatching(
81-
/Error formatting relative time.\nRangeError: Invalid unit argument for (.*) 'invalid'/
81+
/Error formatting relative time.\nRangeError: Invalid unit(.*)invalid/
8282
)
8383
);
8484
expect(console.error).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)