diff --git a/code/base-project/base-vue/packages/ao-design/src/popup/index.scss b/code/base-project/base-vue/packages/ao-design/src/popup/index.scss index 680be5f0..59a4d20a 100644 --- a/code/base-project/base-vue/packages/ao-design/src/popup/index.scss +++ b/code/base-project/base-vue/packages/ao-design/src/popup/index.scss @@ -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; +} diff --git a/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx b/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx index 602d42c1..d8877d62 100644 --- a/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx +++ b/code/base-project/base-vue/packages/ao-design/src/popup/index.tsx @@ -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, + 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(null) - const floatingRef = ref(null) - const arrowRef = ref(null) + const triggerRef = ref(null) // 触发 引用 + const floatingRef = ref(null) // float 引用 + const arrowRef = ref(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 | 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 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 ( + + {slotContent.map(v => cloneVNode(v))} + + ) + } - 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 = ( - - {slotContent.map(v => cloneVNode(v))} - - ) - } + onBeforeUnmount(() => { + document.removeEventListener('click', handleClickOutside) + clearAutoUpdate() + }) + + watch(visible, async (val) => { + if (val) { + setupAutoUpdate() } else { - triggerSlot = ( - - {slotContent.map(v => cloneVNode(v))} - - ) + clearAutoUpdate() } + }) + + return () => { + // 懒加载 + const shouldRenderPopup = props.destroyOnClose ? visible.value : hasRendered.value return ( <> - {triggerSlot} + {renderTriggerSlot()} - - {visible.value && ( -
- Popup 内容 -
+ + {shouldRenderPopup && ( +
+ +
+ {slots.content ? slots.content() : props.content} + {props.showArrow && ( +
+ )} +
+
)} diff --git a/code/base-project/base-vue/packages/ao-design/src/popup/old.tsx b/code/base-project/base-vue/packages/ao-design/src/popup/old.tsx new file mode 100644 index 00000000..7286b58e --- /dev/null +++ b/code/base-project/base-vue/packages/ao-design/src/popup/old.tsx @@ -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, + 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(null) // 触发 引用 + const floatingRef = ref(null) // float 引用 + const innerFloatingRef = ref(null) // 真正的 float 引用 + const arrowRef = ref(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 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 = ( + + {slotContent.map(v => cloneVNode(v))} + + ) + } + + // 懒加载标志 + const shouldRenderPopup = props.destroyOnClose ? visible.value : hasRendered.value + + return ( + <> + {triggerSlot} + + + {shouldRenderPopup && ( +
+ { + floatingRef.value = null + update() + }} + > +
+ {slots.content ? slots.content() : props.content} + {props.showArrow && ( +
+ )} +
+ +
+ )} + + + ) + } + }, +}) diff --git a/code/base-project/base-vue/src/App.vue b/code/base-project/base-vue/src/App.vue index 78dd2916..5f1d4fec 100644 --- a/code/base-project/base-vue/src/App.vue +++ b/code/base-project/base-vue/src/App.vue @@ -1,22 +1,145 @@ - +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 - - +