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