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