index.tsx raw
1 import { cn } from '@/lib/utils'
2 import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
3 import { ReactNode, useEffect, useRef, useState } from 'react'
4 import { useTranslation } from 'react-i18next'
5 import { ScrollArea, ScrollBar } from '../ui/scroll-area'
6
7 type TabDefinition = {
8 value: string
9 label: string
10 }
11
12 export default function Tabs({
13 tabs,
14 value,
15 onTabChange,
16 threshold = 800,
17 options = null
18 }: {
19 tabs: TabDefinition[]
20 value: string
21 onTabChange?: (tab: string) => void
22 threshold?: number
23 options?: ReactNode
24 }) {
25 const { t } = useTranslation()
26 const { deepBrowsing, lastScrollTop } = useDeepBrowsing()
27 const tabRefs = useRef<(HTMLDivElement | null)[]>([])
28 const containerRef = useRef<HTMLDivElement | null>(null)
29 const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
30
31 const updateIndicatorPosition = () => {
32 const activeIndex = tabs.findIndex((tab) => tab.value === value)
33 if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
34 const activeTab = tabRefs.current[activeIndex]
35 const { offsetWidth, offsetLeft } = activeTab
36 const padding = 24 // 12px padding on each side
37 setIndicatorStyle({
38 width: offsetWidth - padding,
39 left: offsetLeft + padding / 2
40 })
41 }
42 }
43
44 useEffect(() => {
45 const animationId = requestAnimationFrame(() => {
46 updateIndicatorPosition()
47 })
48
49 return () => {
50 cancelAnimationFrame(animationId)
51 }
52 }, [tabs, value])
53
54 useEffect(() => {
55 if (!containerRef.current) return
56
57 const resizeObserver = new ResizeObserver(() => {
58 updateIndicatorPosition()
59 })
60
61 const intersectionObserver = new IntersectionObserver(
62 (entries) => {
63 entries.forEach((entry) => {
64 if (entry.isIntersecting) {
65 requestAnimationFrame(() => {
66 updateIndicatorPosition()
67 })
68 }
69 })
70 },
71 { threshold: 0 }
72 )
73
74 intersectionObserver.observe(containerRef.current)
75
76 tabRefs.current.forEach((tab) => {
77 if (tab) resizeObserver.observe(tab)
78 })
79
80 return () => {
81 resizeObserver.disconnect()
82 intersectionObserver.disconnect()
83 }
84 }, [tabs, value])
85
86 return (
87 <div
88 ref={containerRef}
89 className={cn(
90 'sticky flex justify-between top-12 bg-background z-30 px-1 w-full transition-all duration-300 border-b',
91 deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : ''
92 )}
93 >
94 <ScrollArea className="flex-1 w-0">
95 <div className="flex w-fit relative">
96 {tabs.map((tab, index) => (
97 <div
98 key={tab.value}
99 ref={(el) => (tabRefs.current[index] = el)}
100 className={cn(
101 `w-fit text-center py-2 px-6 my-1 font-semibold whitespace-nowrap clickable cursor-pointer rounded-xl transition-all duration-200`,
102 value === tab.value
103 ? 'text-foreground'
104 : 'text-muted-foreground hover:text-foreground'
105 )}
106 onClick={() => {
107 onTabChange?.(tab.value)
108 }}
109 >
110 {t(tab.label)}
111 </div>
112 ))}
113 <div
114 className="absolute bottom-0 h-1 bg-gradient-to-r from-primary to-primary-hover rounded-full transition-all duration-300"
115 style={{
116 width: `${indicatorStyle.width}px`,
117 left: `${indicatorStyle.left}px`
118 }}
119 />
120 </div>
121 <ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
122 </ScrollArea>
123 {options && <div className="py-1 flex items-center">{options}</div>}
124 </div>
125 )
126 }
127