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