1import { css } from '@emotion/react';
2import { theme, typography, LinkBase } from '@expo/styleguide';
3import { spacing } from '@expo/styleguide-base';
4import { ArrowUpRightIcon } from '@expo/styleguide-icons';
5import { useRouter } from 'next/compat/router';
6import type { PropsWithChildren } from 'react';
7import { useEffect, useRef } from 'react';
8
9import { stripVersionFromPath } from '~/common/utilities';
10import { NavigationRoute } from '~/types/common';
11
12type SidebarLinkProps = PropsWithChildren<{
13  info: NavigationRoute;
14}>;
15
16const HEAD_NAV_HEIGHT = 160;
17
18const isLinkInViewport = (element: HTMLAnchorElement) => {
19  const rect = element.getBoundingClientRect();
20  return (
21    rect.top - HEAD_NAV_HEIGHT >= 0 &&
22    rect.left >= 0 &&
23    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
24    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
25  );
26};
27
28export const SidebarLink = ({ info, children }: SidebarLinkProps) => {
29  const router = useRouter();
30  const ref = useRef<HTMLAnchorElement>(null);
31
32  const checkSelection = () => {
33    // Special case for root url
34    if (info.name === 'Introduction') {
35      if (router?.asPath.match(/\/versions\/[\w.]+\/$/) || router?.asPath === '/versions/latest/') {
36        return true;
37      }
38    }
39
40    const linkUrl = stripVersionFromPath(info.as || info.href);
41    return (
42      linkUrl === stripVersionFromPath(router?.pathname) ||
43      linkUrl === stripVersionFromPath(router?.asPath)
44    );
45  };
46
47  const isSelected = checkSelection();
48
49  useEffect(() => {
50    if (isSelected && ref?.current && !isLinkInViewport(ref?.current)) {
51      setTimeout(() => ref?.current && ref.current.scrollIntoView({ behavior: 'smooth' }), 50);
52    }
53  }, []);
54
55  if (info.hidden) {
56    return null;
57  }
58
59  const customDataAttributes = isSelected && {
60    'data-sidebar-anchor-selected': true,
61  };
62  const isExternal = info.href.startsWith('http');
63
64  return (
65    <div css={STYLES_CONTAINER}>
66      <LinkBase
67        href={info.href as string}
68        ref={ref}
69        css={[STYLES_LINK, isSelected && STYLES_LINK_ACTIVE]}
70        {...customDataAttributes}>
71        {isSelected && <div css={STYLES_ACTIVE_BULLET} />}
72        {children}
73        {isExternal && <ArrowUpRightIcon className="icon-sm text-icon-secondary ml-auto" />}
74      </LinkBase>
75    </div>
76  );
77};
78
79const STYLES_LINK = css`
80  ${typography.fontSizes[14]}
81  display: flex;
82  flex-direction: row;
83  text-decoration: none;
84  color: ${theme.text.secondary};
85  transition: 50ms ease color;
86  align-items: center;
87  padding-left: ${spacing[2]}px;
88  scroll-margin: 60px;
89  width: 100%;
90
91  &:hover {
92    color: ${theme.text.link};
93  }
94
95  &:hover svg {
96    color: ${theme.button.tertiary.icon};
97  }
98`;
99
100const STYLES_LINK_ACTIVE = css`
101  color: ${theme.text.link};
102  padding-left: 0;
103  margin-left: -${spacing[2.5]}px;
104`;
105
106const STYLES_CONTAINER = css`
107  display: flex;
108  min-height: 32px;
109  align-items: center;
110  padding: ${spacing[1]}px;
111  padding-right: ${spacing[2]}px;
112`;
113
114const STYLES_ACTIVE_BULLET = css`
115  height: 6px;
116  width: 6px;
117  min-height: 6px;
118  min-width: 6px;
119  background-color: ${theme.text.link};
120  border-radius: 100%;
121  margin: ${spacing[2]}px ${spacing[1.5]}px;
122  align-self: self-start;
123`;
124