xref: /expo/docs/ui/components/Text/index.tsx (revision 0a4db0c7)
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(
53  Element: TextElement,
54  textStyle?: SerializedStyles,
55  skipBaseStyle: boolean = false
56) {
57  function TextComponent(props: TextComponentProps) {
58    const {
59      testID,
60      tag,
61      className,
62      weight: textWeight,
63      theme: textTheme,
64      crawlable = true,
65      ...rest
66    } = props;
67    const TextElementTag = tag ?? Element;
68
69    return (
70      <TextElementTag
71        className={className}
72        css={[
73          !skipBaseStyle && baseTextStyle,
74          textStyle,
75          textWeight && { fontWeight: typography.utility.weight[textWeight].fontWeight },
76          textTheme && { color: theme.text[textTheme] },
77        ]}
78        data-testid={testID}
79        data-heading={(crawlable && CRAWLABLE_HEADINGS.includes(TextElementTag)) || undefined}
80        data-text={(crawlable && CRAWLABLE_TEXT.includes(TextElementTag)) || undefined}
81        {...rest}
82      />
83    );
84  }
85  TextComponent.displayName = `Text(${Element})`;
86  return TextComponent;
87}
88
89const baseTextStyle = css({
90  ...typography.body.paragraph,
91  color: theme.text.default,
92});
93
94const link = css({
95  cursor: 'pointer',
96  textDecoration: 'none',
97
98  ':hover': {
99    transition: durations.hover,
100    opacity: 0.8,
101  },
102});
103
104const linkStyled = css({
105  ...typography.utility.anchor,
106
107  // note(Cedric): transform prevents a 1px shift on hover on Safari
108  transform: 'translate3d(0,0,0)',
109
110  ':hover': {
111    textDecoration: 'underline',
112
113    code: {
114      textDecoration: 'inherit',
115    },
116  },
117
118  'span, code, strong, em, b, i': {
119    color: theme.text.link,
120  },
121});
122
123const codeStyle = css({
124  borderColor: theme.border.secondary,
125  borderRadius: borderRadius.sm,
126  verticalAlign: 'initial',
127  wordBreak: 'unset',
128});
129
130export const kbdStyle = css({
131  fontWeight: 500,
132  color: theme.text.secondary,
133  padding: `0 ${spacing[1]}px`,
134  boxShadow: `0 0.1rem 0 1px ${theme.border.default}`,
135  borderRadius: borderRadius.sm,
136  position: 'relative',
137  display: 'inline-flex',
138  margin: 0,
139  minWidth: 22,
140  justifyContent: 'center',
141  top: -1,
142});
143
144const { h1, h2, h3, h4, h5 } = typography.headers.default;
145const codeInHeaderStyle = { '& code': { fontSize: '95%' } };
146
147const h1Style = {
148  ...h1,
149  fontWeight: 600,
150  marginTop: spacing[2],
151  marginBottom: spacing[2],
152  ...codeInHeaderStyle,
153};
154
155const h2Style = {
156  ...h2,
157  fontWeight: 600,
158  marginTop: spacing[8],
159  marginBottom: spacing[3.5],
160  '& a:focus-visible': { outlineOffset: spacing[1] },
161  ...codeInHeaderStyle,
162};
163
164const h3Style = {
165  ...h3,
166  fontWeight: 600,
167  marginTop: spacing[6],
168  marginBottom: spacing[2.5],
169  '& a:focus-visible': { outlineOffset: spacing[1] },
170  ...codeInHeaderStyle,
171};
172
173const h4Style = {
174  ...h4,
175  fontWeight: 600,
176  marginTop: spacing[6],
177  marginBottom: spacing[1.5],
178  ...codeInHeaderStyle,
179};
180
181const h5Style = {
182  ...h5,
183  fontWeight: 600,
184  marginTop: spacing[4],
185  marginBottom: spacing[1],
186  ...codeInHeaderStyle,
187};
188
189const paragraphStyle = {
190  strong: {
191    wordBreak: 'break-word',
192  },
193};
194
195export const H1 = createTextComponent(TextElement.H1, css(h1Style));
196export const RawH2 = createTextComponent(TextElement.H2, css(h2Style));
197export const H2 = createPermalinkedComponent(RawH2, { baseNestingLevel: 2 });
198export const RawH3 = createTextComponent(TextElement.H3, css(h3Style));
199export const H3 = createPermalinkedComponent(RawH3, { baseNestingLevel: 3 });
200export const RawH4 = createTextComponent(TextElement.H4, css(h4Style));
201export const H4 = createPermalinkedComponent(RawH4, { baseNestingLevel: 4 });
202export const RawH5 = createTextComponent(TextElement.H5, css(h5Style));
203export const H5 = createPermalinkedComponent(RawH5, { baseNestingLevel: 5 });
204
205export const P = createTextComponent(TextElement.P, css(paragraphStyle as CSSObject));
206export const CODE = createTextComponent(
207  TextElement.CODE,
208  css([typography.utility.inlineCode, codeStyle]),
209  true
210);
211export const LI = createTextComponent(TextElement.LI, css(typography.body.li));
212export const LABEL = createTextComponent(TextElement.SPAN, css(typography.body.label));
213export const HEADLINE = createTextComponent(TextElement.P, css(typography.body.headline));
214export const FOOTNOTE = createTextComponent(TextElement.P, css(typography.body.footnote));
215export const CAPTION = createTextComponent(TextElement.P, css(typography.body.caption));
216export const CALLOUT = createTextComponent(TextElement.P, css(typography.body.callout));
217export const BOLD = createTextComponent(TextElement.STRONG, css({ fontWeight: 600 }));
218export const DEMI = createTextComponent(TextElement.SPAN, css({ fontWeight: 500 }));
219export const UL = createTextComponent(
220  TextElement.UL,
221  css([typography.body.ul, { listStyle: 'disc' }])
222);
223export const OL = createTextComponent(
224  TextElement.OL,
225  css([typography.body.ol, { listStyle: 'decimal' }])
226);
227export const PRE = createTextComponent(TextElement.PRE, css(typography.utility.pre as CSSObject));
228export const KBD = createTextComponent(
229  TextElement.KBD,
230  css([typography.utility.pre as CSSObject, kbdStyle])
231);
232export const MONOSPACE = createTextComponent(TextElement.CODE);
233
234const isExternalLink = (href?: string) => href?.includes('://');
235
236export const A = (props: LinkBaseProps & { isStyled?: boolean }) => {
237  const { isStyled, openInNewTab, ...rest } = props;
238  return (
239    <LinkBase
240      css={[link, !isStyled && linkStyled]}
241      openInNewTab={openInNewTab ?? isExternalLink(props.href)}
242      {...rest}
243    />
244  );
245};
246A.displayName = 'Text(a)';
247