diff --git a/frontend/src/hooks/use-scroll-spy.ts b/frontend/src/hooks/use-scroll-spy.ts new file mode 100644 index 00000000..7793fc89 --- /dev/null +++ b/frontend/src/hooks/use-scroll-spy.ts @@ -0,0 +1,72 @@ +import { RefObject, useLayoutEffect, useState } from 'react'; + +type ScrollSpyItem = { + id: string; + ref: RefObject; +}; + +type ScrollSpyOptions = { + overBounds: boolean; +}; + +const DEFAULT_OPTIONS = { + overBounds: true, + thresholdPercentage: 20, +}; + +const useScrollSpy = (items: ScrollSpyItem[], options?: ScrollSpyOptions) => { + const [activeId, setActiveId] = useState(null); + + useLayoutEffect(() => { + const scrollListener = () => { + const windowHeight = window.innerHeight; + const viewingPosition = (DEFAULT_OPTIONS.thresholdPercentage * windowHeight) / 100; + + const itemsPositions = items.map(({ id, ref }) => { + const element = ref?.current; + if (!element) return null; + + const boundingRect = element.getBoundingClientRect(); + + return { + id, + top: boundingRect.top, + bottom: boundingRect.bottom, + }; + }); + + const activeItem = itemsPositions.find(({ top: itemTop, bottom: itemBottom }) => { + return itemTop < viewingPosition && itemBottom > viewingPosition; + }); + + if (!activeItem?.id && (options?.overBounds || DEFAULT_OPTIONS.overBounds) === true) { + const firstItem = itemsPositions[0]; + const lastItem = itemsPositions[itemsPositions.length - 1]; + + if (viewingPosition < firstItem.top) { + setActiveId(firstItem.id); + } + + if (viewingPosition > lastItem.bottom) { + setActiveId(lastItem.id); + } + } else { + setActiveId(activeItem?.id); + } + }; + + scrollListener(); + + window.addEventListener('resize', scrollListener); + window.addEventListener('scroll', scrollListener); + + return () => { + window.removeEventListener('resize', scrollListener); + window.removeEventListener('scroll', scrollListener); + }; + }, [items, options]); + + return activeId; +}; + +export default useScrollSpy; diff --git a/frontend/src/layouts/static-page.tsx b/frontend/src/layouts/static-page.tsx index dce992c3..9f000e62 100644 --- a/frontend/src/layouts/static-page.tsx +++ b/frontend/src/layouts/static-page.tsx @@ -10,13 +10,16 @@ import ArrowRight from '@/styles/icons/arrow-right.svg?sprite'; type SidebarProps = { sections: { [key: string]: { + id: string; name: string; ref: MutableRefObject; }; }; + activeSection?: string; + arrowColor?: 'black' | 'orange' | 'purple'; }; -const Sidebar: React.FC = ({ sections }) => { +const Sidebar: React.FC = ({ sections, activeSection, arrowColor = 'black' }) => { if (!sections) return null; const handleClick = (key) => { @@ -28,7 +31,7 @@ const Sidebar: React.FC = ({ sections }) => { return (