From 5136bb90bdd3e388be25132ca57a7dee8340485c Mon Sep 17 00:00:00 2001 From: oneao Date: Sun, 7 Sep 2025 21:52:14 +0800 Subject: [PATCH] u --- .../packages/ao-design/src/popup/index.scss | 62 ++- .../packages/ao-design/src/popup/index.tsx | 219 ++++++++++- code/base-project/base-vue/src/App.vue | 61 ++- code/base-project/base-vue/test.html | 367 +++++++++++++----- 4 files changed, 595 insertions(+), 114 deletions(-) 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 2468d012..3dd93799 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,10 +1,58 @@ -@use '../_styles/_variables.scss' as *; +.popup-reference { + display: inline-block; /* keeps reference size */ +} -$popup-prefix: #{$prefix}-popup; +.popup { + position: absolute; + background: #333; + color: #fff; + padding: 6px 10px; + border-radius: 4px; + font-size: 13px; + white-space: nowrap; + z-index: 1000; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); + + .popup-content { + pointer-events: none; + } + + .popup-arrow { + position: absolute; + width: 0; + height: 0; + border-style: solid; + } + + &.popup-top .popup-arrow { + bottom: -6px; + left: 50%; + transform: translateX(-50%); + border-width: 6px 6px 0 6px; + border-color: #333 transparent transparent transparent; + } -.#{$popup-prefix} { - position: absolute; - top: 0px; - left: 0px; - width: 100%; + &.popup-bottom .popup-arrow { + top: -6px; + left: 50%; + transform: translateX(-50%); + border-width: 0 6px 6px 6px; + border-color: transparent transparent #333 transparent; + } + + &.popup-left .popup-arrow { + right: -6px; + top: 50%; + transform: translateY(-50%); + border-width: 6px 0 6px 6px; + border-color: transparent transparent transparent #333; + } + + &.popup-right .popup-arrow { + left: -6px; + top: 50%; + transform: translateY(-50%); + border-width: 6px 6px 6px 0; + border-color: transparent #333 transparent transparent; + } } 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 5a694975..61dc6c82 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,15 +1,216 @@ -import { defineComponent } from 'vue' -import { getPrefixCls } from '../_utils/getPrefixCls' +import type { PropType } from 'vue' +import { cloneVNode, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, Teleport } from 'vue' + +type Placement = 'top' | 'bottom' | 'left' | 'right' +type Trigger = 'hover' | 'click' export default defineComponent({ - name: 'Popup', + name: 'APopup', + props: { + placement: { type: String as PropType, default: 'top' }, + trigger: { type: String as PropType, default: 'hover' }, + content: { type: String, required: true }, + attach: { type: [String, Object] as PropType, default: 'body' }, + }, setup(props, { slots }) { - const prefixCls = getPrefixCls('popup') + const reference = ref(null) + const popup = ref(null) + const visible = ref(false) - return () => ( -
- {/* {slots?.default()} */} -
- ) + const getAttachTarget = (): HTMLElement => { + if (typeof props.attach === 'string') { + const el = document.querySelector(props.attach) + return (el as HTMLElement) || document.body + } + return props.attach as HTMLElement || document.body + } + + const show = async () => { + if (visible.value) + return + visible.value = true + await nextTick() + updatePosition() + attachPopupHover() + if (props.trigger === 'click') + document.addEventListener('click', onClickOutside) + } + + const hide = () => { + if (!visible.value) + return + visible.value = false + if (props.trigger === 'click') + document.removeEventListener('click', onClickOutside) + } + + const toggle = () => (visible.value ? hide() : show()) + + const onClickOutside = (e: MouseEvent) => { + const t = e.target as Node + if (!reference.value || !popup.value) + return + if (!reference.value.contains(t) && !popup.value.contains(t)) + hide() + } + + const updatePosition = () => { + if (!reference.value || !popup.value) + return + + const attachTarget = getAttachTarget() + const rect = reference.value.getBoundingClientRect() + const attachRect = attachTarget === document.body ? { left: 0, top: 0 } : attachTarget.getBoundingClientRect() + const scrollX = attachTarget === document.body ? window.scrollX : attachTarget.scrollLeft + const scrollY = attachTarget === document.body ? window.scrollY : attachTarget.scrollTop + + const tip = popup.value + const gap = 8 + + const prevDisplay = tip.style.display + tip.style.display = 'block' + tip.style.visibility = 'hidden' + const tipRect = tip.getBoundingClientRect() + tip.style.visibility = '' + tip.style.display = visible.value ? 'block' : prevDisplay || 'none' + + let left = 0 + let top = 0 + let finalPlacement = props.placement + + const baseX = rect.left - attachRect.left + scrollX + const baseY = rect.top - attachRect.top + scrollY + + // 自动翻转逻辑 + if (props.placement === 'top') { + left = baseX + rect.width / 2 - tipRect.width / 2 + top = baseY - tipRect.height - gap + if (top < 0) { + finalPlacement = 'bottom' + top = baseY + rect.height + gap + } + } + else if (props.placement === 'bottom') { + left = baseX + rect.width / 2 - tipRect.width / 2 + top = baseY + rect.height + gap + const maxY = attachTarget === document.body ? window.innerHeight + scrollY : attachTarget.clientHeight + scrollY + if (top + tipRect.height > maxY) { + finalPlacement = 'top' + top = baseY - tipRect.height - gap + } + } + else if (props.placement === 'left') { + left = baseX - tipRect.width - gap + top = baseY + rect.height / 2 - tipRect.height / 2 + if (left < 0) { + finalPlacement = 'right' + left = baseX + rect.width + gap + } + } + else { + left = baseX + rect.width + gap + top = baseY + rect.height / 2 - tipRect.height / 2 + const maxX = attachTarget === document.body ? window.innerWidth + scrollX : attachTarget.clientWidth + scrollX + if (left + tipRect.width > maxX) { + finalPlacement = 'left' + left = baseX - tipRect.width - gap + } + } + + left = Math.max(6, left) + top = Math.max(6, top) + + tip.style.left = `${left}px` + tip.style.top = `${top}px` + + const arrow = tip.querySelector('.popup-arrow') as HTMLElement + if (arrow) { + const minOffset = 12 + if (finalPlacement === 'top' || finalPlacement === 'bottom') { + let arrowOffset = baseX + rect.width / 2 - left + arrowOffset = Math.min(Math.max(arrowOffset, minOffset), tipRect.width - minOffset) + arrow.style.left = `${arrowOffset}px` + arrow.style.top = '' + } + else { + let arrowOffset = baseY + rect.height / 2 - top + arrowOffset = Math.min(Math.max(arrowOffset, minOffset), tipRect.height - minOffset) + arrow.style.top = `${arrowOffset}px` + arrow.style.left = '' + } + } + + tip.className = `popup popup-${finalPlacement}` + } + + const attachPopupHover = () => { + if (!popup.value || props.trigger !== 'hover') + return + popup.value.addEventListener('mouseenter', show) + popup.value.addEventListener('mouseleave', hide) + } + + const detachPopupHover = () => { + if (!popup.value) + return + popup.value.removeEventListener('mouseenter', show) + popup.value.removeEventListener('mouseleave', hide) + } + + onMounted(() => { + if (reference.value) { + if (props.trigger === 'hover') { + reference.value.addEventListener('mouseenter', show) + reference.value.addEventListener('mouseleave', hide) + } + else { + reference.value.addEventListener('click', (e) => { + e.stopPropagation() + toggle() + }) + } + } + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + }) + + onBeforeUnmount(() => { + if (reference.value) { + reference.value.removeEventListener('mouseenter', show) + reference.value.removeEventListener('mouseleave', hide) + reference.value.removeEventListener('click', toggle) + } + detachPopupHover() + document.removeEventListener('click', onClickOutside) + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + }) + + return () => { + const defaultSlot = slots.default?.() + if (!defaultSlot || defaultSlot.length !== 1) + return null + + // 克隆触发元素并绑定 ref 和事件 + const triggerEl = cloneVNode(defaultSlot[0], { ref: reference }) + + return ( + <> + {triggerEl} + +