diff --git a/example/src/Examples/TextExample.tsx b/example/src/Examples/TextExample.tsx
index 18f1eb8b84..7eb5a71796 100644
--- a/example/src/Examples/TextExample.tsx
+++ b/example/src/Examples/TextExample.tsx
@@ -85,6 +85,27 @@ const TextExample = () => {
Body Small
+
+ Nested text
+
+
+
+ Unstyled child, stays Headline Small
+
+
+
+ Styled child, italic but still Headline Small
+
+
+
+
+ Child variant wins, renders Body Small
+
+
+
+ Bold parent, and the nested child inherits the weight
+
+
Custom Variant
@@ -104,6 +125,16 @@ const styles = StyleSheet.create({
text: {
marginVertical: 4,
},
+ heading: {
+ marginTop: 24,
+ marginBottom: 4,
+ },
+ nestedChild: {
+ fontStyle: 'italic',
+ },
+ boldParent: {
+ fontWeight: 'bold',
+ },
});
export default TextExample;
diff --git a/src/components/Typography/AnimatedText.tsx b/src/components/Typography/AnimatedText.tsx
index ea47c43b58..c517fbe715 100644
--- a/src/components/Typography/AnimatedText.tsx
+++ b/src/components/Typography/AnimatedText.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { Animated, StyleSheet, Text } from 'react-native';
import type { StyleProp, TextProps, TextStyle } from 'react-native';
+import { NestedTextContext } from './NestedTextContext';
import type { VariantProp } from './types';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
@@ -46,6 +47,9 @@ function AnimatedText({
}: Props) {
const theme = useInternalTheme(themeOverrides);
const { direction: writingDirection } = useLocale();
+ const isNested = React.useContext(NestedTextContext);
+
+ let element: React.ReactElement;
if (variant) {
const font = theme.fonts[variant];
@@ -57,7 +61,7 @@ function AnimatedText({
);
}
- return (
+ element = (
);
+ } else if (isNested) {
+ // Declare only what this component was asked for. Everything else, including
+ // the font and the color, inherits from the enclosing text.
+ element = ;
} else {
const font = theme.fonts.bodyMedium;
const textStyle = {
...font,
color: theme.colors.onSurface,
};
- return (
+ element = (
);
}
+
+ return (
+
+ {element}
+
+ );
}
const styles = StyleSheet.create({
diff --git a/src/components/Typography/NestedTextContext.tsx b/src/components/Typography/NestedTextContext.tsx
new file mode 100644
index 0000000000..a337203be0
--- /dev/null
+++ b/src/components/Typography/NestedTextContext.tsx
@@ -0,0 +1,14 @@
+import * as React from 'react';
+
+/**
+ * Tells a `Text` or `AnimatedText` that it is rendered inside another one.
+ *
+ * React Native's `Text` inherits the resolved style of an enclosing `Text`, so a
+ * nested one only has to declare what it wants to change. Applying the default
+ * font here regardless would overwrite everything it should have inherited, so a
+ * nested component without a `variant` leaves those properties unset instead.
+ *
+ * Lives in its own module because `Text` renders `AnimatedText` in its nesting
+ * checks, so importing the context from either of them would form a cycle.
+ */
+export const NestedTextContext = React.createContext(false);
diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx
index df957411f9..6138e263a8 100644
--- a/src/components/Typography/Text.tsx
+++ b/src/components/Typography/Text.tsx
@@ -4,6 +4,7 @@ import { StyleSheet, Text as NativeText } from 'react-native';
import type { StyleProp, TextStyle } from 'react-native';
import AnimatedText from './AnimatedText';
+import { NestedTextContext } from './NestedTextContext';
import type { VariantProp } from './types';
import { useLocale } from '../../core/locale';
import { useInternalTheme } from '../../core/theming';
@@ -87,11 +88,14 @@ const Text = ({
// FIXME: destructure it in TS 4.6+
const theme = useInternalTheme(initialTheme);
const { direction: writingDirection } = useLocale();
+ const isNested = React.useContext(NestedTextContext);
React.useImperativeHandle(ref, () => ({
setNativeProps: (args: Object) => root.current?.setNativeProps(args),
}));
+ let element: React.ReactElement;
+
if (variant) {
let font = theme.fonts[variant];
let textStyle = [font, style];
@@ -124,10 +128,14 @@ const Text = ({
//
// Nested
//
- // Solution: To address the following scenario, the code below overrides the
- // parent's style with children's style:
+ // Solution: To address the following scenario, the code below lets the
+ // children's style win over the parent's, while keeping the
+ // parent's `variant` as the base. Dropping the base instead
+ // would leave a parent wrapping an unstyled `Text` with no
+ // typography at all, since there is no child style to take over
+ // from it.
if (!props.variant) {
- textStyle = [style, props.style];
+ textStyle = [font, style, props.style];
}
}
@@ -139,7 +147,7 @@ const Text = ({
);
}
- return (
+ element = (
);
+ } else if (isNested) {
+ // Declare only what this `Text` was asked for. Everything else, including
+ // the font and the color, inherits from the enclosing `Text`.
+ element = ;
} else {
const font = theme.fonts.default;
const textStyle = {
...font,
color: theme.colors?.onSurface,
};
- return (
+ element = (
);
}
+
+ return (
+
+ {element}
+
+ );
};
const styles = StyleSheet.create({
diff --git a/src/components/__tests__/Typography/Text.test.tsx b/src/components/__tests__/Typography/Text.test.tsx
index b470f47f32..56196f8451 100644
--- a/src/components/__tests__/Typography/Text.test.tsx
+++ b/src/components/__tests__/Typography/Text.test.tsx
@@ -5,6 +5,7 @@ import { render, screen } from '../../../test-utils';
import configureFonts from '../../../theme/fonts';
import { LightTheme } from '../../../theme/schemes';
import { tokens } from '../../../theme/tokens';
+import AnimatedText from '../../Typography/AnimatedText';
import Text, { customText } from '../../Typography/Text';
const content = 'Something rendered as a child content';
@@ -116,6 +117,110 @@ it("nested text without variant, but with styles, should override parent's style
expect(screen.getByTestId('parent-text')).toHaveStyle(customStyle);
});
+it("nested unstyled text should leave the parent's variant intact", async () => {
+ await render(
+
+ Test
+
+ );
+
+ expect(screen.getByTestId('parent-text')).toHaveStyle(
+ LightTheme.fonts.displayLarge
+ );
+});
+
+it("nested styled text should only override the parent's clashing properties", async () => {
+ await render(
+
+ Test
+
+ );
+
+ expect(screen.getByTestId('parent-text')).toHaveStyle({
+ // The child wins where the two overlap,
+ fontSize: 50,
+ // but the rest of the parent's variant survives.
+ letterSpacing: LightTheme.fonts.displayLarge.letterSpacing,
+ lineHeight: LightTheme.fonts.displayLarge.lineHeight,
+ });
+});
+
+it('nested text alongside other content inherits instead of resetting', async () => {
+ await render(
+
+ Parent child
+
+ );
+
+ // React Native's `Text` inherits from the enclosing `Text`, so the child must
+ // not restate the default font, which would override what it inherits.
+ const { fontFamily, fontWeight, letterSpacing } = LightTheme.fonts.default;
+
+ expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontFamily });
+ expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontWeight });
+ expect(screen.getByTestId('child-text')).not.toHaveStyle({ letterSpacing });
+});
+
+it('nested text keeps applying its own style while inheriting the rest', async () => {
+ await render(
+
+ Parent{' '}
+
+ child
+
+
+ );
+
+ expect(screen.getByTestId('child-text')).toHaveStyle({
+ fontStyle: 'italic',
+ });
+});
+
+it('text outside of another text still gets the default font', async () => {
+ await render({content});
+ const { fontFamily, fontWeight } = LightTheme.fonts.default;
+
+ expect(screen.getByTestId('lone-text')).toHaveStyle({
+ fontFamily,
+ fontWeight,
+ });
+});
+
+it('text nested in animated text inherits instead of resetting', async () => {
+ await render(
+
+ Parent child
+
+ );
+ const { fontFamily, fontWeight } = LightTheme.fonts.default;
+
+ expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontFamily });
+ expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontWeight });
+});
+
+it('animated text nested in text inherits instead of resetting', async () => {
+ await render(
+
+ Parent child
+
+ );
+ // `AnimatedText` falls back to `bodyMedium` rather than the default font.
+ const { fontFamily, fontSize } = LightTheme.fonts.bodyMedium;
+
+ expect(screen.getByTestId('child-animated')).not.toHaveStyle({ fontFamily });
+ expect(screen.getByTestId('child-animated')).not.toHaveStyle({ fontSize });
+});
+
+it('animated text outside of any text still gets its fallback font', async () => {
+ await render({content});
+ const { fontFamily, fontSize } = LightTheme.fonts.bodyMedium;
+
+ expect(screen.getByTestId('lone-animated')).toHaveStyle({
+ fontFamily,
+ fontSize,
+ });
+});
+
it('throws when custom variant not provided', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});