1- import { NavLink } from 'react-router' ;
1+ import { NavLink , useLocation } from 'react-router' ;
2+ import { useLayoutEffect , useRef , useState } from 'react' ;
23
34export interface TabItem {
45 label : string ;
@@ -13,50 +14,98 @@ interface TabsProps {
1314 className ?: string ;
1415}
1516
17+ const SIDE_MARGIN_PX = 16 ;
18+
1619export default function Tabs ( { tabs, activeTab, onTabChange, className = "" } : TabsProps ) {
20+ const location = useLocation ( ) ;
21+ const hasPath = tabs . some ( ( t ) => t . path ) ;
22+ const currentActiveTab = hasPath
23+ ? tabs . find ( ( t ) => t . path && location . pathname . startsWith ( t . path ) ) ?. value || tabs [ 0 ] ?. value
24+ : activeTab ;
25+
26+ const activeIndex = Math . max (
27+ 0 ,
28+ tabs . findIndex ( ( t ) => t . value === currentActiveTab )
29+ ) ;
30+
31+ const wrapRef = useRef < HTMLDivElement | null > ( null ) ;
32+ const [ wrapWidth , setWrapWidth ] = useState ( 0 ) ;
33+
34+ useLayoutEffect ( ( ) => {
35+ const el = wrapRef . current ;
36+ if ( ! el ) return ;
37+
38+ const measure = ( ) => setWrapWidth ( el . clientWidth ) ;
39+ measure ( ) ;
40+
41+ const ro = new ResizeObserver ( ( ) => measure ( ) ) ;
42+ ro . observe ( el ) ;
43+
44+ return ( ) => ro . disconnect ( ) ;
45+ } , [ ] ) ;
46+
47+ const pxToRem = ( px : number ) => {
48+ if ( typeof window === "undefined" ) return `${ px / 16 } rem` ;
49+ const root = window . getComputedStyle ( document . documentElement ) . fontSize ;
50+ const base = Number . parseFloat ( root ) || 16 ;
51+ return `${ px / base } rem` ;
52+ } ;
53+
54+ const trackWidth = Math . max ( 0 , wrapWidth - SIDE_MARGIN_PX * 2 ) ;
55+ const tabWidth = trackWidth / tabs . length ;
56+ const indicatorWidth = tabWidth ;
57+ const indicatorX = SIDE_MARGIN_PX + tabWidth * activeIndex ;
58+
1759 return (
18- < div className = { `flex w-full bg-white border-b border-bluegray-2 shrink-0 ${ className } ` } >
19- { tabs . map ( ( tab ) => {
20- const isSelected = activeTab === tab . value ;
60+ < div ref = { wrapRef } className = { `relative w-full h-12.5 bg-white shrink-0 ${ className } ` } >
61+ < div className = "flex h-full items-center px-4" >
62+ { tabs . map ( ( tab ) => {
63+ const isSelected = currentActiveTab === tab . value ;
2164
22- // Mode 1: Link-based Tab
23- if ( tab . path ) {
65+ // Link-based Tab
66+ if ( tab . path ) {
67+ return (
68+ < NavLink
69+ key = { tab . value }
70+ to = { tab . path }
71+ className = "flex-1 select-none"
72+ >
73+ { ( { isActive } ) => (
74+ < div
75+ className = { `h-full flex items-center justify-center text-title2 transition-colors cursor-pointer ${ isActive ? 'text-(--color-core-1)' : 'text-text-gray3'
76+ } `}
77+ >
78+ { tab . label }
79+ </ div >
80+ ) }
81+ </ NavLink >
82+ ) ;
83+ }
84+
85+ // Button-based Tab
2486 return (
25- < NavLink
87+ < button
2688 key = { tab . value }
27- to = { tab . path }
28- className = "flex-1 select-none"
89+ type = "button"
90+ onClick = { ( ) => onTabChange ?.( tab . value ) }
91+ className = { `flex-1 h-full flex items-center justify-center text-title2 transition-colors ${ isSelected ? 'text-(--color-core-1)' : 'text-text-gray3'
92+ } `}
2993 >
30- { ( { isActive } ) => (
31- < div
32- className = { `py-4 text-title-2 text-center relative transition-colors cursor-pointer font-semibold ${ isActive ? 'text-core-1' : 'text-text-gray3'
33- } `}
34- >
35- { tab . label }
36- { isActive && (
37- < div className = "absolute bottom-0 left-1/2 -translate-x-1/2 w-[150px] h-[2px] bg-core-1" />
38- ) }
39- </ div >
40- ) }
41- </ NavLink >
94+ { tab . label }
95+ </ button >
4296 ) ;
43- }
44-
45- // Mode 2: Button-based Tab (State)
46- return (
47- < button
48- key = { tab . value }
49- onClick = { ( ) => onTabChange ?.( tab . value ) }
50- className = { `flex-1 py-4 text-title-2 text-center relative transition-colors select-none font-semibold ${ isSelected ? 'text-core-1' : 'text-text-gray3'
51- } `}
52- >
53- { tab . label }
54- { isSelected && (
55- < div className = "absolute bottom-0 left-1/2 -translate-x-1/2 w-[150px] h-[2px] bg-core-1" />
56- ) }
57- </ button >
58- ) ;
59- } ) }
97+ } ) }
98+ </ div >
99+
100+ < div className = "absolute bottom-0 left-0 right-0 h-px bg-black/10" />
101+
102+ < div
103+ className = "absolute bottom-0 h-0.5 bg-(--color-success) transition-transform duration-200"
104+ style = { {
105+ width : pxToRem ( indicatorWidth ) ,
106+ transform : `translateX(${ pxToRem ( indicatorX ) } )` ,
107+ } }
108+ />
60109 </ div >
61110 ) ;
62111}
0 commit comments