579 lines
14 KiB
TypeScript
579 lines
14 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import React, { ReactNode, useEffect, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Dimensions,
|
|
Pressable,
|
|
Modal as RNModal,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
|
|
// Types
|
|
export interface ModalProps {
|
|
/** Whether the modal dialog is visible or not */
|
|
open?: boolean;
|
|
/** The modal dialog's title */
|
|
title?: ReactNode;
|
|
/** Whether a close (x) button is visible on top right or not */
|
|
closable?: boolean;
|
|
/** Custom close icon */
|
|
closeIcon?: ReactNode;
|
|
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
|
|
maskClosable?: boolean;
|
|
/** Centered Modal */
|
|
centered?: boolean;
|
|
/** Width of the modal dialog */
|
|
width?: number | string;
|
|
/** Whether to apply loading visual effect for OK button or not */
|
|
confirmLoading?: boolean;
|
|
/** Text of the OK button */
|
|
okText?: string;
|
|
/** Text of the Cancel button */
|
|
cancelText?: string;
|
|
/** Button type of the OK button */
|
|
okType?: "primary" | "default" | "dashed" | "text" | "link";
|
|
/** Footer content, set as footer={null} when you don't need default buttons */
|
|
footer?: ReactNode | null;
|
|
/** Whether show mask or not */
|
|
mask?: boolean;
|
|
/** The z-index of the Modal */
|
|
zIndex?: number;
|
|
/** Specify a function that will be called when a user clicks the OK button */
|
|
onOk?: (e?: any) => void | Promise<void>;
|
|
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
|
|
onCancel?: (e?: any) => void;
|
|
/** Callback when the animation ends when Modal is turned on and off */
|
|
afterOpenChange?: (open: boolean) => void;
|
|
/** Specify a function that will be called when modal is closed completely */
|
|
afterClose?: () => void;
|
|
/** Custom className */
|
|
className?: string;
|
|
/** Modal body content */
|
|
children?: ReactNode;
|
|
/** Whether to unmount child components on close */
|
|
destroyOnClose?: boolean;
|
|
/** The ok button props */
|
|
okButtonProps?: any;
|
|
/** The cancel button props */
|
|
cancelButtonProps?: any;
|
|
/** Whether support press esc to close */
|
|
keyboard?: boolean;
|
|
}
|
|
|
|
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
|
|
/** Type of the confirm modal */
|
|
type?: "info" | "success" | "error" | "warning" | "confirm";
|
|
/** Content */
|
|
content?: ReactNode;
|
|
/** Custom icon */
|
|
icon?: ReactNode;
|
|
}
|
|
|
|
// Modal Component
|
|
const Modal: React.FC<ModalProps> & {
|
|
info: (props: ConfirmModalProps) => ModalInstance;
|
|
success: (props: ConfirmModalProps) => ModalInstance;
|
|
error: (props: ConfirmModalProps) => ModalInstance;
|
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
|
useModal: () => [
|
|
{
|
|
info: (props: ConfirmModalProps) => ModalInstance;
|
|
success: (props: ConfirmModalProps) => ModalInstance;
|
|
error: (props: ConfirmModalProps) => ModalInstance;
|
|
warning: (props: ConfirmModalProps) => ModalInstance;
|
|
confirm: (props: ConfirmModalProps) => ModalInstance;
|
|
},
|
|
ReactNode
|
|
];
|
|
} = ({
|
|
open = false,
|
|
title,
|
|
closable = true,
|
|
closeIcon,
|
|
maskClosable = true,
|
|
centered = false,
|
|
width = 520,
|
|
confirmLoading = false,
|
|
okText = "OK",
|
|
cancelText = "Cancel",
|
|
okType = "primary",
|
|
footer,
|
|
mask = true,
|
|
zIndex = 1000,
|
|
onOk,
|
|
onCancel,
|
|
afterOpenChange,
|
|
afterClose,
|
|
className,
|
|
children,
|
|
destroyOnClose = false,
|
|
okButtonProps,
|
|
cancelButtonProps,
|
|
keyboard = true,
|
|
}) => {
|
|
const [visible, setVisible] = useState(open);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setVisible(open);
|
|
if (afterOpenChange) {
|
|
afterOpenChange(open);
|
|
}
|
|
}, [open, afterOpenChange]);
|
|
|
|
const handleOk = async () => {
|
|
if (onOk) {
|
|
setLoading(true);
|
|
try {
|
|
await onOk();
|
|
// Không tự động đóng modal - để parent component quyết định
|
|
} catch (error) {
|
|
console.error("Modal onOk error:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
} else {
|
|
setVisible(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (onCancel) {
|
|
onCancel();
|
|
} else {
|
|
// Nếu không có onCancel, tự động đóng modal
|
|
setVisible(false);
|
|
}
|
|
};
|
|
|
|
const handleMaskPress = () => {
|
|
if (maskClosable) {
|
|
handleCancel();
|
|
}
|
|
};
|
|
|
|
const handleRequestClose = () => {
|
|
if (keyboard) {
|
|
handleCancel();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!visible && afterClose) {
|
|
const timer = setTimeout(() => {
|
|
afterClose();
|
|
}, 300); // Wait for animation to complete
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [visible, afterClose]);
|
|
|
|
const renderFooter = () => {
|
|
if (footer === null) {
|
|
return null;
|
|
}
|
|
|
|
if (footer !== undefined) {
|
|
return <View style={styles.footer}>{footer}</View>;
|
|
}
|
|
|
|
return (
|
|
<View style={styles.footer}>
|
|
<TouchableOpacity
|
|
style={[styles.button, styles.cancelButton]}
|
|
onPress={handleCancel}
|
|
disabled={loading || confirmLoading}
|
|
{...cancelButtonProps}
|
|
>
|
|
<Text style={styles.cancelButtonText}>{cancelText}</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.button,
|
|
styles.okButton,
|
|
okType === "primary" && styles.primaryButton,
|
|
(loading || confirmLoading) && styles.disabledButton,
|
|
]}
|
|
onPress={handleOk}
|
|
disabled={loading || confirmLoading}
|
|
{...okButtonProps}
|
|
>
|
|
{loading || confirmLoading ? (
|
|
<ActivityIndicator color="#fff" size="small" />
|
|
) : (
|
|
<Text style={styles.okButtonText}>{okText}</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const modalWidth =
|
|
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
|
|
|
|
return (
|
|
<RNModal
|
|
visible={visible}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={handleRequestClose}
|
|
statusBarTranslucent
|
|
>
|
|
<Pressable
|
|
style={[
|
|
styles.overlay,
|
|
centered && styles.centered,
|
|
{ zIndex },
|
|
!mask && styles.noMask,
|
|
]}
|
|
onPress={handleMaskPress}
|
|
>
|
|
<Pressable
|
|
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
|
|
onPress={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
{(title || closable) && (
|
|
<View style={styles.header}>
|
|
{title && (
|
|
<Text style={styles.title} numberOfLines={1}>
|
|
{title}
|
|
</Text>
|
|
)}
|
|
{closable && (
|
|
<TouchableOpacity
|
|
style={styles.closeButton}
|
|
onPress={handleCancel}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
>
|
|
{closeIcon || (
|
|
<Ionicons name="close" size={24} color="#666" />
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Body */}
|
|
<View style={styles.body}>
|
|
{(!destroyOnClose || visible) && children}
|
|
</View>
|
|
|
|
{/* Footer */}
|
|
{renderFooter()}
|
|
</Pressable>
|
|
</Pressable>
|
|
</RNModal>
|
|
);
|
|
};
|
|
|
|
// Confirm Modal Component
|
|
const ConfirmModal: React.FC<
|
|
ConfirmModalProps & { visible: boolean; onClose: () => void }
|
|
> = ({
|
|
visible,
|
|
onClose,
|
|
type = "confirm",
|
|
title,
|
|
content,
|
|
icon,
|
|
okText = "OK",
|
|
cancelText = "Cancel",
|
|
onOk,
|
|
onCancel,
|
|
...restProps
|
|
}) => {
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const getIcon = () => {
|
|
if (icon !== undefined) return icon;
|
|
|
|
const iconProps = { size: 24, style: { marginRight: 12 } };
|
|
switch (type) {
|
|
case "info":
|
|
return (
|
|
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
|
|
);
|
|
case "success":
|
|
return (
|
|
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
|
|
);
|
|
case "error":
|
|
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
|
|
case "warning":
|
|
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
|
|
default:
|
|
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
|
|
}
|
|
};
|
|
|
|
const handleOk = async () => {
|
|
if (onOk) {
|
|
setLoading(true);
|
|
try {
|
|
await onOk();
|
|
onClose();
|
|
} catch (error) {
|
|
console.error("Confirm modal onOk error:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
} else {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (onCancel) {
|
|
onCancel();
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={visible}
|
|
title={title}
|
|
onOk={handleOk}
|
|
onCancel={type === "confirm" ? handleCancel : undefined}
|
|
okText={okText}
|
|
cancelText={cancelText}
|
|
confirmLoading={loading}
|
|
footer={
|
|
type === "confirm" ? undefined : (
|
|
<View style={styles.footer}>
|
|
<TouchableOpacity
|
|
style={[styles.button, styles.okButton, styles.primaryButton]}
|
|
onPress={handleOk}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color="#fff" size="small" />
|
|
) : (
|
|
<Text style={styles.okButtonText}>{okText}</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
)
|
|
}
|
|
{...restProps}
|
|
>
|
|
<View style={styles.confirmContent}>
|
|
{getIcon()}
|
|
<Text style={styles.confirmText}>{content}</Text>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
// Modal Instance
|
|
export interface ModalInstance {
|
|
destroy: () => void;
|
|
update: (config: ConfirmModalProps) => void;
|
|
}
|
|
|
|
// Container for imperatively created modals - Not used in React Native
|
|
// Static methods will return instance but won't render imperatively
|
|
// Use Modal.useModal() hook for proper context support
|
|
|
|
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
|
|
console.warn(
|
|
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
|
|
);
|
|
|
|
return {
|
|
destroy: () => {
|
|
console.warn(
|
|
"Modal.destroy() called but static modals are not supported in React Native"
|
|
);
|
|
},
|
|
update: (newConfig: ConfirmModalProps) => {
|
|
console.warn(
|
|
"Modal.update() called but static modals are not supported in React Native"
|
|
);
|
|
},
|
|
};
|
|
};
|
|
|
|
// Static methods
|
|
Modal.info = (props: ConfirmModalProps) =>
|
|
createConfirmModal({ ...props, type: "info" });
|
|
Modal.success = (props: ConfirmModalProps) =>
|
|
createConfirmModal({ ...props, type: "success" });
|
|
Modal.error = (props: ConfirmModalProps) =>
|
|
createConfirmModal({ ...props, type: "error" });
|
|
Modal.warning = (props: ConfirmModalProps) =>
|
|
createConfirmModal({ ...props, type: "warning" });
|
|
Modal.confirm = (props: ConfirmModalProps) =>
|
|
createConfirmModal({ ...props, type: "confirm" });
|
|
|
|
// useModal hook
|
|
Modal.useModal = () => {
|
|
const [modals, setModals] = useState<ReactNode[]>([]);
|
|
|
|
const createModal = (
|
|
config: ConfirmModalProps,
|
|
type: ConfirmModalProps["type"]
|
|
) => {
|
|
const id = `modal-${Date.now()}-${Math.random()}`;
|
|
|
|
const destroy = () => {
|
|
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
|
|
};
|
|
|
|
const update = (newConfig: ConfirmModalProps) => {
|
|
setModals((prev) =>
|
|
prev.map((modal: any) =>
|
|
modal.key === id ? (
|
|
<ConfirmModal
|
|
key={id}
|
|
visible={true}
|
|
onClose={destroy}
|
|
{...config}
|
|
{...newConfig}
|
|
type={type}
|
|
/>
|
|
) : (
|
|
modal
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
const modalElement = (
|
|
<ConfirmModal
|
|
key={id}
|
|
visible={true}
|
|
onClose={destroy}
|
|
{...config}
|
|
type={type}
|
|
/>
|
|
);
|
|
|
|
setModals((prev) => [...prev, modalElement]);
|
|
|
|
return { destroy, update };
|
|
};
|
|
|
|
const modalMethods = {
|
|
info: (props: ConfirmModalProps) => createModal(props, "info"),
|
|
success: (props: ConfirmModalProps) => createModal(props, "success"),
|
|
error: (props: ConfirmModalProps) => createModal(props, "error"),
|
|
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
|
|
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
|
|
};
|
|
|
|
const contextHolder = <>{modals}</>;
|
|
|
|
return [modalMethods, contextHolder];
|
|
};
|
|
|
|
// Styles
|
|
const styles = StyleSheet.create({
|
|
overlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
|
justifyContent: "flex-start",
|
|
paddingTop: 100,
|
|
alignItems: "center",
|
|
},
|
|
centered: {
|
|
justifyContent: "center",
|
|
paddingTop: 0,
|
|
},
|
|
noMask: {
|
|
backgroundColor: "transparent",
|
|
},
|
|
modal: {
|
|
backgroundColor: "#fff",
|
|
borderRadius: 8,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.15,
|
|
shadowRadius: 12,
|
|
elevation: 8,
|
|
maxHeight: "90%",
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: 24,
|
|
paddingTop: 20,
|
|
paddingBottom: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#f0f0f0",
|
|
},
|
|
title: {
|
|
fontSize: 18,
|
|
fontWeight: "600",
|
|
color: "#000",
|
|
flex: 1,
|
|
},
|
|
closeButton: {
|
|
padding: 4,
|
|
marginLeft: 12,
|
|
},
|
|
body: {
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 20,
|
|
},
|
|
footer: {
|
|
flexDirection: "row",
|
|
justifyContent: "flex-end",
|
|
alignItems: "center",
|
|
paddingHorizontal: 24,
|
|
paddingBottom: 20,
|
|
paddingTop: 12,
|
|
gap: 8,
|
|
},
|
|
button: {
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 6,
|
|
minWidth: 70,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: 36,
|
|
},
|
|
cancelButton: {
|
|
backgroundColor: "#fff",
|
|
borderWidth: 1,
|
|
borderColor: "#d9d9d9",
|
|
},
|
|
cancelButtonText: {
|
|
color: "#000",
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
},
|
|
okButton: {
|
|
backgroundColor: "#1890ff",
|
|
},
|
|
primaryButton: {
|
|
backgroundColor: "#1890ff",
|
|
},
|
|
okButtonText: {
|
|
color: "#fff",
|
|
fontSize: 14,
|
|
fontWeight: "500",
|
|
},
|
|
disabledButton: {
|
|
opacity: 0.6,
|
|
},
|
|
confirmContent: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
},
|
|
confirmText: {
|
|
flex: 1,
|
|
fontSize: 14,
|
|
color: "#000",
|
|
lineHeight: 22,
|
|
},
|
|
});
|
|
|
|
export default Modal;
|