1import { css } from '@emotion/react'; 2import { spacing } from '@expo/styleguide-base'; 3import { useMemo } from 'react'; 4 5import { HeadingEntry, useHeadingsObserver } from './useHeadingObserver'; 6 7import { LayoutScroll, useAutoScrollTo } from '~/ui/components/Layout'; 8import { A, CALLOUT } from '~/ui/components/Text'; 9 10type TableOfContentsLinkProps = HeadingEntry & { 11 isActive?: boolean; 12}; 13 14export function TableOfContents() { 15 const { headings, activeId } = useHeadingsObserver(); 16 const autoScroll = useAutoScrollTo(activeId ? `[data-toc-id="${activeId}"]` : ''); 17 18 return ( 19 <LayoutScroll ref={autoScroll.ref}> 20 <nav css={containerStyle}> 21 <CALLOUT css={titleStyle} weight="medium"> 22 On this page 23 </CALLOUT> 24 <ul css={listStyle}> 25 {headings.map(heading => ( 26 <li key={`heading-${heading.id}`} data-toc-id={heading.id}> 27 <TableOfContentsLink {...heading} isActive={heading.id === activeId} /> 28 </li> 29 ))} 30 </ul> 31 </nav> 32 </LayoutScroll> 33 ); 34} 35 36function TableOfContentsLink({ id, element, isActive }: TableOfContentsLinkProps) { 37 const info = useMemo(() => getHeadingInfo(element), [element]); 38 39 return ( 40 <A css={[linkStyle, getHeadingIndent(element)]} href={`#${id}`}> 41 <CALLOUT weight={isActive ? 'medium' : 'regular'} tag="span"> 42 {info.text} 43 </CALLOUT> 44 </A> 45 ); 46} 47 48const containerStyle = css({ 49 display: 'block', 50 width: '100%', 51 padding: spacing[8], 52}); 53 54const titleStyle = css({ 55 marginTop: spacing[4], 56 marginBottom: spacing[1.5], 57}); 58 59const listStyle = css({ 60 listStyle: 'none', 61}); 62 63const linkStyle = css({ 64 display: 'block', 65 padding: `${spacing[1.5]}px 0`, 66}); 67 68export function getHeadingIndent(heading: HTMLHeadingElement) { 69 const level = Math.max(Number(heading.tagName.slice(1)) - 2, 0); 70 return { paddingLeft: spacing[2] * level }; 71} 72 73/** 74 * Parse the heading information from an HTML heading element. 75 * If it contains parenthesis, we try to extract the function name only. 76 */ 77export function getHeadingInfo(heading: HTMLHeadingElement) { 78 const text = heading.textContent || ''; 79 const functionOpenChar = text.indexOf('('); 80 81 return functionOpenChar >= 0 && text[functionOpenChar - 1].trim() 82 ? { type: 'code', text: text.slice(0, functionOpenChar) } 83 : { type: 'text', text }; 84} 85