u
This commit is contained in:
1 parent
5a3f484b8b
commit
0172becffb
4 files changed
+688
-119
No files matched your search
@@ -1,140 +1,284 @@
|
||||
import type { VNode } from 'vue'
|
||||
import { arrow, offset, useFloating } from '@floating-ui/vue'
|
||||
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: {
|
||||
trigger: {
|
||||
type: String as () => 'hover' | 'click' | 'focus',
|
||||
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: true,
|
||||
},
|
||||
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,
|
||||
},
|
||||
popupClass: {
|
||||
type: [String, Array, Object],
|
||||
default: '',
|
||||
},
|
||||
popupStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const prefixCls = getPrefixCls('popup')
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const floatingRef = ref<HTMLElement | null>(null)
|
||||
const arrowRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null) // 触发 引用
|
||||
const floatingRef = ref<HTMLElement | null>(null) // float 引用
|
||||
const arrowRef = ref<HTMLElement | null>(null) // 箭头引用
|
||||
const visible = ref(false)
|
||||
const hasRendered = ref(false)
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
// 浮层样式和箭头数据
|
||||
const floatingStyles = ref({})
|
||||
const middlewareData = ref({ arrow: {} as any })
|
||||
const { floatingStyles, middlewareData, placement, update } = useFloating(
|
||||
triggerRef,
|
||||
floatingRef,
|
||||
{
|
||||
placement: props.placement,
|
||||
middleware: [offset(10), flip(), arrow({ element: arrowRef })],
|
||||
// whileElementsMounted: autoUpdate,
|
||||
},
|
||||
)
|
||||
|
||||
let floatingInstance: ReturnType<typeof useFloating> | null = null
|
||||
|
||||
// 初始化 FloatingUI,确保触发节点 DOM 已挂载
|
||||
const initFloating = async () => {
|
||||
if (!triggerRef.value || floatingInstance)
|
||||
return
|
||||
await nextTick()
|
||||
const { x, y, strategy, middlewareData: md, update } = useFloating(
|
||||
triggerRef,
|
||||
floatingRef,
|
||||
{
|
||||
placement: 'top',
|
||||
middleware: [offset(10), arrow({ element: arrowRef })],
|
||||
},
|
||||
)
|
||||
floatingInstance = { x, y, strategy, middlewareData: md, update }
|
||||
|
||||
// watch 可见性变化,更新位置
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (v && floatingInstance?.update)
|
||||
floatingInstance.update()
|
||||
},
|
||||
)
|
||||
|
||||
// watch x/y 变化,实时更新样式
|
||||
watch([x, y, md], () => {
|
||||
floatingStyles.value = {
|
||||
position: strategy.value,
|
||||
left: `${x.value ?? 0}px`,
|
||||
top: `${y.value ?? 0}px`,
|
||||
}
|
||||
middlewareData.value = md.value
|
||||
}, { immediate: true })
|
||||
// 获取真实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
|
||||
}
|
||||
|
||||
// popup 显隐
|
||||
const showPopup = () => { visible.value = true; initFloating() }
|
||||
const hidePopup = () => { visible.value = false }
|
||||
const togglePopup = () => { visible.value = !visible.value; initFloating() }
|
||||
// 清理监听滚动
|
||||
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 prepareFloating = async () => {
|
||||
if (!hasRendered.value)
|
||||
hasRendered.value = true
|
||||
fitFloatingWidth()
|
||||
}
|
||||
|
||||
const showPopup = async () => {
|
||||
await prepareFloating()
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
const hidePopup = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const togglePopup = async () => {
|
||||
await prepareFloating()
|
||||
visible.value = !visible.value
|
||||
}
|
||||
|
||||
// 事件绑定
|
||||
const triggers = Array.isArray(props.trigger) ? props.trigger : [props.trigger]
|
||||
const eventProps: Record<string, (e: Event) => void> = {}
|
||||
if (props.trigger === 'hover') {
|
||||
|
||||
if (triggers.includes('hover')) {
|
||||
eventProps.onMouseenter = showPopup
|
||||
eventProps.onMouseleave = hidePopup
|
||||
}
|
||||
else if (props.trigger === 'click') {
|
||||
if (triggers.includes('click')) {
|
||||
eventProps.onClick = togglePopup
|
||||
}
|
||||
else if (props.trigger === 'focus') {
|
||||
if (triggers.includes('focus')) {
|
||||
eventProps.onFocus = showPopup
|
||||
eventProps.onBlur = hidePopup
|
||||
}
|
||||
if (triggers.includes('contextMenu')) {
|
||||
eventProps.onContextmenu = (e: Event) => {
|
||||
e.preventDefault()
|
||||
togglePopup()
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 点击其他地方关闭
|
||||
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)) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 Teleport 挂载目标
|
||||
const getTeleportTarget = () => {
|
||||
if (props.appendTo instanceof HTMLElement)
|
||||
return props.appendTo
|
||||
if (typeof props.appendTo === 'string') {
|
||||
const el = document.querySelector(props.appendTo)
|
||||
if (el)
|
||||
return el
|
||||
}
|
||||
return 'body'
|
||||
}
|
||||
|
||||
// 渲染 trigger slot
|
||||
const renderTriggerSlot = () => {
|
||||
const slotContent = slots.reference?.() || slots.default?.() || []
|
||||
let triggerSlot: VNode | null = null
|
||||
if (slotContent.length === 1)
|
||||
return cloneVNode(slotContent[0], { ref: triggerRef, ...eventProps })
|
||||
return (
|
||||
<span ref={triggerRef} style="display:inline-flex;" {...eventProps}>
|
||||
{slotContent.map(v => cloneVNode(v))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (slotContent.length === 1) {
|
||||
const child = slotContent[0]
|
||||
const isHoisted = child.patchFlag != null && child.patchFlag === -1
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
if (!isHoisted) {
|
||||
triggerSlot = cloneVNode(child, { ref: triggerRef, ...eventProps })
|
||||
}
|
||||
else {
|
||||
triggerSlot = (
|
||||
<span ref={triggerRef} style="display:inline-flex;" {...eventProps}>
|
||||
{slotContent.map(v => cloneVNode(v))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
clearAutoUpdate()
|
||||
})
|
||||
|
||||
watch(visible, async (val) => {
|
||||
if (val) {
|
||||
setupAutoUpdate()
|
||||
}
|
||||
else {
|
||||
triggerSlot = (
|
||||
<span ref={triggerRef} style="display:inline-flex;" {...eventProps}>
|
||||
{slotContent.map(v => cloneVNode(v))}
|
||||
</span>
|
||||
)
|
||||
clearAutoUpdate()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 懒加载
|
||||
const shouldRenderPopup = props.destroyOnClose ? visible.value : hasRendered.value
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerSlot}
|
||||
{renderTriggerSlot()}
|
||||
|
||||
<Teleport to="body">
|
||||
{visible.value && (
|
||||
<div ref={floatingRef} class={prefixCls} style={floatingStyles.value}>
|
||||
Popup 内容
|
||||
<div
|
||||
class={`${prefixCls}-arrow`}
|
||||
ref={arrowRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: middlewareData.value.arrow?.x != null
|
||||
? `${middlewareData.value.arrow.x}px`
|
||||
: undefined,
|
||||
top: middlewareData.value.arrow?.y != null
|
||||
? `${middlewareData.value.arrow.y}px`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<Teleport to={getTeleportTarget()}>
|
||||
{shouldRenderPopup && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
name="popup-fade"
|
||||
appear
|
||||
>
|
||||
<div
|
||||
v-show={visible.value}
|
||||
ref={floatingRef}
|
||||
class={[prefixCls, props.popupClass]}
|
||||
style={{
|
||||
...floatingStyles.value,
|
||||
position: 'absolute',
|
||||
...props.popupStyle,
|
||||
}}
|
||||
data-popup-placement={placement.value}
|
||||
>
|
||||
{slots.content ? slots.content() : props.content}
|
||||
{props.showArrow && (
|
||||
<div
|
||||
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>
|
||||
|
||||
Reference in new issue
Block a user