1import { css } from '@emotion/react';
2import { Button, mergeClasses } from '@expo/styleguide';
3import { breakpoints, spacing } from '@expo/styleguide-base';
4import { ArrowCircleUpIcon, LayoutAlt03Icon } from '@expo/styleguide-icons';
5import * as React from 'react';
6
7import DocumentationSidebarRightLink from './DocumentationSidebarRightLink';
8
9import { BASE_HEADING_LEVEL, Heading, HeadingManager } from '~/common/headingManager';
10import withHeadingManager, {
11  HeadingManagerProps,
12} from '~/components/page-higher-order/withHeadingManager';
13import { CALLOUT } from '~/ui/components/Text';
14
15const sidebarStyle = css({
16  padding: spacing[6],
17  paddingTop: spacing[14],
18  paddingBottom: spacing[12],
19  width: 280,
20
21  [`@media screen and (max-width: ${breakpoints.medium + 124}px)`]: {
22    width: '100%',
23  },
24});
25
26const UPPER_SCROLL_LIMIT_FACTOR = 1 / 4;
27const LOWER_SCROLL_LIMIT_FACTOR = 3 / 4;
28
29const ACTIVE_ITEM_OFFSET_FACTOR = 1 / 10;
30
31const isDynamicScrollAvailable = () => {
32  if (!history?.replaceState) {
33    return false;
34  }
35
36  if (window.matchMedia('(prefers-reduced-motion)').matches) {
37    return false;
38  }
39
40  return true;
41};
42
43type Props = React.PropsWithChildren<{
44  maxNestingDepth?: number;
45  selfRef?: React.RefObject<any>;
46  contentRef?: React.RefObject<any>;
47}>;
48
49type PropsWithHM = Props & { headingManager: HeadingManager };
50
51type State = {
52  activeSlug: string | null;
53  showScrollTop: boolean;
54};
55
56class DocumentationSidebarRight extends React.Component<PropsWithHM, State> {
57  static defaultProps = {
58    maxNestingDepth: 4,
59  };
60
61  state = {
62    activeSlug: null,
63    showScrollTop: false,
64  };
65
66  private slugScrollingTo: string | null = null;
67  private activeItemRef = React.createRef<HTMLAnchorElement>();
68
69  public handleContentScroll(contentScrollPosition: number) {
70    const { headings } = this.props.headingManager;
71
72    for (const { ref, slug } of headings) {
73      if (!ref || !ref.current) {
74        continue;
75      }
76      this.setState({ showScrollTop: contentScrollPosition > 120 });
77      if (
78        ref.current.offsetTop >=
79          contentScrollPosition + window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR &&
80        ref.current.offsetTop <= contentScrollPosition + window.innerHeight / 2
81      ) {
82        if (slug !== this.state.activeSlug) {
83          // we can enable scrolling again
84          if (slug === this.slugScrollingTo) {
85            this.slugScrollingTo = null;
86          }
87          this.setState({ activeSlug: slug }, this.updateSelfScroll);
88        }
89        return;
90      }
91    }
92  }
93
94  render() {
95    const { headings } = this.props.headingManager;
96
97    //filter out headings nested too much
98    const displayedHeadings = headings.filter(
99      head =>
100        head.level <= BASE_HEADING_LEVEL + this.props.maxNestingDepth! &&
101        head.title.toLowerCase() !== 'see also'
102    );
103
104    return (
105      <nav css={sidebarStyle} data-sidebar>
106        <CALLOUT
107          weight="medium"
108          className="absolute -mt-14 bg-default w-[248px] flex min-h-[32px] pt-4 pb-2 gap-2 mb-2 items-center select-none">
109          <LayoutAlt03Icon className="icon-sm" /> On this page
110          <Button
111            theme="quaternary"
112            size="xs"
113            className={mergeClasses(
114              'ml-auto mr-2 px-2 transition-opacity duration-300',
115              !this.state.showScrollTop && 'opacity-0 pointer-events-none'
116            )}
117            onClick={e => this.handleTopClick(e)}>
118            <ArrowCircleUpIcon className="icon-sm text-icon-secondary" />
119          </Button>
120        </CALLOUT>
121        {displayedHeadings.map(heading => {
122          const isActive = heading.slug === this.state.activeSlug;
123          return (
124            <DocumentationSidebarRightLink
125              key={heading.slug}
126              heading={heading}
127              onClick={e => this.handleLinkClick(e, heading)}
128              isActive={isActive}
129              ref={isActive ? this.activeItemRef : undefined}
130              shortenCode
131            />
132          );
133        })}
134      </nav>
135    );
136  }
137
138  /**
139   * Scrolls sidebar to keep active element always visible
140   */
141  private updateSelfScroll = () => {
142    const selfScroll = this.props.selfRef?.current?.getScrollRef().current;
143    const activeItemPos = this.activeItemRef.current?.offsetTop;
144
145    if (!selfScroll || !activeItemPos || this.slugScrollingTo) {
146      return;
147    }
148
149    const { scrollTop } = selfScroll;
150    const upperThreshold = window.innerHeight * UPPER_SCROLL_LIMIT_FACTOR;
151    const lowerThreshold = window.innerHeight * LOWER_SCROLL_LIMIT_FACTOR;
152
153    if (activeItemPos < scrollTop + upperThreshold) {
154      selfScroll.scrollTo({ behavior: 'auto', top: Math.max(0, activeItemPos - upperThreshold) });
155    } else if (activeItemPos > scrollTop + lowerThreshold) {
156      selfScroll.scrollTo({ behavior: 'auto', top: activeItemPos - lowerThreshold });
157    }
158  };
159
160  private handleLinkClick = (event: React.MouseEvent<HTMLAnchorElement>, heading: Heading) => {
161    if (!isDynamicScrollAvailable()) {
162      return;
163    }
164
165    event.preventDefault();
166    const { slug, ref } = heading;
167
168    // disable sidebar scrolling until we reach that slug
169    this.slugScrollingTo = slug;
170
171    this.props.contentRef?.current?.getScrollRef().current?.scrollTo({
172      behavior: 'smooth',
173      top: ref.current?.offsetTop - window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR,
174    });
175    history.replaceState(history.state, '', '#' + slug);
176  };
177
178  private handleTopClick = (
179    event: React.MouseEvent<HTMLButtonElement> | React.MouseEvent<HTMLAnchorElement>
180  ) => {
181    if (!isDynamicScrollAvailable()) {
182      return;
183    }
184
185    event.preventDefault();
186
187    this.props.contentRef?.current?.getScrollRef().current?.scrollTo({
188      behavior: 'smooth',
189      top: 0,
190    });
191    history.replaceState(history.state, '', ' ');
192  };
193}
194
195type ReactRefProps = { reactRef: React.Ref<DocumentationSidebarRight> };
196
197const SidebarWithHeadingManager = withHeadingManager(function SidebarWithHeadingManager({
198  reactRef,
199  ...props
200}: Props & HeadingManagerProps & ReactRefProps) {
201  return <DocumentationSidebarRight {...props} ref={reactRef} />;
202}) as React.FC<Props & ReactRefProps>;
203
204SidebarWithHeadingManager.displayName = 'SidebarRightRefWrapper';
205
206const SidebarForwardRef = React.forwardRef<DocumentationSidebarRight, Props>((props, ref) => (
207  <SidebarWithHeadingManager {...props} reactRef={ref} />
208));
209
210export type SidebarRightComponentType = DocumentationSidebarRight;
211
212export default SidebarForwardRef;
213