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