@redocly/theme
Version:
Shared UI components lib
212 lines (183 loc) • 6.15 kB
text/typescript
/**
* 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, []);
}