Repository navigation
fix(text): inherit parent styles in nested Text #5040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
71bb6ce
6e1fe71
c26b3c1
3af3142
cda2eee
8b2cb9d
adb1f93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| 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); | ||
|
|
||
| /** | ||
| * Whether `children` can hold a nested `Text`, so that the provider above is | ||
| * only rendered when something is there to consume it. | ||
| * | ||
| * Mirrors the check React Native runs before wrapping its own text ancestor | ||
| * context, down to the array cutoff. The point of the check is to save the | ||
| * provider's overhead, so it has to stay cheaper than what it saves: three | ||
| * children covers the common cases without walking a long list on every render. | ||
| * | ||
| * Takes `unknown` rather than `ReactNode` because `AnimatedText` widens its | ||
| * `children` with animated values, and this check only looks at the shape. | ||
| */ | ||
| export const canContainNestedText = (children: unknown) => { | ||
| if (children == null) { | ||
| return false; | ||
| } | ||
|
|
||
| if (Array.isArray(children) && children.length <= 3) { | ||
| return children.some( | ||
| (child: unknown) => child != null && typeof child === 'object' | ||
| ); | ||
| } | ||
|
|
||
| // Anything longer is assumed to contain an element, since an array is an | ||
| // object itself. | ||
| return typeof children === 'object'; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { StyleSheet, Text as NativeText } from 'react-native'; | |||||
| import type { StyleProp, TextStyle } from 'react-native'; | ||||||
|
|
||||||
| import AnimatedText from './AnimatedText'; | ||||||
| import { canContainNestedText, 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 = ({ | |||||
| // <Chip> | ||||||
| // <Text style={{fontSize: 30}}>Nested</Text> | ||||||
| // </Chip> | ||||||
| // 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 = ( | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A nested For example, When style={
isNested
? textStyle
: [
styles.text,
{ writingDirection, color: theme.colors.onSurface },
textStyle,
]
}This keeps the style array of a top-level |
||||||
| <NativeText | ||||||
| ref={root} | ||||||
| style={[ | ||||||
|
|
@@ -150,20 +158,34 @@ const Text = ({ | |||||
| {...rest} | ||||||
| /> | ||||||
| ); | ||||||
| } else if (isNested) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This branch also ignores the
Suggested change
(via Claude, verify if it makes sense) |
||||||
| // Declare only what this `Text` was asked for. Everything else, including | ||||||
| // the font and the color, inherits from the enclosing `Text`. | ||||||
| element = <NativeText {...rest} ref={root} style={style} />; | ||||||
| } else { | ||||||
| const font = theme.fonts.default; | ||||||
| const textStyle = { | ||||||
| ...font, | ||||||
| color: theme.colors?.onSurface, | ||||||
| }; | ||||||
| return ( | ||||||
| element = ( | ||||||
| <NativeText | ||||||
| {...rest} | ||||||
| ref={root} | ||||||
| style={[styles.text, textStyle, { writingDirection }, style]} | ||||||
| /> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| if (!canContainNestedText(rest.children)) { | ||||||
| return element; | ||||||
| } | ||||||
|
|
||||||
| return ( | ||||||
| <NestedTextContext.Provider value={true}> | ||||||
| {element} | ||||||
| </NestedTextContext.Provider> | ||||||
| ); | ||||||
|
Doberjohn marked this conversation as resolved.
|
||||||
| }; | ||||||
|
|
||||||
| const styles = StyleSheet.create({ | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,120 @@ 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( | ||
| <Text testID="parent-text" variant="displayLarge"> | ||
| <Text>Test</Text> | ||
| </Text> | ||
| ); | ||
|
|
||
| expect(screen.getByTestId('parent-text')).toHaveStyle( | ||
| LightTheme.fonts.displayLarge | ||
| ); | ||
| }); | ||
|
|
||
| it("nested styled text should only override the parent's clashing properties", async () => { | ||
| await render( | ||
| <Text testID="parent-text" variant="displayLarge"> | ||
| <Text style={{ fontSize: 50 }}>Test</Text> | ||
| </Text> | ||
| ); | ||
|
|
||
| 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 () => { | ||
| const parentColor = 'tomato'; | ||
|
|
||
| await render( | ||
| <Text | ||
| testID="parent-text" | ||
| variant="displayLarge" | ||
| style={{ color: parentColor }} | ||
| > | ||
| Parent <Text testID="child-text">child</Text> | ||
| </Text> | ||
| ); | ||
|
|
||
| const { fontFamily, fontWeight, letterSpacing } = LightTheme.fonts.default; | ||
|
|
||
| expect(screen.getByTestId('parent-text')).toHaveStyle({ | ||
| color: parentColor, | ||
| }); | ||
| expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontFamily }); | ||
| expect(screen.getByTestId('child-text')).not.toHaveStyle({ fontWeight }); | ||
| expect(screen.getByTestId('child-text')).not.toHaveStyle({ letterSpacing }); | ||
| expect(screen.getByTestId('child-text')).not.toHaveStyle({ | ||
| color: LightTheme.colors.onSurface, | ||
| }); | ||
|
Comment on lines
+169
to
+171
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't this inherit parent colors? |
||
| }); | ||
|
Doberjohn marked this conversation as resolved.
|
||
|
|
||
| it('nested text keeps applying its own style while inheriting the rest', async () => { | ||
| await render( | ||
| <Text variant="displayLarge"> | ||
| Parent{' '} | ||
| <Text testID="child-text" style={{ fontStyle: 'italic' }}> | ||
| child | ||
| </Text> | ||
| </Text> | ||
| ); | ||
|
|
||
| expect(screen.getByTestId('child-text')).toHaveStyle({ | ||
| fontStyle: 'italic', | ||
| }); | ||
| }); | ||
|
Comment on lines
+174
to
+187
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this doesn't assert what everything the title says. it should just be consolidated into the above test |
||
|
|
||
| it('text outside of another text still gets the default font', async () => { | ||
| await render(<Text testID="lone-text">{content}</Text>); | ||
| const { fontFamily, fontWeight } = LightTheme.fonts.default; | ||
|
|
||
| expect(screen.getByTestId('lone-text')).toHaveStyle({ | ||
| fontFamily, | ||
| fontWeight, | ||
| }); | ||
| }); | ||
|
Comment on lines
+189
to
+197
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. doesn't this duplicate "renders v3 Text component without variant..."? |
||
|
|
||
| it('text nested in animated text inherits instead of resetting', async () => { | ||
| await render( | ||
| <AnimatedText variant="displayLarge"> | ||
| Parent <Text testID="child-text">child</Text> | ||
| </AnimatedText> | ||
| ); | ||
| 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( | ||
| <Text variant="displayLarge"> | ||
| Parent <AnimatedText testID="child-animated">child</AnimatedText> | ||
| </Text> | ||
| ); | ||
| // `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(<AnimatedText testID="lone-animated">{content}</AnimatedText>); | ||
| const { fontFamily, fontSize } = LightTheme.fonts.bodyMedium; | ||
|
|
||
| expect(screen.getByTestId('lone-animated')).toHaveStyle({ | ||
| fontFamily, | ||
| fontSize, | ||
| }); | ||
| }); | ||
|
Comment on lines
+199
to
+232
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure we need separate tests for animated text. |
||
|
|
||
| it('throws when custom variant not provided', async () => { | ||
| jest.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this doesn't make much sense. what makes children longer than 3 different?