387 lines
9.7 KiB
TypeScript
387 lines
9.7 KiB
TypeScript
import { ReactNode } from "react"
|
|
import {
|
|
Modal as RNModal,
|
|
ModalProps as RNModalProps,
|
|
Platform,
|
|
Pressable,
|
|
StyleProp,
|
|
TextStyle,
|
|
View,
|
|
ViewStyle,
|
|
} from "react-native"
|
|
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
|
|
|
import { useAppTheme } from "@/theme/context"
|
|
import type { ThemedStyle } from "@/theme/types"
|
|
import { s } from "@/utils/responsive"
|
|
|
|
import { Button, ButtonProps } from "./Button"
|
|
import { Icon } from "./Icon"
|
|
import { Text, TextProps } from "./Text"
|
|
|
|
type Presets = "center" | "bottom" | "fullscreen"
|
|
|
|
export interface ModalProps extends Omit<RNModalProps, "children"> {
|
|
/**
|
|
* Whether the modal is visible.
|
|
*/
|
|
visible: boolean
|
|
/**
|
|
* Callback when the modal is requested to close.
|
|
*/
|
|
onClose: () => void
|
|
/**
|
|
* Modal position preset.
|
|
* - `center`: Centered dialog (default)
|
|
* - `bottom`: Bottom sheet style, slides up from bottom
|
|
* - `fullscreen`: Full screen modal
|
|
* @default "center"
|
|
*/
|
|
preset?: Presets
|
|
/**
|
|
* Title text which is looked up via i18n.
|
|
*/
|
|
titleTx?: TextProps["tx"]
|
|
/**
|
|
* Title text to display if not using `titleTx`.
|
|
*/
|
|
title?: string
|
|
/**
|
|
* Optional title options to pass to i18n.
|
|
*/
|
|
titleTxOptions?: TextProps["txOptions"]
|
|
/**
|
|
* Style overrides for the title text.
|
|
*/
|
|
titleStyle?: StyleProp<TextStyle>
|
|
/**
|
|
* Whether to show the close button in the header.
|
|
* @default true
|
|
*/
|
|
showCloseButton?: boolean
|
|
/**
|
|
* Whether to close the modal when pressing the overlay.
|
|
* @default true
|
|
*/
|
|
closeOnOverlayPress?: boolean
|
|
/**
|
|
* The content to display inside the modal.
|
|
*/
|
|
children?: ReactNode
|
|
/**
|
|
* Style overrides for the overlay.
|
|
*/
|
|
overlayStyle?: StyleProp<ViewStyle>
|
|
/**
|
|
* Style overrides for the content container.
|
|
*/
|
|
contentStyle?: StyleProp<ViewStyle>
|
|
/**
|
|
* Props for the confirm button (right button).
|
|
* If provided, the footer buttons will be shown.
|
|
*/
|
|
confirmButtonProps?: ButtonProps
|
|
/**
|
|
* Props for the cancel button (left button).
|
|
* If provided along with confirmButtonProps, both buttons will be shown.
|
|
*/
|
|
cancelButtonProps?: ButtonProps
|
|
/**
|
|
* Custom footer component.
|
|
* Overrides confirmButtonProps and cancelButtonProps.
|
|
*/
|
|
FooterComponent?: ReactNode
|
|
/**
|
|
* Animation type for the modal.
|
|
* Defaults based on preset: "fade" for center, "slide" for bottom/fullscreen
|
|
*/
|
|
animationType?: "none" | "slide" | "fade"
|
|
/**
|
|
* Maximum width of the modal content (only applies to "center" preset).
|
|
* @default 400
|
|
*/
|
|
maxWidth?: number
|
|
}
|
|
|
|
/**
|
|
* A reusable modal component with overlay, title, close button, and footer actions.
|
|
* Supports theming, internationalization, and multiple position presets.
|
|
* @param {ModalProps} props - The props for the `Modal` component.
|
|
* @returns {JSX.Element} The rendered `Modal` component.
|
|
* @example
|
|
* // Center modal (default)
|
|
* <Modal
|
|
* visible={isVisible}
|
|
* onClose={() => setIsVisible(false)}
|
|
* titleTx="common:confirm"
|
|
* >
|
|
* <Text tx="modal:content" />
|
|
* </Modal>
|
|
*
|
|
* @example
|
|
* // Bottom sheet modal
|
|
* <Modal
|
|
* visible={isVisible}
|
|
* onClose={() => setIsVisible(false)}
|
|
* preset="bottom"
|
|
* titleTx="profile:editProfile"
|
|
* >
|
|
* <TextField ... />
|
|
* </Modal>
|
|
*/
|
|
export function Modal(props: ModalProps) {
|
|
const {
|
|
visible,
|
|
onClose,
|
|
preset = "center",
|
|
titleTx,
|
|
title,
|
|
titleTxOptions,
|
|
titleStyle: $titleStyleOverride,
|
|
showCloseButton = true,
|
|
closeOnOverlayPress = true,
|
|
children,
|
|
overlayStyle: $overlayStyleOverride,
|
|
contentStyle: $contentStyleOverride,
|
|
confirmButtonProps,
|
|
cancelButtonProps,
|
|
FooterComponent,
|
|
animationType,
|
|
maxWidth = s(400),
|
|
...rest
|
|
} = props
|
|
|
|
const { themed, theme } = useAppTheme()
|
|
const insets = useSafeAreaInsets()
|
|
|
|
const hasTitleArea = !!(titleTx || title || showCloseButton)
|
|
const hasFooter = !!(FooterComponent || confirmButtonProps)
|
|
|
|
// Determine animation type based on preset
|
|
const resolvedAnimationType = animationType ?? (preset === "center" ? "fade" : "slide")
|
|
|
|
// Build overlay style based on preset
|
|
const $overlayStyle: StyleProp<ViewStyle> = [
|
|
themed($overlayBase),
|
|
preset === "center" && themed($overlayCenter),
|
|
preset === "bottom" && $overlayBottom,
|
|
preset === "fullscreen" && $overlayFullscreen,
|
|
$overlayStyleOverride,
|
|
]
|
|
|
|
// Build content container style based on preset
|
|
const $contentContainerStyle: StyleProp<ViewStyle> = [
|
|
themed($contentBase),
|
|
preset === "center" && [themed($contentCenter), { maxWidth }],
|
|
preset === "bottom" && [themed($contentBottom), { paddingBottom: insets.bottom || s(24) }],
|
|
preset === "fullscreen" && [
|
|
themed($contentFullscreen),
|
|
{ paddingTop: insets.top, paddingBottom: insets.bottom },
|
|
],
|
|
$contentStyleOverride,
|
|
]
|
|
|
|
const $titleStyle: StyleProp<TextStyle> = [
|
|
themed($titleBase),
|
|
preset === "bottom" && $titleBottom,
|
|
$titleStyleOverride,
|
|
]
|
|
|
|
const renderHeader = () =>
|
|
hasTitleArea ? (
|
|
<View style={preset === "bottom" ? $headerBottom : $header}>
|
|
{(titleTx || title) && (
|
|
<Text
|
|
preset="subheading"
|
|
tx={titleTx}
|
|
text={title}
|
|
txOptions={titleTxOptions}
|
|
style={$titleStyle}
|
|
/>
|
|
)}
|
|
{showCloseButton && (
|
|
<Pressable
|
|
onPress={onClose}
|
|
style={[themed($closeButton), preset === "bottom" && $closeButtonBottom]}
|
|
hitSlop={{ top: s(10), bottom: s(10), left: s(10), right: s(10) }}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Close"
|
|
>
|
|
<Icon icon="x" size={20} color={theme.colors.textDim} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
) : null
|
|
|
|
const renderFooter = () =>
|
|
hasFooter ? (
|
|
<View style={themed($footer)}>
|
|
{FooterComponent ? (
|
|
FooterComponent
|
|
) : (
|
|
<>
|
|
{cancelButtonProps && (
|
|
<Button preset="default" style={themed($footerButton)} {...cancelButtonProps} />
|
|
)}
|
|
{confirmButtonProps && (
|
|
<Button preset="filled" style={themed($footerButton)} {...confirmButtonProps} />
|
|
)}
|
|
</>
|
|
)}
|
|
</View>
|
|
) : null
|
|
|
|
// For bottom preset, wrap content in KeyboardAwareScrollView
|
|
const renderContent = () => {
|
|
if (preset === "bottom") {
|
|
return (
|
|
<KeyboardAwareScrollView
|
|
bottomOffset={Platform.OS === "ios" ? 20 : 0}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={$scrollContent}
|
|
>
|
|
{renderHeader()}
|
|
<View style={themed($body)}>{children}</View>
|
|
{renderFooter()}
|
|
</KeyboardAwareScrollView>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{renderHeader()}
|
|
<View style={themed($body)}>{children}</View>
|
|
{renderFooter()}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<RNModal
|
|
visible={visible}
|
|
transparent
|
|
animationType={resolvedAnimationType}
|
|
onRequestClose={onClose}
|
|
statusBarTranslucent
|
|
{...rest}
|
|
>
|
|
<Pressable style={$overlayStyle} onPress={closeOnOverlayPress ? onClose : undefined}>
|
|
<Pressable style={$contentContainerStyle} onPress={(e) => e.stopPropagation()}>
|
|
{/* Drag Handle for bottom preset */}
|
|
{preset === "bottom" && <View style={themed($dragHandle)} />}
|
|
|
|
{renderContent()}
|
|
</Pressable>
|
|
</Pressable>
|
|
</RNModal>
|
|
)
|
|
}
|
|
|
|
// Base styles
|
|
const $overlayBase: ThemedStyle<ViewStyle> = () => ({
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
})
|
|
|
|
const $contentBase: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
|
backgroundColor: colors.background,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.md,
|
|
})
|
|
|
|
// Center preset styles
|
|
const $overlayCenter: ThemedStyle<ViewStyle> = () => ({
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
padding: s(24),
|
|
})
|
|
|
|
const $contentCenter: ThemedStyle<ViewStyle> = () => ({
|
|
borderRadius: s(12),
|
|
width: "100%",
|
|
overflow: "hidden",
|
|
})
|
|
|
|
// Bottom preset styles
|
|
const $overlayBottom: ViewStyle = {
|
|
justifyContent: "flex-end",
|
|
}
|
|
|
|
const $contentBottom: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
borderTopLeftRadius: s(20),
|
|
borderTopRightRadius: s(20),
|
|
paddingTop: spacing.sm,
|
|
maxHeight: "90%",
|
|
})
|
|
|
|
const $dragHandle: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
|
width: s(36),
|
|
height: s(4),
|
|
borderRadius: s(2),
|
|
backgroundColor: colors.separator,
|
|
alignSelf: "center",
|
|
marginBottom: spacing.sm,
|
|
})
|
|
|
|
const $titleBottom: TextStyle = {
|
|
textAlign: "center",
|
|
}
|
|
|
|
// Fullscreen preset styles
|
|
const $overlayFullscreen: ViewStyle = {
|
|
backgroundColor: "transparent",
|
|
}
|
|
|
|
const $contentFullscreen: ThemedStyle<ViewStyle> = () => ({
|
|
flex: 1,
|
|
borderRadius: 0,
|
|
})
|
|
|
|
// Common styles
|
|
const $header: ViewStyle = {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}
|
|
|
|
const $headerBottom: ViewStyle = {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}
|
|
|
|
const $titleBase: ThemedStyle<TextStyle> = () => ({
|
|
textAlign: "left",
|
|
})
|
|
|
|
const $closeButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
padding: spacing.xxs,
|
|
marginLeft: spacing.sm,
|
|
})
|
|
|
|
const $closeButtonBottom: ViewStyle = {
|
|
position: "absolute",
|
|
right: 0,
|
|
top: 0,
|
|
}
|
|
|
|
const $body: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
paddingVertical: spacing.md,
|
|
})
|
|
|
|
const $footer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
flexDirection: "row",
|
|
justifyContent: "flex-end",
|
|
gap: spacing.sm,
|
|
paddingTop: spacing.sm,
|
|
})
|
|
|
|
const $footerButton: ThemedStyle<ViewStyle> = () => ({
|
|
minWidth: s(80),
|
|
})
|
|
|
|
const $scrollContent: ViewStyle = {
|
|
flexGrow: 1,
|
|
}
|