Skip to content

Commit

Permalink
🪆 Improve Table of Contents
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanc1 committed Nov 8, 2023
1 parent c4f154d commit 12edf51
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 110 deletions.
31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions packages/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,18 @@
"dependencies": {
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"@myst-theme/common": "^0.5.10",
"@myst-theme/diagrams": "^0.5.10",
"@myst-theme/frontmatter": "^0.5.10",
"@myst-theme/jupyter": "^0.5.10",
"@myst-theme/common": "^0.5.10",
"@myst-theme/providers": "^0.5.10",
"@radix-ui/react-collapsible": "^1.0.3",
"classnames": "^2.3.2",
"lodash.throttle": "^4.1.1",
"myst-common": "^1.1.8",
"myst-spec-ext": "^1.1.8",
"myst-config": "^1.1.8",
"myst-demo": "^0.5.10",
"myst-spec-ext": "^1.1.8",
"myst-to-react": "^0.5.10",
"nbtx": "^0.2.3",
"node-cache": "^5.1.2",
Expand All @@ -36,10 +37,10 @@
"unist-util-select": "^4.0.1"
},
"peerDependencies": {
"@remix-run/node": "^1.17 || ^2.0",
"@remix-run/react": "^1.17 || ^2.0",
"@types/react": "^16.8 || ^17.0 || ^18.0",
"@types/react-dom": "^16.8 || ^17.0 || ^18.0",
"@remix-run/react": "^1.17 || ^2.0",
"@remix-run/node": "^1.17 || ^2.0",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
Expand Down
111 changes: 5 additions & 106 deletions packages/site/src/components/Navigation/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -1,115 +1,14 @@
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import { useLocation, useNavigation } from '@remix-run/react';
import type { SiteManifest } from 'myst-config';
import { useNavigation } from '@remix-run/react';
import {
useNavLinkProvider,
useNavOpen,
useSiteManifest,
useBaseurl,
withBaseurl,
useGridSystemProvider,
useThemeTop,
} from '@myst-theme/providers';
import { getProjectHeadings, type Heading } from '@myst-theme/common';

type Props = {
folder?: string;
headings: Heading[];
sections?: ManifestProject[];
};

type ManifestProject = Required<SiteManifest>['projects'][0];

const HeadingLink = ({
path,
isIndex,
title,
children,
}: {
path: string;
isIndex?: boolean;
title?: string;
children: React.ReactNode;
}) => {
const { pathname } = useLocation();
const NavLink = useNavLinkProvider();
const exact = pathname === path;
const baseurl = useBaseurl();
const [, setOpen] = useNavOpen();
return (
<NavLink
prefetch="intent"
title={title}
className={({ isActive }: { isActive: boolean }) =>
classNames('block break-words', {
'mb-8 lg:mb-3 font-semibold': isIndex,
'text-slate-900 dark:text-slate-200': isIndex && !exact,
'text-blue-500 dark:text-blue-400': isIndex && exact,
'border-l pl-4 text-blue-500 border-current dark:text-blue-400': !isIndex && isActive,
'font-semibold': isActive,
'border-l pl-4 border-transparent hover:border-slate-400 dark:hover:border-slate-500 text-slate-700 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-300':
!isIndex && !isActive,
})
}
to={withBaseurl(path, baseurl)}
suppressHydrationWarning // The pathname is not defined on the server always.
onClick={() => {
// Close the nav panel if it is open
setOpen(false);
}}
>
{children}
</NavLink>
);
};

const HEADING_CLASSES = 'text-slate-900 leading-5 dark:text-slate-100';

const Headings = ({ folder, headings, sections }: Props) => {
const secs = sections || [];
return (
<ul className="space-y-6 lg:space-y-2">
{secs.map((sec) => {
if (sec.slug === folder) {
return headings.map((heading, index) => (
<li
key={heading.slug || index}
className={classNames('', {
[HEADING_CLASSES]: heading.level === 'index',
'font-semibold': heading.level === 'index',
// 'pl-4': heading.level === 2,
// 'pl-6': heading.level === 3,
// 'pl-8': heading.level === 4,
// 'pl-10': heading.level === 5,
// 'pl-12': heading.level === 6,
})}
>
{heading.path ? (
<HeadingLink
title={heading.title}
path={heading.path}
isIndex={heading.level === 'index'}
>
{heading.short_title || heading.title}
</HeadingLink>
) : (
<h5 className="mb-3 font-semibold break-words lg:mt-8 dark:text-white">
{heading.short_title || heading.title}
</h5>
)}
</li>
));
}
return (
<li key={sec.slug} className={classNames('p-1 my-2 lg:hidden', HEADING_CLASSES)}>
<HeadingLink path={`/${sec.slug}`}>{sec.short_title || sec.title}</HeadingLink>
</li>
);
})}
</ul>
);
};
import { getProjectHeadings } from '@myst-theme/common';
import { Toc } from './TableOfContentsItems.js';

export function useTocHeight<T extends HTMLElement = HTMLElement>(top = 0, inset = 0) {
const container = useRef<T>(null);
Expand Down Expand Up @@ -189,7 +88,7 @@ export const TableOfContents = ({
aria-label="Table of Contents"
className="flex-grow overflow-y-auto transition-opacity mt-6 pb-3 ml-3 xl:ml-0 mr-3 max-w-[350px]"
>
<Headings folder={projectSlug} headings={headings} sections={config?.projects} />
<Toc headings={headings} />
</nav>
{footer && (
<div
Expand Down Expand Up @@ -221,7 +120,7 @@ export const InlineTableOfContents = ({
if (!headings) return null;
return (
<nav aria-label="Table of Contents" className={className} ref={tocRef}>
<Headings folder={projectSlug} headings={headings} sections={config?.projects} />
<Toc headings={headings} />
</nav>
);
};
162 changes: 162 additions & 0 deletions packages/site/src/components/Navigation/TableOfContentsItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useEffect } from 'react';
import classNames from 'classnames';
import * as Collapsible from '@radix-ui/react-collapsible';
import type { Heading } from '@myst-theme/common';
import { useBaseurl, useNavLinkProvider, useNavOpen, withBaseurl } from '@myst-theme/providers';
import { useLocation, useNavigation } from '@remix-run/react';
import { ChevronRightIcon } from '@heroicons/react/24/solid';

type NestedHeading = Heading & { id: string; children: NestedHeading[] };

function nestToc(toc: Heading[]): NestedHeading[] {
const items: NestedHeading[] = [];
const stack: (Omit<NestedHeading, 'level'> & { level: number })[] = [];

toc.forEach((tocItem, id) => {
const item = tocItem as NestedHeading;
item.children = [];
item.id = String(id);
if (item.level === 'index') {
while (stack.length) stack.pop();
items.push(item);
return;
}
while (stack.length && stack[stack.length - 1].level >= item.level) {
stack.pop();
}
const top = stack[stack.length - 1];
if (top) {
top.children.push(item);
} else {
items.push(item);
}
stack.push(item as any);
});
return items;
}

function childrenOpen(headings: NestedHeading[], pathname: string): string[] {
return headings
.map((heading) => {
if (heading.path === pathname) return [heading.id];
const open = childrenOpen(heading.children, pathname);
if (open.length === 0) return [];
return [heading.id, ...open];
})
.flat();
}

export const Toc = ({ headings }: { headings: Heading[] }) => {
const nested = nestToc(headings);
return (
<div className="w-full px-1 dark:text-white">
{nested.map((item) => (
<NestedToc heading={item} key={item.id} />
))}
</div>
);
};

function LinkItem({
className,
heading,
onClick,
}: {
className?: string;
heading: NestedHeading;
onClick?: () => void;
}) {
const NavLink = useNavLinkProvider();
const baseurl = useBaseurl();
const [, setOpen] = useNavOpen();
if (!heading.path) {
return (
<div
title={heading.title}
className={classNames('block break-words rounded', className)}
onClick={() => {
onClick?.();
}}
>
{heading.short_title || heading.title}
</div>
);
}
return (
<NavLink
prefetch="intent"
title={heading.title}
className={classNames(
'block break-words focus:outline outline-blue-200 outline-2 rounded',
className,
)}
to={withBaseurl(heading.path, baseurl)}
onClick={() => {
onClick?.();
setOpen(false);
}}
>
{heading.short_title || heading.title}
</NavLink>
);
}

const NestedToc = ({ heading }: { heading: NestedHeading }) => {
const { pathname } = useLocation();
const startOpen = childrenOpen([heading], pathname).includes(heading.id);
const nav = useNavigation();
nav.state;
const [open, setOpen] = React.useState(startOpen);
useEffect(() => {
if (nav.state === 'idle') setOpen(startOpen);
}, [nav.state]);
const exact = pathname === heading.path;
if (!heading.children || heading.children.length === 0) {
return (
<LinkItem
className={classNames('p-2 my-1 rounded-lg', {
'bg-blue-300/30': exact,
'hover:bg-slate-300/30': !exact,
'font-bold': heading.level === 'index',
})}
heading={heading}
/>
);
}
return (
<Collapsible.Root className="w-full" open={open} onOpenChange={setOpen}>
<div
className={classNames(
'flex flex-row w-full gap-2 px-2 my-1 text-left rounded-lg outline-none',
{
'bg-blue-300/30': exact,
'hover:bg-slate-300/30': !exact,
},
)}
>
<LinkItem
className={classNames('py-2 grow', {
'font-semibold text-blue-800 dark:text-blue-200': startOpen,
'cursor-pointer': !heading.path,
})}
heading={heading}
onClick={() => setOpen(heading.path ? true : !open)}
/>
<Collapsible.Trigger asChild>
<button className="self-center flex-none rounded-md group hover:bg-slate-300/30 focus:outline outline-blue-200 outline-2">
<ChevronRightIcon
className="transition-transform duration-300 group-data-[state=open]:rotate-90 text-text-slate-700 dark:text-slate-100"
height="1.5rem"
width="1.5rem"
/>
</button>
</Collapsible.Trigger>
</div>
<Collapsible.Content className="pl-3 pr-[2px] collapsible-content">
{heading.children.map((item) => (
<NestedToc heading={item} key={item.id} />
))}
</Collapsible.Content>
</Collapsible.Root>
);
};
1 change: 1 addition & 0 deletions styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
@import './grid.css';
@import './hover.css';
@import './proof.css';
@import './toc.css';
Loading

0 comments on commit 12edf51

Please sign in to comment.