UNPKG

@redocly/theme

Version:

Shared UI components lib

212 lines (183 loc) 6.15 kB
/** * Based on https://github.com/roginfarrer/collapsed/blob/main/packages/react-collapsed/src/index.ts * Simplified for our usecase. */ import { useState, useRef, useEffect, useLayoutEffect as useReactLayoutEffect, useCallback, } from 'react'; import type React from 'react'; import type { RefObject } from 'react'; const useLayoutEffect = typeof window === 'undefined' ? useEffect : useReactLayoutEffect; export interface UseCollapseProps { isExpanded?: boolean; collapseElRef: React.RefObject<HTMLElement | null>; onTransitionStateChange?: ( state: | 'collapseEnd' | 'expandEnd' | 'collapseStart' | 'expandStart' | 'collapsing' | 'expanding', ) => void; } const easing = 'cubic-bezier(0.4, 0, 0.2, 1)'; const collapsedHeight = `0px`; export function useCollapse({ isExpanded, collapseElRef, onTransitionStateChange: configOnTransitionStateChange = () => {}, }: UseCollapseProps) { const prevExpanded = useRef(isExpanded); const [isAnimating, setIsAnimating] = useState(false); const onTransitionStateChange = useEvent(configOnTransitionStateChange); // Animation frames const frameId = useRef<number | undefined>(undefined); const endFrameId = useRef<{ id?: number } | undefined>(undefined); useLayoutEffect(() => { const collapse = collapseElRef.current; if (!collapse) return; if (isExpanded === prevExpanded.current) return; prevExpanded.current = isExpanded; function getDuration(height: number | string) { return getAutoHeightDuration(height); } const getTransitionStyles = (height: number | string) => `height ${getDuration(height)}ms ${easing}`; const setTransitionEndTimeout = (duration: number) => { function endTransition() { if (isExpanded) { setStyles(collapse, { height: '', overflow: '', transition: '', display: '', pointerEvents: 'auto', }); onTransitionStateChange('expandEnd'); } else { setStyles(collapse, { transition: '', pointerEvents: '' }); onTransitionStateChange('collapseEnd'); } setIsAnimating(false); } if (endFrameId.current) { clearAnimationTimeout(endFrameId.current); } endFrameId.current = setAnimationTimeout(endTransition, duration); }; setIsAnimating(true); if (isExpanded) { frameId.current = requestAnimationFrame(() => { onTransitionStateChange('expandStart'); setStyles(collapse, { display: 'block', overflow: 'hidden', pointerEvents: 'none', height: collapsedHeight, }); frameId.current = requestAnimationFrame(() => { onTransitionStateChange('expanding'); const height = getElementHeight(collapseElRef); setTransitionEndTimeout(getDuration(height)); if (collapseElRef.current) { // Order is important! Setting directly. collapseElRef.current.style.transition = getTransitionStyles(height); collapseElRef.current.style.height = `${height}px`; } }); }); } else { frameId.current = requestAnimationFrame(() => { onTransitionStateChange('collapseStart'); const height = getElementHeight(collapseElRef); setTransitionEndTimeout(getDuration(height)); setStyles(collapse, { transition: getTransitionStyles(height), height: `${height}px`, pointerEvents: 'none', }); frameId.current = requestAnimationFrame(() => { onTransitionStateChange('collapsing'); setStyles(collapse, { height: collapsedHeight, overflow: 'hidden', }); }); }); } return () => { if (frameId.current) cancelAnimationFrame(frameId.current); if (endFrameId.current) clearAnimationTimeout(endFrameId.current); }; }, [isExpanded, collapseElRef, onTransitionStateChange]); return { isExpanded, style: !isAnimating && !isExpanded ? { // collapsed and not animating display: 'none', height: collapsedHeight, overflow: 'hidden', } : {}, }; } // https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98 export function getAutoHeightDuration(height: number | string): number { if (!height || typeof height === 'string') { return 0; } const constant = height / 36; return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); } export function getElementHeight(el: RefObject<HTMLElement | null>): number { // scrollHeight will give us the height of the element, even if it's not visible. // clientHeight, offsetHeight, nor getBoundingClientRect().height will do so return el.current?.scrollHeight ?? 0; } export function setAnimationTimeout(callback: () => void, timeout: number) { const startTime = performance.now(); const frame: { id?: number } = {}; function call() { frame.id = requestAnimationFrame((now) => { if (now - startTime > timeout) { callback(); } else { call(); } }); } call(); return frame; } export function clearAnimationTimeout(frame: { id?: number }) { if (frame.id) cancelAnimationFrame(frame.id); } function setStyles<T extends Partial<CSSStyleDeclaration>>( target: HTMLElement | null, newStyles: T, ) { if (!target) return; for (const property in newStyles) { const value = newStyles[property]; if (value) { target.style[property] = value; } else { target.style.removeProperty(property); } } } /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export function useEvent<T extends (...args: any[]) => any>(callback?: T): T { const ref = useRef<T | undefined>(callback); useEffect(() => { ref.current = callback; }); return useCallback(((...args: Parameters<T>) => ref.current?.(...args)) as T, []); }