1import { css } from '@emotion/react';
2import { breakpoints } from '@expo/styleguide';
3import * as React from 'react';
4
5import DocumentationSidebarRightLink from './DocumentationSidebarRightLink';
6
7import { BASE_HEADING_LEVEL, Heading, HeadingManager } from '~/common/headingManager';
8import withHeadingManager, {
9  HeadingManagerProps,
10} from '~/components/page-higher-order/withHeadingManager';
11
12const STYLES_SIDEBAR = css`
13  padding: 20px 24px 24px 24px;
14  width: 280px;
15
16  @media screen and (max-width: ${breakpoints.medium + 124}px) {
17    width: 100%;
18  }
19`;
20
21const UPPER_SCROLL_LIMIT_FACTOR = 1 / 4;
22const LOWER_SCROLL_LIMIT_FACTOR = 3 / 4;
23
24const ACTIVE_ITEM_OFFSET_FACTOR = 1 / 6;
25
26const isDynamicScrollAvailable = () => {
27  if (!history?.replaceState) {
28    return false;
29  }
30
31  if (window.matchMedia('(prefers-reduced-motion)').matches) {
32    return false;
33  }
34
35  return true;
36};
37
38type Props = React.PropsWithChildren<{
39  maxNestingDepth?: number;
40  selfRef?: React.RefObject<any>;
41  contentRef?: React.RefObject<any>;
42}>;
43
44type PropsWithHM = Props & { headingManager: HeadingManager };
45
46type State = {
47  activeSlug: string | null;
48};
49
50class DocumentationSidebarRight extends React.Component<PropsWithHM, State> {
51  static defaultProps = {
52    maxNestingDepth: 4,
53  };
54
55  state = {
56    activeSlug: null,
57  };
58
59  private slugScrollingTo: string | null = null;
60  private activeItemRef = React.createRef<HTMLAnchorElement>();
61
62  public handleContentScroll(contentScrollPosition: number) {
63    const { headings } = this.props.headingManager;
64
65    for (const { ref, slug } of headings) {
66      if (!ref || !ref.current) {
67        continue;
68      }
69      if (
70        ref.current.offsetTop >=
71          contentScrollPosition + window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR &&
72        ref.current.offsetTop <= contentScrollPosition + window.innerHeight / 2
73      ) {
74        if (slug !== this.state.activeSlug) {
75          // we can enable scrolling again
76          if (slug === this.slugScrollingTo) {
77            this.slugScrollingTo = null;
78          }
79          this.setState({ activeSlug: slug }, this.updateSelfScroll);
80        }
81        return;
82      }
83    }
84  }
85
86  render() {
87    const { headings } = this.props.headingManager;
88
89    //filter out headings nested too much
90    const displayedHeadings = headings.filter(
91      head =>
92        head.level <= BASE_HEADING_LEVEL + this.props.maxNestingDepth! &&
93        head.title.toLowerCase() !== 'see also'
94    );
95
96    return (
97      <nav css={STYLES_SIDEBAR} data-sidebar>
98        {displayedHeadings.map(heading => {
99          const isActive = heading.slug === this.state.activeSlug;
100          return (
101            <DocumentationSidebarRightLink
102              key={heading.slug}
103              heading={heading}
104              onClick={e => this.handleLinkClick(e, heading)}
105              isActive={isActive}
106              ref={isActive ? this.activeItemRef : undefined}
107              shortenCode
108            />
109          );
110        })}
111      </nav>
112    );
113  }
114
115  /**
116   * Scrolls sidebar to keep active element always visible
117   */
118  private updateSelfScroll = () => {
119    const selfScroll = this.props.selfRef?.current?.getScrollRef().current;
120    const activeItemPos = this.activeItemRef.current?.offsetTop;
121
122    if (!selfScroll || !activeItemPos || this.slugScrollingTo) {
123      return;
124    }
125
126    const { scrollTop } = selfScroll;
127    const upperThreshold = window.innerHeight * UPPER_SCROLL_LIMIT_FACTOR;
128    const lowerThreshold = window.innerHeight * LOWER_SCROLL_LIMIT_FACTOR;
129
130    if (activeItemPos < scrollTop + upperThreshold) {
131      selfScroll.scrollTo({ behavior: 'auto', top: Math.max(0, activeItemPos - upperThreshold) });
132    } else if (activeItemPos > scrollTop + lowerThreshold) {
133      selfScroll.scrollTo({ behavior: 'auto', top: activeItemPos - lowerThreshold });
134    }
135  };
136
137  private handleLinkClick = (event: React.MouseEvent<HTMLAnchorElement>, heading: Heading) => {
138    if (!isDynamicScrollAvailable()) {
139      return;
140    }
141
142    event.preventDefault();
143    const { title, slug, ref } = heading;
144
145    // disable sidebar scrolling until we reach that slug
146    this.slugScrollingTo = slug;
147
148    this.props.contentRef?.current?.getScrollRef().current?.scrollTo({
149      behavior: 'smooth',
150      top: ref.current?.offsetTop - window.innerHeight * ACTIVE_ITEM_OFFSET_FACTOR,
151    });
152    history.replaceState(history.state, title, '#' + slug);
153  };
154}
155
156type ReactRefProps = { reactRef: React.Ref<DocumentationSidebarRight> };
157
158const SidebarWithHeadingManager = withHeadingManager(function SidebarWithHeadingManager({
159  reactRef,
160  ...props
161}: Props & HeadingManagerProps & ReactRefProps) {
162  return <DocumentationSidebarRight {...props} ref={reactRef} />;
163}) as React.FC<Props & ReactRefProps>;
164
165SidebarWithHeadingManager.displayName = 'SidebarRightRefWrapper';
166
167const SidebarForwardRef = React.forwardRef<DocumentationSidebarRight, Props>((props, ref) => (
168  <SidebarWithHeadingManager {...props} reactRef={ref} />
169));
170
171export type SidebarRightComponentType = DocumentationSidebarRight;
172
173export default SidebarForwardRef;
174