Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions example/src/Examples/TextExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ const TextExample = () => {
Body Small
</Text>

<Text style={styles.heading} variant="titleMedium">
Nested text
</Text>

<Text style={styles.text} variant="headlineSmall">
<Text>Unstyled child, stays Headline Small</Text>
</Text>
<Text style={styles.text} variant="headlineSmall">
<Text style={styles.nestedChild}>
Styled child, italic but still Headline Small
</Text>
</Text>
<Text style={styles.text} variant="headlineSmall">
<Text variant="bodySmall">
Child variant wins, renders Body Small
</Text>
</Text>
<Text style={[styles.text, styles.boldParent]} variant="headlineSmall">
Bold parent, <Text>and the nested child inherits the weight</Text>
</Text>

<PaperProvider theme={theme}>
<Text style={styles.text} variant="customVariant">
Custom Variant
Expand All @@ -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;
18 changes: 16 additions & 2 deletions src/components/Typography/AnimatedText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,6 +47,9 @@ function AnimatedText({
}: Props<never>) {
const theme = useInternalTheme(themeOverrides);
const { direction: writingDirection } = useLocale();
const isNested = React.useContext(NestedTextContext);

let element: React.ReactElement;

if (variant) {
const font = theme.fonts[variant];
Expand All @@ -57,7 +61,7 @@ function AnimatedText({
);
}

return (
element = (
<Animated.Text
ref={ref}
{...rest}
Expand All @@ -69,13 +73,17 @@ function AnimatedText({
]}
/>
);
} 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 = <Animated.Text ref={ref} {...rest} style={style} />;
} else {
const font = theme.fonts.bodyMedium;
const textStyle = {
...font,
color: theme.colors.onSurface,
};
return (
element = (
<Animated.Text
ref={ref}
{...rest}
Expand All @@ -90,6 +98,12 @@ function AnimatedText({
/>
);
}

return (
<NestedTextContext.Provider value={true}>
{element}
</NestedTextContext.Provider>
);
}

const styles = StyleSheet.create({
Expand Down
14 changes: 14 additions & 0 deletions src/components/Typography/NestedTextContext.tsx
Original file line number Diff line number Diff line change
@@ -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);
28 changes: 23 additions & 5 deletions src/components/Typography/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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];
}
}

Expand All @@ -139,7 +147,7 @@ const Text = ({
);
}

return (
element = (
<NativeText
ref={root}
style={[
Expand All @@ -150,20 +158,30 @@ const Text = ({
{...rest}
/>
);
} 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 = <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]}
/>
);
}

return (
<NestedTextContext.Provider value={true}>
{element}
</NestedTextContext.Provider>
);
};

const styles = StyleSheet.create({
Expand Down
105 changes: 105 additions & 0 deletions src/components/__tests__/Typography/Text.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<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 () => {
await render(
<Text variant="displayLarge">
Parent <Text testID="child-text">child</Text>
</Text>
);

// 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(
<Text variant="displayLarge">
Parent{' '}
<Text testID="child-text" style={{ fontStyle: 'italic' }}>
child
</Text>
</Text>
);

expect(screen.getByTestId('child-text')).toHaveStyle({
fontStyle: 'italic',
});
});

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,
});
});

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,
});
});

it('throws when custom variant not provided', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});

Expand Down
Loading