import React from 'react'; import { Pressable, View, Text, StyleSheet, StyleProp, ViewStyle, TextStyle, PressableProps, PressableStateCallbackType, Platform, } from 'react-native'; import { useUx4gTheme } from '../../theme/Ux4gThemeContext'; import { UX4GColors } from '../../foundation/colors'; import { Ux4gIconProp } from '../button/Button'; // Dynamically require react-native-svg for crisp vector icons if available let SvgComponent: any = null; let PathComponent: any = null; let CircleComponent: any = null; try { const rns = require('react-native-svg'); SvgComponent = rns.Svg; PathComponent = rns.Path; CircleComponent = rns.Circle; } catch (e) { // react-native-svg not available, fallback to native View/Text } export type Ux4gRadioButtonSize = 'small' | 'medium' | 'large'; export type Ux4gRadioButtonDescriptionVariant = | 'helper' | 'error' | 'warning' | 'success'; /** * Visual status for the radio circle itself (independent of selection). * Drives the ring color so callers can render error / warning / success * states consistently (e.g. validation errors on the parent form). */ export type Ux4gRadioButtonStatus = | 'defaultStatus' | 'error' | 'warning' | 'success'; export interface Ux4gRadioButtonProps extends Omit { /** The value represented by this radio button */ value: T; /** The currently selected group value */ groupValue?: T | null; /** Callback triggered when the radio button or label is pressed (`(value: T) => void`) */ onChanged?: (value: T) => void; /** Primary label text displayed next to the radio circle */ label?: string; /** Secondary description/helper text displayed below the label */ description?: string; /** Size of the radio circle (`small` = 16pt, `medium` = 20pt, `large` = 24pt). Defaults to `medium`. */ size?: Ux4gRadioButtonSize; /** Whether the field is required. Appends a red asterisk (`*`) to the label. Defaults to `false`. */ isRequired?: boolean; /** Trailing icon rendered after the label */ trailingIcon?: Ux4gIconProp; /** Variant styling for the description text and icon (`helper`, `error`, `warning`, `success`). Defaults to `helper`. */ descriptionVariant?: Ux4gRadioButtonDescriptionVariant; /** Visual status for the radio circle (`defaultStatus`, `error`, `warning`, `success`). Defaults to `defaultStatus`. */ status?: Ux4gRadioButtonStatus; /** Optional override for the radio circle color. Takes precedence over `status` and primary color. */ color?: string; /** Custom label text foreground color */ labelColor?: string; /** Style override for the label `Text` */ labelStyle?: StyleProp; /** Style override for the description `Text` */ descriptionStyle?: StyleProp; /** Whether the radio button is interactive. Defaults to `true`. */ enabled?: boolean; /** Style for the outer row/container `Pressable` */ style?: | StyleProp | ((state: PressableStateCallbackType) => StyleProp); } const SIZE_MAP: Record< Ux4gRadioButtonSize, { boxSize: number; labelTypographyKey: 'lM_default' | 'lL_default' | 'lXL_default'; descriptionTypographyKey: 'lS_default' | 'lM_default' | 'lL_default'; } > = { small: { boxSize: 16, labelTypographyKey: 'lM_default', descriptionTypographyKey: 'lS_default', }, medium: { boxSize: 20, labelTypographyKey: 'lL_default', descriptionTypographyKey: 'lM_default', }, large: { boxSize: 24, labelTypographyKey: 'lXL_default', descriptionTypographyKey: 'lL_default', }, }; /** * Helper to append hex opacity ('1A' for 10%, '1F' for 12%, '61' for 38%, 'B3' for 70%) to hex strings. */ const getHexWithAlpha = (baseHex: string, alphaHex: string): string => { if (!baseHex || !baseHex.startsWith('#')) return baseHex; const cleanHex = baseHex.slice(0, 7); return `${cleanHex}${alphaHex}`; }; export function Ux4gRadioButton({ value, groupValue, onChanged, label, description, size = 'medium', isRequired = false, trailingIcon, descriptionVariant = 'helper', status = 'defaultStatus', color, labelColor, labelStyle, descriptionStyle, enabled = true, style, testID, accessibilityLabel, accessibilityRole = 'radio', ...restProps }: Ux4gRadioButtonProps) { const theme = useUx4gTheme(); const sizeCfg = SIZE_MAP[size]; const isSelected = value === groupValue && groupValue !== undefined && groupValue !== null; // 1. Resolve description text color let descriptionColor: string; switch (descriptionVariant) { case 'helper': descriptionColor = getHexWithAlpha(theme.colors.onSurface, 'B3'); // 70% alpha break; case 'error': descriptionColor = theme.colors.error; break; case 'warning': descriptionColor = theme.colors.warning; break; case 'success': descriptionColor = theme.colors.success; break; } // 2. Resolve label & description typography const labelTypography = theme.typography[sizeCfg.labelTypographyKey]; const descriptionTypography = theme.typography[sizeCfg.descriptionTypographyKey]; const effectiveLabelColor = !enabled ? getHexWithAlpha(theme.colors.onSurface, '61') // 38% alpha : labelColor ?? theme.colors.onSurface; // 3. Resolve status color let statusColor: string | null = null; switch (status) { case 'error': statusColor = theme.colors.error; break; case 'warning': statusColor = theme.colors.warning; break; case 'success': statusColor = theme.colors.success; break; case 'defaultStatus': statusColor = null; break; } const unselectedBorderColor = theme.isDark ? UX4GColors.neutral700 : UX4GColors.neutral300; const defaultPrimaryColor = theme.isDark ? UX4GColors.primary300 : theme.colors.primary; const radioColor = !enabled ? getHexWithAlpha(theme.colors.onSurface, '61') // 38% alpha : isSelected ? color ?? statusColor ?? defaultPrimaryColor : unselectedBorderColor; // 4. Ring / Donut thickness and disabled fills const selectedRingThickness = sizeCfg.boxSize * 0.28; const disabledFillColor = getHexWithAlpha(theme.colors.onSurface, '1A'); // 10% alpha const handlePress = () => { if (!enabled || !onChanged) return; onChanged(value); }; const hasLabel = label !== undefined && label.trim().length > 0; const hasDescription = description !== undefined && description.trim().length > 0; // Helper to render trailing icon const renderTrailingIcon = () => { if (!trailingIcon) return null; const iconColor = enabled ? theme.colors.onSurface : getHexWithAlpha(theme.colors.onSurface, '61'); if (typeof trailingIcon === 'function') { return trailingIcon({ color: iconColor, size: sizeCfg.boxSize }); } return trailingIcon; }; // Helper to render description variant icon const renderDescriptionIcon = () => { if (SvgComponent && PathComponent) { if (descriptionVariant === 'error') { return ( {CircleComponent && ( )} ); } if (descriptionVariant === 'warning') { return ( ); } if (descriptionVariant === 'success') { return ( {CircleComponent && ( )} ); } // helper / default info return ( {CircleComponent && ( )} ); } // Fallback if react-native-svg is not available let symbol = 'ⓘ'; if (descriptionVariant === 'error') symbol = '⚠'; if (descriptionVariant === 'warning') symbol = '⚠'; if (descriptionVariant === 'success') symbol = '✓'; return ( {symbol} ); }; const rippleColor = getHexWithAlpha(theme.colors.primary, '1F'); const innerDotSize = Math.max(2, sizeCfg.boxSize - selectedRingThickness * 2); return ( { const customStyle = typeof style === 'function' ? style(state) : style; return [ styles.rowContainer, { alignItems: hasDescription ? 'flex-start' : 'center', opacity: state.pressed && enabled && Platform.OS !== 'android' ? 0.8 : 1, }, customStyle, ]; }} {...restProps} > [ styles.radioAlignmentWrapper, { opacity: state.pressed && enabled && Platform.OS !== 'android' ? 0.8 : 1, }, ]} > {isSelected ? ( ) : ( )} {(hasLabel || hasDescription) && ( {hasLabel && ( {label} {isRequired && ( {' *'} )} {trailingIcon && ( {renderTrailingIcon()} )} )} {hasDescription && ( {renderDescriptionIcon()} {description} )} )} ); } const styles = StyleSheet.create({ rowContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, }, radioAlignmentWrapper: { justifyContent: 'center', alignItems: 'center', }, radioCircle: { justifyContent: 'center', alignItems: 'center', overflow: 'hidden', }, textColumn: { flex: 1, marginLeft: 8, justifyContent: 'center', }, labelRow: { flexDirection: 'row', alignItems: 'center', flexWrap: 'nowrap', }, labelText: { includeFontPadding: false, }, requiredText: { includeFontPadding: false, }, trailingIconWrapper: { marginLeft: 4, justifyContent: 'center', alignItems: 'center', }, descriptionRow: { flexDirection: 'row', alignItems: 'center', }, descriptionIconContainer: { marginRight: 4, justifyContent: 'center', alignItems: 'center', }, descriptionText: { flex: 1, includeFontPadding: false, textAlignVertical: 'center', }, fallbackIconText: { fontSize: 14, includeFontPadding: false, lineHeight: 16, textAlignVertical: 'center', }, });