178 lines
4.3 KiB
TypeScript
178 lines
4.3 KiB
TypeScript
![]() |
// 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>
|
||
|
);
|
||
|
};
|