TelegramPWA/src/components/middle/hooks/useHeaderPane.tsx

164 lines
3.9 KiB
TypeScript

import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useUnmountCleanup,
} from '../../../lib/teact/teact';
import { setExtraStyles } from '../../../lib/teact/teact-dom';
import { requestForcedReflow, requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import useTimeout from '../../../hooks/schedulers/useTimeout';
import useLastCallback from '../../../hooks/useLastCallback';
import useResizeObserver from '../../../hooks/useResizeObserver';
import useThrottledCallback from '../../../hooks/useThrottledCallback';
export interface PaneState {
element?: HTMLElement;
height: number;
isOpen?: boolean;
}
// Max slide transition duration
const CLOSE_DURATION = 450;
const RESIZE_THROTTLE = 100;
export default function useHeaderPane<RefType extends HTMLElement = HTMLDivElement>({
ref: providedRef,
isOpen,
isDisabled,
withResizeObserver,
onStateChange,
} : {
ref?: RefObject<RefType | null>;
isOpen?: boolean;
isDisabled?: boolean;
withResizeObserver?: boolean;
onStateChange?: (state: PaneState) => void;
}) {
const [shouldRender, setShouldRender] = useState(isOpen);
// eslint-disable-next-line no-null/no-null
const localRef = useRef<RefType>(null);
const ref = providedRef || localRef;
const lastHeightRef = useRef(0);
const reset = useLastCallback(() => {
setShouldRender(true);
onStateChange?.({
element: undefined,
height: 0,
isOpen: false,
});
});
useEffect(() => {
if (isDisabled) {
reset();
}
}, [isDisabled]);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
}
}, [isOpen]);
useUnmountCleanup(reset);
useTimeout(() => {
setShouldRender(false);
onStateChange?.({
height: 0,
isOpen: false,
});
}, !isOpen ? CLOSE_DURATION : undefined);
// Should be `useCallback` to trigger effect on deps change
const handleUpdate = useCallback(() => {
const element = ref.current;
if (isDisabled || !element || !shouldRender) return;
if (!isOpen) {
onStateChange?.({
element,
height: 0,
isOpen: false,
});
return;
}
requestForcedReflow(() => {
const currentHeight = element.offsetHeight;
lastHeightRef.current = currentHeight;
return () => {
onStateChange?.({
element,
height: currentHeight,
isOpen,
});
};
});
}, [isOpen, shouldRender, isDisabled, ref, onStateChange]);
const handleResize = useThrottledCallback(() => {
const element = ref.current;
if (!element) return;
const newHeight = element.offsetHeight;
if (newHeight === lastHeightRef.current) {
return;
}
handleUpdate();
}, [handleUpdate, ref], RESIZE_THROTTLE, true);
useLayoutEffect(handleUpdate, [handleUpdate]);
useResizeObserver(ref, handleResize, !withResizeObserver || !shouldRender);
return {
shouldRender,
ref,
};
}
export function applyAnimationState(list: PaneState[], noTransition = false) {
let cumulativeHeight = 0;
for (let i = 0; i < list.length; i++) {
const state = list[i];
const element = state.element;
if (!element) {
continue;
}
const shiftPx = `${cumulativeHeight}px`;
const apply = () => {
setExtraStyles(element, {
transform: `translateY(${state.isOpen ? shiftPx : `calc(${shiftPx} - 100%)`})`,
zIndex: String(-i),
transition: noTransition ? 'none' : '',
});
};
if (!element.dataset.isPanelOpen && state.isOpen && !noTransition) {
// Start animation right above its final position
setExtraStyles(element, {
transform: `translateY(calc(${shiftPx} - 100%))`,
zIndex: String(-i),
transition: 'none',
});
element.dataset.isPanelOpen = 'true';
requestNextMutation(apply);
} else {
apply();
}
cumulativeHeight += state.height;
}
}