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