Files
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

156 lines
4.2 KiB
Vue

<template>
<div ref="containerRef" class="relative inline-block">
<div class="cursor-pointer" @click="toggle">
<slot name="trigger" :is-open="isOpen" />
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform scale-95 opacity-0 translate-y-2"
enter-to-class="transform scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform scale-100 opacity-100 translate-y-0"
leave-to-class="transform scale-95 opacity-0 translate-y-2"
>
<div
v-if="isOpen"
ref="contentRef"
:style="contentStyle"
class="fixed z-[10000] bg-[#1e1e1e]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<slot name="content" :close="close" />
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch, onUnmounted, type StyleValue } from 'vue'
const props = defineProps({
placement: {
type: String,
default: 'bottom-end' // bottom-start, bottom-end, top-start, top-end
},
offset: {
type: Number,
default: 8
}
})
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const contentStyle = ref<StyleValue>({})
const updatePosition = () => {
if (!isOpen.value || !containerRef.value || !contentRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const contentRect = contentRef.value.getBoundingClientRect()
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
let top = 0
let left = 0
// Vertical positioning base
if (props.placement.startsWith('top')) {
top = triggerRect.top - contentRect.height - props.offset
} else {
top = triggerRect.bottom + props.offset
}
// Horizontal positioning base
if (props.placement.endsWith('end')) {
left = triggerRect.right - contentRect.width
} else {
left = triggerRect.left
}
// Boundary checks
// Right edge
if (left + contentRect.width > windowWidth - 10) {
left = windowWidth - contentRect.width - 10
}
// Left edge
if (left < 10) {
left = 10
}
// Flip vertical if needed
if (props.placement.startsWith('bottom') && top + contentRect.height > windowHeight - 10) {
// Try top
const topSpace = triggerRect.top
if (topSpace > contentRect.height + props.offset) {
top = triggerRect.top - contentRect.height - props.offset
}
} else if (props.placement.startsWith('top') && top < 10) {
// Try bottom
const bottomSpace = windowHeight - triggerRect.bottom
if (bottomSpace > contentRect.height + props.offset) {
top = triggerRect.bottom + props.offset
}
}
contentStyle.value = {
top: `${top}px`,
left: `${left}px`
}
}
const close = () => {
isOpen.value = false
}
const toggle = async () => {
if (isOpen.value) {
close()
} else {
isOpen.value = true
await nextTick()
updatePosition()
}
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (
containerRef.value &&
!containerRef.value.contains(target) &&
contentRef.value &&
!contentRef.value.contains(target)
) {
close()
}
}
const handleScrollOrResize = () => {
if (isOpen.value) {
updatePosition()
}
}
watch(isOpen, (val) => {
if (val) {
window.addEventListener('scroll', handleScrollOrResize, true)
window.addEventListener('resize', handleScrollOrResize)
// Use setTimeout to avoid immediate trigger if the click that opened it bubbles up
setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0)
} else {
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
document.removeEventListener('mousedown', handleClickOutside)
}
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
document.removeEventListener('mousedown', handleClickOutside)
})
</script>