Files
workspace/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx
T
2025-09-12 17:32:27 +08:00

372 lines
10 KiB
TypeScript

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<string | HTMLElement>,
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<HTMLElement | null>(null) // 触发 引用
const floatingRef = ref<HTMLElement | null>(null) // float 引用
const arrowRef = ref<HTMLElement | null>(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<string, (e: Event) => 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<HTMLElement>(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 (
<span
style="display:inline-flex;"
{...eventProps}
ref={(el) => {
triggerRef.value = getDOM(el)
}}
>
{slotContent.map(v => cloneVNode(v))}
</span>
)
}
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 <div class={`${prefixCls}-content`}>{content}</div>
}
return content
}
return () => {
// 懒加载
const shouldRenderPopup = props.destroyOnClose
? localVisible.value // 需要销毁时,只在显示时渲染
: localVisible.value || hasRendered.value // 不销毁时保持已渲染
return (
<>
{renderTriggerSlot()}
<Teleport to={targetEl.value}>
{shouldRenderPopup && (
<div
style={{
position: 'absolute',
top: '0px',
left: '0px',
width: '100%',
}}
>
<Transition
name="popup-fade"
appear
>
<div
v-show={localVisible.value}
ref={floatingRef}
class={[prefixCls, props.class]}
style={{
...floatingStyles.value,
position: 'absolute',
...props.style,
}}
data-popup-placement={placement.value}
>
{slots.content ? slots.content() : renderContent(props.content)}
{props.showArrow && (
<span
class={`${prefixCls}-arrow`}
ref={arrowRef}
style={{
left:
middlewareData.value.arrow?.x != null
? `${middlewareData.value.arrow.x}px`
: '',
top:
middlewareData.value.arrow?.y != null
? `${middlewareData.value.arrow.y}px`
: '',
}}
/>
)}
</div>
</Transition>
</div>
)}
</Teleport>
</>
)
}
},
})