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) 覆盖(回归组件自身能力)
299 lines
9.1 KiB
Vue
299 lines
9.1 KiB
Vue
<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>
|