mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
548 lines
14 KiB
Vue
548 lines
14 KiB
Vue
<template>
|
||
<div class="tooltip-wrapper" ref="wrapperRef">
|
||
<!-- 触发器 -->
|
||
<div
|
||
class="tooltip-trigger"
|
||
:tabindex="focusable ? 0 : -1"
|
||
:aria-describedby="visible ? ariaId : undefined"
|
||
@mouseenter="onTriggerMouseEnter"
|
||
@mouseleave="onTriggerMouseLeave"
|
||
@click="onTriggerClick"
|
||
@focus="onTriggerFocus"
|
||
@blur="onTriggerBlur"
|
||
>
|
||
<slot />
|
||
</div>
|
||
|
||
<!-- 提示内容(Teleport 到 body) -->
|
||
<Teleport to="body" v-if="mounted">
|
||
<Transition name="tooltip-fade">
|
||
<div
|
||
v-show="visible"
|
||
:id="ariaId"
|
||
ref="tooltipRef"
|
||
class="tooltip-content"
|
||
:class="[
|
||
`tooltip-${currentPlacement}`,
|
||
dark ? 'tooltip-dark' : 'tooltip-light',
|
||
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
|
||
]"
|
||
:style="tooltipInlineStyle"
|
||
role="tooltip"
|
||
>
|
||
<div class="tooltip-inner">
|
||
<slot name="content">
|
||
{{ content }}
|
||
</slot>
|
||
</div>
|
||
|
||
<!-- 箭头 -->
|
||
<div
|
||
class="tooltip-arrow"
|
||
:class="`tooltip-arrow-${currentPlacement}`"
|
||
:style="arrowStyle"
|
||
></div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
ref,
|
||
computed,
|
||
onMounted,
|
||
onBeforeUnmount,
|
||
nextTick,
|
||
watch,
|
||
defineProps,
|
||
defineEmits,
|
||
defineOptions,
|
||
useId,
|
||
} from 'vue'
|
||
|
||
defineOptions({ name: 'Tooltip' })
|
||
|
||
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
|
||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||
|
||
const props = defineProps({
|
||
content: { type: String, default: '' },
|
||
trigger: {
|
||
type: String as () => Trigger,
|
||
default: 'hover',
|
||
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
|
||
},
|
||
placement: {
|
||
type: String as () => Placement,
|
||
default: 'top',
|
||
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
|
||
},
|
||
dark: { type: Boolean, default: false },
|
||
delay: { type: Number, default: 100 },
|
||
disabled: { type: Boolean, default: false },
|
||
focusable: { type: Boolean, default: true },
|
||
offset: { type: Number, default: 8 },
|
||
maxWidth: { type: [String, Number], default: '200px' },
|
||
/** 隐藏延时(毫秒),hover 离开后等待一点点以防抖 */
|
||
hideDelay: { type: Number, default: 80 },
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'show'): void
|
||
(e: 'hide'): void
|
||
}>()
|
||
|
||
const wrapperRef = ref<HTMLElement | null>(null)
|
||
const tooltipRef = ref<HTMLElement | null>(null)
|
||
const visible = ref(false)
|
||
const currentPlacement = ref<Placement>(props.placement)
|
||
const ariaId = ref(`tooltip-${useId()}`)
|
||
const mounted = ref(false)
|
||
|
||
let showTimer: number | null = null
|
||
let hideTimer: number | null = null
|
||
let ro: ResizeObserver | null = null
|
||
let rafId: number | null = null
|
||
|
||
const maxWidthValue = computed(() => {
|
||
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||
})
|
||
|
||
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
|
||
|
||
const tooltipInlineStyle = computed(() => ({
|
||
position: 'fixed',
|
||
top: '0px',
|
||
left: '0px',
|
||
zIndex: 2000,
|
||
maxWidth: maxWidthValue.value,
|
||
transform: tooltipTransform.value,
|
||
}))
|
||
|
||
const arrowStyle = ref<Record<string, string>>({})
|
||
|
||
const clearTimers = () => {
|
||
if (showTimer) {
|
||
window.clearTimeout(showTimer)
|
||
showTimer = null
|
||
}
|
||
if (hideTimer) {
|
||
window.clearTimeout(hideTimer)
|
||
hideTimer = null
|
||
}
|
||
}
|
||
|
||
const show = async () => {
|
||
if (props.disabled) return
|
||
clearTimers()
|
||
showTimer = window.setTimeout(async () => {
|
||
visible.value = true
|
||
emit('show')
|
||
await nextTick()
|
||
updatePosition()
|
||
}, props.delay)
|
||
}
|
||
|
||
const hide = () => {
|
||
clearTimers()
|
||
hideTimer = window.setTimeout(() => {
|
||
visible.value = false
|
||
emit('hide')
|
||
}, props.hideDelay)
|
||
}
|
||
|
||
const showImmediately = async () => {
|
||
if (props.disabled) return
|
||
clearTimers()
|
||
visible.value = true
|
||
emit('show')
|
||
await nextTick()
|
||
updatePosition()
|
||
}
|
||
const hideImmediately = () => {
|
||
clearTimers()
|
||
visible.value = false
|
||
emit('hide')
|
||
}
|
||
|
||
// 触发器事件
|
||
const onTriggerMouseEnter = () => {
|
||
if (props.trigger === 'hover') show()
|
||
}
|
||
const onTriggerMouseLeave = () => {
|
||
// 关键修改:hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
|
||
if (props.trigger === 'hover') hide()
|
||
}
|
||
const onTriggerClick = () => {
|
||
if (props.trigger !== 'click') return
|
||
visible.value ? hideImmediately() : showImmediately()
|
||
}
|
||
const onTriggerFocus = () => {
|
||
if (props.trigger === 'focus') showImmediately()
|
||
}
|
||
const onTriggerBlur = () => {
|
||
if (props.trigger === 'focus') hideImmediately()
|
||
}
|
||
|
||
// 点击外部关闭(只对 click 模式)
|
||
const onClickOutside = (e: MouseEvent) => {
|
||
if (props.trigger !== 'click') return
|
||
const w = wrapperRef.value
|
||
const t = tooltipRef.value
|
||
const target = e.target as Node
|
||
if (w && !w.contains(target) && t && !t.contains(target)) {
|
||
hideImmediately()
|
||
}
|
||
}
|
||
|
||
// 定位算法
|
||
function clamp(n: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, n))
|
||
}
|
||
|
||
function computeBasePosition(
|
||
placement: Placement,
|
||
triggerRect: DOMRect,
|
||
tooltipRect: DOMRect,
|
||
offset: number,
|
||
) {
|
||
const centerX = triggerRect.left + triggerRect.width / 2
|
||
const centerY = triggerRect.top + triggerRect.height / 2
|
||
|
||
switch (placement) {
|
||
case 'top':
|
||
return {
|
||
top: triggerRect.top - tooltipRect.height - offset,
|
||
left: centerX - tooltipRect.width / 2,
|
||
}
|
||
case 'bottom':
|
||
return {
|
||
top: triggerRect.bottom + offset,
|
||
left: centerX - tooltipRect.width / 2,
|
||
}
|
||
case 'left':
|
||
return {
|
||
top: centerY - tooltipRect.height / 2,
|
||
left: triggerRect.left - tooltipRect.width - offset,
|
||
}
|
||
case 'right':
|
||
return {
|
||
top: centerY - tooltipRect.height / 2,
|
||
left: triggerRect.right + offset,
|
||
}
|
||
}
|
||
}
|
||
|
||
function positionWithSmartFlip(
|
||
preferred: Placement,
|
||
triggerRect: DOMRect,
|
||
tooltipRect: DOMRect,
|
||
offset: number,
|
||
) {
|
||
const padding = 8
|
||
const vw = window.innerWidth
|
||
const vh = window.innerHeight
|
||
|
||
let placement: Placement = preferred
|
||
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
|
||
|
||
const outTop = top < padding
|
||
const outBottom = top + tooltipRect.height > vh - padding
|
||
const outLeft = left < padding
|
||
const outRight = left + tooltipRect.width > vw - padding
|
||
|
||
if (
|
||
placement === 'top' &&
|
||
outTop &&
|
||
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
|
||
) {
|
||
placement = 'bottom'
|
||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||
} else if (
|
||
placement === 'bottom' &&
|
||
outBottom &&
|
||
triggerRect.top - offset - tooltipRect.height >= padding
|
||
) {
|
||
placement = 'top'
|
||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||
} else if (
|
||
placement === 'left' &&
|
||
outLeft &&
|
||
triggerRect.right + offset + tooltipRect.width <= vw - padding
|
||
) {
|
||
placement = 'right'
|
||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||
} else if (
|
||
placement === 'right' &&
|
||
outRight &&
|
||
triggerRect.left - offset - tooltipRect.width >= padding
|
||
) {
|
||
placement = 'left'
|
||
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||
}
|
||
|
||
top = clamp(top, padding, vh - tooltipRect.height - padding)
|
||
left = clamp(left, padding, vw - tooltipRect.width - padding)
|
||
|
||
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
|
||
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
|
||
|
||
return { placement, top, left, arrowLeft, arrowTop }
|
||
}
|
||
|
||
const updatePosition = () => {
|
||
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
|
||
if (rafId) cancelAnimationFrame(rafId)
|
||
rafId = requestAnimationFrame(() => {
|
||
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
|
||
const tooltipEl = tooltipRef.value!
|
||
if (!triggerEl) return
|
||
|
||
const triggerRect = triggerEl.getBoundingClientRect()
|
||
const tooltipRect = tooltipEl.getBoundingClientRect()
|
||
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
|
||
props.placement,
|
||
triggerRect,
|
||
tooltipRect,
|
||
props.offset,
|
||
)
|
||
|
||
currentPlacement.value = placement
|
||
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
|
||
if (placement === 'top' || placement === 'bottom') {
|
||
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
|
||
} else {
|
||
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
|
||
}
|
||
})
|
||
}
|
||
|
||
const onEnvChanged = () => {
|
||
if (visible.value) updatePosition()
|
||
}
|
||
|
||
watch(
|
||
() => props.disabled,
|
||
(v) => {
|
||
if (v && visible.value) hideImmediately()
|
||
},
|
||
)
|
||
watch(
|
||
() => props.placement,
|
||
() => {
|
||
if (visible.value) nextTick(updatePosition)
|
||
},
|
||
)
|
||
watch(visible, (v) => {
|
||
if (!mounted.value) return
|
||
if (v) {
|
||
if ('ResizeObserver' in window && !ro) {
|
||
ro = new ResizeObserver(() => updatePosition())
|
||
if (tooltipRef.value) ro.observe(tooltipRef.value)
|
||
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
|
||
if (triggerEl) ro.observe(triggerEl)
|
||
}
|
||
updatePosition()
|
||
} else {
|
||
if (ro) {
|
||
ro.disconnect()
|
||
ro = null
|
||
}
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
mounted.value = true
|
||
window.addEventListener('resize', onEnvChanged, { passive: true })
|
||
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
|
||
document.addEventListener('click', onClickOutside, true)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clearTimers()
|
||
if (rafId) cancelAnimationFrame(rafId)
|
||
if (ro) {
|
||
ro.disconnect()
|
||
ro = null
|
||
}
|
||
document.removeEventListener('click', onClickOutside, true)
|
||
window.removeEventListener('resize', onEnvChanged)
|
||
window.removeEventListener('scroll', onEnvChanged, true)
|
||
})
|
||
|
||
// 暴露给父组件(manual 可用)
|
||
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tooltip-wrapper {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.tooltip-trigger {
|
||
display: inline-block;
|
||
outline: none;
|
||
}
|
||
.tooltip-trigger:focus-visible {
|
||
outline: 2px solid var(--primary-color);
|
||
outline-offset: 2px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.tooltip-content {
|
||
will-change: transform;
|
||
pointer-events: auto; /* 默认允许交互(click/focus 模式) */
|
||
}
|
||
.tooltip-noninteractive {
|
||
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tooltip-inner {
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
word-wrap: break-word;
|
||
border: 1px solid transparent;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
/* 主题 */
|
||
.tooltip-light .tooltip-inner {
|
||
background-color: var(--background-color);
|
||
color: var(--text-color);
|
||
border-color: var(--normal-border-color);
|
||
}
|
||
.tooltip-dark .tooltip-inner {
|
||
background-color: rgba(0, 0, 0, 0.9);
|
||
color: #fff;
|
||
}
|
||
|
||
/* 箭头(用 CSS 变量控制偏移) */
|
||
.tooltip-arrow {
|
||
position: absolute;
|
||
width: 0;
|
||
height: 0;
|
||
border-style: solid;
|
||
}
|
||
|
||
/* 顶部 */
|
||
.tooltip-top .tooltip-arrow-top {
|
||
bottom: -6px;
|
||
left: var(--arrow-left, 50%);
|
||
transform: translateX(-50%);
|
||
border-width: 6px 6px 0 6px;
|
||
}
|
||
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
||
border-color: var(--normal-border-color) transparent transparent transparent;
|
||
}
|
||
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -7px;
|
||
left: -6px;
|
||
border-width: 6px 6px 0 6px;
|
||
border-style: solid;
|
||
border-color: var(--background-color) transparent transparent transparent;
|
||
}
|
||
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||
}
|
||
|
||
/* 底部 */
|
||
.tooltip-bottom .tooltip-arrow-bottom {
|
||
top: -6px;
|
||
left: var(--arrow-left, 50%);
|
||
transform: translateX(-50%);
|
||
border-width: 0 6px 6px 6px;
|
||
}
|
||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
||
border-color: transparent transparent var(--normal-border-color) transparent;
|
||
}
|
||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 1px;
|
||
left: -6px;
|
||
border-width: 0 6px 6px 6px;
|
||
border-style: solid;
|
||
border-color: transparent transparent var(--background-color) transparent;
|
||
}
|
||
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
||
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
||
}
|
||
|
||
/* 左侧 */
|
||
.tooltip-left .tooltip-arrow-left {
|
||
right: -6px;
|
||
top: var(--arrow-top, 50%);
|
||
transform: translateY(-50%);
|
||
border-width: 6px 0 6px 6px;
|
||
}
|
||
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
||
border-color: transparent transparent transparent var(--normal-border-color);
|
||
}
|
||
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -6px;
|
||
left: -7px;
|
||
border-width: 6px 0 6px 6px;
|
||
border-style: solid;
|
||
border-color: transparent transparent transparent var(--background-color);
|
||
}
|
||
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
||
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
||
}
|
||
|
||
/* 右侧 */
|
||
.tooltip-right .tooltip-arrow-right {
|
||
left: -6px;
|
||
top: var(--arrow-top, 50%);
|
||
transform: translateY(-50%);
|
||
border-width: 6px 6px 6px 0;
|
||
}
|
||
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
||
border-color: transparent var(--normal-border-color) transparent transparent;
|
||
}
|
||
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -6px;
|
||
left: 1px;
|
||
border-width: 6px 6px 6px 0;
|
||
border-style: solid;
|
||
border-color: transparent var(--background-color) transparent transparent;
|
||
}
|
||
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
||
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
||
}
|
||
|
||
/* 过渡动画 */
|
||
.tooltip-fade-enter-active,
|
||
.tooltip-fade-leave-active {
|
||
transition:
|
||
opacity 0.18s ease,
|
||
transform 0.18s ease;
|
||
}
|
||
.tooltip-fade-enter-from,
|
||
.tooltip-fade-leave-to {
|
||
opacity: 0;
|
||
transform: translate3d(0, 4px, 0) scale(0.98);
|
||
}
|
||
|
||
/* 响应式微调 */
|
||
@media (max-width: 768px) {
|
||
.tooltip-inner {
|
||
padding: 6px 10px;
|
||
font-size: 13px;
|
||
max-width: 250px;
|
||
}
|
||
}
|
||
</style>
|