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 +
+ Alice, + Bob, and + Charlie +
+``` + 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 -
- Alice, +

+ Alice, Bob, and Charlie -

+

``` 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' ); });