This commit is contained in:
oneao committed 2025-09-11 17:31:55 +08:00
1 parent b24015fbae
commit 6d4f1ee402
19 files changed
+782 -108

No files matched your search

@@ -7,7 +7,6 @@ $popup-prefix: '#{$prefix}-popup';
position: absolute;
background-color: $bg-color-container;
color: $text-color-base;
padding: 4px 8px;
font-size: 13px;
border-radius: $border-radius;
z-index: 1000;
@@ -29,7 +28,9 @@ $popup-prefix: '#{$prefix}-popup';
transform-origin: center;
}
}
&-content {
padding: 4px 8px;
}
// 上方弹出,箭头在底部
&[data-popup-placement^='top'] > &-arrow {
bottom: -4px;
@@ -1,4 +1,4 @@
import type { PropType } from 'vue'
import type { PropType, VNode } from 'vue'
import { arrow, autoUpdate, flip, offset, useFloating } from '@floating-ui/vue'
import {
@@ -17,6 +17,8 @@ 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'>
@@ -33,7 +35,7 @@ export default defineComponent({
},
showArrow: {
type: Boolean,
default: true,
default: false,
},
destroyOnClose: {
type: Boolean,
@@ -60,18 +62,19 @@ export default defineComponent({
default: () => ({}),
},
content: {
type: String,
type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode),
default: '',
},
},
setup(props, { slots }) {
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 visible = ref(false)
const hasRendered = ref(false)
const localVisible = ref(props.visible)
const hasRendered = ref(props.visible)
let cleanup: (() => void) | null = null
const { floatingStyles, middlewareData, placement, update } = useFloating(
@@ -132,25 +135,43 @@ export default defineComponent({
floatEl.style.width = `${triggerEl.offsetWidth}px`
}
// 准备浮动框
const prepareFloating = async () => {
const showPopup = async () => {
if (!hasRendered.value)
hasRendered.value = true
await nextTick()
fitFloatingWidth()
localVisible.value = true
setupAutoUpdate()
emit('update:visible', true)
}
const showPopup = async () => {
await prepareFloating()
visible.value = true
}
const hidePopup = async () => {
localVisible.value = false
const hidePopup = () => {
visible.value = false
clearAutoUpdate()
emit('update:visible', false)
}
const togglePopup = async () => {
await prepareFloating()
visible.value = !visible.value
if (!hasRendered.value)
hasRendered.value = true
await nextTick()
fitFloatingWidth()
localVisible.value = !localVisible.value
await nextTick()
if (localVisible.value) {
setupAutoUpdate()
}
else {
clearAutoUpdate()
}
emit('update:visible', localVisible.value)
}
// 事件绑定
@@ -181,29 +202,57 @@ export default defineComponent({
const triggerEl = getDOM(triggerRef.value)
const floatingEl = getDOM(floatingRef.value)
if (!triggerEl?.contains(target) && !floatingEl?.contains(target)) {
visible.value = false
localVisible.value = false
}
}
// 获取 Teleport 挂载目标
const getTeleportTarget = () => {
if (props.appendTo instanceof HTMLElement)
return props.appendTo
if (typeof props.appendTo === 'string') {
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)
return el
targetEl.value = el as HTMLElement
}
return 'body'
}
})
// 渲染 trigger slot
const renderTriggerSlot = () => {
const slotContent = slots.reference?.() || slots.default?.() || []
if (slotContent.length === 1)
return cloneVNode(slotContent[0], { ref: triggerRef, ...eventProps })
// 只有一个子节点时
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 ref={triggerRef} style="display:inline-flex;" {...eventProps}>
<span
style="display:inline-flex;"
{...eventProps}
ref={(el) => {
triggerRef.value = getDOM(el)
}}
>
{slotContent.map(v => cloneVNode(v))}
</span>
)
@@ -218,24 +267,29 @@ export default defineComponent({
clearAutoUpdate()
})
watch(visible, async (val) => {
if (val) {
setupAutoUpdate()
}
else {
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 ? visible.value : hasRendered.value
const shouldRenderPopup = props.destroyOnClose
? localVisible.value // 需要销毁时,只在显示时渲染
: localVisible.value || hasRendered.value // 不销毁时保持已渲染
return (
<>
{renderTriggerSlot()}
<Teleport to={getTeleportTarget()}>
<Teleport to={targetEl.value}>
{shouldRenderPopup && (
<div
style={{
@@ -250,7 +304,7 @@ export default defineComponent({
appear
>
<div
v-show={visible.value}
v-show={localVisible.value}
ref={floatingRef}
class={[prefixCls, props.popupClass]}
style={{
@@ -260,7 +314,7 @@ export default defineComponent({
}}
data-popup-placement={placement.value}
>
{slots.content ? slots.content() : props.content}
{slots.content ? slots.content() : renderContent(props.content)}
{props.showArrow && (
<span
class={`${prefixCls}-arrow`}