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