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(() => {});