diff --git a/apps/documentation/src/components/mdx-provider/mdx-h2/mdx-h2.tsx b/apps/documentation/src/components/mdx-provider/mdx-h2/mdx-h2.tsx deleted file mode 100644 index 0c30846e..00000000 --- a/apps/documentation/src/components/mdx-provider/mdx-h2/mdx-h2.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Title } from '@mantine/core' -import { useRef, type HTMLAttributes } from 'react' - -export default function MdxH2( - props: HTMLAttributes, -): JSX.Element { - return ( - - ) -} diff --git a/apps/documentation/src/components/mdx-provider/mdx-provider.tsx b/apps/documentation/src/components/mdx-provider/mdx-provider.tsx index 534a3482..3e5be0eb 100644 --- a/apps/documentation/src/components/mdx-provider/mdx-provider.tsx +++ b/apps/documentation/src/components/mdx-provider/mdx-provider.tsx @@ -5,9 +5,8 @@ import type { ReactNode } from 'react' import MdxPre from './mdx-pre' import MdxQuote from './mdx-quote' import MdxCode from './mdx-code' -import MdxTitle from './mdx-title' +import { h } from './mdx-title' import MdxBold from './mdx-bold/mdx-bold' -import MdxH2 from './mdx-h2/mdx-h2' interface MdxProviderProps { children: ReactNode @@ -24,8 +23,12 @@ export default function MdxProvider({ pre: MdxPre, blockquote: MdxQuote, code: MdxCode, - h1: MdxTitle, - h2: MdxH2, + h1: h(1), + h2: h(2), + h3: h(3), + h4: h(4), + h5: h(5), + h6: h(6), strong: MdxBold, }} > diff --git a/apps/documentation/src/components/mdx-provider/mdx-title/index.ts b/apps/documentation/src/components/mdx-provider/mdx-title/index.ts index db92451e..8004c8c7 100644 --- a/apps/documentation/src/components/mdx-provider/mdx-title/index.ts +++ b/apps/documentation/src/components/mdx-provider/mdx-title/index.ts @@ -1,3 +1,5 @@ -import MdxTitle from './mdx-title' +import MdxTitle, { h } from './mdx-title' export default MdxTitle + +export { h } diff --git a/apps/documentation/src/components/mdx-provider/mdx-title/mdx-title.tsx b/apps/documentation/src/components/mdx-provider/mdx-title/mdx-title.tsx index 0326e553..7a070a5f 100644 --- a/apps/documentation/src/components/mdx-provider/mdx-title/mdx-title.tsx +++ b/apps/documentation/src/components/mdx-provider/mdx-title/mdx-title.tsx @@ -2,15 +2,23 @@ import { Title } from '@mantine/core' import type { HTMLAttributes } from 'react' export default function MdxTitle( - props: HTMLAttributes<HTMLHeadingElement>, + props: HTMLAttributes<HTMLHeadingElement> & { order: number }, ): JSX.Element { return ( <Title + data-heading={props.children} + data-order={props.order} + mt={20} {...props} - order={1} id={String(props.children ?? '') .toLowerCase() .replaceAll(' ', '-')} /> ) } + +export const h = + (order: 1 | 2 | 3 | 4 | 5 | 6) => + (props: HTMLAttributes<HTMLHeadingElement>): JSX.Element => ( + <MdxTitle order={order} {...props} /> + ) diff --git a/apps/documentation/src/components/table-of-content/get-headings.ts b/apps/documentation/src/components/table-of-content/get-headings.ts new file mode 100644 index 00000000..3c94ae3c --- /dev/null +++ b/apps/documentation/src/components/table-of-content/get-headings.ts @@ -0,0 +1,38 @@ +/** + * Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents + */ +export interface Heading { + depth: number + content: string + id: string + getNode: () => HTMLHeadingElement +} + +function getHeadingsData(headings: HTMLHeadingElement[]): Heading[] { + const result: Heading[] = [] + + for (const heading of headings) { + if (heading.id) { + result.push({ + depth: parseInt(heading.getAttribute('data-order'), 10), + content: heading.getAttribute('data-heading') || '', + id: heading.id, + getNode: () => + document.getElementById(heading.id) as HTMLHeadingElement, + }) + } + } + + return result +} + +export function getHeadings(): Heading[] { + const root = document.getElementById('mdx-root') + console.log(root) + + if (!root) { + return [] + } + + return getHeadingsData(Array.from(root.querySelectorAll('[data-heading]'))) +} diff --git a/apps/documentation/src/components/table-of-content/index.ts b/apps/documentation/src/components/table-of-content/index.ts new file mode 100644 index 00000000..16f972af --- /dev/null +++ b/apps/documentation/src/components/table-of-content/index.ts @@ -0,0 +1,6 @@ +/* + * Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents + */ +import { TableOfContents } from './table-of-content' + +export default TableOfContents diff --git a/apps/documentation/src/components/table-of-content/table-of-content.module.css b/apps/documentation/src/components/table-of-content/table-of-content.module.css new file mode 100644 index 00000000..80f23130 --- /dev/null +++ b/apps/documentation/src/components/table-of-content/table-of-content.module.css @@ -0,0 +1,120 @@ +/* + * Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents + */ +.wrapper { + padding-left: var(--mantine-spacing-md); + position: sticky; + top: var(--mantine-spacing-xl); + right: 0; + padding-top: 55px; + flex: 0 0 calc(var(--docs-table-of-contents-width) - 20px); + width: 200px; + + @mixin rtl { + padding-left: 0; + padding-right: var(--mantine-spacing-md); + right: auto; + left: 0; + } + + &[data-with-tabs] { + padding-top: 0; + top: calc(var(--mantine-spacing-xl) + 60px); + } +} + +.inner { + padding-bottom: var(--mantine-spacing-xl); + padding-left: var(--mantine-spacing-md); + display: flex; + flex-direction: column; + justify-content: space-between; + + @mixin rtl { + padding-left: 0; + padding-right: var(--mantine-spacing-md); + } +} + +.link { + display: block; + border-left: 1px solid transparent; + padding: 8px var(--mantine-spacing-md); + margin-left: -1px; + padding-left: calc(var(--toc-link-offset) * var(--mantine-spacing-lg)); + border-top-right-radius: var(--mantine-radius-sm); + border-bottom-right-radius: var(--mantine-radius-sm); + + @mixin light { + color: var(--mantine-color-gray-7); + } + + @mixin dark { + color: var(--mantine-color-dark-1); + } + + @mixin rtl { + border-left: 0; + border-right: 1px solid transparent; + margin-left: 0; + margin-right: -1px; + border-top-left-radius: var(--mantine-radius-sm); + border-bottom-left-radius: var(--mantine-radius-sm); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &[data-active] { + border-color: var(--mantine-color-violet-5); + + @mixin light { + color: var(--mantine-color-violet-8); + background-color: var(--mantine-color-violet-0); + } + + @mixin dark { + color: var(--mantine-color-violet-1); + background-color: #534a6d; + } + } +} + +.header { + display: flex; + align-items: center; + margin-bottom: var(--mantine-spacing-md); +} + +.title { + margin-left: var(--mantine-spacing-md); + + @mixin rtl { + margin-left: 0; + margin-right: var(--mantine-spacing-md); + } +} + +.items { + border-left: 1px solid; + + @mixin light { + border-color: var(--mantine-color-gray-3) !important; + } + + @mixin dark { + border-color: var(--mantine-color-dark-4) !important; + } + + @mixin rtl { + border-left: 0; + border-right: 1px solid; + + @mixin light { + border-color: var(--mantine-color-gray-3) !important; + } + + @mixin dark { + border-color: var(--mantine-color-dark-4) !important; + } + } +} diff --git a/apps/documentation/src/components/table-of-content/table-of-content.tsx b/apps/documentation/src/components/table-of-content/table-of-content.tsx new file mode 100644 index 00000000..c1b5db33 --- /dev/null +++ b/apps/documentation/src/components/table-of-content/table-of-content.tsx @@ -0,0 +1,113 @@ +/* + * Component inspired by: https://github.com/mantinedev/mantine/tree/master/apps/mantine.dev/src/components/TableOfContents + */ +import { useEffect, useRef, useState } from 'react' +import { useRouter, Link } from 'tuono' +import { IconList } from '@tabler/icons-react' +import { Box, rem, ScrollArea, Text } from '@mantine/core' +import { getHeadings, type Heading } from './get-headings' +import classes from './table-of-content.module.css' + +interface TableOfContentsProps { + withTabs: boolean +} + +function getActiveElement(rects: DOMRect[]): number { + if (rects.length === 0) { + return -1 + } + + const closest = rects.reduce( + (acc, item, index) => { + if (Math.abs(acc.position) < Math.abs(item.y)) { + return acc + } + + return { + index, + position: item.y, + } + }, + { index: 0, position: rects[0].y }, + ) + + return closest.index +} + +export function TableOfContents({ + withTabs, +}: TableOfContentsProps): JSX.Element | null { + const [active, setActive] = useState(0) + const [headings, setHeadings] = useState<Heading[]>([]) + const headingsRef = useRef<Heading[]>([]) + const router = useRouter() + + const filteredHeadings = headings.filter((heading) => heading.depth > 1) + + const handleScroll = (): void => { + setActive( + getActiveElement( + headingsRef.current.map((d) => d.getNode().getBoundingClientRect()), + ), + ) + } + + useEffect(() => { + const _headings = getHeadings() + headingsRef.current = _headings + setHeadings(_headings) + setActive( + getActiveElement( + _headings.map((d) => d.getNode().getBoundingClientRect()), + ), + ) + window.addEventListener('scroll', handleScroll) + return (): void => window.removeEventListener('scroll', handleScroll) + }, [router.pathname]) + + if (filteredHeadings.length === 0) { + return null + } + + const items = filteredHeadings.map((heading, index) => ( + <Text<Link> + key={heading.id} + component={Link} + fz="sm" + className={classes.link} + mod={{ active: active === index }} + href={`#${heading.id}`} + __vars={{ '--toc-link-offset': `${heading.depth - 1}` }} + > + {heading.content} + </Text> + )) + + return ( + <Box + component="nav" + mod={{ 'with-tabs': withTabs }} + className={classes.wrapper} + visibleFrom="lg" + > + <div className={classes.inner}> + <div> + <div className={classes.header}> + <IconList + style={{ width: rem(20), height: rem(20) }} + stroke={1.5} + /> + <Text className={classes.title}>Table of contents</Text> + </div> + <ScrollArea.Autosize + mah={`calc(100vh - ${rem(140)})`} + type="never" + offsetScrollbars + > + <div className={classes.items}>{items}</div> + </ScrollArea.Autosize> + </div> + </div> + </Box> + ) +} diff --git a/apps/documentation/src/routes/documentation/__root.tsx b/apps/documentation/src/routes/documentation/__root.tsx index 608fafe5..1a0be371 100644 --- a/apps/documentation/src/routes/documentation/__root.tsx +++ b/apps/documentation/src/routes/documentation/__root.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react' -import { AppShell, Container } from '@mantine/core' +import { AppShell, Container, Flex } from '@mantine/core' import MdxProvider from '../../components/mdx-provider' import EditPage from '../../components/edit-page' +import TableOfContents from '../../components/table-of-content' interface RootRouteProps { children: ReactNode @@ -10,9 +11,16 @@ interface RootRouteProps { export default function RootRoute({ children }: RootRouteProps): JSX.Element { return ( <AppShell.Main> - <Container component="article" p={20}> - <MdxProvider>{children}</MdxProvider> - <EditPage /> + <Container p={20} size="lg"> + <Flex> + <Container id="mdx-root" component="article"> + <MdxProvider>{children}</MdxProvider> + <EditPage /> + </Container> + <Container size="xs"> + <TableOfContents withTabs={false} /> + </Container> + </Flex> </Container> </AppShell.Main> ) diff --git a/apps/documentation/src/routes/documentation/getting-started.mdx b/apps/documentation/src/routes/documentation/getting-started.mdx index 0def2ef9..3dad689d 100644 --- a/apps/documentation/src/routes/documentation/getting-started.mdx +++ b/apps/documentation/src/routes/documentation/getting-started.mdx @@ -12,6 +12,8 @@ import Breadcrumbs, { Element } from '../../components/breadcrumbs' # Getting started +## The CLI + Tuono is the CLI that provides all the needed commands to handle the fullstack project. > ☝️ Check the [installation](/documentation/installation) page if you haven't installed the