template_0205

This commit is contained in:
Sofio
2026-02-05 13:16:05 +08:00
commit d93e4d9c9f
197 changed files with 52810 additions and 0 deletions

View File

@@ -0,0 +1,386 @@
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,
}