"use client"; import React, { createContext, forwardRef, type ReactNode, useCallback, useContext, useEffect, useRef, useState, } 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-[2px] bg-primary dark:bg-primary", pills: "hidden", underlined: "h-[2px] 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" | "none">("none"); const updateScrollPos = useCallback(() => { const el = scrollRef.current; if (!el) return; const canScroll = el.scrollWidth > el.clientWidth + 1; if (!canScroll) { setScrollPos("none"); 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 needsMask = scrollPos !== "none"; const maskStart = scrollPos === "start" || scrollPos === "none" ? "black" : "transparent"; const maskEnd = scrollPos === "end" || scrollPos === "none" ? "black" : "transparent"; const maskImage = needsMask ? `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})` : undefined; 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 };