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