UNPKG

@redocly/theme

Version:

Shared UI components lib

181 lines (146 loc) 5.66 kB
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import type { CodeWalkthroughStepAttr } from '@redocly/config'; import { getAdjacentValues } from '@redocly/theme/core/utils'; import { ACTIVE_STEP_QUERY_PARAM } from '@redocly/theme/core/constants'; type ActiveStep = string | null; type CodeWalkthroughStep = CodeWalkthroughStepAttr & { compRef?: HTMLElement; }; export type WalkthroughStepsState = { activeStep: ActiveStep; setActiveStep: (stepId: ActiveStep) => void; register: (element: HTMLElement) => void; unregister: (element: HTMLElement) => void; lockObserver?: React.RefObject<boolean>; filtersElementRef?: React.RefObject<HTMLDivElement | null>; }; export function useCodeWalkthroughSteps( steps: CodeWalkthroughStep[], enableDeepLink: boolean, ): WalkthroughStepsState { const location = useLocation(); const navigate = useNavigate(); const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); const observerRef = useRef<IntersectionObserver | null>(null); const filtersElementRef = useRef<HTMLDivElement>(null); const lockObserver = useRef<boolean>(false); // Track observed elements in case new observer needs to be created const observedElementsRef = useRef(new Set<HTMLElement>()); const [activeStep, setActiveStep] = useState<ActiveStep>( enableDeepLink ? searchParams.get(ACTIVE_STEP_QUERY_PARAM) : null, ); // eslint-disable-next-line react-hooks/exhaustive-deps const _steps = useMemo(() => steps, [JSON.stringify(steps)]); const register = useCallback( (element: HTMLElement) => { // for some reason, the observer is not ready immediately setTimeout(() => { if (observerRef.current) { const stepKey = Number(element.dataset.stepKey); if (Number.isInteger(stepKey) && stepKey >= 0 && _steps[stepKey]) { _steps[stepKey].compRef = element; } observerRef.current.observe(element); observedElementsRef.current.add(element); } }, 10); }, [_steps], ); const unregister = useCallback( (element: HTMLElement) => { if (observerRef.current) { const stepKey = Number(element.dataset.stepKey); if (Number.isInteger(stepKey) && stepKey >= 0 && _steps[stepKey]) { _steps[stepKey].compRef = undefined; } observerRef.current.unobserve(element); observedElementsRef.current.delete(element); } }, [_steps], ); const observerCallback = useCallback( (entries: IntersectionObserverEntry[]) => { if (lockObserver.current) { return; } const renderedSteps = _steps.filter((step) => Boolean(step.compRef)); if (renderedSteps.length < 2) { setActiveStep(renderedSteps[0]?.id || null); return; } for (const entry of entries) { const stepKey = Number((entry.target as HTMLElement)?.dataset?.stepKey); if (!Number.isInteger(stepKey) || stepKey < 0) { continue; } const { intersectionRatio, boundingClientRect, rootBounds, isIntersecting } = entry; const step = _steps[stepKey]; const stepIndex = renderedSteps.findIndex( (renderedStep) => renderedStep.stepKey === step.stepKey, ); const { next } = getAdjacentValues(renderedSteps, stepIndex); const intersectionAtTop = rootBounds?.bottom !== undefined && boundingClientRect.top < rootBounds.top; const stepGoesIn = isIntersecting; if ( intersectionRatio > 0.8 && intersectionRatio < 1 && intersectionAtTop && activeStep === null ) { setActiveStep(step.id); break; } if (intersectionRatio < 1 && intersectionRatio !== 0 && intersectionAtTop) { let newStep: string | null = null; if (stepGoesIn) { newStep = step.id; } else if (next) { newStep = next.id; } if (newStep !== activeStep) { setActiveStep(newStep); } break; } } }, [_steps, activeStep], ); useEffect(() => { const filtersElementHeight = filtersElementRef.current?.clientHeight || 0; const navbarHeight = document.querySelector('nav')?.clientHeight || 0; const newObserver = new IntersectionObserver(observerCallback, { threshold: [0.8, 0.85, 0.9, 0.95], rootMargin: `-${filtersElementHeight + navbarHeight}px 0px 0px 0px`, }); for (const observedElement of observedElementsRef.current) { newObserver.observe(observedElement); } // Unobserve all from the old observer observerRef.current?.disconnect(); observerRef.current = newObserver; }, [observerCallback]); /** * Update the URL search params with the current state of the filters and inputs */ useEffect(() => { if (!enableDeepLink) { return; } const newSearchParams = new URLSearchParams(Array.from(searchParams.entries())); if (activeStep) { newSearchParams.set(ACTIVE_STEP_QUERY_PARAM, activeStep); } else { newSearchParams.delete(ACTIVE_STEP_QUERY_PARAM); } const newSearch = newSearchParams.toString(); if (newSearch === location.search.substring(1)) return; navigate({ search: newSearch }, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeStep]); return { register, unregister, lockObserver, filtersElementRef, activeStep, setActiveStep }; }