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;