import * as RadixRadioGroup from '@radix-ui/react-radio-group'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; import { createRef, memo, useCallback, useEffect, useMemo, useRef, } from 'react'; import { withUnit } from '../../utils/with-unit'; import * as styles from './styles.css'; import type { RadioItem, RadioProps } from './types'; /** * ### Radio button group (Tabs) * A tab-like radio button group * * #### 1. Basic usage with fixed width * ```tsx * * ``` * * #### 2. Dynamic width * ```tsx * * ``` * * #### 3. `ReactNode` as label * ```tsx * const [value, setValue] = useState('ai'); * const items: RadioItem[] = [ * { * value: 'ai', * label: , * style: { width: 28 }, * }, * { * value: 'calendar', * label: , * style: { width: 28 }, * }, * ]; * return ( * * ); * ``` */ export const RadioGroup = memo(function RadioGroup({ items, value, width, style, padding = 2, gap = 4, borderRadius = 10, itemHeight = 28, animationDuration = 250, animationEasing = 'cubic-bezier(.18,.22,0,1)', activeItemClassName, activeItemStyle, iconMode, onChange, }: RadioProps) { const animationTImerRef = useRef | null>(null); const finalItems = useMemo(() => { return items .map(value => typeof value === 'string' ? ({ value } as RadioItem) : value ) .map(item => ({ ...item, ref: createRef(), indicatorRef: createRef(), })); }, [items]); const finalStyle = useMemo( () => ({ width, ...style, ...assignInlineVars({ [styles.outerPadding]: withUnit(padding, 'px'), [styles.outerRadius]: withUnit(borderRadius, 'px'), [styles.itemGap]: withUnit(gap, 'px'), [styles.itemHeight]: withUnit(itemHeight, 'px'), }), }), [width, style, padding, borderRadius, gap, itemHeight] ); const animate = useCallback( (oldValue?: string, newValue?: string) => { if (!oldValue || !newValue) return; const oldItem = finalItems.find(item => item.value === oldValue); const newItem = finalItems.find(item => item.value === newValue); if (!oldItem || !newItem) return; const oldRect = oldItem.ref.current?.getBoundingClientRect(); const newRect = newItem.ref.current?.getBoundingClientRect(); if (!oldRect || !newRect) return; const activeIndicator = newItem.indicatorRef.current; if (!activeIndicator) return; activeIndicator.style.transform = `translate3d(${oldRect.left - newRect.left}px,0,0)`; activeIndicator.style.transition = 'none'; activeIndicator.style.width = `${oldRect.width}px`; const animation = `${withUnit(animationDuration, 'ms')} ${animationEasing}`; if (animationTImerRef.current) clearTimeout(animationTImerRef.current); animationTImerRef.current = setTimeout(() => { animationTImerRef.current = null; activeIndicator.style.transition = `transform ${animation}, width ${animation}`; activeIndicator.style.transform = 'none'; activeIndicator.style.width = ''; }, 50); }, [animationDuration, animationEasing, finalItems] ); // animate on value change // useEffect: in case that value is changed from outside const prevValue = useRef(value); useEffect(() => { const currentValue = value; const previousValue = prevValue.current; if (currentValue !== previousValue) { animate(previousValue, currentValue); prevValue.current = currentValue; } }, [animate, value]); return ( {finalItems.map(({ customRender, ...item }, index) => { const testId = item.testId ? { 'data-testid': item.testId } : {}; const active = item.value === value; const classMap = { [styles.radioButton]: true }; if (activeItemClassName) classMap[activeItemClassName] = active; if (item.className) classMap[item.className] = true; const style = { ...item.style }; if (activeItemStyle && active) Object.assign(style, activeItemStyle); return ( {customRender?.(item, index) ?? item.label ?? item.value} ); })} ); });