From 3248a6259bcd4457f20fbec734383690d65a846d Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 11:30:01 +0100
Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Support=20formatting=20of=20React?=
=?UTF-8?q?=20elements=20via=20`format.list(=E2=80=A6)`.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/next-intl/package.json | 6 +-
packages/use-intl/package.json | 2 +-
.../use-intl/src/core/createFormatter.tsx | 72 +++++++++++++++----
.../use-intl/test/react/useFormatter.test.tsx | 59 +++++++++++++++
4 files changed, 121 insertions(+), 18 deletions(-)
diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json
index 6e0063504..63b4fa83b 100644
--- a/packages/next-intl/package.json
+++ b/packages/next-intl/package.json
@@ -114,11 +114,11 @@
"size-limit": [
{
"path": "dist/production/index.react-client.js",
- "limit": "12.865 KB"
+ "limit": "12.99 KB"
},
{
"path": "dist/production/index.react-server.js",
- "limit": "14.15 KB"
+ "limit": "13.75 KB"
},
{
"path": "dist/production/navigation.react-client.js",
@@ -134,7 +134,7 @@
},
{
"path": "dist/production/server.react-server.js",
- "limit": "12.82 KB"
+ "limit": "12.945 KB"
},
{
"path": "dist/production/middleware.js",
diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json
index 24d03e83c..203b6052f 100644
--- a/packages/use-intl/package.json
+++ b/packages/use-intl/package.json
@@ -90,7 +90,7 @@
"size-limit": [
{
"path": "dist/production/index.js",
- "limit": "12.4 kB"
+ "limit": "12.51 kB"
}
]
}
diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx
index ba4bf29b8..15230ff12 100644
--- a/packages/use-intl/src/core/createFormatter.tsx
+++ b/packages/use-intl/src/core/createFormatter.tsx
@@ -1,3 +1,4 @@
+import {ReactNode} from 'react';
import DateTimeFormatOptions from './DateTimeFormatOptions';
import Formats from './Formats';
import IntlError, {IntlErrorCode} from './IntlError';
@@ -6,6 +7,8 @@ import RelativeTimeFormatOptions from './RelativeTimeFormatOptions';
import TimeZone from './TimeZone';
import {defaultOnError} from './defaults';
+type SimpleReactNodes = string | number | boolean | null | undefined;
+
const SECOND = 1;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
@@ -103,17 +106,17 @@ export default function createFormatter({
return options;
}
- function getFormattedValue(
- value: Value,
+ function getFormattedValue(
formatOrOptions: string | Options | undefined,
typeFormats: Record | undefined,
- formatter: (options?: Options) => string
+ formatter: (options?: Options) => Output,
+ getFallback: () => Output
) {
let options;
try {
options = resolveFormatOrOptions(typeFormats, formatOrOptions);
} catch (error) {
- return String(value);
+ return getFallback();
}
try {
@@ -122,7 +125,7 @@ export default function createFormatter({
onError(
new IntlError(IntlErrorCode.FORMATTING_ERROR, (error as Error).message)
);
- return String(value);
+ return getFallback();
}
}
@@ -134,7 +137,6 @@ export default function createFormatter({
formatOrOptions?: string | DateTimeFormatOptions
) {
return getFormattedValue(
- value,
formatOrOptions,
formats?.dateTime,
(options) => {
@@ -154,7 +156,8 @@ export default function createFormatter({
}
return new Intl.DateTimeFormat(locale, options).format(value);
- }
+ },
+ () => String(value)
);
}
@@ -163,10 +166,10 @@ export default function createFormatter({
formatOrOptions?: string | NumberFormatOptions
) {
return getFormattedValue(
- value,
formatOrOptions,
formats?.number,
- (options) => new Intl.NumberFormat(locale, options).format(value)
+ (options) => new Intl.NumberFormat(locale, options).format(value),
+ () => String(value)
);
}
@@ -237,12 +240,53 @@ export default function createFormatter({
}
}
- function list(
- value: Iterable,
+ function list(
+ value: Iterable,
formatOrOptions?: string | Intl.ListFormatOptions
- ) {
- return getFormattedValue(value, formatOrOptions, formats?.list, (options) =>
- new Intl.ListFormat(locale, options).format(value)
+ ): Value extends SimpleReactNodes ? string : ReactNode {
+ const serializedValue: Array = [];
+ let hasRichValues: boolean | undefined;
+ const richValues: Record = {};
+
+ let index = 0;
+ for (const item of value) {
+ if (
+ item && // `null` is an `object` too
+ typeof item === 'object'
+ ) {
+ const id = String(index);
+ richValues[id] = item;
+ serializedValue.push(id);
+ hasRichValues = true;
+ } else {
+ serializedValue.push(String(item));
+ }
+ index++;
+ }
+
+ return getFormattedValue<
+ Intl.ListFormatOptions,
+ Value extends SimpleReactNodes ? string : ReactNode
+ >(
+ formatOrOptions,
+ formats?.list,
+ // @ts-expect-error -- `hasRichValues` is used to determine the return type, but TypeScript can't infer the meaning of this variable correctly
+ (options) => {
+ const result = new Intl.ListFormat(locale, options)
+ .formatToParts(serializedValue)
+ .map((part) =>
+ part.type === 'literal'
+ ? part.value
+ : richValues[part.value] || part.value
+ );
+
+ if (hasRichValues) {
+ return result;
+ } else {
+ return result.join('');
+ }
+ },
+ () => String(value)
);
}
diff --git a/packages/use-intl/test/react/useFormatter.test.tsx b/packages/use-intl/test/react/useFormatter.test.tsx
index b21ec84a9..062e3721a 100644
--- a/packages/use-intl/test/react/useFormatter.test.tsx
+++ b/packages/use-intl/test/react/useFormatter.test.tsx
@@ -513,6 +513,65 @@ describe('list', () => {
screen.getByText('apple, banana, and orange');
});
+ it('returns a string for non-JSX elements', () => {
+ function Component() {
+ const format = useFormatter();
+ const value = ['apple', 23, true, null, undefined];
+ const result = format.list(value);
+ expect(typeof result).toBe('string');
+
+ function expectString(v: string) {
+ return v;
+ }
+
+ return expectString(result);
+ }
+
+ render(
+
+
+
+ );
+
+ screen.getByText('apple, 23, true, null, and undefined');
+ });
+
+ it('formats a list of rich elements', () => {
+ const users = [
+ {id: 1, name: 'Alice'},
+ {id: 2, name: 'Bob'},
+ {id: 3, name: 'Charlie'}
+ ];
+
+ function Component() {
+ const format = useFormatter();
+ const result = format.list(
+ users.map((user) => (
+
+ {user.name}
+
+ ))
+ );
+
+ function expectReactNode(v: ReactNode) {
+ return v;
+ }
+
+ expect(Array.isArray(result)).toBe(true);
+ return expectReactNode(result);
+ }
+
+ const {container} = render(
+
+
+
+ );
+
+ expect(container.innerHTML).toEqual(
+ 'Alice, Bob, and Charlie'
+ );
+ });
+
it('accepts a set', () => {
renderList(new Set(['apple', 'banana', 'orange']));
screen.getByText('apple, banana, and orange');
From 59c63a03c7a6497ea264572d3dcdc8ce183230ab Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 11:44:49 +0100
Subject: [PATCH 2/7] Docs
---
docs/pages/docs/usage/lists.mdx | 41 +++++++++++++++++--
.../use-intl/src/core/createFormatter.tsx | 6 +--
2 files changed, 40 insertions(+), 7 deletions(-)
diff --git a/docs/pages/docs/usage/lists.mdx b/docs/pages/docs/usage/lists.mdx
index 48e0fe30b..03f7d3092 100644
--- a/docs/pages/docs/usage/lists.mdx
+++ b/docs/pages/docs/usage/lists.mdx
@@ -30,10 +30,7 @@ See [the MDN docs about `ListFormat`](https://developer.mozilla.org/en-US/docs/W
Note that lists can can currently only be formatted via `useFormatter`, there's no equivalent inline syntax for messages at this point.
-
- To reuse list formats for multiple components, you can configure [global
- formats](/docs/usage/configuration#formats).
-
+To reuse list formats for multiple components, you can configure [global formats](/docs/usage/configuration#formats).
How can I render an array of messages?
@@ -41,3 +38,39 @@ Note that lists can can currently only be formatted via `useFormatter`, there's
See the [arrays of messages guide](/docs/usage/messages#arrays-of-messages).
+
+## Accepted values
+
+While [`Intl.ListFormat#format`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/format) only accepts `string` values, `format.list` accepts a broader range of values that can be formatted:
+
+- `string`
+- `number`
+- `boolean`
+- `ReactNode`
+- `null`
+- `undefined`
+
+Note that if you pass elements that are a non-primitive `ReactNode`, the formatter will return a `ReactNode` too.
+
+```tsx
+import {useFormatter} from 'next-intl';
+
+function Component() {
+ const format = useFormatter();
+
+ const users = [
+ {id: 1, name: 'Alice'},
+ {id: 2, name: 'Bob'},
+ {id: 3, name: 'Charlie'}
+ ];
+
+ // Returns a `ReactNode`
+ return format.list(
+ users.map((user) => (
+
+ {user.name}
+
+ ))
+ );
+}
+```
diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx
index 15230ff12..eb0f7252e 100644
--- a/packages/use-intl/src/core/createFormatter.tsx
+++ b/packages/use-intl/src/core/createFormatter.tsx
@@ -7,7 +7,7 @@ import RelativeTimeFormatOptions from './RelativeTimeFormatOptions';
import TimeZone from './TimeZone';
import {defaultOnError} from './defaults';
-type SimpleReactNodes = string | number | boolean | null | undefined;
+type PrimitiveReactNodes = string | number | boolean | null | undefined;
const SECOND = 1;
const MINUTE = SECOND * 60;
@@ -243,7 +243,7 @@ export default function createFormatter({
function list(
value: Iterable,
formatOrOptions?: string | Intl.ListFormatOptions
- ): Value extends SimpleReactNodes ? string : ReactNode {
+ ): Value extends PrimitiveReactNodes ? string : ReactNode {
const serializedValue: Array = [];
let hasRichValues: boolean | undefined;
const richValues: Record = {};
@@ -266,7 +266,7 @@ export default function createFormatter({
return getFormattedValue<
Intl.ListFormatOptions,
- Value extends SimpleReactNodes ? string : ReactNode
+ Value extends PrimitiveReactNodes ? string : ReactNode
>(
formatOrOptions,
formats?.list,
From bed41cadcf13a788fe52d988330daf60f7bf81f2 Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 12:11:32 +0100
Subject: [PATCH 3/7] Don't accept number, boolean, null and undefined
---
docs/pages/docs/usage/lists.mdx | 16 ++++----------
.../use-intl/src/core/createFormatter.tsx | 16 +++++---------
.../use-intl/test/react/useFormatter.test.tsx | 22 +++++++++----------
3 files changed, 20 insertions(+), 34 deletions(-)
diff --git a/docs/pages/docs/usage/lists.mdx b/docs/pages/docs/usage/lists.mdx
index 03f7d3092..271c6c0f7 100644
--- a/docs/pages/docs/usage/lists.mdx
+++ b/docs/pages/docs/usage/lists.mdx
@@ -39,18 +39,9 @@ See the [arrays of messages guide](/docs/usage/messages#arrays-of-messages).
-## Accepted values
+## Formatting of React elements [#react-elements]
-While [`Intl.ListFormat#format`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/format) only accepts `string` values, `format.list` accepts a broader range of values that can be formatted:
-
-- `string`
-- `number`
-- `boolean`
-- `ReactNode`
-- `null`
-- `undefined`
-
-Note that if you pass elements that are a non-primitive `ReactNode`, the formatter will return a `ReactNode` too.
+Apart from string values, you can also pass React elements to the formatting function:
```tsx
import {useFormatter} from 'next-intl';
@@ -64,7 +55,6 @@ function Component() {
{id: 3, name: 'Charlie'}
];
- // Returns a `ReactNode`
return format.list(
users.map((user) => (
@@ -74,3 +64,5 @@ function Component() {
);
}
```
+
+Note that `format.list` will return an `Iterable` in this case.
diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx
index eb0f7252e..00a0651cc 100644
--- a/packages/use-intl/src/core/createFormatter.tsx
+++ b/packages/use-intl/src/core/createFormatter.tsx
@@ -1,4 +1,4 @@
-import {ReactNode} from 'react';
+import {ReactElement} from 'react';
import DateTimeFormatOptions from './DateTimeFormatOptions';
import Formats from './Formats';
import IntlError, {IntlErrorCode} from './IntlError';
@@ -7,8 +7,6 @@ import RelativeTimeFormatOptions from './RelativeTimeFormatOptions';
import TimeZone from './TimeZone';
import {defaultOnError} from './defaults';
-type PrimitiveReactNodes = string | number | boolean | null | undefined;
-
const SECOND = 1;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
@@ -240,20 +238,18 @@ export default function createFormatter({
}
}
- function list(
+ type FormattableListValue = string | ReactElement | Iterable;
+ function list(
value: Iterable,
formatOrOptions?: string | Intl.ListFormatOptions
- ): Value extends PrimitiveReactNodes ? string : ReactNode {
+ ): Value extends string ? string : Iterable {
const serializedValue: Array = [];
let hasRichValues: boolean | undefined;
const richValues: Record = {};
let index = 0;
for (const item of value) {
- if (
- item && // `null` is an `object` too
- typeof item === 'object'
- ) {
+ if (typeof item === 'object') {
const id = String(index);
richValues[id] = item;
serializedValue.push(id);
@@ -266,7 +262,7 @@ export default function createFormatter({
return getFormattedValue<
Intl.ListFormatOptions,
- Value extends PrimitiveReactNodes ? string : ReactNode
+ Value extends string ? string : Iterable
>(
formatOrOptions,
formats?.list,
diff --git a/packages/use-intl/test/react/useFormatter.test.tsx b/packages/use-intl/test/react/useFormatter.test.tsx
index 062e3721a..d00fda8d6 100644
--- a/packages/use-intl/test/react/useFormatter.test.tsx
+++ b/packages/use-intl/test/react/useFormatter.test.tsx
@@ -509,14 +509,9 @@ describe('list', () => {
}
it('formats a list', () => {
- renderList(['apple', 'banana', 'orange']);
- screen.getByText('apple, banana, and orange');
- });
-
- it('returns a string for non-JSX elements', () => {
function Component() {
const format = useFormatter();
- const value = ['apple', 23, true, null, undefined];
+ const value = ['apple', 'banana', 'orange'];
const result = format.list(value);
expect(typeof result).toBe('string');
@@ -533,7 +528,7 @@ describe('list', () => {
);
- screen.getByText('apple, 23, true, null, and undefined');
+ screen.getByText('apple, banana, and orange');
});
it('formats a list of rich elements', () => {
@@ -545,13 +540,16 @@ describe('list', () => {
function Component() {
const format = useFormatter();
- const result = format.list(
- users.map((user) => (
+
+ const result = format.list([
+ ...users.map((user) => (
{user.name}
- ))
- );
+ )),
+ // An `Iterable` as a single element
+ [One, Two]
+ ]);
function expectReactNode(v: ReactNode) {
return v;
@@ -568,7 +566,7 @@ describe('list', () => {
);
expect(container.innerHTML).toEqual(
- 'Alice, Bob, and Charlie'
+ 'Alice, Bob, Charlie, and OneTwo'
);
});
From 9e81a23e1497840ba5397d64a407a02d4fcd13d6 Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 12:13:34 +0100
Subject: [PATCH 4/7] Cleanup
---
packages/use-intl/test/react/useFormatter.test.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/use-intl/test/react/useFormatter.test.tsx b/packages/use-intl/test/react/useFormatter.test.tsx
index d00fda8d6..6083aa0fe 100644
--- a/packages/use-intl/test/react/useFormatter.test.tsx
+++ b/packages/use-intl/test/react/useFormatter.test.tsx
@@ -1,6 +1,6 @@
import {render, screen} from '@testing-library/react';
import {parseISO} from 'date-fns';
-import React, {ComponentProps, ReactNode} from 'react';
+import React, {ComponentProps, ReactNode, ReactElement} from 'react';
import {it, expect, describe, vi} from 'vitest';
import {
DateTimeFormatOptions,
@@ -551,12 +551,12 @@ describe('list', () => {
[One, Two]
]);
- function expectReactNode(v: ReactNode) {
+ function expectIterableReactElement(v: Iterable) {
return v;
}
expect(Array.isArray(result)).toBe(true);
- return expectReactNode(result);
+ return expectIterableReactElement(result);
}
const {container} = render(
From 0e33e4599e96a8c3aea4b6183d8ef2b224344552 Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 13:20:36 +0100
Subject: [PATCH 5/7] Improve docs
---
docs/pages/docs/usage/lists.mdx | 26 ++++++++++++++++++--------
1 file changed, 18 insertions(+), 8 deletions(-)
diff --git a/docs/pages/docs/usage/lists.mdx b/docs/pages/docs/usage/lists.mdx
index 271c6c0f7..6eac6e8ff 100644
--- a/docs/pages/docs/usage/lists.mdx
+++ b/docs/pages/docs/usage/lists.mdx
@@ -41,7 +41,7 @@ See the [arrays of messages guide](/docs/usage/messages#arrays-of-messages).
## Formatting of React elements [#react-elements]
-Apart from string values, you can also pass React elements to the formatting function:
+Apart from string values, you can also pass arrays of React elements to the formatting function:
```tsx
import {useFormatter} from 'next-intl';
@@ -55,14 +55,24 @@ function Component() {
{id: 3, name: 'Charlie'}
];
- return format.list(
- users.map((user) => (
-
- {user.name}
-
- ))
- );
+ const items = users.map((user) => (
+
+ {user.name}
+
+ ));
+
+ return {format.list(items)}
;
}
```
+**Result:**
+
+```html
+
+```
+
Note that `format.list` will return an `Iterable` in this case.
From 8cbd759b1f0c6e272d69f091c7e22971eac8d120 Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 13:22:17 +0100
Subject: [PATCH 6/7] Improve docs
---
docs/pages/docs/usage/lists.mdx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/pages/docs/usage/lists.mdx b/docs/pages/docs/usage/lists.mdx
index 6eac6e8ff..e7fa671b6 100644
--- a/docs/pages/docs/usage/lists.mdx
+++ b/docs/pages/docs/usage/lists.mdx
@@ -61,18 +61,18 @@ function Component() {
));
- return {format.list(items)}
;
+ return {format.list(items)}
;
}
```
**Result:**
```html
-
+
```
Note that `format.list` will return an `Iterable` in this case.
From 7fab25b55377a9cca7bc250052f47e1e768e829a Mon Sep 17 00:00:00 2001
From: Jan Amann
Date: Thu, 8 Feb 2024 16:52:15 +0100
Subject: [PATCH 7/7] Remove `Iterable` as input and slightly
smaller
---
packages/use-intl/package.json | 2 +-
.../use-intl/src/core/createFormatter.tsx | 24 ++++++++++---------
.../use-intl/test/react/useFormatter.test.tsx | 12 ++++------
3 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json
index 203b6052f..de3dad5f5 100644
--- a/packages/use-intl/package.json
+++ b/packages/use-intl/package.json
@@ -90,7 +90,7 @@
"size-limit": [
{
"path": "dist/production/index.js",
- "limit": "12.51 kB"
+ "limit": "12.5 kB"
}
]
}
diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx
index 00a0651cc..45105bca0 100644
--- a/packages/use-intl/src/core/createFormatter.tsx
+++ b/packages/use-intl/src/core/createFormatter.tsx
@@ -238,25 +238,27 @@ export default function createFormatter({
}
}
- type FormattableListValue = string | ReactElement | Iterable;
+ type FormattableListValue = string | ReactElement;
function list(
value: Iterable,
formatOrOptions?: string | Intl.ListFormatOptions
): Value extends string ? string : Iterable {
const serializedValue: Array = [];
- let hasRichValues: boolean | undefined;
- const richValues: Record = {};
+ const richValues = new Map();
+ // `formatToParts` only accepts strings, therefore we have to temporarily
+ // replace React elements with a placeholder ID that can be used to retrieve
+ // the original value afterwards.
let index = 0;
for (const item of value) {
+ let serializedItem;
if (typeof item === 'object') {
- const id = String(index);
- richValues[id] = item;
- serializedValue.push(id);
- hasRichValues = true;
+ serializedItem = String(index);
+ richValues.set(serializedItem, item);
} else {
- serializedValue.push(String(item));
+ serializedItem = String(item);
}
+ serializedValue.push(serializedItem);
index++;
}
@@ -266,17 +268,17 @@ export default function createFormatter({
>(
formatOrOptions,
formats?.list,
- // @ts-expect-error -- `hasRichValues` is used to determine the return type, but TypeScript can't infer the meaning of this variable correctly
+ // @ts-expect-error -- `richValues.size` is used to determine the return type, but TypeScript can't infer the meaning of this correctly
(options) => {
const result = new Intl.ListFormat(locale, options)
.formatToParts(serializedValue)
.map((part) =>
part.type === 'literal'
? part.value
- : richValues[part.value] || part.value
+ : richValues.get(part.value) || part.value
);
- if (hasRichValues) {
+ if (richValues.size > 0) {
return result;
} else {
return result.join('');
diff --git a/packages/use-intl/test/react/useFormatter.test.tsx b/packages/use-intl/test/react/useFormatter.test.tsx
index 6083aa0fe..c2a53f897 100644
--- a/packages/use-intl/test/react/useFormatter.test.tsx
+++ b/packages/use-intl/test/react/useFormatter.test.tsx
@@ -541,15 +541,13 @@ describe('list', () => {
function Component() {
const format = useFormatter();
- const result = format.list([
- ...users.map((user) => (
+ const result = format.list(
+ users.map((user) => (
{user.name}
- )),
- // An `Iterable` as a single element
- [One, Two]
- ]);
+ ))
+ );
function expectIterableReactElement(v: Iterable) {
return v;
@@ -566,7 +564,7 @@ describe('list', () => {
);
expect(container.innerHTML).toEqual(
- 'Alice, Bob, Charlie, and OneTwo'
+ 'Alice, Bob, and Charlie'
);
});