From 9a2d0b355c2b3fd44f154bfe7cfd07f5455d0f55 Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Sat, 31 Aug 2024 20:18:07 +0200 Subject: [PATCH] feat: collapsible nav (#317) * wip * radix collapsible * initial open state * tweaks --- package-lock.json | 200 ++++++++++++++++++++++++++++- package.json | 1 + src/app/[...slug]/Menu.tsx | 2 +- src/app/[...slug]/layout.tsx | 2 +- src/app/layout.tsx | 8 +- src/components/Nav.tsx | 67 ---------- src/components/Nav/Nav.tsx | 33 +++++ src/components/Nav/NavCategory.tsx | 92 +++++++++++++ src/components/Nav/index.ts | 2 + src/components/mdx/Toc/Toc.tsx | 21 +-- tailwind.config.ts | 14 ++ 11 files changed, 359 insertions(+), 83 deletions(-) delete mode 100644 src/components/Nav.tsx create mode 100644 src/components/Nav/Nav.tsx create mode 100644 src/components/Nav/NavCategory.tsx create mode 100644 src/components/Nav/index.ts diff --git a/package-lock.json b/package-lock.json index bbd1dd94..4fe54d4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@codesandbox/sandpack-react": "^2.19.0", + "@radix-ui/react-collapsible": "^1.1.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/typography": "^0.5.14", "clsx": "^2.1.1", @@ -1086,6 +1087,203 @@ "node": ">=12" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz", + "integrity": "sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-hook/intersection-observer": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-hook/intersection-observer/-/intersection-observer-3.1.2.tgz", @@ -1504,7 +1702,7 @@ "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*" diff --git a/package.json b/package.json index a6145643..0552e979 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@codesandbox/sandpack-react": "^2.19.0", + "@radix-ui/react-collapsible": "^1.1.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/typography": "^0.5.14", "clsx": "^2.1.1", diff --git a/src/app/[...slug]/Menu.tsx b/src/app/[...slug]/Menu.tsx index 05ff80ed..42f38638 100644 --- a/src/app/[...slug]/Menu.tsx +++ b/src/app/[...slug]/Menu.tsx @@ -60,7 +60,7 @@ export function Menu({ > diff --git a/src/app/[...slug]/layout.tsx b/src/app/[...slug]/layout.tsx index 579e0388..b203a63c 100644 --- a/src/app/[...slug]/layout.tsx +++ b/src/app/[...slug]/layout.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import Nav from '@/components/Nav' +import { Nav } from '@/components/Nav' import Search from '@/components/Search' import { Toc } from '@/components/mdx/Toc' import cn from '@/lib/cn' diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cf053d2f..d6ddd374 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -62,10 +62,10 @@ export default function RootLayout({ {children} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx deleted file mode 100644 index 1d2e4ee4..00000000 --- a/src/components/Nav.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Doc } from '@/app/[...slug]/DocsContext' -import cn from '@/lib/cn' -import Link from 'next/link' -import * as React from 'react' - -interface NavItemProps { - doc: Doc - asPath: string -} - -function NavItem({ doc, asPath }: NavItemProps) { - const active = doc.url === `/${asPath}` - - return ( - - {doc.title} - - ) -} - -type NavList = Record> - -function Nav({ docs, asPath }: { docs: Doc[]; asPath: string }) { - const nav = React.useMemo( - () => - docs.reduce((acc, doc) => { - const [...rest] = doc.slug - const [page, category] = rest.reverse() - - if (category && !acc[category]) acc[category] = {} - - // @ts-ignore - if (category) acc[category][page] = doc - else acc[page] = doc - - return acc - }, {} as NavList), - [docs], - ) - - return ( - - ) -} - -export default Nav diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx new file mode 100644 index 00000000..d7219967 --- /dev/null +++ b/src/components/Nav/Nav.tsx @@ -0,0 +1,33 @@ +import { Doc } from '@/app/[...slug]/DocsContext' +import * as React from 'react' +import { NavCategory } from './NavCategory' + +type NavList = Record> + +export function Nav({ docs, asPath }: { docs: Doc[]; asPath: string }) { + const nav = React.useMemo( + () => + docs.reduce((acc, doc) => { + const page = doc.slug.at(-1) + const category = doc.slug.at(-2) || 'root' + + acc[category] ??= {} + if (page) acc[category][page] = doc + + return acc + }, {} as NavList), + [docs], + ) + + return ( +
    + {Object.entries(nav).map(([category, docs]) => { + return ( +
  • + +
  • + ) + })} +
+ ) +} diff --git a/src/components/Nav/NavCategory.tsx b/src/components/Nav/NavCategory.tsx new file mode 100644 index 00000000..f4fe3b03 --- /dev/null +++ b/src/components/Nav/NavCategory.tsx @@ -0,0 +1,92 @@ +'use client' + +import { Doc } from '@/app/[...slug]/DocsContext' +import cn from '@/lib/cn' +import * as Collapsible from '@radix-ui/react-collapsible' +import Link from 'next/link' +import { ComponentProps, useState } from 'react' +import { IoIosArrowDown } from 'react-icons/io' + +const INDEX_PAGE = 'introduction' + +export function NavCategory({ + category, + docs, + asPath, +}: { + category: string + docs: Record + asPath: string +}) { + const docsEntries = Object.entries(docs) + + const docIndexEntry = docsEntries.find(([page]) => page === INDEX_PAGE) + const categoryHref = docIndexEntry ? docIndexEntry[1].url : docsEntries[0][1].url + + const [open, setOpen] = useState(docsEntries.some(([, doc]) => doc.url === `/${asPath}`)) + + return ( + doc.url === `/${asPath}`) && 'opacity-50', + )} + open={open} + onOpenChange={setOpen} + > +
+ + {category.replace(/\-/g, ' ')} + + +
+ +
+
+
+ + +
    + {docsEntries + .filter(([page]) => page !== INDEX_PAGE) + .map(([page, doc]) => ( +
  • + + {doc.title} + +
  • + ))} +
