import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { View, Text, Pressable, StyleSheet, StyleProp, ViewStyle, TextStyle, Animated, Easing, Platform, Dimensions, TouchableWithoutFeedback, GestureResponderEvent, } from 'react-native'; import { UX4GColors } from '../../foundation/colors'; import { useUx4gTheme } from '../../theme/Ux4gThemeContext'; let Svg: any = null; let Path: any = null; try { const RNSvg = require('react-native-svg'); Svg = RNSvg.Svg || RNSvg.default; Path = RNSvg.Path; } catch (e) { // react-native-svg fallback } // Built-in vector icons for 100% crisp, self-contained rendering export const FabBuiltInIcon: React.FC<{ name: 'plus' | 'close' | 'message' | 'share' | 'call' | string; size?: number; color?: string; }> = ({ name, size = 20, color = UX4GColors.white }) => { if (Svg && Path) { switch (name) { case 'plus': case 'add': return ( ); case 'close': case 'cross': case 'cancel': return ( ); case 'message': case 'chat': case 'mail': return ( ); case 'share': case 'fork': return ( ); case 'call': case 'phone': return ( ); default: break; } } // Text/unicode fallback let symbol = '+'; if (name === 'close' || name === 'cross') symbol = '✕'; else if (name === 'message' || name === 'chat') symbol = '💬'; else if (name === 'share' || name === 'fork') symbol = '🔗'; else if (name === 'call' || name === 'phone') symbol = '📞'; return ( {symbol} ); }; export type Ux4gFabVariant = | 'icon' | 'labelled' | 'menu-joined' | 'menu-spaced' | 'menu-labelled'; export type Ux4gFabColorTheme = 'brand' | 'surface'; export type Ux4gFabPosition = | 'bottom-right' | 'bottom-left' | 'bottom-center' | 'top-right' | 'top-left' | 'none'; export type Ux4gFabSize = 'small' | 'medium' | 'large'; export interface Ux4gFabActionItem { /** Unique key or id for the action */ id: string; /** Text label displayed in 'menu-labelled' variant or tooltip */ label?: string; /** Icon name (e.g. 'plus', 'message', 'share', 'call') or custom React node / render function */ icon?: string | React.ReactNode | ((props: { color: string; size: number }) => React.ReactNode); /** Callback fired when this action item is pressed */ onPress?: () => void; /** Custom background color override for this action item */ backgroundColor?: string; /** Custom content/icon/text color override */ contentColor?: string; /** Disable this action item */ disabled?: boolean; /** Test identifier */ testID?: string; } export interface Ux4gFabProps { /** Layout variant. Options: 'icon', 'labelled', 'menu-joined', 'menu-spaced', 'menu-labelled'. Defaults to 'icon'. */ variant?: Ux4gFabVariant; /** Color theme. Options: 'brand' (purple filled) or 'surface' (white elevated). Defaults to 'brand'. */ colorTheme?: Ux4gFabColorTheme; /** Text label for 'labelled' variant or extended FAB */ label?: string; /** Main icon when closed or idle. Defaults to 'plus'. */ icon?: string | React.ReactNode | ((props: { color: string; size: number }) => React.ReactNode); /** Active / open icon when speed-dial is expanded. Defaults to 'close'. */ activeIcon?: string | React.ReactNode | ((props: { color: string; size: number }) => React.ReactNode); /** Action items list for menu variants ('menu-joined', 'menu-spaced', 'menu-labelled') */ actions?: Ux4gFabActionItem[]; /** Controlled open state for speed-dial menus */ isOpen?: boolean; /** Initial open state when uncontrolled */ defaultOpen?: boolean; /** Callback fired when speed-dial menu opens or closes */ onToggle?: (isOpen: boolean) => void; /** Callback fired on main button press */ onPress?: (event: GestureResponderEvent) => void; /** Screen position preset. Defaults to 'none' (inline flow). */ position?: Ux4gFabPosition; /** Custom position offset overrides when positioned */ offset?: { bottom?: number; top?: number; right?: number; left?: number; }; /** * Whether this FAB is positioned above a Bottom Navigation Bar (`Ux4gBottomNavigationBar`). * When true, automatically offsets the FAB's bottom position (defaulting to 72dp nav height + 16dp spacing = 88dp) * so it floats sequentially above the bottom navigation bar without overlapping any tabs. * @default false */ aboveBottomNav?: boolean; /** * Alias or custom height override for the bottom navigation bar. * Can accept `true` (uses default 72dp bottom nav height) or an explicit number in pixels (e.g. 64 or 80). */ bottomNavOffset?: boolean | number; /** * Alignment of speed-dial menu items relative to the FAB button. * If omitted, automatically aligns to `'end'` (right-aligned) for `'menu-labelled'` and right-positioned FABs, * `'start'` for left-positioned FABs, or `'center'`. */ alignment?: 'start' | 'center' | 'end' | 'left' | 'right'; /** * Whether to automatically detect and respect safe area insets (e.g. system navigation bar) * @default true */ useSafeArea?: boolean; /** Whether to show a semi-transparent backdrop scrim when menu is open. Defaults to false. */ showBackdrop?: boolean; /** Callback when backdrop scrim is pressed */ onBackdropPress?: () => void; /** Whether the FAB is disabled */ disabled?: boolean; /** Sizing preset. Defaults to 'medium'. */ size?: Ux4gFabSize; /** Custom style for the main FAB button */ style?: StyleProp; /** Custom style for the overall container wrapper */ containerStyle?: StyleProp; /** Custom text style for labels */ labelStyle?: StyleProp; /** Custom background color override */ backgroundColor?: string; /** Custom content (icon/text) color override */ contentColor?: string; /** Custom elevation shadow level */ elevation?: number; /** Enable smooth open/close scale and fade animation. Defaults to true. */ animated?: boolean; /** Accessibility label */ accessibilityLabel?: string; /** Test identifier */ testID?: string; } const DEFAULT_ACTIONS: Ux4gFabActionItem[] = [ { id: 'add', label: 'Add', icon: 'plus' }, { id: 'message', label: 'Message', icon: 'message' }, { id: 'share', label: 'Share', icon: 'share' }, { id: 'call', label: 'Call', icon: 'call' }, ]; export const Ux4gFab: React.FC = ({ variant = 'icon', colorTheme = 'brand', label = 'Button', icon = 'plus', activeIcon = 'close', actions = DEFAULT_ACTIONS, isOpen: controlledIsOpen, defaultOpen = false, onToggle, onPress, position = 'none', alignment, offset, aboveBottomNav = false, bottomNavOffset, useSafeArea = true, showBackdrop = false, onBackdropPress, disabled = false, size = 'medium', style, containerStyle, labelStyle, backgroundColor, contentColor, elevation = 4, animated = true, accessibilityLabel, testID, }) => { const { colors } = useUx4gTheme(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = controlledIsOpen !== undefined; const isMenuOpen = isControlled ? controlledIsOpen : internalOpen; const isMenuVariant = variant === 'menu-joined' || variant === 'menu-spaced' || variant === 'menu-labelled'; const isFloating = position !== 'none'; const isRightAligned = alignment === 'end' || alignment === 'right' || (alignment === undefined && (position === 'bottom-right' || position === 'top-right' || (position === 'none' && variant === 'menu-labelled'))); const isLeftAligned = alignment === 'start' || alignment === 'left' || (alignment === undefined && (position === 'bottom-left' || position === 'top-left')); // Animation values const animValue = useRef(new Animated.Value(isMenuOpen ? 1 : 0)).current; useEffect(() => { if (animated) { Animated.timing(animValue, { toValue: isMenuOpen ? 1 : 0, duration: 220, easing: Easing.bezier(0.2, 0, 0, 1), useNativeDriver: true, }).start(); } else { animValue.setValue(isMenuOpen ? 1 : 0); } }, [isMenuOpen, animated, animValue]); const handleToggle = useCallback(() => { if (disabled) return; const nextState = !isMenuOpen; if (!isControlled) { setInternalOpen(nextState); } onToggle?.(nextState); }, [disabled, isMenuOpen, isControlled, onToggle]); const handleMainPress = useCallback( (e: GestureResponderEvent) => { if (disabled) return; if (isMenuVariant) { handleToggle(); } onPress?.(e); }, [disabled, isMenuVariant, handleToggle, onPress] ); const handleBackdropPress = useCallback(() => { if (onBackdropPress) { onBackdropPress(); } else { if (!isControlled) setInternalOpen(false); onToggle?.(false); } }, [onBackdropPress, isControlled, onToggle]); // Color calculation based on theme and overrides const isBrand = colorTheme === 'brand'; const brandPrimary = colors?.primary || UX4GColors.primary; const surfaceWhite = UX4GColors.white; // Main trigger colors const mainBgColor = backgroundColor || (isBrand ? brandPrimary : surfaceWhite); const mainFgColor = contentColor || (isBrand ? UX4GColors.white : brandPrimary); // Sizing let mainButtonWidth = 56; let mainButtonHeight = 56; let iconSize = 24; let fontSize = 15; if (size === 'small') { mainButtonWidth = 44; mainButtonHeight = 44; iconSize = 20; fontSize = 13; } else if (size === 'large') { mainButtonWidth = 64; mainButtonHeight = 64; iconSize = 28; fontSize = 16; } // Render helper for icons const renderIcon = ( iconProp: any, defaultColor: string, currentIconSize: number ) => { if (!iconProp) return null; if (typeof iconProp === 'string') { return ; } if (typeof iconProp === 'function') { return iconProp({ color: defaultColor, size: currentIconSize }); } return iconProp; }; // Main trigger icon: if menu is open, show activeIcon (e.g. 'close'), else normal icon const currentIconProp = isMenuVariant && isMenuOpen ? activeIcon : icon; // Standard UX4G dimensions for sequential bottom nav stacking const DEFAULT_BOTTOM_OFFSET = 24; const DEFAULT_BOTTOM_NAV_HEIGHT = 80; // Ux4gBottomNavigationBar standard height (58 content + 8 top + 12 bottom = 78dp) const DEFAULT_BOTTOM_NAV_GAP = 16; // Clean spacing between Bottom Nav and FAB const isAboveBottomNav = aboveBottomNav || bottomNavOffset === true || typeof bottomNavOffset === 'number'; const resolvedBottomNavHeight = typeof bottomNavOffset === 'number' ? bottomNavOffset : DEFAULT_BOTTOM_NAV_HEIGHT; const resolvedBottomOffset = useMemo(() => { const userOffset = offset?.bottom; if (isAboveBottomNav) { // FAB is sequentially positioned above the Bottom Navigation Bar const gap = userOffset ?? DEFAULT_BOTTOM_NAV_GAP; // On iOS, BottomNavigationBar uses SafeAreaView which adds 34dp on modern iPhones, // so FAB must also add 34dp to float cleanly above the elevated bar. // On Android, BottomNavigationBar sits at the bottom of the window/screen, so no extra inset is needed. const safeInset = useSafeArea ? (Platform.OS === 'ios' ? 34 : 0) : 0; return resolvedBottomNavHeight + gap + safeInset; } // Normal floating FAB (anchored near screen bottom) const baseOffset = userOffset ?? DEFAULT_BOTTOM_OFFSET; if (!useSafeArea) { return baseOffset; } const safeInset = Platform.OS === 'android' ? (() => { const screenH = Dimensions.get('screen').height; const windowH = Dimensions.get('window').height; const navBarDiff = Math.max(0, screenH - windowH); // When Android navigation bar is fixed (navBarDiff > 20), the window is already // bounded strictly above the navigation bar (navBarDiff > 20), so safeInset is 0. // When navigation bar is auto-hidden or gesture-based (navBarDiff <= 20), // apply 16dp to keep the FAB safely above the gesture pill / screen edge. return navBarDiff > 20 ? 0 : 16; })() : Platform.OS === 'ios' ? 34 : 0; return baseOffset + safeInset; }, [offset?.bottom, isAboveBottomNav, resolvedBottomNavHeight, useSafeArea]); // Position styles const getPositionStyle = (): ViewStyle => { if (!isFloating) return {}; const pos: ViewStyle = { position: 'absolute', zIndex: 9999 }; const defaultSideOffset = 24; switch (position) { case 'bottom-right': pos.bottom = resolvedBottomOffset; pos.right = offset?.right ?? defaultSideOffset; break; case 'bottom-left': pos.bottom = resolvedBottomOffset; pos.left = offset?.left ?? defaultSideOffset; break; case 'bottom-center': pos.bottom = resolvedBottomOffset; pos.alignSelf = 'center'; break; case 'top-right': pos.top = offset?.top ?? defaultSideOffset; pos.right = offset?.right ?? defaultSideOffset; break; case 'top-left': pos.top = offset?.top ?? defaultSideOffset; pos.left = offset?.left ?? defaultSideOffset; break; } return pos; }; // Render Joined Menu Capsule const renderMenuJoined = () => { if (!actions || actions.length === 0) return null; return ( {actions.map((action, index) => { const actionIconColor = action.contentColor || (isBrand ? UX4GColors.neutral800 : UX4GColors.neutral800); return ( { action.onPress?.(); if (!isControlled) setInternalOpen(false); onToggle?.(false); }} style={({ pressed }) => [ styles.menuJoinedItem, pressed && styles.itemPressed, action.disabled && styles.disabled, ]} accessibilityRole="button" accessibilityLabel={action.label} > {renderIcon(action.icon, actionIconColor, 20)} ); })} ); }; // Render Spaced Menu Circles const renderMenuSpaced = () => { if (!actions || actions.length === 0) return null; return ( {actions.map((action, index) => { const itemBg = action.backgroundColor || surfaceWhite; const itemFg = action.contentColor || (isBrand ? UX4GColors.neutral800 : brandPrimary); return ( { action.onPress?.(); if (!isControlled) setInternalOpen(false); onToggle?.(false); }} style={({ pressed }) => [ styles.menuSpacedCircle, { backgroundColor: itemBg }, pressed && styles.itemPressed, action.disabled && styles.disabled, ]} accessibilityRole="button" accessibilityLabel={action.label} > {renderIcon(action.icon, itemFg, 20)} ); })} ); }; // Render Labelled Menu Pills const renderMenuLabelled = () => { if (!actions || actions.length === 0) return null; return ( {actions.map((action, index) => { const itemBg = action.backgroundColor || surfaceWhite; const iconColor = action.contentColor || (isBrand ? brandPrimary : UX4GColors.neutral900); const textColor = action.contentColor || (isBrand ? brandPrimary : UX4GColors.neutral900); return ( { action.onPress?.(); if (!isControlled) setInternalOpen(false); onToggle?.(false); }} style={({ pressed }) => [ styles.menuLabelledPill, { backgroundColor: itemBg }, isRightAligned ? { alignSelf: 'flex-end' } : isLeftAligned ? { alignSelf: 'flex-start' } : { alignSelf: 'center' }, pressed && styles.itemPressed, action.disabled && styles.disabled, ]} accessibilityRole="button" accessibilityLabel={action.label} > {renderIcon(action.icon, iconColor, 18)} {action.label ? ( {action.label} ) : null} ); })} ); }; const isLabelledVariant = variant === 'labelled'; return ( <> {showBackdrop && isMenuVariant && isMenuOpen && ( )} {/* Menu Speed-Dial Content Above Trigger */} {isMenuVariant && ( {variant === 'menu-joined' && renderMenuJoined()} {variant === 'menu-spaced' && renderMenuSpaced()} {variant === 'menu-labelled' && renderMenuLabelled()} )} {/* Main Trigger Button */} [ isLabelledVariant ? styles.mainPillButton : styles.mainCircleButton, { backgroundColor: mainBgColor, shadowColor: isBrand ? brandPrimary : '#000', elevation: elevation, }, !isBrand && styles.surfaceBorder, isLabelledVariant ? { height: mainButtonHeight * 0.86, borderRadius: (mainButtonHeight * 0.86) / 2, } : { width: mainButtonWidth, height: mainButtonHeight, borderRadius: mainButtonWidth / 2, }, pressed && styles.mainPressed, disabled && styles.disabled, style, ]} > {renderIcon(currentIconProp, mainFgColor, iconSize)} {isLabelledVariant && label ? ( {label} ) : null} ); }; const styles = StyleSheet.create({ rootContainer: { alignItems: 'center', justifyContent: 'center', }, menuWrapper: { justifyContent: 'flex-end', marginBottom: 12, }, mainCircleButton: { alignItems: 'center', justifyContent: 'center', ...Platform.select({ ios: { shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.22, shadowRadius: 8, }, android: { elevation: 6, }, web: { boxShadow: '0px 4px 14px rgba(74, 43, 194, 0.25)', cursor: 'pointer', } as any, }), }, mainPillButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 22, gap: 8, ...Platform.select({ ios: { shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.22, shadowRadius: 8, }, android: { elevation: 6, }, web: { boxShadow: '0px 4px 14px rgba(74, 43, 194, 0.25)', cursor: 'pointer', } as any, }), }, surfaceBorder: { borderWidth: 1, borderColor: '#E2E8F0', ...Platform.select({ ios: { shadowColor: '#000000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 6, }, android: { elevation: 4, }, web: { boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.08)', } as any, }), }, mainLabelText: { fontWeight: '600', textAlign: 'center', includeFontPadding: false, }, mainPressed: { transform: [{ scale: 0.95 }], opacity: 0.9, }, itemPressed: { transform: [{ scale: 0.92 }], opacity: 0.8, }, disabled: { opacity: 0.45, }, // Menu Joined Capsule menuJoinedCapsule: { borderRadius: 24, paddingVertical: 6, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#E2E8F0', ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.12, shadowRadius: 8, }, android: { elevation: 5, }, web: { boxShadow: '0px 4px 16px rgba(0, 0, 0, 0.1)', } as any, }), }, menuJoinedItem: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', marginVertical: 2, ...Platform.select({ web: { cursor: 'pointer', } as any, }), }, // Menu Spaced Circles menuSpacedContainer: { alignItems: 'center', gap: 12, }, menuSpacedCircle: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#E2E8F0', ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 6, }, android: { elevation: 4, }, web: { boxShadow: '0px 3px 10px rgba(0, 0, 0, 0.08)', cursor: 'pointer', } as any, }), }, // Menu Labelled Pills menuLabelledContainer: { alignItems: 'center', gap: 10, width: 'auto', }, menuLabelledPill: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 9, paddingHorizontal: 18, borderRadius: 999, gap: 8, borderWidth: 1, borderColor: '#E2E8F0', backgroundColor: '#FFFFFF', flexShrink: 0, ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 6, }, android: { elevation: 3, }, web: { boxShadow: '0px 3px 10px rgba(0, 0, 0, 0.07)', cursor: 'pointer', whiteSpace: 'nowrap', } as any, }), }, menuLabelledText: { fontSize: 14, fontWeight: '500', includeFontPadding: false, flexShrink: 0, ...Platform.select({ web: { whiteSpace: 'nowrap', } as any, }), }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.35)', zIndex: 9998, }, });