diff --git a/docs/Translate.md b/docs/Translate.md index 0549119c277..1b46e4b83d9 100644 --- a/docs/Translate.md +++ b/docs/Translate.md @@ -89,6 +89,33 @@ const messages = { {% endraw %} +### React Element Interpolation + +Unlike `useTranslate`, `` supports React elements as interpolation values. This is useful when you need to include styled or interactive content within a translated message. + +{% raw %} + +```tsx +const messages = { + custom: { + welcome: 'Hello, %{name}! Welcome to %{app}.', + }, +}; + +John, + app: react-admin, + }} +/> +// Hello, John! Welcome to react-admin. +``` + +{% endraw %} + +**Tip:** This feature is only available in the `` component, not in the `useTranslate` hook. + One particular option is `smart_count`, which is used for pluralization. {% raw %} diff --git a/docs_headless/src/content/docs/Translate.md b/docs_headless/src/content/docs/Translate.md index 2c55d23d8de..a96624a7b8f 100644 --- a/docs_headless/src/content/docs/Translate.md +++ b/docs_headless/src/content/docs/Translate.md @@ -82,6 +82,29 @@ const messages = { // Hello, John! ``` +### React Element Interpolation + +Unlike `useTranslate`, `` supports React elements as interpolation values. This is useful when you need to include styled or interactive content within a translated message. + +```tsx +const messages = { + custom: { + welcome: 'Hello, %{name}! Welcome to %{app}.', + }, +}; + +John, + app: react-admin, + }} +/> +// Hello, John! Welcome to react-admin. +``` + +**Tip:** This feature is only available in the `` component, not in the `useTranslate` hook. + One particular option is `smart_count`, which is used for pluralization. ```tsx diff --git a/packages/ra-core/src/i18n/Translate.spec.tsx b/packages/ra-core/src/i18n/Translate.spec.tsx index c8763d3c0c0..9a6634f2941 100644 --- a/packages/ra-core/src/i18n/Translate.spec.tsx +++ b/packages/ra-core/src/i18n/Translate.spec.tsx @@ -7,6 +7,7 @@ import { NoTranslation, NoTranslationWithChildrenAsNode, NoTranslationWithChildrenAsString, + ReactElementInterpolation, } from './Translate.stories'; describe('', () => { @@ -45,4 +46,11 @@ describe('', () => { const { container } = render(); expect(container.innerHTML).toBe('It cost 6.00 $'); }); + + it('should render the translation with React element interpolation', () => { + const { container } = render(); + expect(container.innerHTML).toBe( + 'Hello John, welcome to react-admin!' + ); + }); }); diff --git a/packages/ra-core/src/i18n/Translate.stories.tsx b/packages/ra-core/src/i18n/Translate.stories.tsx index 3e172b76239..3c137610c3c 100644 --- a/packages/ra-core/src/i18n/Translate.stories.tsx +++ b/packages/ra-core/src/i18n/Translate.stories.tsx @@ -51,3 +51,22 @@ export const Options = () => ( ); + +export const ReactElementInterpolation = () => ( + + `Hello ${name}, welcome to ${place}!`, + }, + }} + > + John, + place: react-admin, + }} + /> + +); diff --git a/packages/ra-core/src/i18n/Translate.tsx b/packages/ra-core/src/i18n/Translate.tsx index a69d1b85938..1cee2dcb473 100644 --- a/packages/ra-core/src/i18n/Translate.tsx +++ b/packages/ra-core/src/i18n/Translate.tsx @@ -1,21 +1,65 @@ -import React, { ReactNode } from 'react'; +import React, { isValidElement, ReactElement, ReactNode } from 'react'; import { useTranslate } from './useTranslate'; +const SPLIT_MARKER = '#@RA_CORE_INTERNAL_SPLIT@#'; + export const Translate = ({ i18nKey, options, children }: TranslateProps) => { const translate = useTranslate(); - const translatedMessage = translate( - i18nKey, - typeof children === 'string' ? { _: children, ...options } : options - ); - if (translatedMessage) { + // Separate React element values from plain values + const elementMap: Record = {}; + const sanitizedOptions: Record = {}; + let placeholderIndex = 0; + + if (options) { + for (const [key, value] of Object.entries(options)) { + if (isValidElement(value)) { + const placeholder = `TRANSLATION_PLACEHOLDER_${placeholderIndex++}`; + elementMap[placeholder] = value; + sanitizedOptions[key] = + `${SPLIT_MARKER}${placeholder}${SPLIT_MARKER}`; + } else { + sanitizedOptions[key] = value; + } + } + } + + const translateOptions = + typeof children === 'string' + ? { _: children, ...sanitizedOptions } + : sanitizedOptions; + + const translatedMessage = translate(i18nKey, translateOptions); + + if (!translatedMessage) { + return children; + } + + // If no elements were extracted, return plain string + if (placeholderIndex === 0) { return <>{translatedMessage}; } - return children; + + // Split the translated string and replace placeholders with React elements + const parts = translatedMessage.split(SPLIT_MARKER); + return ( + <> + {/* After splitting by SPLIT_MARKER, even indices are text and odd indices are placeholders */} + {parts.map((part, index) => + index % 2 === 1 ? ( + + {elementMap[part]} + + ) : ( + {part} + ) + )} + + ); }; export interface TranslateProps { i18nKey: string; children?: ReactNode; - options?: Object; + options?: Record; } diff --git a/packages/ra-i18n-i18next/src/index.spec.tsx b/packages/ra-i18n-i18next/src/index.spec.tsx index 445765a63dd..52bb240b49c 100644 --- a/packages/ra-i18n-i18next/src/index.spec.tsx +++ b/packages/ra-i18n-i18next/src/index.spec.tsx @@ -6,6 +6,7 @@ import { WithCustomOptions, WithLazyLoadedLanguages, TranslateComponent, + TranslateWithReactElement, } from './index.stories'; describe('i18next i18nProvider', () => { @@ -79,4 +80,13 @@ describe('i18next i18nProvider', () => { ); }); }); + + test('should support React elements in options', async () => { + const { container } = render(); + await waitFor(() => { + expect(container.innerHTML).toContain( + 'Hello, John! Welcome to react-admin.' + ); + }); + }); }); diff --git a/packages/ra-i18n-i18next/src/index.stories.tsx b/packages/ra-i18n-i18next/src/index.stories.tsx index 7030893a189..e9bc46a3218 100644 --- a/packages/ra-i18n-i18next/src/index.stories.tsx +++ b/packages/ra-i18n-i18next/src/index.stories.tsx @@ -9,8 +9,9 @@ import { import i18n from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; import englishMessages from 'ra-language-english'; +import frenchMessages from 'ra-language-french'; import fakeRestDataProvider from 'ra-data-fakerest'; -import { Translate, I18nContextProvider } from 'ra-core'; +import { Translate, I18nContextProvider, useI18nProvider } from 'ra-core'; import { useI18nextProvider, convertRaTranslationsToI18next } from './index'; export default { @@ -173,6 +174,74 @@ export const WithCustomOptions = () => { ); }; +export const TranslateWithReactElement = () => { + const i18nProvider = useI18nextProvider({ + options: { + resources: { + en: { + translation: convertRaTranslationsToI18next({ + ...englishMessages, + custom: { + welcome: 'Hello, %{name}! Welcome to %{place}.', + }, + }), + }, + fr: { + translation: convertRaTranslationsToI18next({ + ...frenchMessages, + custom: { + welcome: 'Bonjour, %{name}! Bienvenue à %{place}.', + }, + }), + }, + }, + }, + availableLocales: [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ], + }); + + if (!i18nProvider) return null; + + return ( + + + + ); +}; + +const TranslateWithReactElementContent = () => { + const i18nProvider = useI18nProvider(); + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + + const handleLocaleChange = async (locale: string) => { + await i18nProvider.changeLocale(locale); + forceUpdate(); + }; + + return ( +
+
+ + +
+
+ John, + place: react-admin, + }} + /> +
+ ); +}; + export const TranslateComponent = () => { const i18nProvider = useI18nextProvider({ options: { diff --git a/packages/ra-i18n-polyglot/src/index.spec.tsx b/packages/ra-i18n-polyglot/src/index.spec.tsx index 06bb83fbf86..33ce6455d3b 100644 --- a/packages/ra-i18n-polyglot/src/index.spec.tsx +++ b/packages/ra-i18n-polyglot/src/index.spec.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { render, waitFor } from '@testing-library/react'; -import { TranslateComponent } from './index.stories'; +import { TranslateComponent, TranslateWithReactElement } from './index.stories'; -describe('i18next i18nProvider', () => { +describe('polyglot i18nProvider', () => { test('should be compatible with the component', async () => { const { container } = render(); await waitFor(() => { @@ -11,4 +11,13 @@ describe('i18next i18nProvider', () => { ); }); }); + + test('should support React elements in options', async () => { + const { container } = render(); + await waitFor(() => { + expect(container.innerHTML).toEqual( + 'Hello, John! Welcome to react-admin.' + ); + }); + }); }); diff --git a/packages/ra-i18n-polyglot/src/index.stories.tsx b/packages/ra-i18n-polyglot/src/index.stories.tsx index fa23bd9e893..40ce22510e3 100644 --- a/packages/ra-i18n-polyglot/src/index.stories.tsx +++ b/packages/ra-i18n-polyglot/src/index.stories.tsx @@ -23,7 +23,7 @@ export default { title: 'ra-i18n-polyglot' }; export const Basic = () => { const i18nProvider = polyglotI18nProvider( - locale => messages[locale], + locale => (locale === 'fr' ? messages['fr'] : messages['en']), 'en', [ { locale: 'en', name: 'English' }, @@ -102,7 +102,7 @@ export const TranslateComponent = () => { }; const i18nProvider = polyglotI18nProvider( - locale => messages[locale], + locale => (locale === 'fr' ? messages['fr'] : messages['en']), 'en', [ { locale: 'en', name: 'English' }, @@ -128,3 +128,49 @@ export const TranslateComponent = () => { ); }; + +export const TranslateWithReactElement = ({ locale = 'en' }) => { + const messages = { + fr: { + ...frenchMessages, + custom: { + welcome: 'Bonjour, %{name}! Bienvenue à %{place}.', + }, + }, + en: { + ...englishMessages, + custom: { + welcome: 'Hello, %{name}! Welcome to %{place}.', + }, + }, + }; + const i18nProvider = polyglotI18nProvider( + locale => (locale === 'fr' ? messages['fr'] : messages['en']), + locale, + [ + { locale: 'en', name: 'English' }, + { locale: 'fr', name: 'Français' }, + ] + ); + + return ( + + John, + place: react-admin, + }} + /> + + ); +}; +TranslateWithReactElement.args = { + locale: 'en', +}; +TranslateWithReactElement.argTypes = { + locale: { + control: 'select', + options: ['en', 'fr'], + }, +};