20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,120 @@
|
||||
.popover-trigger {
|
||||
display: inline-flex;
|
||||
/* grid 上下文下收缩到内容宽度,避免被同列最宽按钮撑大导致浮层居中偏移 */
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
/* 在完整轮廓上统一投影,让箭头和主体拥有连续的视觉重量。 */
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: var(--fms-z-popover);
|
||||
filter: drop-shadow(var(--fms-popover-shadow));
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
position: relative;
|
||||
padding: var(--fms-popover-padding);
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
}
|
||||
|
||||
.popover-follow-trigger-width .popover-content {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 与 Antdv Next 一致:箭头是浮层轮廓的一部分,不使用独立边框或阴影。 */
|
||||
.popover-arrow {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.popover-arrow-content {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 8px;
|
||||
background: var(--fms-card);
|
||||
clip-path: polygon(0 100%, 50% 0, 100% 100%);
|
||||
}
|
||||
|
||||
/* 浮层在触发器上方,箭头在浮层下边,尖角朝下 */
|
||||
.popover-top .popover-arrow {
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100%) rotate(180deg);
|
||||
}
|
||||
|
||||
/* 浮层在触发器下方,箭头在浮层上边,尖角朝上 */
|
||||
.popover-bottom .popover-arrow {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
}
|
||||
|
||||
/* 浮层在触发器左侧,箭头在浮层右边,尖角朝右 */
|
||||
.popover-left .popover-arrow {
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateX(100%) translateY(-50%) rotate(90deg);
|
||||
}
|
||||
|
||||
/* 浮层在触发器右侧,箭头在浮层左边,尖角朝左 */
|
||||
.popover-right .popover-arrow {
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateX(-100%) translateY(-50%) rotate(-90deg);
|
||||
}
|
||||
|
||||
/* start / end 对齐时,箭头从居中移到靠近触发器边缘一侧 */
|
||||
.popover-top-start .popover-arrow,
|
||||
.popover-bottom-start .popover-arrow {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.popover-top-end .popover-arrow,
|
||||
.popover-bottom-end .popover-arrow {
|
||||
left: auto;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.popover-left-start .popover-arrow,
|
||||
.popover-right-start .popover-arrow {
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.popover-left-end .popover-arrow,
|
||||
.popover-right-end .popover-arrow {
|
||||
top: auto;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
/* 过渡只动 opacity / transform,低成本且支持 reduced-motion */
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { PLACEMENTS, computePosition } from '../utils/position.js'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
/** 受控打开状态;不传则非受控,由 defaultOpen + 内部状态控制 */
|
||||
open: { type: Boolean, default: undefined },
|
||||
/** 非受控时的初始打开状态 */
|
||||
defaultOpen: { type: Boolean, default: false },
|
||||
/** 触发方式 */
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'click',
|
||||
validator: (v) => ['click', 'hover', 'focus', 'contextmenu', 'manual'].includes(v),
|
||||
},
|
||||
/** 期望方位 */
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
validator: (v) => PLACEMENTS.includes(v),
|
||||
},
|
||||
/** 与触发器的间距(px);箭头尖端固定伸出 8px,offset 需大于 8 以留出间隙 */
|
||||
offset: { type: Number, default: 12 },
|
||||
/** 是否显示箭头 */
|
||||
arrow: { type: Boolean, default: true },
|
||||
/** 禁用态 */
|
||||
disabled: { type: Boolean, default: false },
|
||||
/** hover 触发打开延迟(ms) */
|
||||
mouseEnterDelay: { type: Number, default: 100 },
|
||||
/** hover 触发关闭延迟(ms) */
|
||||
mouseLeaveDelay: { type: Number, default: 100 },
|
||||
/** 透传到浮层内容盒子的额外 class,便于派生组件定制内容样式 */
|
||||
contentClass: { type: String, default: '' },
|
||||
/** 浮层宽度是否严格跟随触发器宽度。
|
||||
用于 Select 等需与触发框等宽对齐的场景;默认 false 由内容自适应撑开。 */
|
||||
followTriggerWidth: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'openChange'])
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const triggerEl = ref(null)
|
||||
const floatEl = ref(null)
|
||||
const internalOpen = ref(props.defaultOpen)
|
||||
const floatStyle = ref({})
|
||||
const resolvedPlacement = ref(props.placement)
|
||||
|
||||
let enterTimer = null
|
||||
let leaveTimer = null
|
||||
let rafId = null
|
||||
let resizeObserver = null
|
||||
// contextmenu 触发时记录右键锚点,浮层对齐鼠标位置;非右键模式恒为 null
|
||||
let pointAnchor = null
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => (props.open !== undefined ? props.open : internalOpen.value),
|
||||
set: (value) => {
|
||||
if (props.open === undefined) internalOpen.value = value
|
||||
emit('update:open', value)
|
||||
emit('openChange', value)
|
||||
},
|
||||
})
|
||||
|
||||
// 翻转/shift 后实际主方向(用于箭头吸附),如 top-start -> top
|
||||
const resolvedBase = computed(() => resolvedPlacement.value.split('-')[0])
|
||||
|
||||
// hover 触发读 tooltip 语义,其余读 dialog
|
||||
const contentRole = computed(() => (props.trigger === 'hover' ? 'tooltip' : 'dialog'))
|
||||
|
||||
/* ---------- 公共方法 ---------- */
|
||||
function setOpen(value) {
|
||||
if (props.disabled && value) return
|
||||
clearTimers()
|
||||
isOpen.value = value
|
||||
if (!value) pointAnchor = null
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setOpen(!isOpen.value)
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (enterTimer) {
|
||||
clearTimeout(enterTimer)
|
||||
enterTimer = null
|
||||
}
|
||||
if (leaveTimer) {
|
||||
clearTimeout(leaveTimer)
|
||||
leaveTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 定位 ---------- */
|
||||
function updatePosition() {
|
||||
const trigger = triggerEl.value
|
||||
const float = floatEl.value
|
||||
if (!trigger || !float) return
|
||||
let floatWidth = float.offsetWidth
|
||||
// 跟随触发器宽度:用 Popover 真实触发器(.popover-trigger)的渲染宽度,
|
||||
// 避免派生组件自己读错 DOM 导致浮层与触发框不对齐
|
||||
if (props.followTriggerWidth) {
|
||||
floatWidth = trigger.offsetWidth
|
||||
floatStyle.value = {
|
||||
...floatStyle.value,
|
||||
width: `${floatWidth}px`,
|
||||
}
|
||||
}
|
||||
// contextmenu 触发时以右键坐标为锚点(零尺寸矩形),否则回退触发器矩形
|
||||
const triggerRect = pointAnchor || trigger.getBoundingClientRect()
|
||||
const { x, y, placement } = computePosition({
|
||||
placement: props.placement,
|
||||
triggerRect,
|
||||
floatSize: { width: floatWidth, height: float.offsetHeight },
|
||||
offset: props.offset,
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
})
|
||||
resolvedPlacement.value = placement
|
||||
// 保留已设置的 width 等字段,仅更新定位
|
||||
floatStyle.value = { ...floatStyle.value, left: `${x}px`, top: `${y}px` }
|
||||
}
|
||||
|
||||
// 滚动/缩放跟随,用 rAF 合并高频触发,避免 layout thrashing
|
||||
function onViewportChange() {
|
||||
if (!isOpen.value || rafId) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelPositionFrame() {
|
||||
if (!rafId) return
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
function startResizeObserver() {
|
||||
if (typeof ResizeObserver === 'undefined' || !floatEl.value) return
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = new ResizeObserver(onViewportChange)
|
||||
resizeObserver.observe(floatEl.value)
|
||||
}
|
||||
|
||||
function stopResizeObserver() {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
/* ---------- 交互:click / hover / focus ---------- */
|
||||
function onClick() {
|
||||
if (props.trigger === 'click') toggle()
|
||||
}
|
||||
|
||||
function onContextMenu(event) {
|
||||
if (props.trigger !== 'contextmenu') return
|
||||
event.preventDefault()
|
||||
const { clientX, clientY } = event
|
||||
// 右键在鼠标位置弹出:构造零尺寸锚点矩形,computePosition 的 flip/shift 会兜底防溢出
|
||||
pointAnchor = { left: clientX, top: clientY, width: 0, height: 0, right: clientX, bottom: clientY }
|
||||
if (isOpen.value) {
|
||||
// 已打开时右键到新位置,仅重新定位不重置状态
|
||||
updatePosition()
|
||||
} else {
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
if (props.trigger !== 'hover') return
|
||||
clearTimers()
|
||||
enterTimer = setTimeout(() => setOpen(true), props.mouseEnterDelay)
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (props.trigger !== 'hover') return
|
||||
clearTimers()
|
||||
leaveTimer = setTimeout(() => setOpen(false), props.mouseLeaveDelay)
|
||||
}
|
||||
|
||||
function onFocusIn() {
|
||||
if (props.trigger === 'focus') setOpen(true)
|
||||
}
|
||||
|
||||
function onFocusOut(event) {
|
||||
if (props.trigger !== 'focus') return
|
||||
// 焦点移入浮层内部(如输入框)时不关闭
|
||||
if (floatEl.value && event.relatedTarget && floatEl.value.contains(event.relatedTarget)) return
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
/* ---------- 全局关闭:click outside / Esc ---------- */
|
||||
function onDocumentPointerDown(event) {
|
||||
if (!isOpen.value) return
|
||||
const target = event.target
|
||||
const inTrigger = triggerEl.value && triggerEl.value.contains(target)
|
||||
const inContent = floatEl.value && floatEl.value.contains(target)
|
||||
if (!inTrigger && !inContent) setOpen(false)
|
||||
}
|
||||
|
||||
function onDocumentKeydown(event) {
|
||||
if (event.key === 'Escape' && isOpen.value) setOpen(false)
|
||||
}
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
// 打开时定位并注册全局监听,关闭/卸载时清理,避免长期全局监听
|
||||
watch(isOpen, (value) => {
|
||||
if (value) {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
window.addEventListener('scroll', onViewportChange, true)
|
||||
nextTick(() => {
|
||||
if (!isOpen.value) return
|
||||
updatePosition()
|
||||
startResizeObserver()
|
||||
})
|
||||
} else {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
window.removeEventListener('scroll', onViewportChange, true)
|
||||
cancelPositionFrame()
|
||||
stopResizeObserver()
|
||||
clearTimers()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
window.removeEventListener('scroll', onViewportChange, true)
|
||||
cancelPositionFrame()
|
||||
stopResizeObserver()
|
||||
clearTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="triggerEl"
|
||||
class="popover-trigger"
|
||||
@click="onClick"
|
||||
@contextmenu="onContextMenu"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</span>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="popover">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="floatEl"
|
||||
class="popover"
|
||||
:class="[
|
||||
`popover-${resolvedBase}`,
|
||||
`popover-${resolvedPlacement}`,
|
||||
{ 'popover-follow-trigger-width': followTriggerWidth },
|
||||
]"
|
||||
:style="floatStyle"
|
||||
>
|
||||
<span v-if="arrow" class="popover-arrow" aria-hidden="true">
|
||||
<span class="popover-arrow-content"></span>
|
||||
</span>
|
||||
<div
|
||||
class="popover-content"
|
||||
:class="contentClass"
|
||||
:role="contentRole"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
Reference in new issue
Block a user