Skip to content

Commit ffbc184

Browse files
authored
Merge pull request #11204 from marmelab/translate-element
Add ability to use elements in Translate interpolation options
2 parents 1e92f79 + 9d6e44e commit ffbc184

File tree

9 files changed

+268
-13
lines changed

9 files changed

+268
-13
lines changed

docs/Translate.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,33 @@ const messages = {
8989

9090
{% endraw %}
9191

92+
### React Element Interpolation
93+
94+
Unlike `useTranslate`, `<Translate>` supports React elements as interpolation values. This is useful when you need to include styled or interactive content within a translated message.
95+
96+
{% raw %}
97+
98+
```tsx
99+
const messages = {
100+
custom: {
101+
welcome: 'Hello, %{name}! Welcome to %{app}.',
102+
},
103+
};
104+
105+
<Translate
106+
i18nKey="custom.welcome"
107+
options={{
108+
name: <strong>John</strong>,
109+
app: <a href="https://marmelab.com/react-admin">react-admin</a>,
110+
}}
111+
/>
112+
// Hello, <strong>John</strong>! Welcome to <a href="...">react-admin</a>.
113+
```
114+
115+
{% endraw %}
116+
117+
**Tip:** This feature is only available in the `<Translate>` component, not in the `useTranslate` hook.
118+
92119
One particular option is `smart_count`, which is used for pluralization.
93120

94121
{% raw %}

docs_headless/src/content/docs/Translate.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,29 @@ const messages = {
8282
// Hello, John!
8383
```
8484

85+
### React Element Interpolation
86+
87+
Unlike `useTranslate`, `<Translate>` supports React elements as interpolation values. This is useful when you need to include styled or interactive content within a translated message.
88+
89+
```tsx
90+
const messages = {
91+
custom: {
92+
welcome: 'Hello, %{name}! Welcome to %{app}.',
93+
},
94+
};
95+
96+
<Translate
97+
i18nKey="custom.welcome"
98+
options={{
99+
name: <strong>John</strong>,
100+
app: <a href="https://marmelab.com/react-admin">react-admin</a>,
101+
}}
102+
/>
103+
// Hello, <strong>John</strong>! Welcome to <a href="...">react-admin</a>.
104+
```
105+
106+
**Tip:** This feature is only available in the `<Translate>` component, not in the `useTranslate` hook.
107+
85108
One particular option is `smart_count`, which is used for pluralization.
86109

87110
```tsx

packages/ra-core/src/i18n/Translate.spec.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
NoTranslation,
88
NoTranslationWithChildrenAsNode,
99
NoTranslationWithChildrenAsString,
10+
ReactElementInterpolation,
1011
} from './Translate.stories';
1112

1213
describe('<Translate />', () => {
@@ -45,4 +46,11 @@ describe('<Translate />', () => {
4546
const { container } = render(<Options />);
4647
expect(container.innerHTML).toBe('It cost 6.00 $');
4748
});
49+
50+
it('should render the translation with React element interpolation', () => {
51+
const { container } = render(<ReactElementInterpolation />);
52+
expect(container.innerHTML).toBe(
53+
'Hello <strong>John</strong>, welcome to <em>react-admin</em>!'
54+
);
55+
});
4856
});

packages/ra-core/src/i18n/Translate.stories.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,22 @@ export const Options = () => (
5151
<Translate i18nKey="custom.myKey" options={{ price: '6' }} />
5252
</TestTranslationProvider>
5353
);
54+
55+
export const ReactElementInterpolation = () => (
56+
<TestTranslationProvider
57+
messages={{
58+
custom: {
59+
myKey: ({ name, place }) =>
60+
`Hello ${name}, welcome to ${place}!`,
61+
},
62+
}}
63+
>
64+
<Translate
65+
i18nKey="custom.myKey"
66+
options={{
67+
name: <strong>John</strong>,
68+
place: <em>react-admin</em>,
69+
}}
70+
/>
71+
</TestTranslationProvider>
72+
);
Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,65 @@
1-
import React, { ReactNode } from 'react';
1+
import React, { isValidElement, ReactElement, ReactNode } from 'react';
22
import { useTranslate } from './useTranslate';
33

4+
const SPLIT_MARKER = '#@RA_CORE_INTERNAL_SPLIT@#';
5+
46
export const Translate = ({ i18nKey, options, children }: TranslateProps) => {
57
const translate = useTranslate();
6-
const translatedMessage = translate(
7-
i18nKey,
8-
typeof children === 'string' ? { _: children, ...options } : options
9-
);
108

11-
if (translatedMessage) {
9+
// Separate React element values from plain values
10+
const elementMap: Record<string, ReactElement> = {};
11+
const sanitizedOptions: Record<string, any> = {};
12+
let placeholderIndex = 0;
13+
14+
if (options) {
15+
for (const [key, value] of Object.entries(options)) {
16+
if (isValidElement(value)) {
17+
const placeholder = `TRANSLATION_PLACEHOLDER_${placeholderIndex++}`;
18+
elementMap[placeholder] = value;
19+
sanitizedOptions[key] =
20+
`${SPLIT_MARKER}${placeholder}${SPLIT_MARKER}`;
21+
} else {
22+
sanitizedOptions[key] = value;
23+
}
24+
}
25+
}
26+
27+
const translateOptions =
28+
typeof children === 'string'
29+
? { _: children, ...sanitizedOptions }
30+
: sanitizedOptions;
31+
32+
const translatedMessage = translate(i18nKey, translateOptions);
33+
34+
if (!translatedMessage) {
35+
return children;
36+
}
37+
38+
// If no elements were extracted, return plain string
39+
if (placeholderIndex === 0) {
1240
return <>{translatedMessage}</>;
1341
}
14-
return children;
42+
43+
// Split the translated string and replace placeholders with React elements
44+
const parts = translatedMessage.split(SPLIT_MARKER);
45+
return (
46+
<>
47+
{/* After splitting by SPLIT_MARKER, even indices are text and odd indices are placeholders */}
48+
{parts.map((part, index) =>
49+
index % 2 === 1 ? (
50+
<React.Fragment key={index}>
51+
{elementMap[part]}
52+
</React.Fragment>
53+
) : (
54+
<React.Fragment key={index}>{part}</React.Fragment>
55+
)
56+
)}
57+
</>
58+
);
1559
};
1660

1761
export interface TranslateProps {
1862
i18nKey: string;
1963
children?: ReactNode;
20-
options?: Object;
64+
options?: Record<string, any>;
2165
}

packages/ra-i18n-i18next/src/index.spec.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
WithCustomOptions,
77
WithLazyLoadedLanguages,
88
TranslateComponent,
9+
TranslateWithReactElement,
910
} from './index.stories';
1011

1112
describe('i18next i18nProvider', () => {
@@ -79,4 +80,13 @@ describe('i18next i18nProvider', () => {
7980
);
8081
});
8182
});
83+
84+
test('should support React elements in <Translate> options', async () => {
85+
const { container } = render(<TranslateWithReactElement />);
86+
await waitFor(() => {
87+
expect(container.innerHTML).toContain(
88+
'Hello, <strong>John</strong>! Welcome to <em>react-admin</em>.'
89+
);
90+
});
91+
});
8292
});

