import * as React from 'react'; import { RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { GestureResponderEvent, KeyboardAvoidingView, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native'; import FocusControl from './accessibility/FocusControl/FocusControl'; import { msleep, Second } from '@joplin/utils/time'; import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import { ModalState } from './accessibility/FocusControl/types'; import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding'; import { _ } from '@joplin/lib/locale'; export interface ModalElementProps extends ModalProps { children: React.ReactNode; containerStyle?: ViewStyle; backgroundColor?: string; modalBackgroundStyle?: ViewStyle; // Extra styles for the accessibility tools dismiss button. For example, // this might be used to display the dismiss button near the top of the // screen (rather than the bottom). dismissButtonStyle?: ViewStyle; // If scrollOverflow is provided, the modal is wrapped in a vertical // ScrollView. This allows the user to scroll parts of dialogs into // view that would otherwise be clipped by the screen edge. scrollOverflow?: boolean|ScrollViewProps; } const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => { const safeAreaPadding = useSafeAreaPadding(); return useMemo(() => { // On Android, the top-level container seems to need to be absolutely positioned // to prevent it from being larger than the screen size: const absoluteFill = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, } satisfies ViewStyle; return StyleSheet.create({ modalBackground: { ...safeAreaPadding, ...(hasScrollView ? { flexGrow: 1, flexShrink: 1, } : absoluteFill), // When hasScrollView, the modal background is wrapped in a ScrollView. In this case, it's // possible to scroll content outside the background into view. To prevent the edge of the // background from being visible, the background color is applied to the ScrollView container // instead: backgroundColor: hasScrollView ? null : backgroundColor, }, keyboardAvoidingView: { ...absoluteFill, flex: 1, }, modalScrollView: { backgroundColor, flexGrow: 1, flexShrink: 1, }, modalScrollViewContent: { // Make the scroll view's scrolling region at least as tall as its container. // This makes it possible to vertically center the content of scrollable modals. flexGrow: 1, }, dismissButton: { position: 'absolute', bottom: 0, height: 12, width: '100%', zIndex: -1, }, }); }, [hasScrollView, safeAreaPadding, backgroundColor]); }; const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEvent)=> void, backdropRef: RefObject) => { const onShouldBackgroundCaptureTouch = useCallback((event: GestureResponderEvent) => { return event.target === backdropRef.current && event.nativeEvent.touches.length === 1; }, [backdropRef]); const onBackgroundTouchFinished = useCallback((event: GestureResponderEvent) => { if (event.target === backdropRef.current) { onRequestClose?.(event); } }, [onRequestClose, backdropRef]); return { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished }; }; const useModalStatus = (containerComponent: View|null, visible: boolean) => { const contentMounted = !!containerComponent; const [controlsFocus, setControlsFocus] = useState(false); useAsyncEffect(async (event) => { if (contentMounted) { setControlsFocus(true); } else { // Accessibility: Work around Android's default focus-setting behavior. // By default, React Native's Modal on Android sets focus about 0.8 seconds // after the modal is dismissed. As a result, the Modal controls focus until // roughly one second after the modal is dismissed. if (Platform.OS === 'android') { await msleep(Second); } if (!event.cancelled) { setControlsFocus(false); } } }, [contentMounted]); let modalStatus = ModalState.Closed; if (controlsFocus) { modalStatus = visible ? ModalState.Open : ModalState.Closing; } else if (visible) { modalStatus = ModalState.Open; } return modalStatus; }; const ModalElement: React.FC = ({ children, containerStyle, backgroundColor, scrollOverflow, modalBackgroundStyle: extraModalBackgroundStyles, dismissButtonStyle, ...modalProps }) => { const styles = useStyles(!!scrollOverflow, backgroundColor); // contentWrapper adds padding. To allow styling the region outside of the modal // (e.g. to add a background), the content is wrapped twice. const content = ( {children} ); const [containerComponent, setContainerComponent] = useState(null); const modalStatus = useModalStatus(containerComponent, modalProps.visible); const containerRef = useRef(null); containerRef.current = containerComponent; const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, containerRef); // A close button for accessibility tools. Since iOS accessibility focus order is based on the position // of the element on the screen, the close button is placed after the modal content, rather than behind. const closeButton = modalProps.onRequestClose ? : null; const contentAndBackdrop = {content} {closeButton} ; const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {}); return ( {scrollOverflow ? ( {contentAndBackdrop} ) : contentAndBackdrop} ); }; export default ModalElement;