+
+
+ ) +} + +function NavItem({ + children, + className, + active, + ...props +}: { + active?: boolean +} & ComponentProps) { + return ( + + {children} + + ) +} diff --git a/src/components/Nav/index.ts b/src/components/Nav/index.ts new file mode 100644 index 00000000..aade2069 --- /dev/null +++ b/src/components/Nav/index.ts @@ -0,0 +1,2 @@ +export * from './Nav' +export * from './NavCategory' diff --git a/src/components/mdx/Toc/Toc.tsx b/src/components/mdx/Toc/Toc.tsx index 30d549a4..f691862a 100644 --- a/src/components/mdx/Toc/Toc.tsx +++ b/src/components/mdx/Toc/Toc.tsx @@ -2,19 +2,22 @@ import type { DocToC } from '@/app/[...slug]/DocsContext' import cn from '@/lib/cn' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' export function Toc({ toc }: { toc: DocToC[] }) { // console.log('toc', toc) const [activeIndex, setActiveIndex] = useState() - const updateActiveIndex = (hash: string) => { - const index = toc.findIndex((item) => item.id === hash.slice(1)) - if (index !== -1) { - setActiveIndex(index) - } - } + const updateActiveIndex = useCallback( + (hash: string) => { + const index = toc.findIndex((item) => item.id === hash.slice(1)) + if (index !== -1) { + setActiveIndex(index) + } + }, + [toc], + ) useEffect(() => { updateActiveIndex(window.location.hash) @@ -27,7 +30,7 @@ export function Toc({ toc }: { toc: DocToC[] }) { return () => { window.removeEventListener('hashchange', onHashChanged) } - }, []) + }, [updateActiveIndex]) // React.useEffect(() => { // const headings = toc.map((heading) => document.getElementById(heading.id)) @@ -56,7 +59,7 @@ export function Toc({ toc }: { toc: DocToC[] }) {