diff --git a/src/components/common/tab/index.tsx b/src/components/common/tab/index.tsx deleted file mode 100644 index 4dc8a37..0000000 --- a/src/components/common/tab/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { cloneElement, ReactElement, useEffect, useRef, useState } from 'react'; - -import * as S from './styles'; - -type TabProps = { - label: string; - value: number; - active?: boolean; - onClick?: () => void; -}; - -type TabsProps = { - selectedTab: number; - onChange: (value: number) => void; - children: ReactElement[]; - position?: string; -}; - -type TabPanelProps = { - children?: ReactElement; - value: number; - selectedIndex: number; -}; - -export const Tab = ({ label, active, onClick }: TabProps) => { - return ( - - {label} - - ); -}; - -export const Tabs = ({ - selectedTab, - onChange, - children, - position, -}: TabsProps) => { - const containerRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - - useEffect(() => { - if (containerRef.current) { - setContainerWidth(containerRef.current.getBoundingClientRect().width); - } - }, [containerRef]); - - const sliderWidth = containerWidth / children.length; - - const tabs = children.map((child) => { - const handleClick = () => { - onChange(child.props.value); - }; - - return cloneElement(child, { - key: child.props.value, - active: child.props.value === selectedTab, - onClick: handleClick, - }); - }); - - return ( - - {tabs} - - - ); -}; - -export const TabPanel = ({ children, value, selectedIndex }: TabPanelProps) => { - const hidden: boolean = value !== selectedIndex; - - return ( - - ); -}; diff --git a/src/components/common/tab/styles.ts b/src/components/common/tab/styles.ts deleted file mode 100644 index d542d83..0000000 --- a/src/components/common/tab/styles.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { css, keyframes } from '@emotion/react'; -import styled from '@emotion/styled'; - -// 챌린지 상세 페이지 css - -export const TabsContainer = styled.div` - display: flex; - position: relative; - top: 50px; - align-self: center; - width: 90%; - margin: 0 auto; - height: 55px; - border-radius: 20px; - background-color: var(--color-green-06); -`; - -export const TabPanelContainer = styled.div` - height: 100%; - width: 100%; - position: relative; - top: 50px; - text-align: center; -`; - -export const Image = styled.img` - position: relative; - margin: auto; - display: block; - height: 40%; - opacity: 20%; - object-fit: cover; - margin-bottom: 28px; - filter: grayscale(100%); -`; - -export const ImageMask = styled.div` - background-color: var(--color-green-06); - position: relative; - top: 50px; -`; - -export const Wrapper = styled.div``; - -type StylizedTabProps = { - active?: boolean; - onClick?: () => void; - inactiveStyle?: React.CSSProperties; -}; - -export const StylizedTab = styled.div` - z-index: 1; - color: var(--color-grey-02); - width: 50%; /* 각 Tab의 너비를 50%로 설정하여 두 개의 Tab이 꽉 차도록 설정 */ - font-size: var(--font-size-md); - background-color: transparent; - border: none; - height: 50px; - text-align: center; - line-height: 50px; - cursor: pointer; - - ${(p) => - p.active && - css` - color: var(--color-white); - font-weight: bold; - border-radius: 20px; - animation: ${inset} 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; - `} -`; - -type StyledTabPanelProps = { - active: boolean; -}; - -export const StyledTabPanel = styled.div` - display: ${(p) => (p.active ? 'flex' : 'none')}; - font-size: 2rem; - flex-direction: column; - width: 100%; - height: 100%; - justify-content: center; -`; - -type TabHeaderContainerProps = { - position?: string; -}; - -export const TabHeaderContainer = styled.div` - position: ${(props) => props.position || 'absolute'}; - width: 100%; -`; - -export const TabsHolder = styled.div` - display: flex; - justify-content: space-between; /* Tab들이 좌우로 정렬되도록 설정 */ - width: 100%; -`; - -type TabSliderProps = { - width: number; - index: number; -}; - -export const TabSlider = styled.div` - position: absolute; - top: 3px; - height: 50px; - background-color: var(--color-green-01); - border-radius: 20px; - transition: 0.2s; - transform: ${({ width, index }) => `translateX(${width * index}px)`}; - width: ${({ width }) => `${width}px`}; -`; - -const inset = keyframes` - 0% { - -webkit-box-shadow: 0 0 0 0 rgba(92, 198, 186, 0); - box-shadow: 0 0 0 0 rgba(92, 198, 186, 0); - } - 100% { - -webkit-box-shadow: 3px 3px 3px rgba(92, 198, 186, 0.5); - box-shadow: 3px 3px 3px rgba(92, 198, 186, 0.5); - } - `; diff --git a/src/components/common/tabs/index.tsx b/src/components/common/tabs/index.tsx new file mode 100644 index 0000000..7f1160d --- /dev/null +++ b/src/components/common/tabs/index.tsx @@ -0,0 +1,74 @@ +import { + cloneElement, + forwardRef, + ReactElement, + useEffect, + useRef, + useState, +} from 'react'; + +import * as S from './styles'; + +type TabsProps = { + selectedTab: number; + onChange: (value: number) => void; + children: ReactElement[]; +}; + +export const Tabs = ({ selectedTab, onChange, children }: TabsProps) => { + const containerRef = useRef(null); + const tabRefs = useRef<(HTMLDivElement | null)[]>([]); // Tab refs 배열 + const [tabWidths, setTabWidths] = useState([]); // 각 Tab 너비 저장 + + // 각 Tab의 너비를 계산하여 상태로 저장 + useEffect(() => { + if (tabRefs.current.length > 0) { + const widths = tabRefs.current.map( + (ref) => ref?.getBoundingClientRect().width || 0 + ); + setTabWidths(widths); + } + }, [children]); + + const sliderWidth = tabWidths[selectedTab] - 4 || 0; // 여백 4px 빼기 + + const tabs = children.map((child, index) => { + const handleClick = () => { + onChange(child.props.value); + }; + + return cloneElement(child, { + key: child.props.value, + active: child.props.value === selectedTab, + onClick: handleClick, + ref: (el: HTMLDivElement) => (tabRefs.current[index] = el), // Tab 요소에 ref 연결 + }); + }); + + return ( + + {tabs} + + + ); +}; + +type TabProps = { + label: string; + value: number; + active?: boolean; + onClick?: () => void; +}; + +export const Tab = forwardRef( + ({ label, active, onClick }, ref) => { + return ( + + {label} + + ); + } +); + +// displayName 설정으로 경고 해결 +Tab.displayName = 'Tab'; diff --git a/src/components/common/tabs/styles.ts b/src/components/common/tabs/styles.ts new file mode 100644 index 0000000..993b914 --- /dev/null +++ b/src/components/common/tabs/styles.ts @@ -0,0 +1,77 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +export const StyledTabs = styled.div<{ + position?: string; +}>` + display: flex; + justify-content: space-between; /* Tab들이 좌우로 정렬되도록 설정 */ + position: relative; + align-self: center; + height: 46px; + margin: 0 16px; + padding: 4px 0; + border-radius: 10px; + background-color: var(--color-green-06); +`; + +// 선택된 탭 +export const TabSlider = styled.div<{ + width: number; + index: number; +}>` + position: absolute; + top: 4px; + left: 4px; + width: ${({ width }) => `${width}px`}; + height: 38px; + background-color: var(--color-white); + border-radius: 10px; + + /* 슬라이딩 애니메이션 */ + transition: 0.2s; + transform: ${({ width, index }) => `translateX(${width * index}px)`}; +`; + +export const StyledTab = styled.div<{ + active?: boolean; + onClick?: () => void; + inactiveStyle?: React.CSSProperties; +}>` + z-index: 1; + width: 50%; /* 각 Tab의 너비를 50%로 설정하여 두 개의 Tab이 꽉 차도록 설정 */ + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: transparent; + border: none; + font-size: var(--font-size-md); + color: var(--color-grey-02); + cursor: pointer; + + ${(p) => + p.active && + css` + color: var(--color-green-01); + font-weight: 600; + `} +`; + +export const StyledTabPanels = styled.div` + height: 100%; + width: 100%; + position: relative; + text-align: center; +`; + +export const StyledTabPanel = styled.div<{ + active: boolean; +}>` + display: ${(p) => (p.active ? 'flex' : 'none')}; + font-size: 2rem; + flex-direction: column; + width: 100%; + height: 100%; + justify-content: center; +`; diff --git a/src/components/common/tabs/tap-panels.tsx b/src/components/common/tabs/tap-panels.tsx new file mode 100644 index 0000000..6d25cd4 --- /dev/null +++ b/src/components/common/tabs/tap-panels.tsx @@ -0,0 +1,27 @@ +import { ReactElement } from 'react'; + +import * as S from './styles'; + +type TapPanelsProps = { + children: ReactElement[]; +}; + +export const TabPanels = ({ children }: TapPanelsProps) => { + return {children}; +}; + +type TabPanelProps = { + children?: ReactElement; + value: number; + selectedIndex: number; +}; + +export const TabPanel = ({ children, value, selectedIndex }: TabPanelProps) => { + const hidden: boolean = value !== selectedIndex; + + return ( + + ); +}; diff --git a/src/pages/challenge-detail/index.tsx b/src/pages/challenge-detail/index.tsx index cd0d99d..346baaf 100644 --- a/src/pages/challenge-detail/index.tsx +++ b/src/pages/challenge-detail/index.tsx @@ -8,7 +8,8 @@ import { ReviewSection } from './review-section/'; import * as S from './styles'; import { type ChallengeDetailData } from '@/apis/challenge-detail/challenge.detail.response'; import DefaultImage from '@/assets/Default-Image.svg'; -import { Tab, TabPanel, Tabs } from '@/components/common/tab'; +import { Tabs, Tab } from '@/components/common/tabs'; +import { TabPanels, TabPanel } from '@/components/common/tabs/tap-panels'; import TopBar from '@/components/features/layout/top-bar'; // const CHALLENGE_GROUP_ID = 38; @@ -27,15 +28,15 @@ const ChallengeDetailPage = () => { const tabsList = [ { label: '설명', - component: data ? : null, + panel: data ? : null, }, { label: '랭킹', - component: data ? : null, + panel: data ? : null, }, { label: '리뷰', - component: data ? : null, + panel: data ? : null, }, ]; @@ -55,7 +56,7 @@ const ChallengeDetailPage = () => { }; fetchChallengeDetail(); - }, []); + }, [challengeGroupId]); // 챌린지 리뷰 페이지에 필요한 챌린지 제목 세션 스토리지에 저장 useEffect(() => { @@ -78,20 +79,19 @@ const ChallengeDetailPage = () => { {data?.category} {data?.title} - - - {tabsList.map((t, index) => ( - - ))} - - - + + + {tabsList.map((t, index) => ( + + ))} + + {tabsList.map((t, index) => ( - {t.component ?? undefined} + {t.panel ?? undefined} ))} - + ); }; diff --git a/src/pages/challenge-detail/styles.ts b/src/pages/challenge-detail/styles.ts index 7365586..1f7c8fa 100644 --- a/src/pages/challenge-detail/styles.ts +++ b/src/pages/challenge-detail/styles.ts @@ -1,21 +1,6 @@ import styled from '@emotion/styled'; -export const TabsContainer = styled.div` - display: flex; - position: relative; - align-self: center; - margin: 0 16px; - height: 55px; - border-radius: 20px; - background-color: var(--color-green-06); -`; - -export const TabPanelContainer = styled.div` - height: 100%; - width: 100%; - position: relative; - text-align: center; -`; +export const Wrapper = styled.div``; export const ImageMask = styled.div` background-color: var(--color-green-06); @@ -33,8 +18,6 @@ export const Image = styled.img` filter: grayscale(100%); `; -export const Wrapper = styled.div``; - export const ChallengeTitleWrapper = styled.div` margin: 16px; display: flex; diff --git a/src/pages/challenge-record/index.tsx b/src/pages/challenge-record/index.tsx index 0bcf751..99d2035 100644 --- a/src/pages/challenge-record/index.tsx +++ b/src/pages/challenge-record/index.tsx @@ -2,14 +2,11 @@ import { useState } from 'react'; import StampBoard from './components/stamp-board'; import Verification from './components/verification'; -import { Tab, TabPanel, Tabs } from '@/components/common/tab'; +import { Tabs, Tab } from '@/components/common/tabs'; +import { TabPanels, TabPanel } from '@/components/common/tabs/tap-panels'; import TopBar from '@/components/features/layout/top-bar'; import styled from '@emotion/styled'; -type TabsContainerProps = { - position?: string; -}; - const ChallengeRecord = () => { const [activeTab, setActiveTab] = useState<0 | 1>(0); @@ -21,20 +18,18 @@ const ChallengeRecord = () => { <> - - handleTab(value)}> - - - - - + handleTab(value)}> + + + + - + ); @@ -49,22 +44,3 @@ const ChallengeRecordLayout = styled.div` height: 100%; margin: 0 1.5rem; `; - -const TabsContainer = styled.div` - display: flex; - align-self: center; - width: 100%; - margin: 1rem auto; - height: 55px; - border-radius: 20px; - background-color: var(--color-green-06); -`; - -const TabPanelContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - width: 100%; - text-align: center; -`;