206 lines
4.8 KiB
Vue
206 lines
4.8 KiB
Vue
<script setup lang="ts">
|
||
import type { CSSProperties } from 'vue';
|
||
import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
|
||
import { onClickOutside } from '@vueuse/core';
|
||
|
||
export type PopoverPlacement =
|
||
| 'top'
|
||
| 'top-start'
|
||
| 'top-end'
|
||
| 'bottom'
|
||
| 'bottom-start'
|
||
| 'bottom-end'
|
||
| 'left'
|
||
| 'left-start'
|
||
| 'left-end'
|
||
| 'right'
|
||
| 'right-start'
|
||
| 'right-end';
|
||
|
||
export type Offset = [number, number];
|
||
|
||
export interface PopoverProps {
|
||
placement?: PopoverPlacement;
|
||
offset?: Offset;
|
||
popoverStyle?: CSSProperties;
|
||
popoverClass?: string;
|
||
trigger?: 'hover' | 'click' | 'clickTarget';
|
||
triggerStyle?: CSSProperties;
|
||
triggerClass?: string;
|
||
hoverDelay?: number; // 悬停延迟关闭时间(ms)
|
||
}
|
||
|
||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||
placement: 'bottom',
|
||
offset: () => [0, 0],
|
||
trigger: 'hover',
|
||
hoverDelay: 0, // 默认300ms延迟关闭
|
||
});
|
||
|
||
const emits = defineEmits<{
|
||
(e: 'show'): void;
|
||
(e: 'hide'): void;
|
||
}>();
|
||
|
||
const triggerRef = ref<HTMLElement | null>(null);
|
||
const popoverRef = ref<HTMLElement | null>(null);
|
||
const floatingArrow = ref<HTMLElement | null>(null);
|
||
const showPopover = ref(false);
|
||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||
// 新增:记录鼠标是否在触发元素或内容区域内
|
||
const isHovering = ref(false);
|
||
|
||
const { floatingStyles } = useFloating(triggerRef, popoverRef, {
|
||
placement: props.placement,
|
||
transform: false,
|
||
whileElementsMounted: autoUpdate,
|
||
middleware: [
|
||
shift(),
|
||
flip(),
|
||
arrow({ element: floatingArrow }),
|
||
offset({
|
||
mainAxis: props.offset[0],
|
||
crossAxis: props.offset[1],
|
||
}),
|
||
],
|
||
});
|
||
|
||
function show() {
|
||
if (!showPopover.value) {
|
||
showPopover.value = true;
|
||
emits('show');
|
||
}
|
||
// 显示时强制清除定时器(无论是否在悬停)
|
||
if (hideTimeout)
|
||
clearTimeout(hideTimeout);
|
||
hideTimeout = null;
|
||
}
|
||
|
||
function hide() {
|
||
if (showPopover.value) {
|
||
showPopover.value = false;
|
||
emits('hide');
|
||
}
|
||
hideTimeout = null;
|
||
}
|
||
|
||
defineExpose({ show, hide });
|
||
|
||
watch(showPopover, (newValue) => {
|
||
if (newValue && props.trigger !== 'hover') {
|
||
onClickOutside(popoverRef, () => hide(), {
|
||
ignore: [triggerRef] as any[],
|
||
});
|
||
}
|
||
});
|
||
|
||
// 触发元素鼠标事件调整(同步isHovering状态)
|
||
function handleTriggerMouseEnter() {
|
||
if (props.trigger === 'hover') {
|
||
isHovering.value = true; // 进入触发元素
|
||
show();
|
||
}
|
||
}
|
||
|
||
function handleTriggerMouseLeave() {
|
||
if (props.trigger === 'hover') {
|
||
isHovering.value = false; // 离开触发元素
|
||
// 仅当鼠标不在内容区域时,才设置延迟关闭
|
||
scheduleHideIfNeeded();
|
||
}
|
||
}
|
||
|
||
// 内容区域鼠标事件调整(同步isHovering状态)
|
||
function handlePopoverMouseEnter() {
|
||
if (props.trigger === 'hover') {
|
||
isHovering.value = true; // 进入内容区域
|
||
if (hideTimeout)
|
||
clearTimeout(hideTimeout); // 取消关闭
|
||
}
|
||
}
|
||
|
||
function handlePopoverMouseLeave() {
|
||
if (props.trigger === 'hover') {
|
||
isHovering.value = false; // 离开内容区域
|
||
// 仅当鼠标不在触发元素时,才设置延迟关闭
|
||
scheduleHideIfNeeded();
|
||
}
|
||
}
|
||
|
||
// 新增:统一延迟关闭逻辑(仅当完全离开两个区域时触发)
|
||
function scheduleHideIfNeeded() {
|
||
// 如果鼠标仍在任一区域(isHovering为true),不关闭
|
||
if (isHovering.value)
|
||
return;
|
||
|
||
// 否则设置延迟关闭
|
||
hideTimeout = setTimeout(() => {
|
||
if (!isHovering.value) {
|
||
// 再次确认是否仍离开
|
||
hide();
|
||
}
|
||
}, props.hoverDelay);
|
||
}
|
||
|
||
function handleClick() {
|
||
if (props.trigger === 'click') {
|
||
showPopover.value ? hide() : show();
|
||
}
|
||
else if (props.trigger === 'clickTarget' && !showPopover.value) {
|
||
show();
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="triggerRef"
|
||
class="popover-trigger"
|
||
:class="[props.triggerClass]"
|
||
:style="[props.triggerStyle]"
|
||
@mouseenter="handleTriggerMouseEnter"
|
||
@mouseleave="handleTriggerMouseLeave"
|
||
@click="handleClick"
|
||
>
|
||
<slot name="trigger" />
|
||
</div>
|
||
|
||
<Teleport to="body">
|
||
<Transition name="popover-fade">
|
||
<div
|
||
v-if="showPopover"
|
||
ref="popoverRef"
|
||
:style="[floatingStyles, props.popoverStyle]"
|
||
class="popover-content-box"
|
||
:class="[props.popoverClass]"
|
||
@mouseenter="handlePopoverMouseEnter"
|
||
@mouseleave="handlePopoverMouseLeave"
|
||
>
|
||
<slot />
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.popover-fade-enter-active,
|
||
.popover-fade-leave-active {
|
||
transition:
|
||
opacity 0.2s ease,
|
||
transform 0.2s ease;
|
||
will-change: transform, opacity;
|
||
}
|
||
|
||
.popover-fade-enter-from,
|
||
.popover-fade-leave-to {
|
||
opacity: 0;
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.popover-fade-enter-to,
|
||
.popover-fade-leave-from {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
</style>
|