test(component): ✅ 测试修改popover
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<!-- 深度思考按钮 -->
|
||||
<script setup lang="ts">
|
||||
import { useChatStore } from '@/stores/modules/chat';
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const isDeepThinking = computed(() => chatStore.isDeepThinking);
|
||||
|
||||
// 切换是否深度思考
|
||||
function setIsDeepThinking() {
|
||||
chatStore.setDeepThinking(!chatStore.isDeepThinking);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'is-select': isDeepThinking }"
|
||||
class="deep-thinking-btn flex items-center p-10px rounded-10px rounded-15px cursor-pointer font-size-12px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
|
||||
@click="setIsDeepThinking"
|
||||
>
|
||||
<el-icon>
|
||||
<ElementPlus />
|
||||
</el-icon>
|
||||
<span>深度思考</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.deep-thinking-btn.is-select {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
border: 1px solid var(--el-color-primary, #409eff);
|
||||
border-radius: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@ function handleClick(item: GetSessionListVO) {
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
position="top-start"
|
||||
:offset="[-14, 14]"
|
||||
:offset="[-14, 10]"
|
||||
:trigger-style="{ cursor: 'pointer' }"
|
||||
popover-class="popover-content"
|
||||
:popover-style="popoverStyle"
|
||||
@@ -58,7 +58,7 @@ function handleClick(item: GetSessionListVO) {
|
||||
<!-- 触发元素插槽 -->
|
||||
<template #trigger>
|
||||
<div
|
||||
class="model-switch-box select-none flex items-center gap-4px px-10px py-8px rounded-15px cursor-pointer font-size-12px"
|
||||
class="model-switch-box select-none flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-12px border-[rgba()]"
|
||||
>
|
||||
<div class="model-switch-box-icon">
|
||||
<SvgIcon name="models" size="12" />
|
||||
@@ -70,7 +70,11 @@ function handleClick(item: GetSessionListVO) {
|
||||
</template>
|
||||
|
||||
<div class="popover-content-box">
|
||||
<div v-for="item in popoverList" :key="item.id" class="popover-content-box-items h-full">
|
||||
<div
|
||||
v-for="item in popoverList"
|
||||
:key="item.id"
|
||||
class="popover-content-box-items flex rounded-8px select-none transition-all transition-duration-300 flex items-center gap-8px p-4px hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
|
||||
>
|
||||
<el-tooltip
|
||||
popper-class="rounded-tooltip"
|
||||
effect="dark"
|
||||
@@ -78,6 +82,7 @@ function handleClick(item: GetSessionListVO) {
|
||||
trigger="hover"
|
||||
:offset="10"
|
||||
:show-arrow="false"
|
||||
transition="zoom-fade"
|
||||
>
|
||||
<template #content>
|
||||
<div class="popover-content-box-item-text text-wrap max-w-200px rounded-lg">
|
||||
@@ -85,15 +90,11 @@ function handleClick(item: GetSessionListVO) {
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
class="popover-content-box-item select-none transition-all transition-duration-300 flex items-center h-full gap-8px p-8px pl-10px pr-12px rounded-lg hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
|
||||
class="popover-content-box-item font-size-12px text-overflow w-full line-height-16px"
|
||||
:class="{ 'bg-[rgba(0,0,0,.04)] is-select': item.modelName === currentModelName }"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div
|
||||
class="popover-content-box-item-text font-size-12px text-overflow max-h-120px line-height-snug"
|
||||
>
|
||||
{{ item.modelName }}
|
||||
</div>
|
||||
{{ item.modelName }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -106,11 +107,32 @@ function handleClick(item: GetSessionListVO) {
|
||||
.model-switch-box {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
border: 1px solid var(--el-color-primary, #409eff);
|
||||
border-radius: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.popover-content-box-item.is-select {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.popover-content-box {
|
||||
// background-color: red;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { onClickOutside, useEventListener } from '@vueuse/core';
|
||||
|
||||
type PopoverPosition =
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end';
|
||||
|
||||
type Offset = [number, number];
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
position: 'bottom',
|
||||
offset: () => [8, 8],
|
||||
boundary: 'viewport',
|
||||
closeOnContentClick: false,
|
||||
closeOnTriggerClick: false,
|
||||
triggerStyle: () => ({}),
|
||||
popoverStyle: () => ({}),
|
||||
popoverClass: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'show'): void;
|
||||
(e: 'hide'): void;
|
||||
(e: 'positionChange', pos: PopoverPosition): void;
|
||||
}>();
|
||||
|
||||
const VIEWPORT_PADDING = 16;
|
||||
interface PopoverProps {
|
||||
position?: PopoverPosition;
|
||||
offset?: Offset;
|
||||
triggerStyle?: CSSProperties;
|
||||
popoverStyle?: CSSProperties;
|
||||
popoverClass?: string;
|
||||
boundary?: 'viewport' | HTMLElement;
|
||||
closeOnContentClick?: boolean;
|
||||
closeOnTriggerClick?: boolean;
|
||||
}
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const popoverRef = ref<HTMLElement | null>(null);
|
||||
const showPoperContent = ref(false);
|
||||
const currentPosition = ref<PopoverPosition>(props.position);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let mutationObserver: MutationObserver | null = null;
|
||||
let updatePositionTimeout: number | null = null;
|
||||
|
||||
// 初始化尺寸与内容变化监听
|
||||
function initObservers() {
|
||||
if (!popoverRef.value)
|
||||
return;
|
||||
|
||||
// 监听弹出框尺寸变化
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(updatePosition);
|
||||
});
|
||||
resizeObserver.observe(popoverRef.value);
|
||||
|
||||
// 监听弹出框内容变化(子节点/样式变更)
|
||||
mutationObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(updatePosition);
|
||||
});
|
||||
mutationObserver.observe(popoverRef.value, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
});
|
||||
}
|
||||
|
||||
// 销毁监听
|
||||
function destroyObservers() {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
mutationObserver = null;
|
||||
}
|
||||
|
||||
// 强制更新定位(处理DOM缓存)
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value || !popoverRef.value)
|
||||
return;
|
||||
|
||||
// 强制触发回流获取最新尺寸
|
||||
triggerRef.value.getBoundingClientRect();
|
||||
popoverRef.value.getBoundingClientRect();
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
const popoverRect = popoverRef.value.getBoundingClientRect();
|
||||
const boundaryRect = getBoundaryRect();
|
||||
|
||||
const adjustedPos = adjustPosition(triggerRect, popoverRect, boundaryRect);
|
||||
currentPosition.value = adjustedPos;
|
||||
emits('positionChange', adjustedPos);
|
||||
|
||||
const { top, left, origin } = calculatePosition(triggerRect, popoverRect, adjustedPos);
|
||||
popoverRef.value.style.top = `${top}px`;
|
||||
popoverRef.value.style.left = `${left}px`;
|
||||
popoverRef.value.style.transformOrigin = origin;
|
||||
}
|
||||
|
||||
// 窗口尺寸变化防抖处理
|
||||
useEventListener('resize', () => {
|
||||
if (updatePositionTimeout)
|
||||
clearTimeout(updatePositionTimeout);
|
||||
updatePositionTimeout = setTimeout(updatePosition, 50);
|
||||
});
|
||||
|
||||
// 显示/隐藏状态监听
|
||||
watch(
|
||||
showPoperContent,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
nextTick(() => {
|
||||
initObservers();
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
else {
|
||||
destroyObservers();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 边界计算(保持不变)
|
||||
function getBoundaryRect(): DOMRect {
|
||||
if (props.boundary === 'viewport') {
|
||||
return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
}
|
||||
return (props.boundary as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
// 定位计算(保持不变)
|
||||
function calculatePosition(triggerRect: DOMRect, popoverRect: DOMRect, position: PopoverPosition) {
|
||||
const [offsetX, offsetY] = props.offset!;
|
||||
const { width: tW, height: tH } = triggerRect;
|
||||
const { width: pW, height: pH } = popoverRect;
|
||||
|
||||
const positionMap: Record<PopoverPosition, { top: number; left: number; origin: string }> = {
|
||||
'top': {
|
||||
top: triggerRect.top - pH - offsetY,
|
||||
left: triggerRect.left + tW / 2 - pW / 2 + offsetX,
|
||||
origin: 'bottom center',
|
||||
},
|
||||
'top-start': {
|
||||
top: triggerRect.top - pH - offsetY,
|
||||
left: triggerRect.left + offsetX,
|
||||
origin: 'bottom left',
|
||||
},
|
||||
'top-end': {
|
||||
top: triggerRect.top - pH - offsetY,
|
||||
left: triggerRect.left + tW - pW + offsetX,
|
||||
origin: 'bottom right',
|
||||
},
|
||||
'bottom': {
|
||||
top: triggerRect.bottom + offsetY,
|
||||
left: triggerRect.left + tW / 2 - pW / 2 + offsetX,
|
||||
origin: 'top center',
|
||||
},
|
||||
'bottom-start': {
|
||||
top: triggerRect.bottom + offsetY,
|
||||
left: triggerRect.left + offsetX,
|
||||
origin: 'top left',
|
||||
},
|
||||
'bottom-end': {
|
||||
top: triggerRect.bottom + offsetY,
|
||||
left: triggerRect.left + tW - pW + offsetX,
|
||||
origin: 'top right',
|
||||
},
|
||||
'left': {
|
||||
top: triggerRect.top + tH / 2 - pH / 2 + offsetY,
|
||||
left: triggerRect.left - pW - offsetX,
|
||||
origin: 'right center',
|
||||
},
|
||||
'left-start': {
|
||||
top: triggerRect.top + offsetY,
|
||||
left: triggerRect.left - pW - offsetX,
|
||||
origin: 'right top',
|
||||
},
|
||||
'left-end': {
|
||||
top: triggerRect.top + tH - pH + offsetY,
|
||||
left: triggerRect.left - pW - offsetX,
|
||||
origin: 'right bottom',
|
||||
},
|
||||
'right': {
|
||||
top: triggerRect.top + tH / 2 - pH / 2 + offsetY,
|
||||
left: triggerRect.right + offsetX,
|
||||
origin: 'left center',
|
||||
},
|
||||
'right-start': {
|
||||
top: triggerRect.top + offsetY,
|
||||
left: triggerRect.right + offsetX,
|
||||
origin: 'left top',
|
||||
},
|
||||
'right-end': {
|
||||
top: triggerRect.top + tH - pH + offsetY,
|
||||
left: triggerRect.right + offsetX,
|
||||
origin: 'left bottom',
|
||||
},
|
||||
};
|
||||
return positionMap[position];
|
||||
}
|
||||
|
||||
// 边界调整(保持不变)
|
||||
function adjustPosition(
|
||||
triggerRect: DOMRect,
|
||||
popoverRect: DOMRect,
|
||||
boundaryRect: DOMRect,
|
||||
): PopoverPosition {
|
||||
const allPositions: PopoverPosition[] = [
|
||||
'top',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'bottom',
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
'left',
|
||||
'left-start',
|
||||
'left-end',
|
||||
'right',
|
||||
'right-start',
|
||||
'right-end',
|
||||
];
|
||||
const candidatePositions = [props.position, ...allPositions.filter(p => p !== props.position)];
|
||||
|
||||
for (const pos of candidatePositions) {
|
||||
const { top, left } = calculatePosition(triggerRect, popoverRect, pos);
|
||||
if (
|
||||
top >= boundaryRect.top + VIEWPORT_PADDING
|
||||
&& left >= boundaryRect.left + VIEWPORT_PADDING
|
||||
&& top + popoverRect.height <= boundaryRect.bottom - VIEWPORT_PADDING
|
||||
&& left + popoverRect.width <= boundaryRect.right - VIEWPORT_PADDING
|
||||
) {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
return props.position;
|
||||
}
|
||||
|
||||
// 交互逻辑(保持不变)
|
||||
function handleTriggerClick() {
|
||||
if (showPoperContent.value) {
|
||||
props.closeOnTriggerClick && hidePopover();
|
||||
}
|
||||
else {
|
||||
showPoperContent.value = true;
|
||||
nextTick(() => emits('show'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentClick(e: MouseEvent) {
|
||||
props.closeOnContentClick && hidePopover();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
showPoperContent.value = false;
|
||||
emits('hide');
|
||||
}
|
||||
|
||||
onClickOutside(popoverRef, () => !props.closeOnTriggerClick && hidePopover(), {
|
||||
ignore: [triggerRef],
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyObservers();
|
||||
updatePositionTimeout && clearTimeout(updatePositionTimeout);
|
||||
});
|
||||
|
||||
defineExpose({ show: () => (showPoperContent.value = true), hide: hidePopover });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
:style="props.triggerStyle"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="showPoperContent"
|
||||
@click.stop="handleTriggerClick"
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="popover-fade" @before-enter="updatePosition">
|
||||
<div
|
||||
v-if="showPoperContent"
|
||||
ref="popoverRef"
|
||||
class="popover-content"
|
||||
:style="props.popoverStyle"
|
||||
:class="[props.popoverClass]"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
:data-popper-placement="currentPosition"
|
||||
@click="handleContentClick"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style 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);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
position: fixed;
|
||||
min-width: 120px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
padding: 12px 16px;
|
||||
z-index: 1000;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 6px solid transparent;
|
||||
}
|
||||
|
||||
[data-popper-placement^="top"]::before {
|
||||
top: 100%;
|
||||
border-top-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="top"]::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="top-start"]::before {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="top-end"]::before {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement^="bottom"]::before {
|
||||
bottom: 100%;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom"]::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom-start"]::before {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom-end"]::before {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement^="left"]::before {
|
||||
left: 100%;
|
||||
border-left-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="left"]::before {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="left-start"]::before {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="left-end"]::before {
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement^="right"]::before {
|
||||
right: 100%;
|
||||
border-right-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="right"]::before {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="right-start"]::before {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="right-end"]::before {
|
||||
bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,29 @@
|
||||
<!-- Popover 弹框 -->
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { flip, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
position: 'bottom',
|
||||
offset: () => [8, 8],
|
||||
boundary: 'viewport',
|
||||
closeOnContentClick: false,
|
||||
closeOnTriggerClick: false,
|
||||
triggerStyle: () => ({}),
|
||||
popoverStyle: () => ({}),
|
||||
popoverClass: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'show'): void;
|
||||
(e: 'hide'): void;
|
||||
(e: 'positionChange', pos: PopoverPosition): void;
|
||||
}>();
|
||||
const reference = ref();
|
||||
const floating = ref(null);
|
||||
const { floatingStyles } = useFloating(reference, floating, {
|
||||
placement: props.position,
|
||||
middleware: [offset(props.offset[0]), flip(), shift()],
|
||||
});
|
||||
|
||||
type PopoverPosition =
|
||||
| 'top'
|
||||
@@ -17,24 +40,6 @@ type PopoverPosition =
|
||||
| 'right-end';
|
||||
|
||||
type Offset = [number, number];
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
position: 'bottom',
|
||||
offset: () => [8, 8],
|
||||
boundary: 'viewport',
|
||||
closeOnContentClick: false,
|
||||
closeOnTriggerClick: false,
|
||||
triggerStyle: () => ({}),
|
||||
popoverStyle: () => ({}),
|
||||
popoverClass: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'show'): void;
|
||||
(e: 'hide'): void;
|
||||
(e: 'positionChange', pos: PopoverPosition): void;
|
||||
}>();
|
||||
|
||||
const VIEWPORT_PADDING = 16;
|
||||
|
||||
interface PopoverProps {
|
||||
position?: PopoverPosition;
|
||||
offset?: Offset;
|
||||
@@ -46,362 +51,42 @@ interface PopoverProps {
|
||||
closeOnTriggerClick?: boolean;
|
||||
}
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const popoverRef = ref<HTMLElement | null>(null);
|
||||
const showPoperContent = ref(false);
|
||||
const currentPosition = ref<PopoverPosition>(props.position);
|
||||
|
||||
// 新增:动画前钩子触发位置计算
|
||||
function beforeEnter() {
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
function getBoundaryRect() {
|
||||
if (props.boundary === 'viewport') {
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: window.innerWidth,
|
||||
bottom: window.innerHeight,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
return (props.boundary as HTMLElement).getBoundingClientRect();
|
||||
}
|
||||
|
||||
function calculatePosition(
|
||||
triggerRect: DOMRect,
|
||||
popoverRect: DOMRect,
|
||||
position: PopoverPosition,
|
||||
): { top: number; left: number; origin: string } {
|
||||
const [offsetX, offsetY] = props.offset!; // 解构X/Y两个方向的偏移量
|
||||
const { width: tWidth, height: tHeight } = triggerRect;
|
||||
const { width: pWidth, height: pHeight } = popoverRect;
|
||||
|
||||
const positionMap: Record<PopoverPosition, { top: number; left: number; origin: string }> = {
|
||||
// 上下方向位置(top/bottom系列):Y轴用offsetY,X轴用offsetX
|
||||
'top': {
|
||||
top: triggerRect.top - pHeight - offsetY, // Y轴:触发元素顶部到弹框底部的距离 - Y偏移
|
||||
left: triggerRect.left + tWidth / 2 - pWidth / 2 + offsetX, // X轴:水平居中 + X偏移
|
||||
origin: 'bottom center',
|
||||
},
|
||||
'top-start': {
|
||||
top: triggerRect.top - pHeight - offsetY,
|
||||
left: triggerRect.left + offsetX, // X轴:触发元素左侧 + X偏移
|
||||
origin: 'bottom left',
|
||||
},
|
||||
'top-end': {
|
||||
top: triggerRect.top - pHeight - offsetY,
|
||||
left: triggerRect.left + tWidth - pWidth + offsetX, // X轴:触发元素右侧 - 弹框宽度 + X偏移
|
||||
origin: 'bottom right',
|
||||
},
|
||||
'bottom': {
|
||||
top: triggerRect.bottom + offsetY, // Y轴:触发元素底部 + Y偏移
|
||||
left: triggerRect.left + tWidth / 2 - pWidth / 2 + offsetX, // X轴:水平居中 + X偏移
|
||||
origin: 'top center',
|
||||
},
|
||||
'bottom-start': {
|
||||
top: triggerRect.bottom + offsetY,
|
||||
left: triggerRect.left + offsetX, // X轴:触发元素左侧 + X偏移
|
||||
origin: 'top left',
|
||||
},
|
||||
'bottom-end': {
|
||||
top: triggerRect.bottom + offsetY,
|
||||
left: triggerRect.left + tWidth - pWidth + offsetX, // X轴:触发元素右侧 - 弹框宽度 + X偏移
|
||||
origin: 'top right',
|
||||
},
|
||||
|
||||
// 左右方向位置(left/right系列):X轴用offsetX,Y轴用offsetY
|
||||
'left': {
|
||||
top: triggerRect.top + tHeight / 2 - pHeight / 2 + offsetY, // Y轴:垂直居中 + Y偏移
|
||||
left: triggerRect.left - pWidth - offsetX, // X轴:触发元素左侧 - 弹框宽度 - X偏移
|
||||
origin: 'right center',
|
||||
},
|
||||
'left-start': {
|
||||
top: triggerRect.top + offsetY, // Y轴:触发元素顶部 + Y偏移
|
||||
left: triggerRect.left - pWidth - offsetX,
|
||||
origin: 'right top',
|
||||
},
|
||||
'left-end': {
|
||||
top: triggerRect.top + tHeight - pHeight + offsetY, // Y轴:触发元素底部 - 弹框高度 + Y偏移
|
||||
left: triggerRect.left - pWidth - offsetX,
|
||||
origin: 'right bottom',
|
||||
},
|
||||
'right': {
|
||||
top: triggerRect.top + tHeight / 2 - pHeight / 2 + offsetY, // Y轴:垂直居中 + Y偏移
|
||||
left: triggerRect.right + offsetX, // X轴:触发元素右侧 + X偏移
|
||||
origin: 'left center',
|
||||
},
|
||||
'right-start': {
|
||||
top: triggerRect.top + offsetY, // Y轴:触发元素顶部 + Y偏移
|
||||
left: triggerRect.right + offsetX,
|
||||
origin: 'left top',
|
||||
},
|
||||
'right-end': {
|
||||
top: triggerRect.top + tHeight - pHeight + offsetY, // Y轴:触发元素底部 - 弹框高度 + Y偏移
|
||||
left: triggerRect.right + offsetX,
|
||||
origin: 'left bottom',
|
||||
},
|
||||
};
|
||||
|
||||
return positionMap[position];
|
||||
}
|
||||
|
||||
function adjustPosition(
|
||||
triggerRect: DOMRect,
|
||||
popoverRect: DOMRect,
|
||||
boundaryRect: DOMRect,
|
||||
): PopoverPosition {
|
||||
const allPositions: PopoverPosition[] = [
|
||||
'top',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'bottom',
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
'left',
|
||||
'left-start',
|
||||
'left-end',
|
||||
'right',
|
||||
'right-start',
|
||||
'right-end',
|
||||
];
|
||||
|
||||
const candidatePositions = [props.position, ...allPositions.filter(p => p !== props.position)];
|
||||
|
||||
for (const pos of candidatePositions) {
|
||||
const { top, left } = calculatePosition(triggerRect, popoverRect, pos);
|
||||
if (
|
||||
top >= boundaryRect.top + VIEWPORT_PADDING
|
||||
&& left >= boundaryRect.left + VIEWPORT_PADDING
|
||||
&& top + popoverRect.height <= boundaryRect.bottom - VIEWPORT_PADDING
|
||||
&& left + popoverRect.width <= boundaryRect.right - VIEWPORT_PADDING
|
||||
) {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
||||
return props.position;
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value || !popoverRef.value)
|
||||
return;
|
||||
|
||||
// 强制获取最新尺寸(避免缓存)
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
const popoverRect = popoverRef.value.getBoundingClientRect();
|
||||
const boundaryRect = getBoundaryRect();
|
||||
|
||||
const adjustedPos = adjustPosition(triggerRect, popoverRect, boundaryRect);
|
||||
currentPosition.value = adjustedPos;
|
||||
emits('positionChange', adjustedPos);
|
||||
|
||||
const { top, left, origin } = calculatePosition(triggerRect, popoverRect, adjustedPos);
|
||||
popoverRef.value.style.top = `${top}px`;
|
||||
popoverRef.value.style.left = `${left}px`;
|
||||
popoverRef.value.style.transformOrigin = origin;
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => showPoperContent.value, () => props.position],
|
||||
async ([newShow]) => {
|
||||
if (newShow) {
|
||||
// 等待弹框元素渲染完成
|
||||
await nextTick();
|
||||
updatePosition(); // 直接调用,无需 requestAnimationFrame
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updatePosition);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
});
|
||||
|
||||
function handleTriggerClick() {
|
||||
showPoperContent.value = !showPoperContent.value;
|
||||
if (showPoperContent.value) {
|
||||
if (props.closeOnTriggerClick)
|
||||
hidePopover();
|
||||
emits('show');
|
||||
}
|
||||
else {
|
||||
showPoperContent.value = true;
|
||||
nextTick(() => emits('show'));
|
||||
emits('hide');
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentClick(e: MouseEvent) {
|
||||
if (props.closeOnContentClick)
|
||||
hidePopover();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
showPoperContent.value = false;
|
||||
emits('hide');
|
||||
}
|
||||
|
||||
onClickOutside(popoverRef, () => !props.closeOnTriggerClick && hidePopover(), {
|
||||
ignore: [triggerRef],
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
show: () => (showPoperContent.value = true),
|
||||
hide: hidePopover,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
:style="props.triggerStyle"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="showPoperContent"
|
||||
@click.stop="handleTriggerClick"
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
<div>
|
||||
<div ref="reference" :style="props.triggerStyle" @click.stop="handleTriggerClick">
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="popover-fade" @before-enter="beforeEnter">
|
||||
<Transition name="popover-fade">
|
||||
<div
|
||||
v-if="showPoperContent"
|
||||
ref="popoverRef"
|
||||
ref="floating"
|
||||
class="popover-content"
|
||||
:style="props.popoverStyle"
|
||||
:style="{
|
||||
...floatingStyles,
|
||||
...props.popoverStyle,
|
||||
}"
|
||||
:class="[props.popoverClass]"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
:data-popper-placement="currentPosition"
|
||||
@click="handleContentClick"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style 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);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
position: fixed;
|
||||
min-width: 120px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
padding: 12px 16px;
|
||||
z-index: 1000;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 6px solid transparent;
|
||||
}
|
||||
|
||||
[data-popper-placement^="top"]::before {
|
||||
top: 100%;
|
||||
border-top-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="top"]::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="top-start"]::before {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="top-end"]::before {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement^="bottom"]::before {
|
||||
bottom: 100%;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom"]::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom-start"]::before {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="bottom-end"]::before {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement^="left"]::before {
|
||||
left: 100%;
|
||||
border-left-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="left"]::before {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="left-start"]::before {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="left-end"]::before {
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement^="right"]::before {
|
||||
right: 100%;
|
||||
border-right-color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-popper-placement="right"]::before {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
[data-popper-placement="right-start"]::before {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
[data-popper-placement="right-end"]::before {
|
||||
bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
Reference in New Issue
Block a user