1import { css } from '@emotion/react'; 2import { theme, typography } from '@expo/styleguide'; 3import Link from 'next/link'; 4import * as React from 'react'; 5 6import { BASE_HEADING_LEVEL, Heading, HeadingType } from '~/common/headingManager'; 7import { Tag } from '~/ui/components/Tag'; 8import { MONOSPACE, CALLOUT } from '~/ui/components/Text'; 9 10const STYLES_LINK = css` 11 transition: 50ms ease color; 12 display: flex; 13 text-decoration: none; 14 margin-bottom: 6px; 15 cursor: pointer; 16 overflow: hidden; 17 text-overflow: ellipsis; 18 align-items: center; 19 justify-content: space-between; 20 21 :focus-visible { 22 position: relative; 23 z-index: 10; 24 } 25`; 26 27const STYLES_LINK_LABEL = css` 28 color: ${theme.text.secondary}; 29 30 :hover { 31 color: ${theme.text.link}; 32 } 33`; 34 35const STYLES_LINK_MONOSPACE = css` 36 overflow: hidden; 37 text-overflow: ellipsis; 38 white-space: nowrap; 39 ${typography.fontSizes[13]} 40`; 41 42const STYLES_LINK_ACTIVE = css` 43 color: ${theme.text.link}; 44`; 45 46const STYLES_TOOLTIP = css` 47 border-radius: 3px; 48 position: absolute; 49 background-color: ${theme.background.subtle}; 50 max-width: 400px; 51 border: 1px solid ${theme.border.default}; 52 padding: 3px 6px; 53 display: inline-block; 54`; 55 56const STYLES_TOOLTIP_TEXT = css` 57 ${typography.fontSizes[13]} 58 color: ${theme.text.default}; 59 word-break: break-word; 60 word-wrap: normal; 61`; 62 63const STYLES_TOOLTIP_CODE = css` 64 ${typography.fontSizes[12]} 65`; 66 67const STYLES_TAG_CONTAINER = css` 68 display: inline-flex; 69`; 70 71const NESTING_OFFSET = 12; 72 73/** 74 * Replaces `Module.someFunction(arguments: argType)` 75 * with `someFunction()` 76 */ 77const trimCodedTitle = (str: string) => { 78 const dotIdx = str.indexOf('.'); 79 if (dotIdx > 0) str = str.substring(dotIdx + 1); 80 81 const parIdx = str.indexOf('('); 82 if (parIdx > 0) str = str.substring(0, parIdx + 1) + ')'; 83 84 return str; 85}; 86 87/** 88 * Determines if element is overflowing 89 * (its children width exceeds container width) 90 * @param {HTMLElement} el element to check 91 */ 92const isOverflowing = (el: HTMLElement) => { 93 if (!el || !el.children) { 94 return false; 95 } 96 97 const childrenWidth = Array.from(el.children).reduce((sum, child) => sum + child.scrollWidth, 0); 98 const indent = parseInt(window.getComputedStyle(el).paddingLeft, 10); 99 return childrenWidth >= el.scrollWidth - indent; 100}; 101 102type TooltipProps = React.PropsWithChildren<{ 103 isCode?: boolean; 104 topOffset: number; 105}>; 106 107const Tooltip = ({ children, isCode, topOffset }: TooltipProps) => { 108 const ContentWrapper = isCode ? MONOSPACE : CALLOUT; 109 return ( 110 <div css={STYLES_TOOLTIP} style={{ right: 24, top: topOffset }}> 111 <ContentWrapper css={[STYLES_TOOLTIP_TEXT, isCode && STYLES_TOOLTIP_CODE]}> 112 {children} 113 </ContentWrapper> 114 </div> 115 ); 116}; 117 118type SidebarLinkProps = { 119 heading: Heading; 120 isActive: boolean; 121 shortenCode: boolean; 122 onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void; 123}; 124 125const DocumentationSidebarRightLink = React.forwardRef<HTMLAnchorElement, SidebarLinkProps>( 126 ({ heading, isActive, shortenCode, onClick }, ref) => { 127 const { slug, level, title, type, tags } = heading; 128 129 const isCode = type === HeadingType.InlineCode; 130 const paddingLeft = NESTING_OFFSET * (level - BASE_HEADING_LEVEL); 131 const displayTitle = shortenCode && isCode ? trimCodedTitle(title) : title; 132 133 const [tooltipVisible, setTooltipVisible] = React.useState(false); 134 const [tooltipOffset, setTooltipOffset] = React.useState(-20); 135 const onMouseOver = (event: React.MouseEvent<HTMLAnchorElement>) => { 136 setTooltipVisible(isOverflowing(event.currentTarget)); 137 setTooltipOffset( 138 event.currentTarget.getBoundingClientRect().top + event.currentTarget.offsetHeight 139 ); 140 }; 141 142 const onMouseOut = () => { 143 setTooltipVisible(false); 144 }; 145 146 const TitleElement = isCode ? MONOSPACE : CALLOUT; 147 148 return ( 149 <> 150 {tooltipVisible && isCode && ( 151 <Tooltip topOffset={tooltipOffset} isCode={isCode}> 152 {displayTitle} 153 </Tooltip> 154 )} 155 <Link 156 ref={ref} 157 onMouseOver={isCode ? onMouseOver : undefined} 158 onMouseOut={isCode ? onMouseOut : undefined} 159 href={'#' + slug} 160 onClick={onClick} 161 css={[STYLES_LINK, paddingLeft && { paddingLeft }]}> 162 <TitleElement 163 css={[ 164 STYLES_LINK_LABEL, 165 isCode && STYLES_LINK_MONOSPACE, 166 isActive && STYLES_LINK_ACTIVE, 167 ]}> 168 {displayTitle} 169 </TitleElement> 170 {tags && tags.length ? ( 171 <div css={STYLES_TAG_CONTAINER}> 172 {tags.map(tag => ( 173 <Tag name={tag} type="toc" key={`${displayTitle}-${tag}`} /> 174 ))} 175 </div> 176 ) : undefined} 177 </Link> 178 </> 179 ); 180 } 181); 182 183export default DocumentationSidebarRightLink; 184