20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
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>