Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support formatting of React elements via format.list(…). #845

Merged
merged 7 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions docs/pages/docs/usage/lists.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,49 @@ 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.

<Callout>
To reuse list formats for multiple components, you can configure [global
formats](/docs/usage/configuration#formats).
</Callout>
To reuse list formats for multiple components, you can configure [global formats](/docs/usage/configuration#formats).

<details>
<summary>How can I render an array of messages?</summary>

See the [arrays of messages guide](/docs/usage/messages#arrays-of-messages).

</details>

## Formatting of React elements [#react-elements]

Apart from string values, you can also pass arrays of React elements to the formatting function:

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

const items = users.map((user) => (
<a key={user.id} href={`/user/${user.id}`}>
{user.name}
</a>
));

return <p>{format.list(items)}</p>;
}
```

**Result:**

```html
<p>
<a href="/user/1">Alice</a>,
<a href="/user/2">Bob</a>, and
<a href="/user/3">Charlie</a>
</p>
```

Note that `format.list` will return an `Iterable<ReactElement>` in this case.
6 changes: 3 additions & 3 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -134,7 +134,7 @@
},
{
"path": "dist/production/server.react-server.js",
"limit": "12.82 KB"
"limit": "12.945 KB"
},
{
"path": "dist/production/middleware.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/use-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
"size-limit": [
{
"path": "dist/production/index.js",
"limit": "12.4 kB"
"limit": "12.5 kB"
}
]
}
70 changes: 56 additions & 14 deletions packages/use-intl/src/core/createFormatter.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ReactElement} from 'react';
import DateTimeFormatOptions from './DateTimeFormatOptions';
import Formats from './Formats';
import IntlError, {IntlErrorCode} from './IntlError';
Expand Down Expand Up @@ -103,17 +104,17 @@ export default function createFormatter({
return options;
}

function getFormattedValue<Value, Options>(
value: Value,
function getFormattedValue<Options, Output>(
formatOrOptions: string | Options | undefined,
typeFormats: Record<string, Options> | 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 {
Expand All @@ -122,7 +123,7 @@ export default function createFormatter({
onError(
new IntlError(IntlErrorCode.FORMATTING_ERROR, (error as Error).message)
);
return String(value);
return getFallback();
}
}

Expand All @@ -134,7 +135,6 @@ export default function createFormatter({
formatOrOptions?: string | DateTimeFormatOptions
) {
return getFormattedValue(
value,
formatOrOptions,
formats?.dateTime,
(options) => {
Expand All @@ -154,7 +154,8 @@ export default function createFormatter({
}

return new Intl.DateTimeFormat(locale, options).format(value);
}
},
() => String(value)
);
}

Expand All @@ -163,10 +164,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)
);
}

Expand Down Expand Up @@ -237,12 +238,53 @@ export default function createFormatter({
}
}

function list(
value: Iterable<string>,
type FormattableListValue = string | ReactElement;
function list<Value extends FormattableListValue>(
value: Iterable<Value>,
formatOrOptions?: string | Intl.ListFormatOptions
) {
return getFormattedValue(value, formatOrOptions, formats?.list, (options) =>
new Intl.ListFormat(locale, options).format(value)
): Value extends string ? string : Iterable<ReactElement> {
const serializedValue: Array<string> = [];
const richValues = new Map<string, Value>();

// `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') {
serializedItem = String(index);
richValues.set(serializedItem, item);
} else {
serializedItem = String(item);
}
serializedValue.push(serializedItem);
index++;
}

return getFormattedValue<
Intl.ListFormatOptions,
Value extends string ? string : Iterable<ReactElement>
>(
formatOrOptions,
formats?.list,
// @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.get(part.value) || part.value
);

if (richValues.size > 0) {
return result;
} else {
return result.join('');
}
},
() => String(value)
);
}

Expand Down
59 changes: 57 additions & 2 deletions packages/use-intl/test/react/useFormatter.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -509,10 +509,65 @@ describe('list', () => {
}

it('formats a list', () => {
renderList(['apple', 'banana', 'orange']);
function Component() {
const format = useFormatter();
const value = ['apple', 'banana', 'orange'];
const result = format.list(value);
expect(typeof result).toBe('string');

function expectString(v: string) {
return v;
}

return expectString(result);
}

render(
<MockProvider>
<Component />
</MockProvider>
);

screen.getByText('apple, banana, and orange');
});

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) => (
<a key={user.id} href={`/user/${user.id}`}>
{user.name}
</a>
))
);

function expectIterableReactElement(v: Iterable<ReactElement>) {
return v;
}

expect(Array.isArray(result)).toBe(true);
return expectIterableReactElement(result);
}

const {container} = render(
<MockProvider>
<Component />
</MockProvider>
);

expect(container.innerHTML).toEqual(
'<a href="/user/1">Alice</a>, <a href="/user/2">Bob</a>, and <a href="/user/3">Charlie</a>'
);
});

it('accepts a set', () => {
renderList(new Set(['apple', 'banana', 'orange']));
screen.getByText('apple, banana, and orange');
Expand Down
Loading