diff --git a/components/CaseTOC.tsx b/components/CaseTOC.tsx new file mode 100644 index 00000000000..3f73134251b --- /dev/null +++ b/components/CaseTOC.tsx @@ -0,0 +1,197 @@ +import { useMemo, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { useHeadingsObserver } from './helpers/useHeadingsObserver'; +import ArrowRight from './icons/ArrowRight'; + +interface TocItem { + lvl: number; + content: string; + slug: string; + children?: TocItem[]; +} + +interface TOCItemProps { + item: TocItem; + index: number; + currSelected: string; + closeMenu: () => void; +} + +interface CaseTOCProps { + className: string; + cssBreakingPoint?: 'xl' | 'lg'; + toc: any[]; +} + +/** + * @description Checks if the item is active. + * + * @param {TocItem} item - The TOC item to check. + * @param {string} currSelected - The currently selected TOC item. + * @returns {boolean} - True if the item is active, otherwise false. + */ +const checkIfActive = (item: TocItem, currSelected: string): boolean => { + return item.slug === currSelected || item.children?.some((child) => checkIfActive(child, currSelected)) || false; +}; + +/** + * @description Converts content to TOC items. + * + * @param {any[]} content - The content to convert to TOC items. + * @param {number} level - The level of the TOC item. + * @returns {TocItem[]} - The array of TOC items. + */ +const convertContentToTocItems = (content: any[], level: number = 1): TocItem[] => { + const tocItems = []; + + for (const section of content) { + const item = { + lvl: level, + content: section.title, + slug: section.title + .replace(/<|>|"|\\|\/|=/gi, '') + .replace(/\s/gi, '-') + .toLowerCase() + }; + + if (section.children && section.children.length > 0) { + const children = convertContentToTocItems(section.children, level + 1); + + (item as TocItem).children = children; + } + + tocItems.push(item); + } + + return tocItems; +}; + +/** + * @description Component representing an item in the table of contents (TOC). + * + * @param {TOCItemProps} props - The props for TOCItem. + * @param {TocItem} props.item - The TOC item. + * @param {number} props.index - The index of the TOC item. + * @param {string} props.currSelected - The currently selected TOC item. + * @param {Function} props.closeMenu - A function to close the menu. + */ +function TOCItem({ item, index, currSelected, closeMenu }: TOCItemProps) { + const [open, setOpen] = useState(false); + const handleClick = () => { + closeMenu(); + setOpen(false); + }; + const active = useMemo(() => checkIfActive(item, currSelected), [item, currSelected]); + + return ( + <> + + {item.children && item.children.length > 0 && ( + + )} + + ); +} + +/** + * @description Component representing a table of contents (TOC) for a case. + * + * @param {CaseTOCProps} props - The props for CaseTOC. + * @param {string} props.className - The CSS class name for the component. + * @param {("xl"|"lg")} [props.cssBreakingPoint="xl"] - The CSS breaking point for responsiveness. + * @param {any[]} props.toc - The table of contents data. + */ +export default function CaseTOC({ className, cssBreakingPoint = 'xl', toc }: CaseTOCProps) { + const { currActive: selected } = useHeadingsObserver(); + const [open, setOpen] = useState(false); + const tocItems = useMemo(() => convertContentToTocItems(toc), [toc]); + + if (!toc || !toc.length) return null; + + return ( +
+
+
+ On this page +
+
setOpen(!open)} + > + +
+
+
+ +
+
+ ); +} diff --git a/components/helpers/useHeadingsObserver.tsx b/components/helpers/useHeadingsObserver.tsx new file mode 100644 index 00000000000..0a340d21633 --- /dev/null +++ b/components/helpers/useHeadingsObserver.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * @description Custom hook to observe headings and set the current active heading + * @example const { currActive } = useHeadingsObserver(); + * @returns {object} currActive - current active heading + */ +export function useHeadingsObserver() { + const observer = useRef(null); + const headingsRef = useRef | []>([]); + const [currActive, setCurrActive] = useState(null); + + useEffect(() => { + const callback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setCurrActive(entry.target.id); + } + }); + }; + + // The heading in from top 20% of the viewport to top 30% of the viewport will be considered as active + observer.current = new IntersectionObserver(callback, { + rootMargin: '-20% 0px -70% 0px' + }); + + headingsRef.current = document.querySelectorAll('h2, h3'); + headingsRef.current.forEach((heading) => { + observer.current?.observe(heading); + }); + + return () => observer.current?.disconnect(); + }, []); + + return { currActive }; +}