This commit is contained in:
oneao committed 2025-09-09 17:30:55 +08:00
1 parent 5a3f484b8b
commit 0172becffb
4 files changed
+688 -119

No files matched your search

@@ -1,31 +1,70 @@
@use '../_styles/_variables.scss' as *;
@use 'sass:color';
$popup-prefix: "#{$prefix}-popup";
$popup-prefix: '#{$prefix}-popup';
.#{$popup-prefix} {
position: absolute;
background-color: #333;
color: #fff;
background-color: $bg-color-container;
color: $text-color-base;
padding: 4px 8px;
font-size: 12px;
font-size: 13px;
border-radius: 2px;
z-index: 1000;
box-sizing: border-box;
box-shadow: $shadow-popup;
text-align: center;
&-arrow {
position: absolute;
width: 8px;
height: 8px;
z-index: 1;
&::before {
position: absolute;
content: '';
width: 8px;
height: 8px;
transform: rotate(45deg);
// background: $bg-color-container;
background: red;
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
// 上方弹出,箭头在底部
&[data-popup-placement^='top'] > &-arrow {
bottom: -4px;
}
&-arrow::before {
position: absolute;
content: '';
width: 8px;
height: 8px;
background: #333;
transform: rotate(45deg);
box-sizing: border-box;
// 下方弹出,箭头在顶部
&[data-popup-placement^='bottom'] > &-arrow {
top: -4px;
}
// 左侧弹出,箭头在右侧
&[data-popup-placement^='left'] > &-arrow {
right: -4px;
}
// 右侧弹出,箭头在左侧
&[data-popup-placement^='right'] > &-arrow {
left: -4px;
}
}
/* 普通淡入淡出动画 */
.popup-fade-enter-from,
.popup-fade-leave-to {
opacity: 0;
}
.popup-fade-enter-to,
.popup-fade-leave-from {
opacity: 1;
}
.popup-fade-enter-active,
.popup-fade-leave-active {
transition: opacity 0.2s ease;
}
@@ -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>
@@ -0,0 +1,263 @@
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, 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) // float 引用
const innerFloatingRef = ref<HTMLElement | null>(null) // 真正的 float 引用
const arrowRef = ref<HTMLElement | null>(null) // 箭头引用
const visible = ref(false)
const hasRendered = ref(false)
const { floatingStyles, middlewareData, placement, update } = useFloating(
triggerRef,
floatingRef,
{
placement: props.placement,
middleware: [offset(10), flip(), arrow({ element: arrowRef })],
whileElementsMounted: autoUpdate,
},
)
const prepareFloating = async () => {
// 首次渲染标记
if (!hasRendered.value)
hasRendered.value = true
// 等待 DOM 更新
await nextTick()
// 绑定浮动元素引用
floatingRef.value = innerFloatingRef.value
update() // 先更新位置
// 获取触发元素的原生 DOM
const triggerEl: HTMLElement | null = (() => {
const val = triggerRef.value
if (!val)
return null
return (val as any)?.$el || val
})()
// 自动设置宽度或最小宽度
if (triggerEl && innerFloatingRef.value) {
if (props.autoFitPopupMinWidth) {
innerFloatingRef.value.style.minWidth = `${triggerEl.offsetWidth}px`
}
if (props.autoFitPopupWidth) {
innerFloatingRef.value.style.width = `${triggerEl.offsetWidth}px`
}
}
}
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 (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 onClickOutside = (event: MouseEvent) => {
const target = event.target as Node
const triggerEl = (triggerRef.value as any)?.$el || triggerRef.value
const floatingEl = floatingRef.value
if (!triggerEl?.contains(target) && !floatingEl?.contains(target)) {
visible.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
// Teleport 挂载目标
const teleportTarget = () => {
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'
}
return () => {
const slotContent = slots.reference?.() || slots.default?.() || []
let triggerSlot: VNode | null = null
if (slotContent.length === 1) {
const child = slotContent[0]
triggerSlot = cloneVNode(child, { ref: triggerRef, ...eventProps })
}
else {
triggerSlot = (
<span ref={triggerRef} style="display:inline-flex;" {...eventProps}>
{slotContent.map(v => cloneVNode(v))}
</span>
)
}
// 懒加载标志
const shouldRenderPopup = props.destroyOnClose ? visible.value : hasRendered.value
return (
<>
{triggerSlot}
<Teleport to={teleportTarget()}>
{shouldRenderPopup && (
<div
style={{
position: 'absolute',
top: '0px',
left: '0px',
width: '100%',
}}
>
<Transition
name="popup-fade"
appear
onAfterLeave={() => {
floatingRef.value = null
update()
}}
>
<div
v-show={visible.value}
ref={innerFloatingRef}
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>
</>
)
}
},
})
+139 -16
View File
@@ -1,22 +1,145 @@
<script lang="ts">
<script lang="ts">
import { defineComponent } from 'vue'
</script>
export default defineComponent({
setup() {
const placements = [
'top',
'top-start',
'top-end',
'right',
'right-start',
'right-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end',
] as const
<template>
<div class="page-center">
<APopup trigger="click">
<button style="margin-right: 12px;">
return { placements }
},
})
</script>
<template>
<div
style="height: 300px; overflow: auto; border: 1px solid #ccc; padding: 20px;"
>
<div style="height: 600px; position: relative;">
<APopup
trigger="click" placement="top" auto-fit-popup-min-width content="你好"
>
<AButton status="primary" style="margin-top: 44px;">
测试
</button>
</AButton>
</APopup>
</div>
</template>
</div>
<div class="demo-circle">
<h3>12方向Popup示例</h3>
<div class="circle-container">
<div
v-for="placement in placements"
:key="placement"
class="circle-item"
:class="placement"
>
<APopup trigger="click" :placement="placement" content="1234567891">
<button>{{ placement }}</button>
</APopup>
</div>
</div>
</div>
</template>
<style scoped>
.page-center {
display: flex;
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
height: 800px; /* 高度撑满整个视口 */
}
</style>
<style scoped>
.demo-circle {
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
}
.circle-container {
position: relative;
width: 400px;
height: 400px;
border: 1px dashed #ccc;
border-radius: 50%;
}
/* 每个方向的按钮位置 */
.circle-item {
position: absolute;
}
/* 上 */
.circle-item.top {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.circle-item.top-start {
top: 0;
left: 25%;
transform: translateX(-50%);
}
.circle-item.top-end {
top: 0;
left: 75%;
transform: translateX(-50%);
}
/* 右 */
.circle-item.right {
top: 50%;
right: 0;
transform: translateY(-50%);
}
.circle-item.right-start {
top: 25%;
right: 0;
transform: translateY(-50%);
}
.circle-item.right-end {
top: 75%;
right: 0;
transform: translateY(-50%);
}
/* 下 */
.circle-item.bottom {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.circle-item.bottom-start {
bottom: 0;
left: 25%;
transform: translateX(-50%);
}
.circle-item.bottom-end {
bottom: 0;
left: 75%;
transform: translateX(-50%);
}
/* 左 */
.circle-item.left {
top: 50%;
left: 0;
transform: translateY(-50%);
}
.circle-item.left-start {
top: 25%;
left: 0;
transform: translateY(-50%);
}
.circle-item.left-end {
top: 75%;
left: 0;
transform: translateY(-50%);
}
</style>