mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
feat: added in-page section navigation for docs (#2720)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -115,7 +115,7 @@ const SmallPrint = () => {
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<footer className="mx-auto w-full max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
|
||||
<footer className="my-10 flex-auto pb-16">
|
||||
<PageNavigation />
|
||||
<SmallPrint />
|
||||
</footer>
|
||||
|
||||
@@ -44,7 +44,7 @@ const Anchor = ({ id, inView, children }: { id: string; inView: boolean; childre
|
||||
);
|
||||
};
|
||||
|
||||
export const Heading = <Level extends 2 | 3>({
|
||||
export const Heading = <Level extends 2 | 3 | 4>({
|
||||
children,
|
||||
tag,
|
||||
label,
|
||||
@@ -59,11 +59,11 @@ export const Heading = <Level extends 2 | 3>({
|
||||
anchor?: boolean;
|
||||
}) => {
|
||||
level = level ?? (2 as Level);
|
||||
let Component = `h${level}` as "h2" | "h3";
|
||||
let ref = useRef<HTMLHeadingElement>(null);
|
||||
let registerHeading = useSectionStore((s) => s.registerHeading);
|
||||
const Component: "h2" | "h3" | "h4" = `h${level}`;
|
||||
const ref = useRef<HTMLHeadingElement>(null);
|
||||
const registerHeading = useSectionStore((s) => s.registerHeading);
|
||||
|
||||
let inView = useInView(ref, {
|
||||
const inView = useInView(ref, {
|
||||
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
|
||||
amount: "all",
|
||||
});
|
||||
@@ -71,8 +71,12 @@ export const Heading = <Level extends 2 | 3>({
|
||||
useEffect(() => {
|
||||
if (level === 2) {
|
||||
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 8 : 6 });
|
||||
} else if (level === 3) {
|
||||
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 7 : 5 });
|
||||
} else if (level === 4) {
|
||||
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 6 : 4 });
|
||||
}
|
||||
});
|
||||
}, [label, level, props.id, registerHeading, tag]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Navigation } from "@/components/Navigation";
|
||||
import { SideNavigation } from "@/components/SideNavigation";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -16,7 +17,7 @@ export const Layout = ({
|
||||
children: React.ReactNode;
|
||||
allSections: Record<string, Array<Section>>;
|
||||
}) => {
|
||||
let pathname = usePathname();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SectionProvider sections={allSections[pathname || ""] ?? []}>
|
||||
@@ -34,9 +35,12 @@ export const Layout = ({
|
||||
<Navigation className="hidden lg:mt-10 lg:block" isMobile={false} />
|
||||
</div>
|
||||
</motion.header>
|
||||
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
|
||||
<main className="flex-auto">{children}</main>
|
||||
<Footer />
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="flex flex-col px-4 pt-14 sm:px-6 lg:w-[calc(100%-20rem)] lg:px-8">
|
||||
<main className="overflow-y-auto overflow-x-hidden">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<SideNavigation pathname={pathname} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionProvider>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import Image, { ImageProps } from "next/image";
|
||||
import React from "react";
|
||||
|
||||
export const MdxImage = (props: ImageProps) => {
|
||||
return <Image {...props} alt={props.alt} />;
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
sizes="100vw"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
82
apps/docs/components/SideNavigation.tsx
Normal file
82
apps/docs/components/SideNavigation.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useTableContentObserver } from "@/hooks/useTableContentObserver";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Heading = {
|
||||
id: string;
|
||||
text: string | null;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export const SideNavigation = ({ pathname }) => {
|
||||
const [headings, setHeadings] = useState<Heading[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
useTableContentObserver(setSelectedId, pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const getHeadings = () => {
|
||||
// Select all heading elements (h2, h3, h4) with an 'id' attribute
|
||||
const headingElements = document.querySelectorAll("h2[id], h3[id], h4[id]");
|
||||
// Convert the NodeList of heading elements into an array and map them to an array of 'Heading' objects
|
||||
const headings: Heading[] = Array.from(headingElements).map((heading) => ({
|
||||
id: heading.id,
|
||||
text: heading.textContent,
|
||||
level: parseInt(heading.tagName.slice(1)),
|
||||
}));
|
||||
|
||||
// Check if there are any h2 headings in the list
|
||||
const hasH2 = headings.some((heading) => heading.level === 2);
|
||||
|
||||
// Update the 'headings' state with the retrieved headings, but only if there are h2 headings
|
||||
setHeadings(hasH2 ? headings : []);
|
||||
};
|
||||
|
||||
getHeadings();
|
||||
}, [pathname]);
|
||||
|
||||
const renderHeading = (items: Heading[], currentLevel: number) => (
|
||||
<ul className="ml-1 mt-4">
|
||||
{items.map((heading, index) => {
|
||||
if (heading.level === currentLevel) {
|
||||
let nextIndex = index + 1;
|
||||
while (nextIndex < items.length && items[nextIndex].level > currentLevel) {
|
||||
nextIndex++;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={heading.text}
|
||||
className={`mb-4 ml-4 text-slate-900 dark:text-white ml-${heading.level === 2 ? 0 : heading.level === 3 ? 4 : 6}`}>
|
||||
<Link
|
||||
href={`#${heading.id}`}
|
||||
onClick={() => setSelectedId(heading.id)}
|
||||
className={`${
|
||||
heading.id === selectedId
|
||||
? "text-brand font-medium text-opacity-35"
|
||||
: "font-normal text-slate-600 hover:text-slate-950 dark:text-white dark:hover:text-slate-50"
|
||||
}`}>
|
||||
{heading.text}
|
||||
</Link>
|
||||
{nextIndex > index + 1 && renderHeading(items.slice(index + 1, nextIndex), currentLevel + 1)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (headings.length) {
|
||||
return (
|
||||
<aside className="fixed right-0 top-0 hidden h-[calc(100%-2.5rem)] w-80 overflow-hidden overflow-y-auto pr-8 pt-16 text-sm [scrollbar-width:none] lg:mt-10 lg:block">
|
||||
<div className="border-l border-slate-200 dark:border-slate-700">
|
||||
<h3 className="ml-5 mt-1 text-xs font-semibold uppercase text-slate-400">on this page</h3>
|
||||
{renderHeading(headings, 2)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -19,10 +19,19 @@ export const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const h2 = (props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, "level">) => {
|
||||
return <Heading level={2} {...props} />;
|
||||
const createHeadingComponent = (level: 2 | 3 | 4) => {
|
||||
const Component = (props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, "level">) => {
|
||||
return <Heading level={level} {...props} />;
|
||||
};
|
||||
|
||||
Component.displayName = `H${level}`;
|
||||
return Component;
|
||||
};
|
||||
|
||||
export const h2 = createHeadingComponent(2);
|
||||
export const h3 = createHeadingComponent(3);
|
||||
export const h4 = createHeadingComponent(4);
|
||||
|
||||
const InfoIcon = (props: React.ComponentPropsWithoutRef<"svg">) => {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||
|
||||
71
apps/docs/hooks/useTableContentObserver.tsx
Normal file
71
apps/docs/hooks/useTableContentObserver.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface HeadingElement extends IntersectionObserverEntry {
|
||||
target: HTMLHeadingElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom hook that sets up an IntersectionObserver to track the visibility of headings on the page.
|
||||
*
|
||||
* @param {Function} setActiveId - A function to set the active heading ID.
|
||||
* @param {string} pathname - The current pathname, used as a dependency for the useEffect hook.
|
||||
* @returns {void}
|
||||
*
|
||||
* This hook performs the following tasks:
|
||||
* 1. Creates a map of heading elements, where the key is the heading's ID and the value is the heading element.
|
||||
* 2. Finds the visible headings (i.e., headings that are currently intersecting with the viewport).
|
||||
* 3. If there is only one visible heading, sets it as the active heading using the `setActiveId` function.
|
||||
* 4. If there are multiple visible headings, sets the active heading to the one that is highest on the page (i.e., the one with the lowest index in the `headingElements` array).
|
||||
* 5. Cleans up the IntersectionObserver and the `headingElementsRef` when the component is unmounted.
|
||||
*/
|
||||
export const useTableContentObserver = (setActiveId: (id: string) => void, pathname: string) => {
|
||||
const headingElementsRef = useRef<Record<string, HeadingElement>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const callback = (headings: HeadingElement[]) => {
|
||||
// Create a map of heading elements, where the key is the heading's ID and the value is the heading element
|
||||
headingElementsRef.current = headings.reduce(
|
||||
(map, headingElement) => {
|
||||
return { ...map, [headingElement.target.id]: headingElement };
|
||||
},
|
||||
{} as Record<string, HeadingElement>
|
||||
);
|
||||
|
||||
// Find the visible headings (i.e., headings that are currently intersecting with the viewport)
|
||||
const visibleHeadings: HeadingElement[] = [];
|
||||
Object.keys(headingElementsRef.current).forEach((key) => {
|
||||
const headingElement = headingElementsRef.current[key];
|
||||
if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
|
||||
});
|
||||
|
||||
// Define a function to get the index of a heading element in the headingElements array
|
||||
const getIndexFromId = (id: string) => headingElements.findIndex((heading) => heading.id === id);
|
||||
|
||||
// If there is only one visible heading, set it as the active heading
|
||||
if (visibleHeadings.length === 1) {
|
||||
setActiveId(visibleHeadings[0].target.id);
|
||||
}
|
||||
// If there are multiple visible headings, set the active heading to the one that is highest on the page
|
||||
else if (visibleHeadings.length > 1) {
|
||||
const sortedVisibleHeadings = visibleHeadings.sort((a, b) => {
|
||||
const aIndex = getIndexFromId(a.target.id);
|
||||
const bIndex = getIndexFromId(b.target.id);
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
setActiveId(sortedVisibleHeadings[0].target.id);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(callback, {
|
||||
rootMargin: "-40px 0px -40% 0px",
|
||||
});
|
||||
|
||||
const headingElements = Array.from(document.querySelectorAll("h2[id], h3[id], h4[id]"));
|
||||
headingElements.forEach((element) => observer.observe(element));
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
headingElementsRef.current = {};
|
||||
};
|
||||
}, [setActiveId, pathname]);
|
||||
};
|
||||
@@ -46,9 +46,9 @@ const rehypeShiki = () => {
|
||||
|
||||
const rehypeSlugify = () => {
|
||||
return (tree) => {
|
||||
let slugify = slugifyWithCounter();
|
||||
const slugify = slugifyWithCounter();
|
||||
visit(tree, "element", (node) => {
|
||||
if (node.tagName === "h2" && !node.properties.id) {
|
||||
if (["h2", "h3", "h4"].includes(node.tagName) && !node.properties.id) {
|
||||
node.properties.id = slugify(toString(node));
|
||||
}
|
||||
});
|
||||
@@ -83,15 +83,15 @@ const rehypeAddMDXExports = (getExports) => {
|
||||
};
|
||||
|
||||
const getSections = (node) => {
|
||||
let sections = [];
|
||||
const sections = [];
|
||||
|
||||
for (let child of node.children ?? []) {
|
||||
if (child.type === "element" && child.tagName === "h2") {
|
||||
for (const child of node.children ?? []) {
|
||||
if (child.type === "element" && ["h2", "h3", "h4"].includes(child.tagName)) {
|
||||
sections.push(`{
|
||||
title: ${JSON.stringify(toString(child))},
|
||||
id: ${JSON.stringify(child.properties.id)},
|
||||
...${child.properties.annotation}
|
||||
}`);
|
||||
title: ${JSON.stringify(toString(child))},
|
||||
id: ${JSON.stringify(child.properties.id)},
|
||||
...${child.properties.annotation}
|
||||
}`);
|
||||
} else if (child.children) {
|
||||
sections.push(...getSections(child));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user