import * as React from 'react' import * as Tabs from '@radix-ui/react-tabs' import type { SAILMarginSize, SAILSize, SAILColorInput } from '../../types/sail' import { isPaletteColor } from '../../utils/colorResolver' import { paletteColorMap } from '../../types/palette-colors.generated' import { mergeClasses } from '../../utils/classNames' import { marginAboveMap, marginBelowMap, buttonSizeMap } from '../../utils/sailMaps' /** * Individual tab configuration */ export interface TabItem { /** Unique identifier for the tab */ value: string /** Text to display on the tab trigger */ label: string /** Content to display when tab is active */ content: React.ReactNode /** Whether this tab is disabled */ disabled?: boolean } /** * Visual variant for tab styling * - UNDERLINE: Standard tabs with a sliding underline indicator (default) * - PILL: Filled background on active tab with a downward caret indicator */ export type TabsVariant = "UNDERLINE" | "PILL" /** * Displays a set of layered sections of content (tab panels) that are displayed one at a time * Inspired by SAIL form field patterns (not an official SAIL component) * * This is a "new SAIL" component - not available in public SAIL but follows * the same conventions and patterns for consistency with other Sailwind components. */ export interface TabsFieldProps { /** Array of tab configurations */ tabs: TabItem[] /** Currently active tab value (controlled) */ value?: string /** Default active tab value (uncontrolled) */ defaultValue?: string /** Callback when active tab changes */ onValueChange?: (value: string) => void /** Visual variant for tab styling */ variant?: TabsVariant /** Orientation of the tabs (only applies to UNDERLINE variant) */ orientation?: "HORIZONTAL" | "VERTICAL" /** Size of the tab triggers */ size?: SAILSize /** Whether tabs should loop when navigating with keyboard */ loop?: boolean /** Determines whether component is displayed */ showWhen?: boolean /** Space added above component */ marginAbove?: SAILMarginSize /** Space added below component */ marginBelow?: SAILMarginSize /** Color scheme for active tabs (hex or semantic) */ color?: "ACCENT" | "POSITIVE" | "NEGATIVE" | "SECONDARY" | SAILColorInput /** Activation mode - whether tabs activate on focus or click */ activationMode?: "AUTOMATIC" | "MANUAL" /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ className?: string } export const TabsField: React.FC = ({ tabs, value, defaultValue, onValueChange, variant = "UNDERLINE", orientation = "HORIZONTAL", size = "STANDARD", loop = true, showWhen = true, marginAbove = "NONE", marginBelow = "STANDARD", color = "ACCENT", activationMode = "AUTOMATIC", className }) => { // Sliding indicator state const [indicatorStyle, setIndicatorStyle] = React.useState({}) const listRef = React.useRef(null) const [internalActive, setInternalActive] = React.useState(value ?? defaultValue ?? tabs[0]?.value) const updateIndicator = React.useCallback(() => { if (!listRef.current) return const activeEl = listRef.current.querySelector('[data-state="active"]') if (!activeEl) return if (orientation === "HORIZONTAL") { setIndicatorStyle({ left: activeEl.offsetLeft, width: activeEl.offsetWidth }) } else { setIndicatorStyle({ top: activeEl.offsetTop, height: activeEl.offsetHeight }) } }, [orientation]) // Update on mount and whenever active tab changes React.useEffect(() => { updateIndicator() }, [internalActive, updateIndicator]) // Sync internalActive when controlled value changes React.useEffect(() => { if (value !== undefined) setInternalActive(value) }, [value]) const handleValueChange = (val: string) => { setInternalActive(val) onValueChange?.(val) } // Visibility control if (!showWhen) return null // Active indicator color const getIndicatorColor = (): string => { const semanticMap: Record = { ACCENT: 'var(--color-blue-500)', POSITIVE: 'var(--color-green-700)', NEGATIVE: 'var(--color-red-700)', SECONDARY: 'var(--color-gray-700)', STANDARD: 'var(--color-gray-900)', } if (semanticMap[color]) return semanticMap[color] if (isPaletteColor(color)) { const segment = paletteColorMap[color].bg.replace('bg-', '') return `var(--color-${segment})` } return color // hex fallback } const indicatorColor = getIndicatorColor() // PILL variant color helpers const getPillBgColor = (): string => { const semanticMap: Record = { ACCENT: 'bg-blue-500', POSITIVE: 'bg-green-700', NEGATIVE: 'bg-red-700', SECONDARY: 'bg-gray-700', STANDARD: 'bg-gray-900', } if (semanticMap[color]) return semanticMap[color] if (isPaletteColor(color)) return paletteColorMap[color].bg return '' // hex — handled via inline style } const getPillTextColor = (): string => { const semanticMap: Record = { ACCENT: 'text-blue-500', POSITIVE: 'text-green-700', NEGATIVE: 'text-red-700', SECONDARY: 'text-gray-700', STANDARD: 'text-gray-900', } if (semanticMap[color]) return semanticMap[color] if (isPaletteColor(color)) return paletteColorMap[color].text return '' // hex — handled via inline style } const isHexColor = typeof color === 'string' && color.startsWith('#') // Container classes const sailClasses = [marginAboveMap[marginAbove], marginBelowMap[marginBelow]].filter(Boolean).join(' ') const containerClasses = mergeClasses(sailClasses, className) const listClasses = variant === "PILL" ? "flex items-end gap-1" : orientation === "VERTICAL" ? "relative flex flex-col" : "relative flex" const contentClasses = orientation === "VERTICAL" ? "pl-4 flex-1 p-4" : "p-4" const rootClasses = orientation === "VERTICAL" ? "flex" : "block" return (
{tabs.map((tab) => { if (variant === "PILL") { const isActive = internalActive === tab.value return ( {tab.label} {isActive && ( )} ) } return ( {tab.label} ) })} {/* Sliding indicators — UNDERLINE variant only */} {variant === "UNDERLINE" && orientation === "HORIZONTAL" && ( {tabs.map((tab) => ( {tab.content} ))}
) }