Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export default function Card({
variants={cardVariants}
initial="hidden"
animate={inView ? "visible" : "hidden"}
transition={{ duration: 0.7, ease: "easeInOut" }}
transition={{ duration: 0.5, ease: "easeInOut" }}
className="relative my-5"
>
<div
Expand Down
129 changes: 129 additions & 0 deletions src/components/MainTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

import { createContext, useContext, useEffect, useRef, useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";

type MainTabContextType = {
activeIndex: number;
setActiveIndex: (index: number) => void;
addTabRefs: (index: number, ref: HTMLLIElement | null) => void;
sliderStyle: { width: number; translateX: number };
};

const MainTabContext = createContext<MainTabContextType | null>(null);

function useTabContext() {
const context = useContext(MainTabContext);
if (!context) {
throw new Error("Tab compound components must be used within a Tab.Root");
}
return context;
}

// Tab의 props
type MainTabProps = {
children: React.ReactNode;
category?: React.ReactNode; // 카태고리 버튼
targetIndex?: number; // 클릭 시 카테고리가 나와야 할 index
gap?: string; // 탭과 카테고리의 간격
};
// Tab 루트 컴포넌트
export default function MainTab({ children, category, targetIndex, gap = "gap-4" }: MainTabProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();

// 현재 활성화된 탭의 인덱스
const [activeIndex, setActiveIndex] = useState(0);
// 슬라이더의 길이 및 X축 이동거리
const [sliderStyle, setSliderStyle] = useState({ width: 0, translateX: 0 });
// 탭들의 ref
const tabRefs = useRef<(HTMLLIElement | null)[]>([]);

// URL에서 `type` 값을 읽어 `activeIndex` 업데이트 (뒤로 가기, 새로고침 대응)
useEffect(() => {
const currentType = searchParams.get("type") || "DALLAEMFIT";
const selectedIndex = SERVICE_TABS.findIndex((t) => t.type === currentType);

if (selectedIndex !== -1 && selectedIndex !== activeIndex) {
setActiveIndex(selectedIndex);
}
}, [searchParams]);

useEffect(() => {
if (!tabRefs.current.length) return; // 아직 ref 배열이 비어 있다면 패스
// 현재 활성화 된 Tab
const activeTab = tabRefs.current[activeIndex];
if (activeTab) {
const width = activeTab.offsetWidth;
// 활성 탭 이전 탭들의 누적 offsetWidth를 계산. gap인 12px을 더해준다.
const offsetLeft = tabRefs.current
.slice(0, activeIndex)
.reduce((acc, el) => acc + (el?.offsetWidth || 0) + 12, 0);
setSliderStyle({ width, translateX: offsetLeft });
}
}, [activeIndex]);

// context에 전달할 값들
const contextValue = {
activeIndex,
setActiveIndex: (index: number) => {
setActiveIndex(index);

// 📌 URL도 함께 업데이트 (뒤로 가기 대응)
const tabType = SERVICE_TABS[index].type;
router.push(`${pathname}?type=${tabType}`);
},
addTabRefs: (index: number, ref: HTMLLIElement | null) => {
tabRefs.current[index] = ref;
},
sliderStyle,
};
return (
<MainTabContext.Provider value={contextValue}>
<div className={`flex flex-col ${gap}`}>
<div className="relative">
{/* 탭 */}
<ul className="flex gap-3 text-lg font-semibold text-gray-400">{children}</ul>
{/* 슬라이더 */}
<div
style={{
width: sliderStyle.width,
transform: `translateX(${sliderStyle.translateX}px)`,
}}
className={`absolute bottom-0 h-[2px] bg-gray-900 transition-all duration-300`}
/>
</div>
{targetIndex === activeIndex && <div>{category}</div>}
</div>
</MainTabContext.Provider>
);
}

// 탭 아이템
type ItemProps = {
index: number;
children: React.ReactNode;
};
MainTab.Item = function ({ index, children }: ItemProps) {
const { activeIndex, setActiveIndex, addTabRefs } = useTabContext();

return (
<li
onClick={() => setActiveIndex(index)}
ref={(el) => {
addTabRefs(index, el);
}}
// 활성화된 탭이면 text의 색을 변경
className={`${activeIndex === index && "text-gray-900"} mb-1 flex cursor-pointer items-center gap-1 transition-colors duration-300`}
>
{children}
</li>
);
};

// 📌 서비스 탭 리스트 (예제)
const SERVICE_TABS = [
{ name: "달램핏", type: "DALLAEMFIT" },
{ name: "워케이션", type: "WORKATION" },
];
54 changes: 34 additions & 20 deletions src/components/ServiceTab.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import CategoryButton from "@/components/CategoryButton";
import Tab from "@/components/Tab";
import MainTab from "@/components/MainTab";
import Dalaemfit from "@/images/dalaemfit.svg";
import Workation from "@/images/workation.svg";

Expand All @@ -23,26 +24,35 @@ type ServiceTabProps = {
isFilteringLoading?: boolean; // 필터링 중인지 판단하는 변수
};

export default function ServiceTab({ searchParams, onCategoryChange, isFilteringLoading }: ServiceTabProps) {
const [selectedTab, setSelectedTab] = useState<"DALLAEMFIT" | "WORKATION">(
() => (searchParams.get("type") || "DALLAEMFIT") as "DALLAEMFIT" | "WORKATION",
);
const [selectedCategory, setSelectedCategory] = useState<string>("전체");
export default function ServiceTab({ onCategoryChange, isFilteringLoading }: ServiceTabProps) {
const searchParams = useSearchParams();

// URL이 변경되었을 때 필터링 로딩 상태 해제
useEffect(() => {
if (!isFilteringLoading) return;
const [selectedTab, setSelectedTab] = useState<string>(
() => SERVICE_TABS.find((t) => t.type === searchParams.get("type"))?.name || "DALLAEMFIT",
);
Comment on lines +30 to +32
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

selectedTab 상태 관리가 일관되지 않습니다.

selectedTab이 탭 이름으로 초기화되지만 코드 일부분에서는 탭 타입으로 취급됩니다. 상태 관리의 일관성을 유지해야 합니다.

const [selectedTab, setSelectedTab] = useState<string>(
-  () => SERVICE_TABS.find((t) => t.type === searchParams.get("type"))?.name || "DALLAEMFIT",
+  () => SERVICE_TABS.find((t) => t.type === searchParams.get("type"))?.type || "DALLAEMFIT",
);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [selectedTab, setSelectedTab] = useState<string>(
() => SERVICE_TABS.find((t) => t.type === searchParams.get("type"))?.name || "DALLAEMFIT",
);
const [selectedTab, setSelectedTab] = useState<string>(
() => SERVICE_TABS.find((t) => t.type === searchParams.get("type"))?.type || "DALLAEMFIT",
);


const currentType = searchParams.get("type") || "DALLAEMFIT";
setSelectedTab(currentType as "DALLAEMFIT" | "WORKATION");
}, [searchParams, isFilteringLoading]);
const [selectedCategory, setSelectedCategory] = useState<string>(
() => CATEGORIES.find((c) => c.type === searchParams.get("type"))?.name || "전체",
);

// searchParams 변경 감지해서 반영
//searchParams 변경 감지해서 반영
useEffect(() => {
const currentType = searchParams.get("type") || "DALLAEMFIT";

if (currentType !== selectedTab) {
setSelectedTab(currentType as "DALLAEMFIT" | "WORKATION");
console.log("선택된 타입: ", currentType);

if (currentType) {
const tabName = SERVICE_TABS.find((t) => t.type === currentType)?.name;

console.log("찾은 탭 이름1:", tabName);
console.log("selectedTab은? ", selectedTab);
if (tabName && tabName == selectedTab) {
setSelectedTab(tabName);
console.log("찾은 탭 이름2:", tabName);

// 📌 `handleTabChange` 실행 (중복 실행 방지됨)
handleTabChange(tabName);
}
}
}, [searchParams]);

Expand All @@ -53,8 +63,12 @@ export default function ServiceTab({ searchParams, onCategoryChange, isFiltering
const tabType = SERVICE_TABS.find((t) => t.name === tabName)?.type;
if (!tabType) return;

setSelectedTab(tabType as "DALLAEMFIT" | "WORKATION");
// URL의 type 값을 가져와서 selectedTab 업데이트
const currentType = searchParams.get("type") || tabType; // 없으면 클릭한 탭을 기본값으로
console.log("핸들러 실행됨11:", currentType);
setSelectedTab(currentType);

setSelectedTab(tabType);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

handleTabChange에서 중복으로 setSelectedTab을 호출하는 구조를 점검하세요.

setSelectedTab(currentType);
...
setSelectedTab(tabType);

최종적으로 selectedTabtabType으로 설정되어 currentType 할당이 무의미해질 수 있습니다. 실제 의도에 맞는지 확인해 주세요.

onCategoryChange(tabType);
handleCategoryReset();
};
Expand All @@ -77,7 +91,7 @@ export default function ServiceTab({ searchParams, onCategoryChange, isFiltering

return (
<>
<Tab
<MainTab
category={
<CategoryButton
categories={CATEGORIES.map((c) => c.name)}
Expand All @@ -93,7 +107,7 @@ export default function ServiceTab({ searchParams, onCategoryChange, isFiltering
targetIndex={0}
>
{SERVICE_TABS.map((tabItem, idx) => (
<Tab.Item key={tabItem.name} index={idx}>
<MainTab.Item key={tabItem.name} index={idx}>
<button
onClick={() => handleTabChange(tabItem.name)}
className="flex items-center"
Expand All @@ -102,9 +116,9 @@ export default function ServiceTab({ searchParams, onCategoryChange, isFiltering
{tabItem.name}
<tabItem.icon className="items-center" />
</button>
</Tab.Item>
</MainTab.Item>
))}
</Tab>
</MainTab>
</>
);
}