import React, { useState, useRef, useEffect, ReactNode, useMemo, useCallback } from 'react'; import { getWebProps } from 'react-native-unistyles/web'; import { tabBarContainerStyles, tabBarTabStyles, tabBarLabelStyles, tabBarIndicatorStyles, tabBarIconStyles } from './TabBar.styles'; import type { TabBarProps, TabBarItem } from './types'; import useMergeRefs from '../hooks/useMergeRefs'; import { getWebAriaProps, generateAccessibilityId } from '../utils/accessibility'; import { IconSvg } from '../Icon/IconSvg/IconSvg.web'; import { isIconName } from '../Icon/icon-resolver'; // Icon size mapping based on size variant const ICON_SIZES: Record = { xs: 14, sm: 16, md: 18, lg: 20, xl: 24, }; // Helper to render icon function renderIcon( icon: TabBarItem['icon'], active: boolean, size: number ): ReactNode { if (!icon) return null; if (typeof icon === 'function') { return icon({ active, size }); } if (isIconName(icon)) { return ; } return icon; } interface TabProps { item: TabBarItem; isActive: boolean; onClick: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; size: TabBarProps['size']; type: TabBarProps['type']; pillMode: TabBarProps['pillMode']; iconPosition: TabBarProps['iconPosition']; justify: TabBarProps['justify']; testID?: string; tabRef: (el: HTMLButtonElement | null) => void; tabId: string; panelId: string; } const Tab: React.FC = ({ item, isActive, onClick, onKeyDown, size, type, pillMode, iconPosition, justify, testID, tabRef, tabId, panelId, }) => { const iconSize = ICON_SIZES[size || 'md'] || 18; // Apply tab variants tabBarTabStyles.useVariants({ size, type, active: isActive, disabled: Boolean(item.disabled), iconPosition, justify, }); // Apply label variants tabBarLabelStyles.useVariants({ size, type, active: isActive, disabled: Boolean(item.disabled), }); // Apply icon variants tabBarIconStyles.useVariants({ size, disabled: Boolean(item.disabled), iconPosition, }); const tabProps = getWebProps([tabBarTabStyles.tab as any]); const labelProps = getWebProps([tabBarLabelStyles.tabLabel as any]); const iconProps = getWebProps([tabBarIconStyles.tabIcon as any]); // Merge refs from getWebProps with our tracking ref const mergedRef = useMergeRefs( tabProps.ref as React.Ref, tabRef ); const icon = renderIcon(item.icon, isActive, iconSize); return ( ); }; /** * Horizontal tab navigation for switching between views or content sections. * Supports standard underline and pill styles with animated indicator. */ const TabBar: React.FC = ({ items, value: controlledValue, defaultValue, onChange, type = 'standard', size = 'md', pillMode = 'light', iconPosition = 'left', justify = 'start', // Spacing variants from ContainerStyleProps gap, padding, paddingVertical, paddingHorizontal, margin, marginVertical, marginHorizontal, style, testID, id, // Accessibility props accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, }) => { const firstItemValue = items[0]?.value || ''; const [internalValue, setInternalValue] = useState(defaultValue || firstItemValue); const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }); const containerRef = useRef(null); const tabRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({}); // Generate unique ID for the tablist const tabListId = useMemo(() => id || generateAccessibilityId('tablist'), [id]); // Generate tab and panel IDs const getTabId = useCallback((itemValue: string) => `${tabListId}-tab-${itemValue}`, [tabListId]); const getPanelId = useCallback((itemValue: string) => `${tabListId}-panel-${itemValue}`, [tabListId]); // Get enabled items for keyboard navigation const enabledItems = useMemo(() => items.filter(item => !item.disabled), [items]); // Keyboard navigation handler const handleKeyDown = useCallback((e: React.KeyboardEvent, itemValue: string) => { const key = e.key; const currentIndex = enabledItems.findIndex(item => item.value === itemValue); let nextIndex = -1; if (key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); nextIndex = currentIndex < enabledItems.length - 1 ? currentIndex + 1 : 0; } else if (key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); nextIndex = currentIndex > 0 ? currentIndex - 1 : enabledItems.length - 1; } else if (key === 'Home') { e.preventDefault(); e.stopPropagation(); nextIndex = 0; } else if (key === 'End') { e.preventDefault(); e.stopPropagation(); nextIndex = enabledItems.length - 1; } if (nextIndex >= 0) { const nextItem = enabledItems[nextIndex]; const tabButton = tabRefs.current[nextItem.value]; tabButton?.focus(); } }, [enabledItems]); // Generate ARIA props const ariaProps = useMemo(() => { return getWebAriaProps({ accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole: accessibilityRole ?? 'tablist', }); }, [accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole]); const value = controlledValue !== undefined ? controlledValue : internalValue; const updateIndicator = () => { const activeButton = tabRefs.current[value]; const container = containerRef.current; if (activeButton && container) { const containerRect = container.getBoundingClientRect(); const buttonRect = activeButton.getBoundingClientRect(); const newStyle = { left: buttonRect.left - containerRect.left, width: buttonRect.width, }; setIndicatorStyle(newStyle); } }; // Update indicator when value changes useEffect(() => { updateIndicator(); }, [value]); // Update indicator on mount and window resize useEffect(() => { // Small delay to ensure DOM is ready const timer = setTimeout(updateIndicator, 0); window.addEventListener('resize', updateIndicator); return () => { clearTimeout(timer); window.removeEventListener('resize', updateIndicator); }; }, []); // Update indicator when items change useEffect(() => { updateIndicator(); }, [items]); const handleTabClick = (e: React.MouseEvent, itemValue: string, disabled?: boolean) => { e.preventDefault(); e.stopPropagation(); if (disabled) return; if (controlledValue === undefined) { setInternalValue(itemValue); } onChange?.(itemValue); }; // Apply container variants tabBarContainerStyles.useVariants({ type, pillMode, justify, gap, padding, paddingVertical, paddingHorizontal, margin, marginVertical, marginHorizontal, }); // Apply indicator variants tabBarIndicatorStyles.useVariants({ type, pillMode, }); const containerProps = getWebProps([tabBarContainerStyles.container as any, style as any]); const indicatorProps = getWebProps([tabBarIndicatorStyles.indicator as any]); // Merge container ref with getWebProps ref const mergedContainerRef = useMergeRefs( containerProps.ref as React.Ref, containerRef ); // For pills type, calculate height from parent const indicatorInlineStyle: React.CSSProperties = { left: `${indicatorStyle.left}px`, width: `${indicatorStyle.width}px`, }; // For pills type, use calc() to set height based on top/bottom if (type === 'pills') { indicatorInlineStyle.height = 'calc(100% - 8px)'; // 100% minus top(4px) + bottom(4px) } return (
{/* Sliding indicator */}
{items.map((item) => { const isActive = value === item.value; return ( handleTabClick(e, item.value, item.disabled)} onKeyDown={(e) => handleKeyDown(e, item.value)} size={size} type={type} pillMode={pillMode} iconPosition={iconPosition} justify={justify} testID={testID} tabId={getTabId(item.value)} panelId={getPanelId(item.value)} tabRef={(el) => { tabRefs.current[item.value] = el; // Update indicator when active tab ref is set if (el && isActive) { requestAnimationFrame(() => updateIndicator()); } }} /> ); })}
); }; export default TabBar;