372 lines
10 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|
|
},
|
|
})
|