mirror of https://github.com/laurent22/joplin.git
192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
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<View>) => {
|
|
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<ModalElementProps> = ({
|
|
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 = (
|
|
<View style={containerStyle}>
|
|
{children}
|
|
</View>
|
|
);
|
|
|
|
|
|
const [containerComponent, setContainerComponent] = useState<View|null>(null);
|
|
const modalStatus = useModalStatus(containerComponent, modalProps.visible);
|
|
|
|
const containerRef = useRef<View|null>(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 ? <Pressable
|
|
style={[styles.dismissButton, dismissButtonStyle]}
|
|
onPress={modalProps.onRequestClose}
|
|
accessibilityLabel={_('Close dialog')}
|
|
accessibilityRole='button'
|
|
/> : null;
|
|
|
|
const contentAndBackdrop = <View
|
|
ref={setContainerComponent}
|
|
style={[styles.modalBackground, extraModalBackgroundStyles]}
|
|
onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
|
|
onResponderRelease={onBackgroundTouchFinished}
|
|
>
|
|
{content}
|
|
{closeButton}
|
|
</View>;
|
|
|
|
const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {});
|
|
return (
|
|
<FocusControl.ModalWrapper state={modalStatus}>
|
|
<Modal
|
|
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
|
|
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
|
|
{...modalProps}
|
|
>
|
|
{scrollOverflow ? (
|
|
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}>
|
|
<ScrollView
|
|
{...extraScrollViewProps}
|
|
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
|
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
|
>{contentAndBackdrop}</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
) : contentAndBackdrop}
|
|
</Modal>
|
|
</FocusControl.ModalWrapper>
|
|
);
|
|
};
|
|
|
|
export default ModalElement;
|