Slide In&Out between Components
因為如果用 scroll top 來計算的話會因為 slide in & out 時要拿掉該 component 的位置,導致 scroll top 的距離有變化,這樣一來在計算上會容易出問題可能會一次使該元素開開關關的,所以改用距離 bottom 的位置來計算,再來 observer 觀察元素有無增減,來觸發計算。
'use client';
import { InfoIcon } from '@chakra-ui/icons';
import { Box, Flex, IconButton, Stack, useDisclosure } from '@chakra-ui/react';
import { debounce, throttle } from 'lodash';
import { usePathname } from 'next/navigation';
import React, { useEffect, useRef, useState } from 'react';
import Header from '@/components/common/DataDisplay/Header/Header';
import AutoLogoutModal from '@/components/common/Modals/modals/AutoLogoutModal';
import Wizard from '@/components/wizard/Wizard';
import { CUSTOM_SCROLL_EVENT_NAME } from '@/constants/event';
import { NextTFunction, useNextTranslation } from '@/i18n/client';
import { getRouteConfig } from '@/routes/utils';
import { useUserStore } from '@/stores/useUserStore';
import MotionHeightBounce from '../../Animation/MotionHeightBounce';
import SlideFadeWithBounce from '../../Animation/SlideFadeWithBounce';
import SlideFadeWithHeight from '../../Animation/SlideFadeWithHeight';
import InfoBox from '../../Feedback/alert/InfoBox';
import ProjectFlow from '../ProjectFlow/ProjectFlow';
import Sidebar from '../sidebar/Sidebar';
interface LayoutProps {
children: React.ReactNode;
header?: React.ReactElement<{ headingRightPart: React.ReactNode }>;
paddingX?: number;
paddingBottom?: number;
}
const SCROLL_TOGGLE_THRESHOLD = 20;
const TOP_BAR_HEIGHT = 80; // 5rem in px
const MUTATION_OBSERVER_DEBOUNCE_MS = 50;
const SCROLL_THROTTLE_MS = 100;
// paddingX and paddingBottom added here to support new form footer that is on same level as header
const LayoutComponent = ({ children, header, paddingX = 4, paddingBottom = 4 }: LayoutProps) => {
const { userInfo } = useUserStore();
const role = userInfo?.role;
const { isOpen, onOpen, onClose } = useDisclosure();
const pathname = usePathname();
const { t } = useNextTranslation();
const pageDescription = (getRouteConfig(pathname) as { description?: (t: NextTFunction) => string })?.description?.(
t,
);
const mainRef = useRef<HTMLDivElement>(null);
const lastBottomDistanceRef = useRef(0);
const [showProjectFlow, setShowProjectFlow] = useState(true);
const calculateDistanceFromBottom = (scrollHeight: number, scrollTop: number, clientHeight: number) =>
scrollHeight - (scrollTop + clientHeight);
const handleContentScroll = (e: React.UIEvent<HTMLElement>) => {
if (!e.target) {
return;
}
const { scrollHeight, scrollTop, clientHeight } = e.target as HTMLDivElement;
const distanceFromBottom = calculateDistanceFromBottom(scrollHeight, scrollTop, clientHeight);
// Always show ProjectFlow near the top
if (scrollTop < TOP_BAR_HEIGHT) {
setShowProjectFlow(true);
return;
}
// Only trigger if scroll difference exceeds threshold
const scrollDiff = Math.abs(distanceFromBottom - lastBottomDistanceRef.current);
if (scrollDiff < SCROLL_TOGGLE_THRESHOLD) {
return;
}
// Determine scroll direction based on distance from bottom
const isScrollingUp = distanceFromBottom > lastBottomDistanceRef.current;
setShowProjectFlow(isScrollingUp);
lastBottomDistanceRef.current = distanceFromBottom;
// Dispatch custom scroll event
const customScrollEvent = new CustomEvent(CUSTOM_SCROLL_EVENT_NAME, {
detail: { scrollY: scrollTop },
});
document.dispatchEvent(customScrollEvent);
};
useEffect(() => {
onOpen();
}, [onOpen, pathname]);
// Update lastBottomDistanceRef when DOM changes to prevent scroll jump
useEffect(() => {
const targetNode = mainRef.current;
if (!targetNode) {
return;
}
const observer = new MutationObserver(
debounce(() => {
const { scrollHeight, scrollTop, clientHeight } = targetNode;
const distanceFromBottom = calculateDistanceFromBottom(scrollHeight, scrollTop, clientHeight);
lastBottomDistanceRef.current = distanceFromBottom;
}, MUTATION_OBSERVER_DEBOUNCE_MS),
);
observer.observe(targetNode, {
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, []);
const headerComponent = React.cloneElement(header || <Header />, {
headingRightPart: pageDescription && (
<SlideFadeWithBounce isOpen={!isOpen}>
<IconButton
size='xs'
aria-label='show-alert'
variant='unstyled'
color='blue.500'
icon={<InfoIcon fontSize={20} mx={0.5} />}
onClick={onOpen}
/>
</SlideFadeWithBounce>
),
});
return (
<>
<Flex backgroundColor='gray.50' width='100vw' height='100vh'>
<Sidebar role={role} />
<Stack
ref={mainRef}
gap={4}
overflow='auto'
flex={1}
onScroll={throttle(handleContentScroll, SCROLL_THROTTLE_MS, { leading: false, trailing: true })}
>
<Box
position='sticky'
display='flex'
flexDir='column'
backgroundColor='gray.50'
zIndex={10}
top='0'
rowGap='4'
>
<Box boxShadow='sm'>{headerComponent}</Box>
<SlideFadeWithHeight in={showProjectFlow} offsetY={-20} reverse={true}>
<ProjectFlow />
</SlideFadeWithHeight>
</Box>
<Flex direction='column' flex='1'>
<Box px={paddingX} flexGrow={1} overflow='hidden' paddingBottom={paddingBottom}>
<Stack gap={4}>
{pageDescription && (
<MotionHeightBounce isVisible={isOpen}>
<InfoBox description={pageDescription} onClickClose={onClose} />
</MotionHeightBounce>
)}
{children}
</Stack>
</Box>
</Flex>
</Stack>
<Wizard />
</Flex>
<AutoLogoutModal />
</>
);
};
export default LayoutComponent;