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,
},
});