packages/ra-i18n-i18next/src/index.stories.tsx

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {
99
import i18n from 'i18next';
1010
import resourcesToBackend from 'i18next-resources-to-backend';
1111
import englishMessages from 'ra-language-english';
12+
import frenchMessages from 'ra-language-french';
1213
import fakeRestDataProvider from 'ra-data-fakerest';
13-
import { Translate, I18nContextProvider } from 'ra-core';
14+
import { Translate, I18nContextProvider, useI18nProvider } from 'ra-core';
1415
import { useI18nextProvider, convertRaTranslationsToI18next } from './index';
1516

1617
export default {
@@ -173,6 +174,74 @@ export const WithCustomOptions = () => {
173174
);
174175
};
175176

177+
export const TranslateWithReactElement = () => {
178+
const i18nProvider = useI18nextProvider({
179+
options: {
180+
resources: {
181+
en: {
182+
translation: convertRaTranslationsToI18next({
183+
...englishMessages,
184+
custom: {
185+
welcome: 'Hello, %{name}! Welcome to %{place}.',
186+
},
187+
}),
188+
},
189+
fr: {
190+
translation: convertRaTranslationsToI18next({
191+
...frenchMessages,
192+
custom: {
193+
welcome: 'Bonjour, %{name}! Bienvenue à %{place}.',
194+
},
195+
}),
196+
},
197+
},
198+
},
199+
availableLocales: [
200+
{ locale: 'en', name: 'English' },
201+
{ locale: 'fr', name: 'Français' },
202+
],
203+
});
204+
205+
if (!i18nProvider) return null;
206+
207+
return (
208+
<I18nContextProvider value={i18nProvider}>
209+
<TranslateWithReactElementContent />
210+
</I18nContextProvider>
211+
);
212+
};
213+
214+
const TranslateWithReactElementContent = () => {
215+
const i18nProvider = useI18nProvider();
216+
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
217+
218+
const handleLocaleChange = async (locale: string) => {
219+
await i18nProvider.changeLocale(locale);
220+
forceUpdate();
221+
};
222+
223+
return (
224+
<div>
225+
<div>
226+
<button onClick={() => handleLocaleChange('en')}>
227+
English
228+
</button>
229+
<button onClick={() => handleLocaleChange('fr')}>
230+
Français
231+
</button>
232+
</div>
233+
<br />
234+
<Translate
235+
i18nKey="custom.welcome"
236+
options={{
237+
name: <strong>John</strong>,
238+
place: <em>react-admin</em>,
239+
}}
240+
/>
241+
</div>
242+
);
243+
};
244+
176245
export const TranslateComponent = () => {
177246
const i18nProvider = useI18nextProvider({
178247
options: {
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as React from 'react';
22
import { render, waitFor } from '@testing-library/react';
3-
import { TranslateComponent } from './index.stories';
3+
import { TranslateComponent, TranslateWithReactElement } from './index.stories';
44

5-
describe('i18next i18nProvider', () => {
5+
describe('polyglot i18nProvider', () => {
66
test('should be compatible with the <Translate> component', async () => {
77
const { container } = render(<TranslateComponent />);
88
await waitFor(() => {
@@ -11,4 +11,13 @@ describe('i18next i18nProvider', () => {
1111
);
1212
});
1313
});
14+
15+
test('should support React elements in <Translate> options', async () => {
16+
const { container } = render(<TranslateWithReactElement />);
17+
await waitFor(() => {
18+
expect(container.innerHTML).toEqual(
19+
'Hello, <strong>John</strong>! Welcome to <em>react-admin</em>.'
20+
);
21+
});
22+
});
1423
});

packages/ra-i18n-polyglot/src/index.stories.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default { title: 'ra-i18n-polyglot' };
2323

2424
export const Basic = () => {
2525
const i18nProvider = polyglotI18nProvider(
26-
locale => messages[locale],
26+
locale => (locale === 'fr' ? messages['fr'] : messages['en']),
2727
'en',
2828
[
2929
{ locale: 'en', name: 'English' },
@@ -102,7 +102,7 @@ export const TranslateComponent = () => {
102102
};
103103

104104
const i18nProvider = polyglotI18nProvider(
105-
locale => messages[locale],
105+
locale => (locale === 'fr' ? messages['fr'] : messages['en']),
106106
'en',
107107
[
108108
{ locale: 'en', name: 'English' },
@@ -128,3 +128,49 @@ export const TranslateComponent = () => {
128128
</I18nContextProvider>
129129
);
130130
};
131+
132+
export const TranslateWithReactElement = ({ locale = 'en' }) => {
133+
const messages = {
134+
fr: {
135+
...frenchMessages,
136+
custom: {
137+
welcome: 'Bonjour, %{name}! Bienvenue à %{place}.',
138+
},
139+
},
140+
en: {
141+
...englishMessages,
142+
custom: {
143+
welcome: 'Hello, %{name}! Welcome to %{place}.',
144+
},
145+
},
146+
};
147+
const i18nProvider = polyglotI18nProvider(
148+
locale => (locale === 'fr' ? messages['fr'] : messages['en']),
149+
locale,
150+
[
151+
{ locale: 'en', name: 'English' },
152+
{ locale: 'fr', name: 'Français' },
153+
]
154+
);
155+
156+
return (
157+
<I18nContextProvider value={i18nProvider}>
158+
<Translate
159+
i18nKey="custom.welcome"
160+
options={{
161+
name: <strong>John</strong>,
162+
place: <em>react-admin</em>,
163+
}}
164+
/>
165+
</I18nContextProvider>
166+
);
167+
};
168+
TranslateWithReactElement.args = {
169+
locale: 'en',
170+
};
171+
TranslateWithReactElement.argTypes = {
172+
locale: {
173+
control: 'select',
174+
options: ['en', 'fr'],
175+
},
176+
};

0 commit comments

Comments
 (0)