import { useEffect, forwardRef, useMemo } from 'react'; import { Modal, View, Text, TouchableOpacity, TouchableWithoutFeedback, BackHandler, Platform, Keyboard, useWindowDimensions } from 'react-native'; import Animated, { useSharedValue, useDerivedValue, useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated'; import { useSafeAreaInsets } from '@idealyst/theme'; import { DialogProps } from './types'; import { dialogStyles } from './Dialog.styles'; import { getNativeInteractiveAccessibilityProps } from '../utils/accessibility'; const Dialog = forwardRef(({ open, onClose, title, children, size = 'md', type = 'default', showCloseButton = true, closeOnBackdropClick = true, animationType: _animationType = 'fade', avoidKeyboard = false, padding: paddingProp = 20, height, maxContentHeight, contentPadding = 24, contentStyle, style, testID, id, BackdropComponent, // Accessibility props accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, }, ref) => { // Generate native accessibility props const nativeA11yProps = useMemo(() => { return getNativeInteractiveAccessibilityProps({ accessibilityLabel: accessibilityLabel ?? title, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole: accessibilityRole ?? 'none', }); }, [accessibilityLabel, title, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole]); const backdropOpacity = useSharedValue(0); const containerScale = useSharedValue(0.9); const containerOpacity = useSharedValue(0); // Get safe area insets const insets = useSafeAreaInsets(); // Animated keyboard height for smooth keyboard avoidance const keyboardHeight = useSharedValue(0); const { height: screenHeight } = useWindowDimensions(); useEffect(() => { if (!avoidKeyboard || !open) { // Animate back to 0 when dialog closes or avoidKeyboard is disabled keyboardHeight.value = withTiming(0, { duration: 250 }); return; } const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const showSub = Keyboard.addListener(showEvent, (e) => { const duration = Platform.OS === 'ios' ? e.duration : 250; keyboardHeight.value = withTiming(e.endCoordinates.height, { duration }); }); const hideSub = Keyboard.addListener(hideEvent, (e) => { const duration = Platform.OS === 'ios' ? (e?.duration ?? 250) : 250; keyboardHeight.value = withTiming(0, { duration }); }); return () => { showSub.remove(); hideSub.remove(); // Animate back to 0 on cleanup so we don't get stuck at a stale value keyboardHeight.value = withTiming(0, { duration: 250 }); }; }, [avoidKeyboard, open]); // Animate in/out when open changes useEffect(() => { if (open) { backdropOpacity.value = withTiming(1, { duration: 200, easing: Easing.out(Easing.ease), }); containerScale.value = withTiming(1, { duration: 250, easing: Easing.out(Easing.cubic), }); containerOpacity.value = withTiming(1, { duration: 200, easing: Easing.out(Easing.ease), }); } else { backdropOpacity.value = withTiming(0, { duration: 150, easing: Easing.in(Easing.ease), }); containerScale.value = withTiming(0.9, { duration: 150, easing: Easing.in(Easing.cubic), }); containerOpacity.value = withTiming(0, { duration: 150, easing: Easing.in(Easing.ease), }); } }, [open]); // Handle Android back button useEffect(() => { if (!open) return; const handleBackPress = () => { onClose(); return true; // Prevent default back behavior }; const backHandler = BackHandler.addEventListener('hardwareBackPress', handleBackPress); return () => backHandler.remove(); }, [open, onClose]); const handleBackdropPress = () => { if (closeOnBackdropClick) { onClose(); } }; const handleClosePress = () => { onClose(); }; // Apply variants dialogStyles.useVariants({ size, type, }); const backdropAnimatedStyle = useAnimatedStyle(() => { return { opacity: backdropOpacity.value, }; }); const containerAnimatedStyle = useAnimatedStyle(() => { 'worklet'; return { opacity: containerOpacity.value, transform: [ { scale: containerScale.value }, ] as { scale: number }[], }; }); // Get dynamic styles - call as functions for theme reactivity const backdropStyle = (dialogStyles.backdrop as any)({}); const containerStyle = (dialogStyles.container as any)({}); const headerStyle = (dialogStyles.header as any)({}); const titleStyle = (dialogStyles.title as any)({}); const closeButtonStyle = (dialogStyles.closeButton as any)({}); const closeButtonTextStyle = (dialogStyles.closeButtonText as any)({}); // Style for custom backdrop component container (fills entire backdrop area) const customBackdropContainerStyle = { position: 'absolute' as const, top: 0, left: 0, right: 0, bottom: 0, }; // Derived bottom offset — animates smoothly with keyboard const topOffset = insets.top + paddingProp; const bottomOffset = useDerivedValue(() => { 'worklet'; return keyboardHeight.value > 0 ? keyboardHeight.value + paddingProp : insets.bottom + paddingProp; }); // Animated style for the positioning wrapper const positioningStyle = useAnimatedStyle(() => { 'worklet'; return { bottom: bottomOffset.value, }; }); // Resolve explicit height (number or percentage string) const resolvedHeight = typeof height === 'string' ? height.endsWith('%') ? (parseFloat(height) / 100) * screenHeight // approximate; animated version below handles it : parseFloat(height) : height; // Only apply flex: 1 to content when the dialog has a definite height to flex against. // Without a definite height, flex: 1 collapses content instead of wrapping naturally. const hasDefiniteHeight = Boolean(resolvedHeight || maxContentHeight); // Static container styles (not dependent on keyboard) const staticContainerStyle = { ...containerStyle, flex: undefined, }; // Animated maxHeight/height that responds to keyboard changes const dialogSizeStyle = useAnimatedStyle(() => { 'worklet'; const currentBottom = bottomOffset.value; const maxAvailable = screenHeight - topOffset - currentBottom; const effectiveMax = maxContentHeight ? Math.min(maxContentHeight, maxAvailable) : maxAvailable; const result: { maxHeight: number; height?: number } = { maxHeight: effectiveMax, }; if (resolvedHeight) { result.height = Math.min(resolvedHeight, effectiveMax); } else if (maxContentHeight) { result.height = effectiveMax; } return result; }); const dialogContainer = ( {(title || showCloseButton) && ( {title && ( {title} )} {showCloseButton && ( × )} )} 0 ? { padding: contentPadding } : undefined, contentStyle]}> {children} ); return ( onClose()} statusBarTranslucent supportedOrientations={['portrait', 'landscape']} testID={testID} > {/* Backdrop layer - positioned absolute, full screen */} {BackdropComponent ? ( ) : ( )} {/* Dialog content - positioned absolute, accounts for keyboard and safe areas */} {dialogContainer} ); }); Dialog.displayName = 'Dialog'; export default Dialog;