import * as React from 'react' import { forwardRef, useMemo, useRef, useCallback, useId, useEffect, } from 'react' import { type TabsListProps, TabsSize, TabsVariant } from './types' import { StyledTabsList } from './StyledTabs' import type { TabsTokensType } from './tabs.token' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import TabsTrigger from './TabsTrigger' import PrimitiveButton from '../Primitives/PrimitiveButton/PrimitiveButton' import { ChevronDown, Plus } from 'lucide-react' import { Tooltip } from '../Tooltip/Tooltip' import SingleSelect from '../SingleSelect/SingleSelect' import { SelectMenuVariant } from '../Select' import { processTabsWithConcatenation, prepareDropdownItems, getActualTabValue, isConcatenatedTab, extractOriginalValues, calculateTabIndicatorPosition, } from './utils' import { FOUNDATION_THEME } from '../../tokens' import Block from '../Primitives/Block/Block' const TabsList = forwardRef( ( { className, variant = TabsVariant.UNDERLINE, size = TabsSize.MD, expanded = false, fitContent = false, items = [], originalItems, onTabClose, onTabAdd, showDropdown = false, showAddButton = false, addButtonTooltip = 'Add new tab', onTabChange, activeTab = '', disable = false, showSkeleton = false, skeletonVariant = 'pulse', stickyHeader = false, offsetTop = 0, children, }, ref ) => { const tabsToken = useResponsiveTokens('TABS') const tabsGroupId = useId() const scrollContainerRef = useRef(null) const tabsListRef = useRef(null) const tabRefsMap = useRef>(new Map()) const isScrollingRef = useRef(false) const hasMountedRef = useRef(false) const prevItemsLengthRef = useRef(items.length) const sourceItems = originalItems || items const processedItems = useMemo( () => processTabsWithConcatenation(items), [items] ) const dropdownItems = useMemo(() => { return prepareDropdownItems(sourceItems, sourceItems) }, [sourceItems]) const originalTabValues = useMemo( () => new Set(sourceItems.map((item) => item.value)), [sourceItems] ) const hasAnySkeleton = useMemo( () => processedItems.some((item) => item.showSkeleton === true), [processedItems] ) const hasAnyChildSkeleton = useMemo(() => { if (showSkeleton) return true return React.Children.toArray(children).some((child) => { if (!React.isValidElement(child)) return false const props = child.props as Record return props.showSkeleton === true }) }, [children, showSkeleton]) const updateIndicator = useCallback(() => { if (variant !== TabsVariant.UNDERLINE || hasAnySkeleton) { return } const listElement = tabsListRef.current const activeTabElement = tabRefsMap.current?.get(activeTab) if (!activeTabElement || !listElement) { return } const { tabLeft, tabWidth } = calculateTabIndicatorPosition( activeTabElement, listElement ) listElement.style.setProperty( '--tabs-indicator-left', `${tabLeft}px` ) listElement.style.setProperty( '--tabs-indicator-width', `${tabWidth}` ) }, [ activeTab, variant, tabRefsMap, hasAnySkeleton, expanded, fitContent, originalItems, ]) useEffect(() => { if ( !activeTab || variant !== TabsVariant.UNDERLINE || hasAnySkeleton ) { return } const listElement = tabsListRef.current const activeTabElement = tabRefsMap.current?.get(activeTab) if (!activeTabElement || !listElement) { return } const needsDelay = !hasMountedRef.current || sourceItems.length !== prevItemsLengthRef.current const delay = needsDelay ? 100 : 0 const timeout = setTimeout(() => { updateIndicator() hasMountedRef.current = true prevItemsLengthRef.current = sourceItems.length }, delay) window.addEventListener('resize', updateIndicator) // Observe container size changes (e.g., when sidebar expands/collapses) const resizeObserver = new ResizeObserver(() => { updateIndicator() }) // Observe both the list element and its parent container resizeObserver.observe(listElement) if (listElement.parentElement) { resizeObserver.observe(listElement.parentElement) } return () => { clearTimeout(timeout) window.removeEventListener('resize', updateIndicator) resizeObserver.disconnect() } }, [ activeTab, variant, hasAnySkeleton, sourceItems.length, expanded, fitContent, originalItems, updateIndicator, ]) useEffect(() => { if (!activeTab || isScrollingRef.current) { return } const scrollContainer = scrollContainerRef.current const activeTabElement = tabRefsMap.current?.get(activeTab) if (!activeTabElement) { return } if (!scrollContainer) { updateIndicator() return } const containerRect = scrollContainer.getBoundingClientRect() const tabRect = activeTabElement.getBoundingClientRect() const isTabVisible = tabRect.left >= containerRect.left && tabRect.right <= containerRect.right if (!isTabVisible) { isScrollingRef.current = true const containerWidth = scrollContainer.offsetWidth const tabOffsetLeft = activeTabElement.offsetLeft const tabWidth = activeTabElement.offsetWidth scrollContainer.scrollTo({ left: Math.max( 0, tabOffsetLeft - containerWidth / 2 + tabWidth / 2 ), behavior: 'smooth', }) const scrollTimer = setTimeout(() => { isScrollingRef.current = false updateIndicator() }, 500) return () => clearTimeout(scrollTimer) } else { updateIndicator() } }, [activeTab, updateIndicator]) // Effect: Scroll to end when new tabs are added useEffect(() => { const currentLength = sourceItems.length const previousLength = prevItemsLengthRef.current if (currentLength <= previousLength) { prevItemsLengthRef.current = currentLength return } const scrollContainer = scrollContainerRef.current if (!scrollContainer) { prevItemsLengthRef.current = currentLength return } isScrollingRef.current = true scrollContainer.scrollTo({ left: scrollContainer.scrollWidth, behavior: 'smooth', }) const scrollTimeout = setTimeout(() => { isScrollingRef.current = false updateIndicator() }, 350) prevItemsLengthRef.current = currentLength return () => clearTimeout(scrollTimeout) }, [sourceItems.length, updateIndicator]) const handleTabClose = useCallback( (processedTabValue: string) => { if (isConcatenatedTab(processedTabValue, originalTabValues)) { const originalValues = extractOriginalValues(processedTabValue) originalValues.forEach((val) => onTabClose?.(val)) } onTabClose?.(processedTabValue) }, [onTabClose, originalTabValues] ) const handleDropdownSelect = useCallback( (value: string) => { onTabChange?.(value) }, [onTabChange] ) const handleAddClick = useCallback(() => { onTabAdd?.() }, [onTabAdd]) const registerTabRef = useCallback( (node: HTMLButtonElement | null, value: string) => { if (node && value) { tabRefsMap.current.set(value, node) } else if (value) { tabRefsMap.current.delete(value) } }, [] ) const setRefs = useCallback( (node: HTMLDivElement | null) => { tabsListRef.current = node if (typeof ref === 'function') { ref(node) } else if (ref) { ref.current = node } }, [ref] ) if (items.length > 0) { return ( {processedItems.map((item) => { const tabValue = getActualTabValue( item.value, originalTabValues ) return ( registerTabRef(node, tabValue) } value={tabValue} variant={variant} size={size} isActive={tabValue === activeTab} tabsGroupId={tabsGroupId} closable={item.newItem} onClose={() => handleTabClose(item.value) } disabled={item.disable} showSkeleton={item.showSkeleton} skeletonVariant={ item.skeletonVariant } leftSlot={item.leftSlot} rightSlot={item.rightSlot} style={{ flexShrink: 0, whiteSpace: 'nowrap', }} data-status={ item.disable ? 'disabled' : 'enabled' } data-id={item.label} > {item.label} ) })} {(showDropdown || showAddButton) && ( {showDropdown && ( )} ) } const renderChildren = () => { return React.Children.map(children, (child) => { if (!React.isValidElement(child)) return child const existingProps = child.props as Record const childDisable = 'disable' in existingProps ? (existingProps.disable as boolean | undefined) : undefined const childValue = 'value' in existingProps ? (existingProps.value as string) : '' const isTabsTrigger = child.type && (child.type as { displayName?: string }).displayName === 'TabsTrigger' const childProps = { ...existingProps, disable: childDisable || disable, variant, size, isActive: childValue === activeTab, tabsGroupId, ...(isTabsTrigger && { showSkeleton: 'showSkeleton' in existingProps ? existingProps.showSkeleton : showSkeleton, skeletonVariant: 'skeletonVariant' in existingProps ? existingProps.skeletonVariant : skeletonVariant, }), ref: (node: HTMLButtonElement) => registerTabRef(node, childValue), } return React.cloneElement(child, childProps) }) } return ( {renderChildren()} ) } ) TabsList.displayName = 'TabsList' export default TabsList