xref: /expo/docs/ui/components/Text/index.tsx (revision cedf99eb)
1import { css, CSSObject, SerializedStyles } from '@emotion/react';
2import { theme, typography, LinkBase, LinkBaseProps } from '@expo/styleguide';
3import { spacing, borderRadius } from '@expo/styleguide-base';
4import * as React from 'react';
5
6import { TextComponentProps, TextElement } from './types';
7
8import { AdditionalProps, HeadingType } from '~/common/headingManager';
9import Permalink from '~/components/Permalink';
10import { durations } from '~/ui/foundations/durations';
11
12export { AnchorContext } from './withAnchor';
13
14const CRAWLABLE_HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5'];
15const CRAWLABLE_TEXT = ['span', 'p', 'li', 'blockquote', 'code', 'pre'];
16
17type PermalinkedComponentProps = React.PropsWithChildren<
18  { level?: number; id?: string } & AdditionalProps & TextComponentProps
19>;
20
21const isDev = process.env.NODE_ENV === 'development';
22
23export const createPermalinkedComponent = (
24  BaseComponent: React.ComponentType<React.PropsWithChildren<TextComponentProps>>,
25  options?: {
26    baseNestingLevel?: number;
27    sidebarType?: HeadingType;
28  }
29) => {
30  const { baseNestingLevel, sidebarType = HeadingType.Text } = options || {};
31  return ({ children, level, id, ...props }: PermalinkedComponentProps) => {
32    const cleanChildren = React.Children.map(children, child => {
33      if (React.isValidElement(child) && child?.props?.href) {
34        isDev &&
35          console.warn(
36            `It looks like the header on this page includes a link, this is an invalid pattern, nested link will be removed!`,
37            child?.props?.href
38          );
39        return (child as JSX.Element)?.props?.children;
40      }
41      return child;
42    });
43    const nestingLevel = baseNestingLevel != null ? (level ?? 0) + baseNestingLevel : undefined;
44    return (
45      <Permalink nestingLevel={nestingLevel} additionalProps={{ ...props, sidebarType }} id={id}>
46        <BaseComponent>{cleanChildren}</BaseComponent>
47      </Permalink>
48    );
49  };
50};
51
52export function createTextComponent(Element: TextElement, textStyle?: SerializedStyles) {
53  function TextComponent(props: TextComponentProps) {
54    const {
55      testID,
56      tag,
57      className,
58      weight: textWeight,
59      theme: textTheme,
60      crawlable = true,
61      ...rest
62    } = props;
63    const TextElementTag = tag ?? Element;
64
65    return (
66      <TextElementTag
67        className={className}
68        css={[
69          baseTextStyle,
70          textStyle,
71          textWeight && { fontWeight: typography.utility.weight[textWeight].fontWeight },
72          textTheme && { color: theme.text[textTheme] },
73        ]}
74        data-testid={testID}
75        data-heading={(crawlable && CRAWLABLE_HEADINGS.includes(TextElementTag)) || undefined}
76        data-text={(crawlable && CRAWLABLE_TEXT.includes(TextElementTag)) || undefined}
77        {...rest}
78      />
79    );
80  }
81  TextComponent.displayName = `Text(${Element})`;
82  return TextComponent;
83}
84
85const baseTextStyle = css({
86  ...typography.body.paragraph,
87  color: theme.text.default,
88});
89
90const link = css({
91  cursor: 'pointer',
92  textDecoration: 'none',
93
94  ':hover': {
95    transition: durations.hover,
96    opacity: 0.8,
97  },
98});
99
100const linkStyled = css({
101  ...typography.utility.anchor,
102
103  // note(Cedric): transform prevents a 1px shift on hover on Safari
104  transform: 'translate3d(0,0,0)',
105
106  ':hover': {
107    textDecoration: 'underline',
108
109    code: {
110      textDecoration: 'inherit',
111    },
112  },
113
114  'span, code, strong, em, b, i': {
115    color: theme.text.link,
116  },
117});
118
119const codeStyle = css({
120  borderColor: theme.border.secondary,
121  borderRadius: borderRadius.sm,
122  verticalAlign: 'initial',
123  wordBreak: 'unset',
124});
125
126export const kbdStyle = css({
127  fontWeight: 500,
128  color: theme.text.secondary,
129  padding: `0 ${spacing[1]}px`,
130  boxShadow: `0 0.1rem 0 1px ${theme.border.default}`,
131  borderRadius: borderRadius.sm,
132  position: 'relative',
133  display: 'inline-flex',
134  margin: 0,
135  minWidth: 22,
136  justifyContent: 'center',
137  top: -1,
138});
139
140const { h1, h2, h3, h4, h5 } = typography.headers.default;
141const codeInHeaderStyle = { '& code': { fontSize: 'inherit' } };
142
143const h1Style = {
144  ...h1,
145  fontWeight: 600,
146  marginTop: spacing[2],
147  marginBottom: spacing[2],
148  ...codeInHeaderStyle,
149};
150
151const h2Style = {
152  ...h2,
153  fontWeight: 600,
154  marginTop: spacing[8],
155  marginBottom: spacing[3.5],
156  ...codeInHeaderStyle,
157};
158
159const h3Style = {
160  ...h3,
161  fontWeight: 600,
162  marginTop: spacing[6],
163  marginBottom: spacing[2.5],
164  ...codeInHeaderStyle,
165};
166
167const h4Style = {
168  ...h4,
169  fontWeight: 600,
170  marginTop: spacing[6],
171  marginBottom: spacing[1.5],
172  ...codeInHeaderStyle,
173};
174
175const h5Style = {
176  ...h5,
177  fontWeight: 600,
178  marginTop: spacing[4],
179  marginBottom: spacing[1],
180  ...codeInHeaderStyle,
181};
182
183const paragraphStyle = {
184  strong: {
185    wordBreak: 'break-word',
186  },
187};
188
189export const H1 = createTextComponent(TextElement.H1, css(h1Style));
190export const RawH2 = createTextComponent(TextElement.H2, css(h2Style));
191export const H2 = createPermalinkedComponent(RawH2, { baseNestingLevel: 2 });
192export const RawH3 = createTextComponent(TextElement.H3, css(h3Style));
193export const H3 = createPermalinkedComponent(RawH3, { baseNestingLevel: 3 });
194export const RawH4 = createTextComponent(TextElement.H4, css(h4Style));
195export const H4 = createPermalinkedComponent(RawH4, { baseNestingLevel: 4 });
196export const RawH5 = createTextComponent(TextElement.H5, css(h5Style));
197export const H5 = createPermalinkedComponent(RawH5, { baseNestingLevel: 5 });
198
199export const P = createTextComponent(TextElement.P, css(paragraphStyle as CSSObject));
200export const CODE = createTextComponent(
201  TextElement.CODE,
202  css([typography.utility.inlineCode, codeStyle])
203);
204export const LI = createTextComponent(TextElement.LI, css(typography.body.li));
205export const LABEL = createTextComponent(TextElement.SPAN, css(typography.body.label));
206export const HEADLINE = createTextComponent(TextElement.P, css(typography.body.headline));
207export const FOOTNOTE = createTextComponent(TextElement.P, css(typography.body.footnote));
208export const CALLOUT = createTextComponent(TextElement.P, css(typography.body.callout));
209export const BOLD = createTextComponent(TextElement.STRONG, css({ fontWeight: 600 }));
210export const DEMI = createTextComponent(TextElement.SPAN, css({ fontWeight: 500 }));
211export const UL = createTextComponent(
212  TextElement.UL,
213  css([typography.body.ul, { listStyle: 'disc' }])
214);
215export const OL = createTextComponent(
216  TextElement.OL,
217  css([typography.body.ol, { listStyle: 'decimal' }])
218);
219export const PRE = createTextComponent(TextElement.PRE, css(typography.utility.pre as CSSObject));
220export const KBD = createTextComponent(
221  TextElement.KBD,
222  css([typography.utility.pre as CSSObject, kbdStyle])
223);
224export const MONOSPACE = createTextComponent(TextElement.CODE);
225
226const isExternalLink = (href?: string) => href?.includes('://');
227
228export const A = (props: LinkBaseProps & { isStyled?: boolean }) => {
229  const { isStyled, openInNewTab, ...rest } = props;
230  return (
231    <LinkBase
232      css={[link, !isStyled && linkStyled]}
233      openInNewTab={openInNewTab ?? isExternalLink(props.href)}
234      {...rest}
235    />
236  );
237};
238A.displayName = 'Text(a)';
239