362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
import { CloseOutlined } from "@ant-design/icons";
|
|
import { Button } from "antd";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useAppSelector } from "redux/hooks";
|
|
import { darkColors, lightColors } from "ThemeConstants";
|
|
|
|
interface ProBottomSheetProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title?: string;
|
|
children: React.ReactNode;
|
|
height?: string | number;
|
|
showHandle?: boolean;
|
|
showCloseButton?: boolean;
|
|
backdrop?: boolean;
|
|
snapPoints?: string[] | number[];
|
|
initialSnap?: number;
|
|
className?: string;
|
|
themeName?: "light" | "dark";
|
|
contentStyle?: React.CSSProperties;
|
|
}
|
|
|
|
export function ProBottomSheet({
|
|
isOpen,
|
|
onClose,
|
|
title,
|
|
children,
|
|
height = 500,
|
|
showHandle = true,
|
|
showCloseButton = true,
|
|
backdrop = true,
|
|
snapPoints = [500],
|
|
initialSnap = 0,
|
|
className = "",
|
|
contentStyle = {},
|
|
}: ProBottomSheetProps) {
|
|
const [currentSnap, setCurrentSnap] = useState(initialSnap);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const startPosRef = useRef(0);
|
|
const sheetRef = useRef<HTMLDivElement>(null);
|
|
const { themeName } = useAppSelector((state) => state.theme);
|
|
|
|
// Handle body scroll locking
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
document.body.style.overflow = "hidden";
|
|
} else {
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// Close mobile keyboard when bottom sheet opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// Blur any active input element to close the mobile keyboard
|
|
const activeElement = document.activeElement as HTMLElement;
|
|
if (
|
|
activeElement &&
|
|
(activeElement.tagName === "INPUT" ||
|
|
activeElement.tagName === "TEXTAREA" ||
|
|
activeElement.isContentEditable)
|
|
) {
|
|
activeElement.blur();
|
|
}
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Event handlers
|
|
const startDrag = useCallback((clientY: number) => {
|
|
setIsDragging(true);
|
|
startPosRef.current = clientY;
|
|
}, []);
|
|
|
|
const handleDrag = useCallback(
|
|
(clientY: number) => {
|
|
if (!isDragging || !sheetRef.current) return;
|
|
|
|
const deltaY = clientY - startPosRef.current;
|
|
sheetRef.current.style.transform = `translateY(${Math.max(0, deltaY)}px)`;
|
|
},
|
|
[isDragging],
|
|
);
|
|
|
|
const endDrag = useCallback(
|
|
(clientY: number) => {
|
|
if (!isDragging) return;
|
|
setIsDragging(false);
|
|
|
|
const deltaY = clientY - startPosRef.current;
|
|
const threshold = 50; // Reduced threshold for better responsiveness
|
|
|
|
if (deltaY > threshold) {
|
|
// Swipe down
|
|
if (currentSnap > 0) {
|
|
setCurrentSnap(currentSnap - 1);
|
|
} else {
|
|
onClose();
|
|
}
|
|
} else if (deltaY < -threshold && currentSnap < snapPoints.length - 1) {
|
|
// Swipe up
|
|
setCurrentSnap(currentSnap + 1);
|
|
}
|
|
|
|
// Reset transform
|
|
if (sheetRef.current) {
|
|
sheetRef.current.style.transform = "translateY(0)";
|
|
}
|
|
},
|
|
[isDragging, currentSnap, snapPoints.length, onClose],
|
|
);
|
|
|
|
// Track if drag started from handle/header area
|
|
const dragStartElementRef = useRef<HTMLElement | null>(null);
|
|
|
|
// Touch event handlers
|
|
const handleTouchStart = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
// Only allow drag if starting from handle or header area
|
|
const target = e.target as HTMLElement;
|
|
const isHandle = target.closest('[style*="cursor: grab"]') !== null;
|
|
const isHeader = target.closest('[style*="borderBottom"]') !== null;
|
|
|
|
if (isHandle || isHeader) {
|
|
dragStartElementRef.current = target;
|
|
startDrag(e.touches[0].clientY);
|
|
}
|
|
},
|
|
[startDrag],
|
|
);
|
|
|
|
const handleTouchMove = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
// Only handle drag if it started from handle/header
|
|
if (!isDragging || !dragStartElementRef.current) return;
|
|
|
|
// Prevent scroll from affecting the bottom sheet
|
|
e.preventDefault();
|
|
handleDrag(e.touches[0].clientY);
|
|
},
|
|
[handleDrag, isDragging],
|
|
);
|
|
|
|
const handleTouchEnd = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
if (dragStartElementRef.current) {
|
|
endDrag(e.changedTouches[0].clientY);
|
|
dragStartElementRef.current = null;
|
|
}
|
|
},
|
|
[endDrag],
|
|
);
|
|
|
|
// Mouse event handlers
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
startDrag(e.clientY);
|
|
},
|
|
[startDrag],
|
|
);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: MouseEvent) => {
|
|
handleDrag(e.clientY);
|
|
},
|
|
[handleDrag],
|
|
);
|
|
|
|
const handleMouseUp = useCallback(
|
|
(e: MouseEvent) => {
|
|
endDrag(e.clientY);
|
|
},
|
|
[endDrag],
|
|
);
|
|
|
|
// Mouse event listeners
|
|
useEffect(() => {
|
|
if (isDragging) {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
|
|
|
// Enhanced styles with better dark themeName support
|
|
const backdropStyle: React.CSSProperties = {
|
|
position: "fixed",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor:
|
|
themeName === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(0, 0, 0, 0.5)",
|
|
backdropFilter: "blur(4px)",
|
|
zIndex: 1000,
|
|
opacity: isOpen ? 1 : 0,
|
|
visibility: isOpen ? "visible" : "hidden",
|
|
transition: "opacity 0.3s ease, visibility 0.3s ease",
|
|
pointerEvents: isOpen ? "auto" : "none",
|
|
};
|
|
|
|
const sheetStyle: React.CSSProperties = {
|
|
position: "fixed",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: snapPoints[currentSnap] || height,
|
|
backgroundColor:
|
|
themeName === "dark" ? darkColors.bgColor : lightColors.bgColor,
|
|
borderTopLeftRadius: 20,
|
|
borderTopRightRadius: 20,
|
|
boxShadow:
|
|
themeName === "dark"
|
|
? "0 -8px 32px rgba(0, 0, 0, 0.6)"
|
|
: "0 -4px 20px rgba(0, 0, 0, 0.15)",
|
|
zIndex: 1001,
|
|
transform: isOpen ? "translateY(0)" : "translateY(100%)",
|
|
transition: isDragging
|
|
? "none"
|
|
: "transform 0.3s cubic-bezier(0.2, 0, 0, 1), height 0.3s ease",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
maxHeight: height,
|
|
border: `1px solid ${themeName === "dark" ? "#363636" : "#e5e7eb"}`,
|
|
borderBottom: "none",
|
|
backdropFilter: themeName === "dark" ? "blur(12px)" : "none",
|
|
width: "100vw",
|
|
};
|
|
|
|
const handleStyle: React.CSSProperties = {
|
|
width: 40,
|
|
height: 4,
|
|
backgroundColor: themeName === "dark" ? "#525252" : "#d1d5db",
|
|
borderRadius: 2,
|
|
margin: "12px auto 8px",
|
|
cursor: "grab",
|
|
transition: "background-color 0.3s ease",
|
|
};
|
|
|
|
const headerStyle: React.CSSProperties = {
|
|
padding: "16px 20px",
|
|
borderBottom: `1px solid ${themeName === "dark" ? "#363636" : "#e5e7eb"}`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
minHeight: 60,
|
|
backdropFilter: themeName === "dark" ? "blur(8px)" : "none",
|
|
};
|
|
|
|
const contentWrapperStyle: React.CSSProperties = {
|
|
flex: 1,
|
|
overflow: "auto",
|
|
padding: "0 20px",
|
|
backgroundColor:
|
|
themeName === "dark" ? darkColors.bgColor : lightColors.bgColor,
|
|
color: themeName === "dark" ? "#ffffff" : "#000000",
|
|
...contentStyle,
|
|
};
|
|
|
|
const closeButtonStyle: React.CSSProperties = {
|
|
color: themeName === "dark" ? "#ffffff" : "#4D5154",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: 8,
|
|
borderRadius: "50%",
|
|
backgroundColor: themeName === "dark" ? "rgba(54, 54, 54, 0.8)" : "#EDEEEE",
|
|
border: `1px solid ${themeName === "dark" ? "#424242" : "transparent"}`,
|
|
transition: "all 0.3s ease",
|
|
cursor: "pointer",
|
|
width: 30,
|
|
height: 30,
|
|
};
|
|
|
|
const titleStyle: React.CSSProperties = {
|
|
fontSize: 18,
|
|
fontWeight: 600,
|
|
color: themeName === "dark" ? "#ffffff" : "#1f2937",
|
|
margin: 0,
|
|
textShadow: themeName === "dark" ? "0 1px 2px rgba(0, 0, 0, 0.3)" : "none",
|
|
transition: "color 0.3s ease",
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{backdrop && <div style={backdropStyle} onClick={onClose} />}
|
|
<div
|
|
ref={sheetRef}
|
|
style={sheetStyle}
|
|
className={className}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{showHandle && (
|
|
<div
|
|
style={handleStyle}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseEnter={() => {
|
|
if (handleStyle) {
|
|
handleStyle.backgroundColor =
|
|
themeName === "dark" ? "#6b6b6b" : "#9ca3af";
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (handleStyle) {
|
|
handleStyle.backgroundColor =
|
|
themeName === "dark" ? "#525252" : "#d1d5db";
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{(title || showCloseButton) && (
|
|
<div style={headerStyle}>
|
|
<h3 style={titleStyle}>{title}</h3>
|
|
{showCloseButton && (
|
|
<Button
|
|
type="text"
|
|
icon={<CloseOutlined />}
|
|
onClick={onClose}
|
|
style={closeButtonStyle}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor =
|
|
themeName === "dark" ? "rgba(66, 66, 66, 0.9)" : "#e5e7eb";
|
|
e.currentTarget.style.transform = "scale(1.05)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor =
|
|
themeName === "dark" ? "rgba(54, 54, 54, 0.8)" : "#EDEEEE";
|
|
e.currentTarget.style.transform = "scale(1)";
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
style={contentWrapperStyle}
|
|
onTouchStart={(e) => {
|
|
// Prevent touch events in content from triggering sheet drag
|
|
e.stopPropagation();
|
|
}}
|
|
onTouchMove={(e) => {
|
|
// Allow normal scrolling in content
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|