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