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