Skip to content

Commit

Permalink
feat: add collapsing and custom hook
Browse files Browse the repository at this point in the history
  • Loading branch information
Shurtu-gal committed Nov 8, 2023
1 parent 6563388 commit a2d7dba
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 25 deletions.
132 changes: 107 additions & 25 deletions components/CaseTOC.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import Scrollspy from "react-scrollspy";
import { twMerge } from "tailwind-merge";
import ArrowRight from "./icons/ArrowRight";
import { useHeadingsObserver } from "./helpers/useHeadingsObserver";

const convertContentToTocItems = (content, level = 1) => {
const tocItems = [];

for(let section of content) {
for (let section of content) {
const item = {
lvl: level,
content: section.title,
Expand All @@ -16,57 +17,138 @@ const convertContentToTocItems = (content, level = 1) => {
.toLowerCase(),
};

tocItems.push(item);
if (section.children && section.children.length > 0) {
const children = convertContentToTocItems(section.children, level + 1);
tocItems.push(...children);
item.children = children;
}

tocItems.push(item);
}

return tocItems;
};

function TOCItem({ item, index, currSelected, closeMenu }) {
const [open, setOpen] = useState(false);
const handleClick = () => {
closeMenu();
setOpen(false);
};

return (
<>
{item.children && item.children.length > 0 && (
<span onClick={() => setOpen(!open)}>
<ArrowRight
className={`${
open ? "rotate-90" : "0"
} transform transition duration-200 ease-in-out h-6 inline-block mr-1 -mt-0.5 text-primary-500 absolute -left-1`}
/>
</span>
)}
<a
className={`mb-1 transition duration-100 ease-in-out text-gray-900 font-normal text-sm font-sans antialiased hover:underline flex items-center ${
currSelected === item.slug && "text-primary-500 font-bold"
}`}
href={`#${item.slug}`}
key={index}
style={{ marginLeft: `${(item.lvl - 1) * 16}px` }}
onClick={handleClick}
>
{item.content}
</a>
{item.children && item.children.length > 0 && (
<ul
className={`left-0 relative ${
open ? "max-h-[1000px]" : "max-h-[0.01px]"
} overflow-hidden transition-all duration-300 ease-in-out`}
>
{item.children.map((item, index) => (
<TOCItem
item={item}
index={index}
key={index}
closeMenu={closeMenu}
currSelected={currSelected}
/>
))}
</ul>
)}
</>
);
}

export default function CaseTOC({
className,
cssBreakingPoint = "xl",
toc,
contentSelector,
}) {
if (!toc || !toc.length) return null;
const tocItems = convertContentToTocItems(toc);

const tocItems = useMemo(() => convertContentToTocItems(toc), [toc]);
const [open, setOpen] = useState(false);
const { currActive: selected } = useHeadingsObserver();

return (
<div className={twMerge(`${className} ${tocItems.length ? '' : 'hidden'} ${cssBreakingPoint === 'xl' ? 'xl:block' : 'lg:block'} md:top-24 md:max-h-(screen-14) z-20`)} onClick={() => setOpen(!open)}>
<div className={`flex cursor-pointer ${tocItems.length ? '' : 'hidden'} ${cssBreakingPoint === 'xl' ? 'xl:cursor-auto' : 'lg:cursor-auto'} xl:mt-2`}>
<h5 className={twMerge(`${open && 'mb-4'} flex-1 text-primary-500 font-medium uppercase tracking-wide text-sm font-sans antialiased ${cssBreakingPoint === 'xl' ? 'xl:mb-4 xl:text-xs xl:text-gray-900 xl:font-bold' : 'lg:mb-4 lg:text-xs lg:text-gray-900 lg:font-bold'}`)}>
<div
className={twMerge(
`${className} ${tocItems.length ? "" : "hidden"} ${
cssBreakingPoint === "xl" ? "xl:block" : "lg:block"
} md:top-24 md:max-h-(screen-14) z-20`,
)}
>
<div
className={`flex cursor-pointer ${tocItems.length ? "" : "hidden"} ${
cssBreakingPoint === "xl" ? "xl:cursor-auto" : "lg:cursor-auto"
} xl:mt-2`}
>
<h5
className={twMerge(
`${
open && "mb-4"
} flex-1 text-primary-500 font-medium uppercase tracking-wide text-sm font-sans antialiased ${
cssBreakingPoint === "xl"
? "xl:mb-4 xl:text-xs xl:text-gray-900 xl:font-bold"
: "lg:mb-4 lg:text-xs lg:text-gray-900 lg:font-bold"
}`,
)}
>
On this page
</h5>
<div className={`text-underline text-center p4 ${cssBreakingPoint === 'xl' ? 'xl:hidden' : 'lg:hidden'}`}>
<ArrowRight className={`${ open ? '-rotate-90' : 'rotate-90' } transform transition duration-200 ease-in-out h-6 -mt-0.5 text-primary-500`} />
<div
className={`text-underline text-center p4 ${
cssBreakingPoint === "xl" ? "xl:hidden" : "lg:hidden"
}`}
onClick={() => setOpen(!open)}
>
<ArrowRight
className={`${
open ? "-rotate-90" : "rotate-90"
} transform transition duration-200 ease-in-out h-6 -mt-0.5 text-primary-500`}
/>
</div>
</div>
<div className={`${!open && 'hidden'} ${cssBreakingPoint === 'xl' ? 'xl:block' : 'lg:block'}`}>
<div
className={`${!open && "hidden"} ${
cssBreakingPoint === "xl" ? "xl:block" : "lg:block"
}`}
>
<Scrollspy
items={tocItems.map(item => item.slug)}
items={tocItems.map((item) => item.slug)}
currentClassName="text-primary-500 font-bold"
componentTag="div"
rootEl={contentSelector}
offset={-120}
>
{
tocItems.map((item, index) => (
<a
className={`block mb-1 transition duration-100 ease-in-out text-gray-900 font-normal text-sm font-sans antialiased hover:underline`}
href={`#${item.slug}`}
key={index}
style={{ marginLeft: `${(item.lvl - 1) * 16}px` }}
>
{item.content}
</a>
))
}
{tocItems.map((item, index) => (
<TOCItem
item={item}
index={index}
key={index}
closeMenu={() => setOpen(false)}
currSelected={selected}
/>
))}
</Scrollspy>
</div>
</div>
Expand Down
36 changes: 36 additions & 0 deletions components/helpers/useHeadingsObserver.js
Original file line number Diff line number Diff line change
@@ -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) => {
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 }
}

0 comments on commit a2d7dba

Please sign in to comment.