1import { css } from '@emotion/react';
2import { theme, typography } from '@expo/styleguide';
3import * as React from 'react';
4
5import { BASE_HEADING_LEVEL, Heading, HeadingType } from '~/common/headingManager';
6import { paragraph } from '~/components/base/typography';
7import { Tag } from '~/ui/components/Tag';
8
9const STYLES_LINK = css`
10  ${paragraph}
11  color: ${theme.text.secondary};
12  transition: 50ms ease color;
13  font-size: 14px;
14  display: flex;
15  text-decoration: none;
16  margin-bottom: 6px;
17  cursor: pointer;
18  overflow: hidden;
19  text-overflow: ellipsis;
20  align-items: center;
21  justify-content: space-between;
22
23  :hover {
24    color: ${theme.link.default};
25  }
26`;
27
28const STYLES_LINK_HEADER = css`
29  font-family: ${typography.fontFaces.medium};
30`;
31
32const STYLES_LINK_LABEL = css`
33  overflow: hidden;
34  text-overflow: ellipsis;
35  white-space: nowrap;
36`;
37
38const STYLES_LINK_CODE = css`
39  font-family: ${typography.fontFaces.mono};
40  font-size: 13px;
41`;
42
43const STYLES_LINK_ACTIVE = css`
44  color: ${theme.link.default};
45`;
46
47const STYLES_TOOLTIP = css`
48  ${typography.fontSizes[13]}
49  border-radius: 3px;
50  position: absolute;
51  color: ${theme.text.default};
52  background-color: ${theme.background.secondary};
53  font-family: ${typography.fontFaces.medium};
54  max-width: 400px;
55  border: 1px solid ${theme.border.default};
56  padding: 3px 6px;
57  word-break: break-word;
58  word-wrap: normal;
59  display: inline-block;
60`;
61
62const STYLES_CODE_TOOLTIP = css`
63  font-family: ${typography.fontFaces.mono};
64  font-size: 11px;
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  return childrenWidth >= el.scrollWidth - parseInt(el.style.paddingLeft, 10);
99};
100
101type TooltipProps = React.PropsWithChildren<{
102  isCode?: boolean;
103  topOffset: number;
104}>;
105
106const Tooltip = ({ children, isCode, topOffset }: TooltipProps) => (
107  <div css={[STYLES_TOOLTIP, isCode && STYLES_CODE_TOOLTIP]} style={{ right: 24, top: topOffset }}>
108    {children}
109  </div>
110);
111
112type SidebarLinkProps = {
113  heading: Heading;
114  isActive: boolean;
115  shortenCode: boolean;
116  onClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
117};
118
119const DocumentationSidebarRightLink = React.forwardRef<HTMLAnchorElement, SidebarLinkProps>(
120  ({ heading, isActive, shortenCode, onClick }, ref) => {
121    const { slug, level, title, type, tags } = heading;
122
123    const isNested = level <= BASE_HEADING_LEVEL;
124    const isCode = type === HeadingType.InlineCode;
125
126    const paddingLeft = NESTING_OFFSET * (level - BASE_HEADING_LEVEL) + 'px';
127    const displayTitle = shortenCode && isCode ? trimCodedTitle(title) : title;
128
129    const [tooltipVisible, setTooltipVisible] = React.useState(false);
130    const [tooltipOffset, setTooltipOffset] = React.useState(-20);
131    const onMouseOver = (event: React.MouseEvent<HTMLAnchorElement>) => {
132      setTooltipVisible(isOverflowing(event.currentTarget));
133      setTooltipOffset(
134        event.currentTarget.getBoundingClientRect().top + event.currentTarget.offsetHeight
135      );
136    };
137
138    const onMouseOut = () => {
139      setTooltipVisible(false);
140    };
141
142    return (
143      <>
144        {tooltipVisible && (
145          <Tooltip topOffset={tooltipOffset} isCode={isCode}>
146            {displayTitle}
147          </Tooltip>
148        )}
149        <a
150          ref={ref}
151          onMouseOver={onMouseOver}
152          onMouseOut={onMouseOut}
153          style={{ paddingLeft }}
154          href={'#' + slug}
155          onClick={onClick}
156          css={[STYLES_LINK, isNested && STYLES_LINK_HEADER, isActive && STYLES_LINK_ACTIVE]}>
157          <span css={[STYLES_LINK_LABEL, isCode && STYLES_LINK_CODE]}>{displayTitle}</span>
158          {tags && tags.length ? (
159            <div css={STYLES_TAG_CONTAINER}>
160              {tags.map(tag => (
161                <Tag name={tag} type="toc" key={`${displayTitle}-${tag}`} />
162              ))}
163            </div>
164          ) : undefined}
165        </a>
166      </>
167    );
168  }
169);
170
171export default DocumentationSidebarRightLink;
172