Skip to content

Commit 0f1b88b

Browse files
committed
feat(react): Support complex DOM trees in overlays (projectfluent#288)
Because: * Sometimes, the line between UI localization and content localization is thin. There are cases in which we should allow localizing longer, structured text with nested overlays. This commit: * Adds a prop, nestedElems, to LocalizedProps and an arg, arg.nestedElems, to Localized.getElement() that allows consumers to opt-in to recursively parse nested elements in overlays. By default this is disabled.
1 parent f073055 commit 0f1b88b

File tree

2 files changed

+52
-34
lines changed

2 files changed

+52
-34
lines changed

fluent-react/src/localization.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createElement,
77
isValidElement,
88
cloneElement,
9+
ReactNode,
910
} from "react";
1011
import { CachedSyncIterable } from "cached-iterable";
1112
import { createParseMarkup, MarkupParser } from "./markup.js";
@@ -101,6 +102,7 @@ export class ReactLocalization {
101102
vars?: Record<string, FluentVariable>;
102103
elems?: Record<string, ReactElement>;
103104
attrs?: Record<string, boolean>;
105+
nestedElems?: boolean;
104106
} = {}
105107
): ReactElement {
106108
const bundle = this.getBundle(id);
@@ -186,9 +188,8 @@ export class ReactLocalization {
186188
return cloneElement(sourceElement, localizedProps, messageValue);
187189
}
188190

189-
let elemsLower: Map<string, ReactElement>;
191+
const elemsLower: Map<string, ReactElement> = new Map();
190192
if (args.elems) {
191-
elemsLower = new Map();
192193
for (let [name, elem] of Object.entries(args.elems)) {
193194
// Ignore elems which are not valid React elements.
194195
if (!isValidElement(elem)) {
@@ -201,39 +202,55 @@ export class ReactLocalization {
201202
// If the message contains markup, parse it and try to match the children
202203
// found in the translation with the args passed to this function.
203204
const translationNodes = this.parseMarkup(messageValue);
204-
const translatedChildren = translationNodes.map(
205-
({ nodeName, textContent }) => {
206-
if (nodeName === "#text") {
207-
return textContent;
208-
}
205+
const translatedChildren = translateChildren(
206+
translationNodes,
207+
elemsLower,
208+
args.nestedElems
209+
);
209210

210-
const childName = nodeName.toLowerCase();
211-
const sourceChild = elemsLower?.get(childName);
211+
return cloneElement(sourceElement, localizedProps, ...translatedChildren);
212+
}
213+
}
212214

213-
// If the child is not expected just take its textContent.
214-
if (!sourceChild) {
215-
return textContent;
216-
}
215+
function translateChildren(
216+
translationNodes: Node[],
217+
elemsLower: Map<string, ReactElement>,
218+
recursive: boolean | undefined
219+
): ReactNode[] {
220+
return translationNodes.map(({ nodeName, textContent, childNodes }) => {
221+
if (nodeName === "#text") {
222+
return textContent;
223+
}
217224

218-
// If the element passed in the elems prop is a known void element,
219-
// explicitly dismiss any textContent which might have accidentally been
220-
// defined in the translation to prevent the "void element tags must not
221-
// have children" error.
222-
if (
223-
typeof sourceChild.type === "string" &&
224-
sourceChild.type in voidElementTags
225-
) {
226-
return sourceChild;
227-
}
225+
const childName = nodeName.toLowerCase();
226+
const sourceChild = elemsLower?.get(childName);
228227

229-
// TODO Protect contents of elements wrapped in <Localized>
230-
// https://github.com/projectfluent/fluent.js/issues/184
231-
// TODO Control localizable attributes on elements passed as props
232-
// https://github.com/projectfluent/fluent.js/issues/185
233-
return cloneElement(sourceChild, undefined, textContent);
234-
}
235-
);
228+
let translatedChildren = recursive
229+
? translateChildren([...childNodes], elemsLower, true)
230+
: [textContent];
236231

237-
return cloneElement(sourceElement, localizedProps, ...translatedChildren);
238-
}
232+
// If the child is not expected just take its content.
233+
if (!sourceChild) {
234+
return recursive
235+
? createElement(Fragment, null, ...translatedChildren)
236+
: textContent;
237+
}
238+
239+
// If the element passed in the elems prop is a known void element,
240+
// explicitly dismiss any textContent which might have accidentally been
241+
// defined in the translation to prevent the "void element tags must not
242+
// have children" error.
243+
if (
244+
typeof sourceChild.type === "string" &&
245+
sourceChild.type in voidElementTags
246+
) {
247+
return sourceChild;
248+
}
249+
250+
// TODO Protect contents of elements wrapped in <Localized>
251+
// https://github.com/projectfluent/fluent.js/issues/184
252+
// TODO Control localizable attributes on elements passed as props
253+
// https://github.com/projectfluent/fluent.js/issues/185
254+
return cloneElement(sourceChild, undefined, ...translatedChildren);
255+
});
239256
}

fluent-react/src/localized.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface LocalizedProps {
1313
children?: ReactNode | Array<ReactNode>;
1414
vars?: Record<string, FluentVariable>;
1515
elems?: Record<string, ReactElement>;
16+
nestedElems?: boolean;
1617
}
1718

1819
/**
@@ -41,7 +42,7 @@ export interface LocalizedProps {
4142
* ```
4243
*/
4344
export function Localized(props: LocalizedProps): ReactElement {
44-
const { id, attrs, vars, elems, children } = props;
45+
const { id, attrs, vars, elems, nestedElems, children } = props;
4546
const l10n = useContext(FluentContext);
4647

4748
if (!l10n) {
@@ -74,7 +75,7 @@ export function Localized(props: LocalizedProps): ReactElement {
7475
return React.createElement(React.Fragment, null, string);
7576
}
7677

77-
return l10n.getElement(source, id, { attrs, vars, elems });
78+
return l10n.getElement(source, id, { attrs, vars, elems, nestedElems });
7879
}
7980

8081
export default Localized;

0 commit comments

Comments
 (0)