import type { PropType, VNode } from 'vue' import { arrow, autoUpdate, flip, offset, useFloating } from '@floating-ui/vue' import { cloneVNode, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, Teleport, Transition, watch, } from 'vue' import { getPrefixCls } from '../_utils/getPrefixCls' export default defineComponent({ name: 'APopup', props: { visible: { type: Boolean, default: false }, trigger: { type: [String, Array] as PropType< 'hover' | 'click' | 'focus' | 'contextMenu' | Array<'hover' | 'click' | 'focus' | 'contextMenu'> >, default: 'hover', }, placement: { type: String as PropType< 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' >, default: 'bottom', }, showArrow: { type: Boolean, default: false, }, destroyOnClose: { type: Boolean, default: false, }, appendTo: { type: [String, Object] as PropType, default: 'body', }, autoFitPopupMinWidth: { type: Boolean, default: false, }, autoFitPopupWidth: { type: Boolean, default: false, }, class: { type: [String, Array, Object], default: '', }, style: { type: Object, default: () => ({}), }, content: { type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode), default: '', }, showDelay: { type: Number, default: 0 }, hideDelay: { type: Number, default: 0 }, }, emits: ['update:visible'], setup(props, { slots, emit }) { const prefixCls = getPrefixCls('popup') const triggerRef = ref(null) // 触发 引用 const floatingRef = ref(null) // float 引用 const arrowRef = ref(null) // 箭头引用 const localVisible = ref(props.visible) const hasRendered = ref(props.visible) // 定时器 let showTimer: number | null = null let hideTimer: number | null = null let cleanup: (() => void) | null = null const { floatingStyles, middlewareData, placement, update } = useFloating( triggerRef, floatingRef, { placement: props.placement, middleware: [offset(10), flip(), arrow({ element: arrowRef })], // whileElementsMounted: autoUpdate, }, ) // 获取真实DOM function getDOM(refValue: any): HTMLElement | null { if (!refValue) return null if (refValue instanceof HTMLElement) return refValue if (refValue.$el instanceof HTMLElement) return refValue.$el return null } // 清理监听滚动 const clearAutoUpdate = () => { if (cleanup) { cleanup() cleanup = null } } // 自动监听滚动 const setupAutoUpdate = async () => { await nextTick() const triggerEl = getDOM(triggerRef.value) const floatingEl = getDOM(floatingRef.value) if (triggerEl && floatingEl) { clearAutoUpdate() cleanup = autoUpdate(triggerEl, floatingEl, update, { ancestorScroll: true, ancestorResize: true, elementResize: true, layoutShift: true, }) update() } } // 浮动框宽度适配 const fitFloatingWidth = () => { const triggerEl = getDOM(triggerRef.value) const floatEl = getDOM(floatingRef.value) if (!triggerEl || !floatEl) return if (props.autoFitPopupMinWidth) floatEl.style.minWidth = `${triggerEl.offsetWidth}px` if (props.autoFitPopupWidth) floatEl.style.width = `${triggerEl.offsetWidth}px` } const clearTimers = () => { if (showTimer) { clearTimeout(showTimer) showTimer = null } if (hideTimer) { clearTimeout(hideTimer) hideTimer = null } } const showPopup = async () => { clearTimers() showTimer = window.setTimeout(async () => { if (!hasRendered.value) hasRendered.value = true await nextTick() fitFloatingWidth() localVisible.value = true setupAutoUpdate() emit('update:visible', true) }, props.showDelay) } const hidePopup = async () => { clearTimers() hideTimer = window.setTimeout(() => { localVisible.value = false clearAutoUpdate() emit('update:visible', false) }, props.hideDelay) } const togglePopup = async () => { clearTimers() if (!hasRendered.value) hasRendered.value = true await nextTick() fitFloatingWidth() if (!localVisible.value) { // 需要显示 showTimer = window.setTimeout(async () => { localVisible.value = true await nextTick() setupAutoUpdate() emit('update:visible', true) }, props.showDelay) } else { // 需要隐藏 hideTimer = window.setTimeout(() => { localVisible.value = false clearAutoUpdate() emit('update:visible', false) }, props.hideDelay) } } // 事件绑定 const triggers = Array.isArray(props.trigger) ? props.trigger : [props.trigger] const eventProps: Record void> = {} if (triggers.includes('hover')) { eventProps.onMouseenter = showPopup eventProps.onMouseleave = hidePopup } if (triggers.includes('click')) { eventProps.onClick = togglePopup } if (triggers.includes('focus')) { eventProps.onFocus = showPopup eventProps.onBlur = hidePopup } if (triggers.includes('contextMenu')) { eventProps.onContextmenu = (e: Event) => { e.preventDefault() togglePopup() } } // 点击其他地方关闭 const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node const triggerEl = getDOM(triggerRef.value) const floatingEl = getDOM(floatingRef.value) if (!triggerEl?.contains(target) && !floatingEl?.contains(target)) { localVisible.value = false } } // 获取 Teleport 挂载目标 const targetEl = ref(document.body) onMounted(() => { if (props.appendTo instanceof HTMLElement) { targetEl.value = props.appendTo } else if (typeof props.appendTo === 'string') { const el = document.querySelector(props.appendTo) if (el) targetEl.value = el as HTMLElement } }) // 渲染 trigger slot const renderTriggerSlot = () => { const slotContent = slots.reference?.() || slots.default?.() || [] // 只有一个子节点时 if (slotContent.length === 1) { const vnode = slotContent[0] return cloneVNode(vnode, { ...eventProps, ref: (el: any) => { // 使用 getDOM 解析真实 DOM triggerRef.value = getDOM(el) // 保留用户传入的 ref const originalRef = (vnode as any).ref if (typeof originalRef === 'function') { originalRef(el) } else if (originalRef && typeof originalRef === 'object') { ;(originalRef as any).value = el } }, }) } // 多个子节点时,包一层 span return ( { triggerRef.value = getDOM(el) }} > {slotContent.map(v => cloneVNode(v))} ) } onMounted(() => { document.addEventListener('click', handleClickOutside) }) onBeforeUnmount(() => { document.removeEventListener('click', handleClickOutside) clearAutoUpdate() }) watch(() => props.visible, (val) => { localVisible.value = val }, { immediate: true }) const renderContent = (content: string | VNode | (() => VNode)) => { if (typeof content === 'function') return content() if (typeof content === 'string') { return
{content}
} return content } return () => { // 懒加载 const shouldRenderPopup = props.destroyOnClose ? localVisible.value // 需要销毁时,只在显示时渲染 : localVisible.value || hasRendered.value // 不销毁时保持已渲染 return ( <> {renderTriggerSlot()} {shouldRenderPopup && (
{slots.content ? slots.content() : renderContent(props.content)} {props.showArrow && ( )}
)}
) } }, })