Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] feat: NavigationTab 컴포넌트 구현 #1037

Open
wants to merge 10 commits into
base: develop
Choose a base branch
from
71 changes: 71 additions & 0 deletions frontend/src/components/common/NavigationTab/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';

import * as S from './styles';

interface TabInfoItem {
name: string;
path: string;
param: string;
}

interface NavigationTabProps {
tabInfoList: TabInfoItem[];
}

const NavigationTab = ({ tabInfoList }: NavigationTabProps) => {
const activeTab = sessionStorage.getItem('activeTab');

const [currentIndex, setCurrentIndex] = useState(Number(activeTab) || 0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

탭에서 선택 상태를 관리하고, 또 그 관리 방법이 useState인 이유가 있는지 궁금해요.
(아마 애니메이션 추가 + 페이지를 각자 다른 사람이 만들고, 탭 위치에 대해서 제대로 얘기한 적이 없어서 해석에 차이가 나는 것 같아요!)

현재 OptionSwtich의 구현을 보면 현재 선택된 옵션 상태가 컴포넌트 외부(훅)로 분리되어 있고, useState가 아닌 단순 js 변수를 사용해요. 그리고 선택된 요소를 url을 통해 판단하고 있어요.

const { pathname } = useLocation();
const { param: reviewRequestCode } = useSearchParamAndQuery({
    paramKey: 'reviewRequestCode',
  });

// 여기서는 includes만 1번 사용하고 있어서 확장성이 조금 떨어지지만 
// 기존 breadcrumb과 유사한 방식으로 변경하면 괜찮을 것 같아요
const isReviewCollection = pathname.includes(ROUTE.reviewCollection);

그리고 이 상태를 이용해 선택 상태를 제어하기 때문에 새로고침이 일어나도 기존 페이지를 불러올 수 있어요.

const reviewDisplayLayoutOptions: OptionSwitchOption[] = [
    {
      label: '목록보기',
      isChecked: !isReviewCollection,
      handleOptionClick: navigateReviewListPage,
    },
    ...

뒤로가기 동작 또한 방문했던 url 기반이기 때문에 자연스럽게 동작합니다.

url 기반 상태관리와 useState 기반 관리 모두 각자 장단점이 있지만, 지금 리뷰미에서는 url을 적극적으로 이용하고 있고(reviewRequestCode를 url에서 가져와 사용하는 등) 이 방식을 사용해도 중요한 정보가 url에 노출되지 않기 때문에, 또 탭 자체도 url과 연관이 깊기 때문에 url 방식이 더 낫다는 의견입니다~!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(제가 이해한 게 맞다면) 링크 관리 페이지에서 작성한 리뷰 확인 페이지를 갖고 있는 건데, 두 페이지는 상하관계가 아니라 형제관계라 Collection 페이지와 비슷한 구조로 가야할 것 같아요~

제가 디코에서 말을 잘못 전달한 것 같아요..😭 디코에서는 제가 링크 관리 페이지에 네비게이션 탭을 두고, 작성한 리뷰 확인 탭을 클릭하면 해당 페이지 안에서 작성한 리뷰 확인 페이지를 불러오는 방식이라고 설명해서 상하 구조로 오해가 생긴 것 같아요. 사실 제가 생각했던 방식은 링크 관리 페이지작성한 리뷰 확인 페이지를 포함하는 부모 페이지를 만들어서, 네비게이션 탭은 부모 페이지에만 두고 탭을 클릭하면 네비게이션 탭은 그대로 유지되면서, 탭 아래의 컨텐츠가 전환되는 방식을 생각했어요. 그래서 부모 페이지를 만들어 하나의 탭만 넣을지, 두 개의 페이지에 각각 탭을 넣을지? 고민했었어요🤔

부모 페이지(/my-page)를 만들어서 리뷰 링크 관리 탭을 클릭하면 /my-page?tab=manage-links로 이동하는 방식?

탭에서 선택 상태를 관리하고, 또 그 관리 방법이 useState인 이유가 있는지 궁금해요.

제가 원했던 탭 방식이 각 탭을 클릭하면 하단바가 애니메이션을 통해 부드럽게 이동하는 것이였어요. 그래서 하단바가 선택된 탭 상태에 따라 width와 left를 바로 계산해서 동적으로 변경해 줘야 하는데 새로고침이나 탭 간 이동 시, 하단바가 깜빡이거나 늦게 나타나는 문제가 있어서 useState와 세션 스토리지를 결합해서 사용했어요..😅

const [currentTabWidth, setCurrentTabWidth] = useState(0);
const [currentTabLeft, setCurrentTabLeft] = useState(0);
const [isTransitionEnabled, setIsTransitionEnabled] = useState(false);

const location = useLocation();
const navigate = useNavigate();

const currentItemRef = useRef<HTMLUListElement>(null);

// URL의 쿼리 파라미터 값을 읽어, 탭 인덱스 업데이트
useEffect(() => {
const queryParams = new URLSearchParams(location.search);
const tabParam = queryParams.get('tab');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useSearchParamAndQuery 훅 이용할 수 있어요

const tabIndex = tabInfoList.findIndex((item) => item.param === tabParam);

if (tabIndex >= 0) {
setCurrentIndex(tabIndex);
sessionStorage.setItem('activeTab', String(tabIndex));
}
}, [location.search]);

// 탭이 변경될 때마다 현재 탭의 크기와 위치 업데이트
useEffect(() => {
if (currentItemRef.current) {
const currentTab = currentItemRef.current.children[currentIndex];
const { width, left } = currentTab.getBoundingClientRect();
setCurrentTabWidth(width);
setCurrentTabLeft(left);
}
}, [currentIndex]);

const handleTabClick = (path: string, index: number) => {
setCurrentIndex(index);
setIsTransitionEnabled(true);
navigate(`${path}?tab=${tabInfoList[index].param}`);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 활성화된 탭을 클릭할 경우 불필요한 상태 업데이트가 발생할 것 같아요. index와 currentIndex가 다른 경우에만 업데이트하는 것은 어떨까요?


return (
<S.NavContainer>
<S.NavList ref={currentItemRef}>
{tabInfoList.map((item, index) => (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로필 탭 리뷰에서도 남겼지만, 합성 컴포넌트 방식을 사용해서 map 대신 태그를 통해 어떤 요소들이 렌더링되는지 바로 알 수 있도록 만드는 방법은 어떨까요?? 링크

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 탭의 정보를 배열로 담아서 map으로 돌리는 방식이 아닌 명시적으로 하나하나 넣어주는 방식인가요?🤔

  return (
    <NavContainer>
      <NavList>
        <NavItem
          label="리뷰 링크 관리"
          $isSelected={currentTabIndex === index}
          onClick={() => navigate('/user/review-link-management')}
        />
        <NavItem
          label="작성한 리뷰 확인"
          $isSelected={currentTabIndex === index}
          onClick={() => navigate('/user/written-review-confirm')}
        />
      </NavList>
    </NavContainer>
  );

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞습니다!! 자식 요소 개수가 적고 정적인 경우에 사용하면 좋을 것 같아요. (옆동네에서 추천받은 방식입니다ㅋㅋ) 지금 리뷰미에서는 배열을 map으로 렌더하는 코드가 대다수지만요.

<S.NavItem key={index} selected={currentIndex === index}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

key에 index를 사용하지 않는게 좋아서 다른 방법으로 key를 구별했으면 좋겠어요

<button onClick={() => handleTabClick(item.path, index)}>{item.name}</button>
</S.NavItem>
))}
</S.NavList>
<S.CurrentNavBar width={currentTabWidth} left={currentTabLeft} isTransitionEnabled={isTransitionEnabled} />
</S.NavContainer>
);
};

export default NavigationTab;
59 changes: 59 additions & 0 deletions frontend/src/components/common/NavigationTab/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import styled from '@emotion/styled';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스타일 컴포넌트에 props들 에 모두 $ 표시 없네요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가하겠습니다....😅


import media from '@/utils/media';

export const NavContainer = styled.nav`
position: relative;
display: flex;
width: 100vw;
border-bottom: 0.1rem solid ${({ theme }) => theme.colors.lightGray};
`;

export const NavList = styled.ul`
display: flex;
gap: 3rem;
padding: 0 2.5rem;
list-style-type: none;

${media.xSmall} {
gap: 0;
width: 100%;
padding: 0;
}
`;

export const NavItem = styled.li<{ selected: boolean }>`
margin-bottom: 1rem;
padding: 0.7rem 1rem;
border-radius: 0.5rem;

button {
font-weight: ${({ theme }) => theme.fontWeight.semibold};
color: ${({ theme, selected }) => (selected ? theme.colors.black : theme.colors.disabled)};

&:hover {
color: ${({ theme }) => theme.colors.black};
}
}

${media.xSmall} {
display: flex;
flex: 1;
justify-content: center;
padding: 0.7rem 0;
}
`;

export const CurrentNavBar = styled.div<{ width: number; left: number; isTransitionEnabled: boolean }>`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스타일 컴포넌트 Props 가 두 개 이상일 경우 따로 타입으로 빼주세요

position: absolute;
bottom: 0;
left: ${({ left }) => `${left}px`};

width: ${({ width }) => `${width}px`};
height: 0.3rem;

background-color: ${({ theme }) => theme.colors.primary};
border-radius: 0.1rem;

transition: ${({ isTransitionEnabled }) => (isTransitionEnabled ? 'all 0.2s ease-in-out' : 'none')};
Copy link
Contributor

@BadaHertz52 BadaHertz52 Jan 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 활성화된 탭 하단에 있는 바의 애니메이션 효과를 위해서 바를 버튼이랑 분리했군요

저는 개인적으로 바 이동 시 애니메이션 효과가 없는게 깔끔하고 좋은 것 같아요. 바 이동 시 현재 활성화된 탭에 따라 계산을 하는데 아래 처럼 오차가 발생할 가능성도 있고요.

스크린샷 2025-01-03 오후 3 39 09

Copy link
Contributor Author

@soosoo22 soosoo22 Jan 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

목록, 모아보기 페이지 OptionSwitch 처럼 각 페이지마다 NavigationTab을 추가한다면, 애니메이션 로직이 있어도... 보이지 않아서 코드를 없앨 예정이에요:)

하단바 오차는 자식 요소의 left 값을 그대로 사용하고 있어서 생긴 오차같아요. 자식 요소의 left 값에서 부모 요소의 left 값을 빼면 오차없이 제대로 계산이 돼요.

   const currentTab = currentItemRef.current.children[currentIndex];
   const parentLeft = currentItemRef.current.getBoundingClientRect().left;
   const currentLeft = currentTab.getBoundingClientRect().left - parentLeft;

추가로! 하단바를 넣은 이유가 애니메이션을 넣고 싶어서..였기 때문에😅 애니메이션이 없어진다면 하단바도 없어지고 버튼의 border-bottom으로 할 것 같아요!

`;
3 changes: 1 addition & 2 deletions frontend/src/components/common/OptionSwitch/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ export const OptionSwitchContainer = styled.ul`

width: 20rem;
height: 4.4rem;
margin-top: 0.9rem;
padding: 0.7rem;

background-color: ${({ theme }) => theme.colors.lightGray};
border-radius: ${({ theme }) => theme.borderRadius.basic};

margin-top: 0.9rem;

@media screen and (max-width: 530px) {
width: 100%;
}
Expand Down
Loading