import type { ReactNode } from "react"; import React, { createContext, useContext, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { AccessibilityInfo, Platform, View, findNodeHandle, useWindowDimensions, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView, } from "@gorhom/bottom-sheet"; import type { BottomSheetBackdropProps, BottomSheetModal as BottomSheetModalType, BottomSheetScrollViewMethods, } from "@gorhom/bottom-sheet"; import { FullWindowOverlay } from "react-native-screens"; import { BottomSheetKeyboardAwareScrollView } from "./BottomSheetKeyboardAwareScrollView"; import type { ContentOverlayProps, ModalBackgroundColor } from "./types"; import { useStyles } from "./ContentOverlay.style"; import { useBottomSheetModalBackHandler } from "./hooks/useBottomSheetModalBackHandler"; import { computeContentOverlayBehavior } from "./computeContentOverlayBehavior"; import { KEYBOARD_TOP_PADDING_AUTO_SCROLL } from "./constants"; import { useIsScreenReaderEnabled } from "../hooks"; import { IconButton } from "../IconButton"; import { Heading } from "../Heading"; import { useAtlantisI18n } from "../hooks/useAtlantisI18n"; import { AtlantisThemeContextProvider, useAtlantisTheme, } from "../AtlantisThemeContext"; /** * Signals whether keyboard handling inside a ContentOverlay is delegated to * a keyboard-aware scroll view (e.g. BottomSheetKeyboardAwareScrollView). * * When `true`, InputText skips registering with the bottom-sheet's internal * keyboard state so that only the scroll view manages keyboard offset — * preventing double-counted spacing. */ const ContentOverlayKeyboardContext = createContext(false); export function useIsKeyboardHandledByScrollView() { return useContext(ContentOverlayKeyboardContext); } const LARGE_SCREEN_BREAKPOINT = 640; function getModalBackgroundColor( variation: ModalBackgroundColor, tokens: ReturnType["tokens"], ) { switch (variation) { case "surface": return tokens["color-surface"]; case "background": return tokens["color-surface--background"]; } } // eslint-disable-next-line max-statements export function ContentOverlay({ children, title, accessibilityLabel, fullScreen = false, showDismiss = false, isDraggable = true, adjustToContentHeight = false, keyboardShouldPersistTaps = false, scrollEnabled = false, modalBackgroundColor = "surface", onClose, onOpen, onBeforeExit, allowDragWithBeforeExit = false, enablePanDownToClose, enableContentPanningGesture, snapPoints: customSnapPoints, loading = false, ref, }: ContentOverlayProps) { const insets = useSafeAreaInsets(); const { width: windowWidth } = useWindowDimensions(); const bottomSheetModalRef = useRef(null); const previousIndexRef = useRef(-1); const [currentPosition, setCurrentPosition] = useState(-1); const styles = useStyles(); const { t } = useAtlantisI18n(); const { effectiveTheme, tokens } = useAtlantisTheme(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); const behavior = computeContentOverlayBehavior( { fullScreen, adjustToContentHeight, isDraggable, hasOnBeforeExit: onBeforeExit !== undefined, allowDragWithBeforeExit, showDismiss, }, { isScreenReaderEnabled, position: currentPosition, }, ); const effectiveIsDraggable = behavior.isDraggable; const shouldShowDismiss = behavior.showDismiss; const isCloseableOnOverlayTap = onBeforeExit === undefined; // Prevent the Overlay from being flush with the top of the screen, even if we are "100%" or "fullscreen" const topInset = insets.top || tokens["space-larger"]; const [showHeaderShadow, setShowHeaderShadow] = useState(false); const overlayHeader = useRef(null); const scrollViewRef = useRef< BottomSheetScrollViewMethods & { scrollTop?: number } >(null); // enableDynamicSizing will add another snap point of the content height const snapPoints = useMemo(() => { if (customSnapPoints && customSnapPoints.length > 0) { return customSnapPoints; } // There is a bug with "restore" behavior after keyboard is dismissed. // https://github.com/gorhom/react-native-bottom-sheet/issues/2465 // providing a 100% snap point "fixes" it for now, but there is an approved PR to fix it // that just needs to be merged and released: https://github.com/gorhom/react-native-bottom-sheet/pull/2511 return ["100%"]; }, [customSnapPoints]); const onCloseController = () => { if (!onBeforeExit) { bottomSheetModalRef.current?.dismiss(); } else { onBeforeExit(); } }; const { handleSheetPositionChange } = useBottomSheetModalBackHandler(onCloseController); useImperativeHandle( ref, () => ({ open: () => { bottomSheetModalRef.current?.present(); }, close: () => { bottomSheetModalRef.current?.dismiss(); }, }), [], ); const handleChange = (index: number, position: number) => { const previousIndex = previousIndexRef.current; setCurrentPosition(position); handleSheetPositionChange(index); if (previousIndex === -1 && index >= 0) { // Transitioned from closed to open onOpen?.(); // Set accessibility focus on header when opened if (overlayHeader.current) { const reactTag = findNodeHandle(overlayHeader.current); if (reactTag) { AccessibilityInfo.setAccessibilityFocus(reactTag); } } } previousIndexRef.current = index; }; const handleOnScroll = () => { const scrollTop = scrollViewRef.current?.scrollTop || 0; setShowHeaderShadow(scrollTop > 0); }; const sheetStyle = useMemo( () => windowWidth > LARGE_SCREEN_BREAKPOINT ? { width: LARGE_SCREEN_BREAKPOINT, marginLeft: (windowWidth - LARGE_SCREEN_BREAKPOINT) / 2, } : undefined, [windowWidth], ); const backgroundStyle = [ styles.background, { backgroundColor: getModalBackgroundColor(modalBackgroundColor, tokens) }, ]; const handleIndicatorStyles = [ styles.handle, !effectiveIsDraggable && { opacity: 0, }, ]; const renderHeader = () => { const closeOverlayA11YLabel = t("ContentOverlay.close", { title: title || "", }); const headerStyles = [ styles.header, { // Background color is necessary for scrollable modals as the content flows behind the header. backgroundColor: getModalBackgroundColor(modalBackgroundColor, tokens), }, ]; const headerShadowStyles = [ showHeaderShadow && styles.headerShadow, { backgroundColor: getModalBackgroundColor(modalBackgroundColor, tokens), }, ]; return title || shouldShowDismiss ? ( {title} {shouldShowDismiss && ( onCloseController()} accessibilityLabel={closeOverlayA11YLabel} testID="ATL-Overlay-CloseButton" /> )} ) : null; }; const backdropComponent = useMemo( () => function ContentOverlayBackdrop(props: BottomSheetBackdropProps) { return ( ); }, [isCloseableOnOverlayTap], ); return ( onClose?.()} > {/* BottomSheetModal renders its children through a portal mounted outside this component's subtree, so the AtlantisThemeContext is lost. Re-apply the active theme here so descendants (e.g. the header title) resolve the correct themed tokens. */} {scrollEnabled ? ( {renderHeader()} {children} ) : ( {renderHeader()} {children} )} ); } function Backdrop( bottomSheetBackdropProps: BottomSheetBackdropProps & { readonly pressBehavior: "none" | "close"; }, ) { const styles = useStyles(); const { pressBehavior, ...props } = bottomSheetBackdropProps; return ( ); } function Container({ children }: { readonly children?: ReactNode }) { return {children}; }