Files
workspace/code/fms/fms-vue/src/components/ui/popover/popover.vue
T
oneao a1079a7150 refactor(ui): Dropdown/Popover 增加 stretch-trigger,结构树宽度改为组件级解决
antdv-next 的 Dropdown/Popover 触发器不额外包裹 span,block 内容天然撑满;
我们组件因统一挂载事件/ref 用 span.popover-trigger(inline-flex 收缩)包裹,
导致结构树(.mst-tree)只有内容宽度。

参照其语义化定制思路,改为组件提供显式能力而非业务侧 :deep 打补丁:
- Popover 新增 stretchTrigger prop:true 时 trigger 加 .popover-trigger--stretch
  (display:block + width:100%,特异性 0,2,0 高于默认)
- Dropdown 透传 stretchTrigger
- ModuleStructureTree 使用 stretch-trigger 撑满分组树面板
- 移除 TableEditPanel 的 :deep(.popover-trigger) 覆盖(回归组件自身能力)
2026-08-28 19:28:40 +08:00

299 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 },
/** 触发器是否撑满父容器宽度(display:block + width:100%)。
默认 false(inline-flex 收缩到内容宽度,适配按钮/图标触发器);
需要触发器占据整行可用宽度的场景(如结构树)设为 true。 */
stretchTrigger: { 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"
:class="{ 'popover-trigger--stretch': stretchTrigger }"
@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>