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        <div css={[STYLES_BULLET, isSelected && 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  margin-left: -${spacing[4] + spacing[0.5]}px;
91
92  &:hover {
93    color: ${theme.text.link};
94  }
95
96  &:hover svg {
97    color: ${theme.button.tertiary.icon};
98  }
99`;
100
101const STYLES_LINK_ACTIVE = css`
102  color: ${theme.text.link};
103`;
104
105const STYLES_CONTAINER = css`
106  display: flex;
107  min-height: 32px;
108  align-items: center;
109  padding: ${spacing[1]}px;
110  padding-right: ${spacing[2]}px;
111`;
112
113const STYLES_BULLET = css`
114  height: 6px;
115  width: 6px;
116  min-height: 6px;
117  min-width: 6px;
118  border-radius: 100%;
119  margin: ${spacing[2]}px ${spacing[1.5]}px;
120  align-self: self-start;
121`;
122
123const STYLES_ACTIVE_BULLET = css`
124  background-color: ${theme.text.link};
125`;
126