import type { ReactNode } from 'react'; import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { Platform, View } from 'react-native'; import { SafeAreaInsetsContext, type EdgeInsets } from 'react-native-safe-area-context'; import { FullWindowOverlay } from '@cdx-ui/primitives'; import type { ToastInsets } from './types'; const ZERO_INSETS: EdgeInsets = { top: 0, bottom: 0, left: 0, right: 0 }; interface InsetsContainerProps { /** * When true, uses a regular View instead of FullWindowOverlay on iOS. * Enables element inspector but toasts won't appear above native modals. * @default false */ disableFullWindowOverlay: boolean; /** * Controls whether VoiceOver treats the overlay window as a modal container. * When `false`, VoiceOver can still access elements behind the overlay. * When `true`, VoiceOver is restricted to elements inside the overlay. * @default false * @platform ios * @unstable This prop maps directly to the native `accessibilityViewIsModal` * on the container view and may change in a future react-native-screens release. */ unstable_accessibilityContainerViewIsModal?: boolean; /** * Optional inset values for all edges * If not provided, defaults to platform-specific values: * - iOS: safe area insets + 0px (top), + 6px (bottom), + 12px (left/right) * - Android: safe area insets + 12px (all edges) */ insets?: ToastInsets; /** * Accessible label for the toast notification region on web. * @default 'Notifications' * @platform web */ announceLabel?: string; /** * Custom wrapper function to wrap the toast content * Receives children and should return a component that wraps them * The wrapper should apply flex: 1 (via className or style) to ensure proper layout * Can be any component wrapper - KeyboardAvoidingView, View, or any custom component */ contentWrapper?: (children: ReactNode) => React.ReactElement; /** * Pause all auto-dismiss timers (WCAG 2.2.1). */ pauseAll: () => void; /** * Resume all auto-dismiss timers. */ resumeAll: () => void; /** * Hide the front-most toast. Used by the Escape key handler on web. */ hide: () => void; /** * Children to render inside the container */ children: ReactNode; } /** * Container component that applies inset padding to position toasts * away from screen edges and safe areas * * Combines custom insets with safe area insets: * - If custom inset is provided, it overrides safe area + default padding * - If not provided, uses platform-specific defaults: * - iOS: safe area inset + 0px for top, + 6px for bottom, + 12px for left/right * - Android: safe area inset + 12px for all edges * * On web, wires pointer/focus/visibility listeners to pause/resume timers * per WCAG 2.2.1 (keyboard/pointer/hidden-tab pausing). */ export function InsetsContainer({ insets, announceLabel = 'Notifications', contentWrapper, children, disableFullWindowOverlay, unstable_accessibilityContainerViewIsModal, pauseAll, resumeAll, hide, }: Readonly) { const safeAreaInsets = useContext(SafeAreaInsetsContext) ?? ZERO_INSETS; const finalInsets = useMemo(() => { return { top: insets?.top ?? safeAreaInsets.top + (Platform.OS === 'ios' ? 0 : 12), bottom: insets?.bottom ?? safeAreaInsets.bottom + (Platform.OS === 'ios' ? 6 : 12), left: insets?.left ?? safeAreaInsets.left + 12, right: insets?.right ?? safeAreaInsets.right + 12, }; }, [safeAreaInsets, insets]); // Track pause sources independently so overlapping pointer + focus // don't accidentally resume while the other source is still active. const pauseSourcesRef = useRef>(new Set()); const pauseAllRef = useRef(pauseAll); const resumeAllRef = useRef(resumeAll); pauseAllRef.current = pauseAll; resumeAllRef.current = resumeAll; const addPauseSource = useCallback((source: string) => { const wasEmpty = pauseSourcesRef.current.size === 0; pauseSourcesRef.current.add(source); if (wasEmpty) pauseAllRef.current(); }, []); const removePauseSource = useCallback((source: string) => { pauseSourcesRef.current.delete(source); if (pauseSourcesRef.current.size === 0) resumeAllRef.current(); }, []); // Ref for the container so we can attach native DOM listeners on web. const containerRef = useRef(null); const hideRef = useRef(hide); hideRef.current = hide; // Web-only: attach pointer, focus, visibility, and keyboard listeners // via native DOM APIs to avoid RNW making the container focusable // (RNW adds tabIndex when React onFocus/onBlur handlers are present). useEffect(() => { if (Platform.OS !== 'web') return; const node = containerRef.current as unknown as HTMLElement | null; const sources = pauseSourcesRef.current; const handleVisibilityChange = () => { if (document.hidden) { addPauseSource('visibility'); } else { removePauseSource('visibility'); } }; // The container is `absolute inset-0` (full viewport). Using // pointerenter/pointerleave here would pause as soon as the cursor // is anywhere on the page — far too aggressive. Instead use // pointerover/pointerout (which bubble) and gate on whether the // pointer is actually over a toast element (identified by the // `data-state` attribute emitted on every toast wrapper). const isInsideToast = (el: EventTarget | null): boolean => { if (!el || !(el instanceof HTMLElement)) return false; return !!el.closest('[data-state]'); }; const handlePointerOver = (e: PointerEvent) => { if (isInsideToast(e.target)) addPauseSource('pointer'); }; const handlePointerOut = (e: PointerEvent) => { if (!isInsideToast(e.relatedTarget)) removePauseSource('pointer'); }; const handleFocusIn = (e: FocusEvent) => { if (isInsideToast(e.target)) addPauseSource('focus'); }; const handleFocusOut = (e: FocusEvent) => { if (!isInsideToast(e.relatedTarget)) removePauseSource('focus'); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); hideRef.current(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); if (node && typeof node.addEventListener === 'function') { node.addEventListener('pointerover', handlePointerOver); node.addEventListener('pointerout', handlePointerOut); node.addEventListener('focusin', handleFocusIn); node.addEventListener('focusout', handleFocusOut); node.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); if (node && typeof node.removeEventListener === 'function') { node.removeEventListener('pointerover', handlePointerOver); node.removeEventListener('pointerout', handlePointerOut); node.removeEventListener('focusin', handleFocusIn); node.removeEventListener('focusout', handleFocusOut); node.removeEventListener('keydown', handleKeyDown); } if (sources.size > 0) { sources.clear(); resumeAllRef.current(); } }; }, [addPauseSource, removePauseSource]); const content = ( {contentWrapper ? contentWrapper(children) : children} ); if (Platform.OS !== 'ios') { return content; } return ( {content} ); }