Files
web-menu-react-version-/src/components/ProBottomSheet/ProBottomSheet.tsx

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>
</>
);
}