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