import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import { useMountEffect } from '../../../hooks/use-mount-effect'; import { getOutletDepth, getTopOutletSetter, pushOutlet, subscribeOutletStack } from './overlay-portal-store'; const IS_ANDROID = Platform.OS === 'android'; const FILL = StyleSheet.create({ layer: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, // The outlet must draw above every overlay panel it portals into. BottomSheet's // panel carries Android `elevation: 24` — the only hard elevation in the overlay // set — so the outlet clears it to keep injected content (e.g. a portaled // button) from slipping below the sheet. No-op on iOS, where `zIndex`/document // order already puts the last sibling on top. ...(IS_ANDROID ? { elevation: 25 } : null), }, }); // Prop type lives above all exports so `useExportsLast` is not triggered. type OverlayPortalProps = { children: ReactNode }; // ─── Outlet ─────────────────────────────────────────────────────────────────── /** * The touch-transparent layer placed inside every overlay's `Modal` window. * * Overlays mount this automatically as their last child — app code should not * render it directly. The layer sits above the overlay panel (it is the last * sibling inside the window) and outside the overlay's `role="dialog"` / * `accessibilityViewIsModal` node so it does not interfere with the focus trap. * * Accessibility note: content injected here is outside the modal's a11y * containment, so it will not be announced as part of the dialog. For * announcement-critical UI additionally call * `AccessibilityInfo.announceForAccessibility` from the injecting component. */ export function OverlayOutlet() { const [content, setContent] = useState(null); // Register the setter while this View is mounted; the cleanup returned by // pushOutlet clears content and pops the entry so a transitioning // OverlayPortal never leaves a stale node in a closing window. useMountEffect(() => pushOutlet(setContent)); if (content === null) return null; return ( {content} ); } // ─── Portal ─────────────────────────────────────────────────────────────────── /** * Renders `children` into the nearest open overlay's outlet, or **in place** * when no overlay is open. * * Mount it once at the root and forget about it — it re-targets automatically * as overlays open and close, including nested overlays (topmost outlet wins). * * ```tsx * // At app root — works on both platforms, no branching needed on Android. * function ToasterWrapper({ children }: { children: ReactNode }) { * return Platform.OS === 'ios' * ? {children} * : {children}; * } * ``` * * **State preservation:** moving between outlets causes React to remount the * subject (the outlet Views live at different positions in the tree). For a * toaster whose state lives in an external store this is harmless. Components * with internal state (a `