"use client" import React, { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState, type ReactNode, } from "react" import { cn } from "@/lib/utils" /* ─────────────────────────── Context (replaces cloneElement) ─────────────────────────── */ interface TabsContextValue { activeValue: string onValueChange: (value: string) => void } const TabsContext = createContext(null) function useTabsContext() { const ctx = useContext(TabsContext) if (!ctx) { throw new Error( "AnimatedTabs compound components must be rendered inside " ) } return ctx } /* ─────────────────────────── Constants (hoisted out of render) ─────────────────────────── */ const SIZE_CLASSES = { sm: "h-[32px] text-sm", md: "h-[40px] text-base", lg: "h-[48px] text-lg", } as const const VARIANT_CLASSES = { default: "", pills: "rounded-full", underlined: "", } as const const ACTIVE_INDICATOR_CLASSES = { default: "h-[4px] bg-primary dark:bg-primary", pills: "hidden", underlined: "h-[4px] bg-primary dark:bg-primary", } as const const HOVER_INDICATOR_CLASSES = { default: "bg-muted dark:bg-muted rounded-[6px]", pills: "bg-muted dark:bg-muted rounded-full", underlined: "bg-muted dark:bg-muted rounded-[6px]", } as const /* ─────────────────────────── XScrollable (internal) ─────────────────────────── */ const XScrollable = forwardRef< HTMLDivElement, { className?: string children?: ReactNode showScrollbar?: boolean contentClassName?: string } & React.HTMLAttributes >(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => { const scrollRef = useRef(null) const dragging = useRef(false) const startX = useRef(0) const startScrollLeft = useRef(0) const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start") const updateScrollPos = useCallback(() => { const el = scrollRef.current if (!el) return const canScroll = el.scrollWidth > el.clientWidth + 1 if (!canScroll) { setScrollPos("start") return } const atStart = el.scrollLeft <= 2 const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2 setScrollPos(atStart ? "start" : atEnd ? "end" : "middle") }, []) useEffect(() => { updateScrollPos() const el = scrollRef.current if (!el) return const ro = new ResizeObserver(updateScrollPos) ro.observe(el) return () => ro.disconnect() }, [updateScrollPos]) const onMouseDown = (e: React.MouseEvent) => { if (!scrollRef.current) return dragging.current = true startX.current = e.clientX startScrollLeft.current = scrollRef.current.scrollLeft } const endDrag = () => { dragging.current = false } const onMouseMove = (e: React.MouseEvent) => { if (!dragging.current || !scrollRef.current) return e.preventDefault() const dx = e.clientX - startX.current scrollRef.current.scrollLeft = startScrollLeft.current - dx } const onWheel = (e: React.WheelEvent) => { if (!scrollRef.current) return const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX if (delta !== 0) { e.preventDefault() scrollRef.current.scrollLeft += delta } } const handleScroll = useCallback(() => { updateScrollPos() }, [updateScrollPos]) const maskStart = scrollPos === "start" ? "black" : "transparent" const maskEnd = scrollPos === "end" ? "black" : "transparent" const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})` return ( // biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */}
{children}
) }) XScrollable.displayName = "XScrollable" /* ─────────────────────────── Tabs (root) ─────────────────────────── */ const Tabs = forwardRef< HTMLDivElement, { defaultValue?: string value?: string onValueChange?: (value: string) => void className?: string children?: ReactNode } >( ( { defaultValue, value, onValueChange, className, children, ...props }, ref ) => { const [activeValue, setActiveValue] = useState(value || defaultValue || "") useEffect(() => { if (value !== undefined) { setActiveValue(value) } }, [value]) const handleValueChange = useCallback( (newValue: string) => { if (value === undefined) { setActiveValue(newValue) } onValueChange?.(newValue) }, [onValueChange, value] ) return (
{children}
) } ) Tabs.displayName = "Tabs" /* ─────────────────────────── TabsList ─────────────────────────── */ type TabsListVariant = "default" | "pills" | "underlined" type TabsListSize = "sm" | "md" | "lg" const TabsList = forwardRef< HTMLDivElement, { className?: string children?: ReactNode showHoverEffect?: boolean showActiveIndicator?: boolean activeIndicatorPosition?: "top" | "bottom" activeIndicatorOffset?: number size?: TabsListSize variant?: TabsListVariant stretch?: boolean ariaLabel?: string showBottomBorder?: boolean bottomBorderClassName?: string activeIndicatorClassName?: string hoverIndicatorClassName?: string } >( ( { className, children, showHoverEffect = true, showActiveIndicator = true, activeIndicatorPosition = "bottom", activeIndicatorOffset = 0, size = "sm", variant = "default", stretch = false, ariaLabel = "Tabs", showBottomBorder = false, bottomBorderClassName, activeIndicatorClassName, hoverIndicatorClassName, ...props }, ref ) => { const { activeValue, onValueChange } = useTabsContext() const [hoveredIndex, setHoveredIndex] = useState(null) const [hoverStyle, setHoverStyle] = useState({}) const [activeStyle, setActiveStyle] = useState({ left: "0px", width: "0px", }) const tabRefs = useRef<(HTMLDivElement | null)[]>([]) const scrollContainerRef = useRef(null) const activeIndex = React.Children.toArray(children).findIndex( (child) => React.isValidElement(child) && (child as React.ReactElement<{ value: string }>).props.value === activeValue ) useEffect(() => { if (hoveredIndex !== null && showHoverEffect) { const hoveredElement = tabRefs.current[hoveredIndex] if (hoveredElement) { const { offsetLeft, offsetWidth } = hoveredElement setHoverStyle({ left: `${offsetLeft}px`, width: `${offsetWidth}px`, }) } } }, [hoveredIndex, showHoverEffect]) const updateActiveIndicator = useCallback(() => { if (showActiveIndicator && activeIndex >= 0) { const activeElement = tabRefs.current[activeIndex] if (activeElement) { const { offsetLeft, offsetWidth } = activeElement setActiveStyle({ left: `${offsetLeft}px`, width: `${offsetWidth}px`, }) } } }, [showActiveIndicator, activeIndex]) useEffect(() => { updateActiveIndicator() }, [updateActiveIndicator]) useEffect(() => { requestAnimationFrame(updateActiveIndicator) }, [updateActiveIndicator]) const scrollTabToCenter = useCallback((index: number) => { const tabElement = tabRefs.current[index] const scrollContainer = scrollContainerRef.current if (tabElement && scrollContainer) { const containerWidth = scrollContainer.offsetWidth const tabWidth = tabElement.offsetWidth const tabLeft = tabElement.offsetLeft const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2 scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" }) } }, []) const setTabRef = useCallback( (el: HTMLDivElement | null, index: number) => { tabRefs.current[index] = el }, [] ) const handleScrollableRef = useCallback((node: HTMLDivElement | null) => { if (node) { const scrollableDiv = node.querySelector( 'div[class*="overflow-x-auto"]' ) if (scrollableDiv) { scrollContainerRef.current = scrollableDiv as HTMLDivElement } } }, []) useEffect(() => { if (activeIndex >= 0) { const timer = setTimeout(() => { scrollTabToCenter(activeIndex) }, 100) return () => clearTimeout(timer) } }, [activeIndex, scrollTabToCenter]) return (
{showBottomBorder && (
)}
{showHoverEffect && ( ) } ) TabsList.displayName = "TabsList" /* ─────────────────────────── TabsTrigger ─────────────────────────── */ const TabsTrigger = forwardRef< HTMLDivElement, { value: string disabled?: boolean label?: string className?: string activeClassName?: string inactiveClassName?: string disabledClassName?: string children?: ReactNode } >( ( { value, disabled = false, label, className, activeClassName, inactiveClassName, disabledClassName, children, ...props }, ref ) => { return (
{label || children}
) } ) TabsTrigger.displayName = "TabsTrigger" /* ─────────────────────────── TabsContent ─────────────────────────── */ const TabsContent = forwardRef< HTMLDivElement, { value: string className?: string children: ReactNode } >( ( { value, className, children, ...props }, ref ) => { const { activeValue } = useTabsContext() if (value !== activeValue) return null return (
{children}
) } ) TabsContent.displayName = "TabsContent" export { Tabs, TabsList, TabsTrigger, TabsContent }