From 12edf5161cfefd1a257edc200abd8c7dadcec623 Mon Sep 17 00:00:00 2001 From: Rowan Cockett Date: Tue, 7 Nov 2023 19:47:50 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=86=20Improve=20Table=20of=20Contents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 31 ++++ packages/site/package.json | 9 +- .../components/Navigation/TableOfContents.tsx | 111 +----------- .../Navigation/TableOfContentsItems.tsx | 162 ++++++++++++++++++ styles/app.css | 1 + styles/toc.css | 27 +++ 6 files changed, 231 insertions(+), 110 deletions(-) create mode 100644 packages/site/src/components/Navigation/TableOfContentsItems.tsx create mode 100644 styles/toc.css diff --git a/package-lock.json b/package-lock.json index ab6d0834d..18329dd5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11738,6 +11738,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", @@ -45808,6 +45838,7 @@ "@myst-theme/frontmatter": "^0.5.10", "@myst-theme/jupyter": "^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", diff --git a/packages/site/package.json b/packages/site/package.json index 4ec9a732a..1c1dbe28e 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -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", @@ -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" }, diff --git a/packages/site/src/components/Navigation/TableOfContents.tsx b/packages/site/src/components/Navigation/TableOfContents.tsx index bc49a95a8..d2f2fa541 100644 --- a/packages/site/src/components/Navigation/TableOfContents.tsx +++ b/packages/site/src/components/Navigation/TableOfContents.tsx @@ -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['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 ( - - 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} - - ); -}; - -const HEADING_CLASSES = 'text-slate-900 leading-5 dark:text-slate-100'; - -const Headings = ({ folder, headings, sections }: Props) => { - const secs = sections || []; - return ( - - ); -}; +import { getProjectHeadings } from '@myst-theme/common'; +import { Toc } from './TableOfContentsItems.js'; export function useTocHeight(top = 0, inset = 0) { const container = useRef(null); @@ -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]" > - + {footer && (
- + ); }; diff --git a/packages/site/src/components/Navigation/TableOfContentsItems.tsx b/packages/site/src/components/Navigation/TableOfContentsItems.tsx new file mode 100644 index 000000000..5084b33c8 --- /dev/null +++ b/packages/site/src/components/Navigation/TableOfContentsItems.tsx @@ -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 & { 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 ( +
+ {nested.map((item) => ( + + ))} +
+ ); +}; + +function LinkItem({ + className, + heading, + onClick, +}: { + className?: string; + heading: NestedHeading; + onClick?: () => void; +}) { + const NavLink = useNavLinkProvider(); + const baseurl = useBaseurl(); + const [, setOpen] = useNavOpen(); + if (!heading.path) { + return ( +
{ + onClick?.(); + }} + > + {heading.short_title || heading.title} +
+ ); + } + return ( + { + onClick?.(); + setOpen(false); + }} + > + {heading.short_title || heading.title} + + ); +} + +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 ( + + ); + } + return ( + +
+ setOpen(heading.path ? true : !open)} + /> + + + +
+ + {heading.children.map((item) => ( + + ))} + +
+ ); +}; diff --git a/styles/app.css b/styles/app.css index cb03d6d0d..3f27905af 100644 --- a/styles/app.css +++ b/styles/app.css @@ -13,3 +13,4 @@ @import './grid.css'; @import './hover.css'; @import './proof.css'; +@import './toc.css'; diff --git a/styles/toc.css b/styles/toc.css new file mode 100644 index 000000000..359d74784 --- /dev/null +++ b/styles/toc.css @@ -0,0 +1,27 @@ +.collapsible-content { + overflow: hidden; +} +.collapsible-content[data-state='open'] { + animation: open-content 300ms ease-out; +} +.collapsible-content[data-state='closed'] { + animation: close-content 300ms ease-out; +} + +@keyframes open-content { + from { + height: 0; + } + to { + height: var(--radix-collapsible-content-height); + } +} + +@keyframes close-content { + from { + height: var(--radix-collapsible-content-height); + } + to { + height: 0; + } +}