collect-system/apps/web/src/components/presentation/popover.tsx

178 lines
4.3 KiB
TypeScript
Raw Normal View History

2025-01-08 00:56:15 +08:00
// components/Popover.tsx
import { motion, AnimatePresence } from "framer-motion";
import React, {
ReactNode,
useState,
cloneElement,
isValidElement,
ReactElement,
} from "react";
type PopoverPosition = "top" | "bottom" | "left" | "right";
type TriggerType = "hover" | "click" | "focus";
interface PopoverProps {
title?: string;
content: ReactNode;
position?: PopoverPosition;
trigger?: TriggerType;
children: ReactNode;
// 可选的延迟时间(毫秒),用于 hover 模式
hoverDelay?: number;
}
const positionStyles = {
top: {
initial: { opacity: 0, y: 10, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
className: "bottom-full left-1/2 -translate-x-1/2 mb-2",
},
bottom: {
initial: { opacity: 0, y: -10, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
className: "top-full left-1/2 -translate-x-1/2 mt-2",
},
left: {
initial: { opacity: 0, x: 10, scale: 0.95 },
animate: { opacity: 1, x: 0, scale: 1 },
className: "right-full top-1/2 -translate-y-1/2 mr-2",
},
right: {
initial: { opacity: 0, x: -10, scale: 0.95 },
animate: { opacity: 1, x: 0, scale: 1 },
className: "left-full top-1/2 -translate-y-1/2 ml-2",
},
};
export const Popover: React.FC<PopoverProps> = ({
title,
content,
position = "right",
trigger = "hover",
children,
hoverDelay = 200,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [hoverTimeout, setHoverTimeout] = useState<number | null>(null);
const handleMouseEnter = () => {
if (trigger === "hover") {
if (hoverTimeout) window.clearTimeout(hoverTimeout);
setIsOpen(true);
}
};
const handleMouseLeave = () => {
if (trigger === "hover") {
// window.setTimeout 返回 number 类型
const timeout = window.setTimeout(
() => setIsOpen(false),
hoverDelay
);
setHoverTimeout(timeout);
}
};
const handleClick = (e: React.MouseEvent) => {
if (trigger === "click") {
setIsOpen(!isOpen);
}
};
const handleFocus = () => {
if (trigger === "focus") {
setIsOpen(true);
}
};
const handleBlur = () => {
if (trigger === "focus") {
setIsOpen(false);
}
};
// 添加类型断言来处理 children 的类型
const childrenWithProps = isValidElement(children)
? cloneElement(children as ReactElement<any>, {
onClick: (e: React.MouseEvent) => {
handleClick(e);
const child = children as ReactElement<{
onClick?: (e: React.MouseEvent) => void;
}>;
if (child.props.onClick) {
child.props.onClick(e);
}
},
onMouseEnter: (e: React.MouseEvent) => {
handleMouseEnter();
const child = children as ReactElement<{
onMouseEnter?: (e: React.MouseEvent) => void;
}>;
if (child.props.onMouseEnter) {
child.props.onMouseEnter(e);
}
},
onMouseLeave: (e: React.MouseEvent) => {
handleMouseLeave();
const child = children as ReactElement<{
onMouseLeave?: (e: React.MouseEvent) => void;
}>;
if (child.props.onMouseLeave) {
child.props.onMouseLeave(e);
}
},
onFocus: (e: React.FocusEvent) => {
handleFocus();
const child = children as ReactElement<{
onFocus?: (e: React.FocusEvent) => void;
}>;
if (child.props.onFocus) {
child.props.onFocus(e);
}
},
onBlur: (e: React.FocusEvent) => {
handleBlur();
const child = children as ReactElement<{
onBlur?: (e: React.FocusEvent) => void;
}>;
if (child.props.onBlur) {
child.props.onBlur(e);
}
},
})
: children;
return (
<div
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
{childrenWithProps}
<AnimatePresence>
{isOpen && (
<motion.div
initial={positionStyles[position].initial}
animate={positionStyles[position].animate}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className={`
absolute z-50 min-w-[200px] bg-white
rounded-lg shadow-lg border border-gray-200
${positionStyles[position].className}
`}>
{title && (
<div className="px-4 py-2 border-b border-gray-100">
<h3 className="font-medium text-gray-900">
{title}
</h3>
</div>
)}
<div className="p-4">{content}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};