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