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