import React, { useEffect, useRef, useState, forwardRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { getWebProps } from 'react-native-unistyles/web'; import { DialogProps } from './types'; import { dialogStyles } from './Dialog.styles'; import Icon from '../Icon'; import useMergeRefs from '../hooks/useMergeRefs'; import { getWebInteractiveAriaProps, generateAccessibilityId } from '../utils/accessibility'; import { flattenStyle } from '../utils/flattenStyle'; import { usePortalContainer } from '../internal/portalContainer'; /** * Modal overlay dialog for focused user interactions and confirmations. * Handles focus trapping, escape key, and backdrop click dismissal. */ const Dialog = forwardRef(({ open, onClose, title, children, size = 'md', type = 'default', showCloseButton = true, closeOnBackdropClick = true, closeOnEscapeKey = true, height, contentPadding = 24, style, testID, id, BackdropComponent, // Accessibility props accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityDescribedBy, }, ref) => { const dialogRef = useRef(null); const previousActiveElementRef = useRef(null); const mouseDownTargetRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [shouldRender, setShouldRender] = useState(false); // Use theme-aware portal container so dialog inherits the correct // color-scheme and theme class in dark mode. const portalContainer = usePortalContainer(); // Generate unique IDs for accessibility const dialogId = useMemo(() => id || generateAccessibilityId('dialog'), [id]); const titleId = useMemo(() => `${dialogId}-title`, [dialogId]); // Generate ARIA props const ariaProps = useMemo(() => { return getWebInteractiveAriaProps({ accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole: accessibilityRole ?? 'dialog', accessibilityDescribedBy, }); }, [accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityDescribedBy]); // Handle mounting/unmounting with animation useEffect(() => { if (open && !shouldRender) { // Opening sequence setShouldRender(true); // Use double requestAnimationFrame to ensure the DOM has fully rendered requestAnimationFrame(() => { requestAnimationFrame(() => { setIsVisible(true); }); }); } else if (!open && shouldRender) { // Closing sequence setIsVisible(false); // Wait for transition to complete before unmounting const timer = setTimeout(() => { setShouldRender(false); }, 150); // Match transition duration return () => clearTimeout(timer); } }, [open, shouldRender]); // Handle escape key useEffect(() => { if (!open || !closeOnEscapeKey) return; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [open, closeOnEscapeKey, onClose]); // Handle focus management useEffect(() => { if (open) { // Store the currently focused element previousActiveElementRef.current = document.activeElement as HTMLElement; // Focus the dialog setTimeout(() => { dialogRef.current?.focus(); }, 0); } else { // Restore focus to the previously focused element if (previousActiveElementRef.current) { previousActiveElementRef.current.focus(); } } }, [open]); // Prevent body scroll when dialog is open useEffect(() => { if (open) { const originalStyle = window.getComputedStyle(document.body).overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalStyle; }; } }, [open]); if (!shouldRender) return null; const handleBackdropMouseDown = (event: React.MouseEvent) => { mouseDownTargetRef.current = event.target; }; const handleBackdropClick = (event: React.MouseEvent) => { if (closeOnBackdropClick && event.target === event.currentTarget && mouseDownTargetRef.current === event.currentTarget) { onClose(); } }; const handleCloseClick = () => { onClose(); }; // Apply variants dialogStyles.useVariants({ size, type, }); const backdropProps = getWebProps([ (dialogStyles.backdrop as any)({}), { opacity: isVisible ? 1 : 0 } ]); const containerProps = getWebProps([ (dialogStyles.container as any)({}), flattenStyle(style), height !== undefined ? { height, display: 'flex', flexDirection: 'column' } : null, isVisible ? { opacity: 1, transform: 'scale(1) translateY(0px)' } : { opacity: 0, transform: 'scale(0.96) translateY(-4px)' } ].filter(Boolean)); const headerProps = getWebProps([(dialogStyles.header as any)({})]); const titleProps = getWebProps([(dialogStyles.title as any)({})]); const closeButtonProps = getWebProps([(dialogStyles.closeButton as any)({})]); const contentProps = getWebProps([ (dialogStyles.content as any)({}), height !== undefined ? { flex: 1, overflow: 'auto' } : null, contentPadding > 0 ? { padding: contentPadding } : null, ].filter(Boolean)); const mergedBackdropRef = useMergeRefs(ref, backdropProps.ref); // Styles for the custom backdrop wrapper (no default backdrop styling) const customBackdropWrapperStyle: React.CSSProperties = { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, }; // Styles for the custom backdrop component container (fills the entire backdrop area) const customBackdropStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }; const dialogContent = BackdropComponent ? (
) : (
); return createPortal(dialogContent, portalContainer); }); Dialog.displayName = 'Dialog'; export default Dialog;