diff --git a/code/base-project/App.vue b/code/base-project/App.vue new file mode 100644 index 00000000..3a94ebbd --- /dev/null +++ b/code/base-project/App.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/code/base-project/base-vue/.vscode/vue3.code-snippets b/code/base-project/base-vue/.vscode/vue3.code-snippets new file mode 100644 index 00000000..8a6c6f5c --- /dev/null +++ b/code/base-project/base-vue/.vscode/vue3.code-snippets @@ -0,0 +1,39 @@ +{ + "Print AoDesignComponent TSX": { + "scope": "typescriptreact", + "prefix": "atsx", + "body": [ + "import { defineComponent } from 'vue';", + "import { getPrefixCls } from '../_utils/getPrefixCls';", + "", + "export default defineComponent({", + " name: '${1:AoComponent}',", + " props: {", + " },", + " emits: [],", + " setup(props, { slots, emit }) {", + " const prefixCls = getPrefixCls('${2:component}');", + " return () => (", + "
", + " {slots.default?.()}", + "
", + " );", + " }", + "})" + ] + }, + "Print AoDesignComponent SCSS": { + "scope": "scss", + "prefix": "ascss", + "body": [ + "@use '../_styles/_variables.scss' as *;", + "@use 'sass:color';", + "", + "$${1:component}-prefix: #{$prefix}-${1:component};", + "", + ".#{${1:component}-prefix} {", + "}" + ] + } + } + \ No newline at end of file diff --git a/code/base-project/base-vue/packages/ao-design/package.json b/code/base-project/base-vue/packages/ao-design/package.json index 4b9ef31a..7ce4eb17 100644 --- a/code/base-project/base-vue/packages/ao-design/package.json +++ b/code/base-project/base-vue/packages/ao-design/package.json @@ -15,6 +15,7 @@ "vue": "^3.3.0" }, "dependencies": { + "@floating-ui/vue": "^1.1.9" }, "devDependencies": { "sass": "^1.90.0", 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 3dd93799..680be5f0 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,58 +1,31 @@ -.popup-reference { - display: inline-block; /* keeps reference size */ -} +@use '../_styles/_variables.scss' as *; +@use 'sass:color'; -.popup { +$popup-prefix: "#{$prefix}-popup"; + +.#{$popup-prefix} { position: absolute; - background: #333; + background-color: #333; color: #fff; - padding: 6px 10px; - border-radius: 4px; - font-size: 13px; - white-space: nowrap; + padding: 4px 8px; + font-size: 12px; + border-radius: 2px; z-index: 1000; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); + box-sizing: border-box; - .popup-content { - pointer-events: none; - } - - .popup-arrow { + &-arrow { position: absolute; - width: 0; - height: 0; - border-style: solid; + width: 8px; + height: 8px; } - &.popup-top .popup-arrow { - bottom: -6px; - left: 50%; - transform: translateX(-50%); - border-width: 6px 6px 0 6px; - border-color: #333 transparent transparent transparent; - } - - &.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; + &-arrow::before { + position: absolute; + content: ''; + width: 8px; + height: 8px; + background: #333; + transform: rotate(45deg); + box-sizing: border-box; } } 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 61dc6c82..feaccafa 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,216 +1,113 @@ -import type { PropType } from 'vue' -import { cloneVNode, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, Teleport } from 'vue' +import type { VNode } from 'vue' +import { arrow, offset, useFloating } from '@floating-ui/vue' +import { cloneVNode, defineComponent, ref, Teleport, onMounted } from 'vue' -type Placement = 'top' | 'bottom' | 'left' | 'right' -type Trigger = 'hover' | 'click' +import { getPrefixCls } from '../_utils/getPrefixCls' export default defineComponent({ 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' }, + /** + * 触发方式 + * - 'hover': 鼠标悬停显示/隐藏 + * - 'click': 点击切换显示/隐藏 + * - 'focus': 聚焦显示,失焦隐藏 + */ + trigger: { + type: String as () => 'hover' | 'click' | 'focus', + default: 'hover', + }, }, setup(props, { slots }) { - const reference = ref(null) - const popup = ref(null) + const prefixCls = getPrefixCls('popup') + const triggerRef = ref(null) + const floatingRef = ref(null) + const arrowRef = ref(null) const visible = ref(false) - 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) - } + // 使用 useFloating 管理浮动层和箭头 + const { floatingStyles, middlewareData } = useFloating( + triggerRef, + floatingRef, + { + placement: 'top', + middleware: [offset(10), arrow({ element: arrowRef })], + }, + ) + // 监听 triggerRef 挂载完成 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) + console.log('triggerRef 已挂载:', triggerRef.value) // 正确打印 + // 可以在此处调用 useFloating 的更新逻辑(如果有需要) }) - 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) - }) + // 显示/隐藏/切换浮动层 + const showPopup = () => (visible.value = true) + const hidePopup = () => (visible.value = false) + const togglePopup = () => (visible.value = !visible.value) - return () => { - const defaultSlot = slots.default?.() - if (!defaultSlot || defaultSlot.length !== 1) - return null + // 绑定事件 + const eventProps: Record void> = {} + if (props.trigger === 'hover') { + eventProps.onMouseenter = () => showPopup() + eventProps.onMouseleave = () => hidePopup() + } + else if (props.trigger === 'click') { + eventProps.onClick = () => togglePopup() + } + else if (props.trigger === 'focus') { + eventProps.onFocus = () => showPopup() + eventProps.onBlur = () => hidePopup() + } - // 克隆触发元素并绑定 ref 和事件 - const triggerEl = cloneVNode(defaultSlot[0], { ref: reference }) + // 获取 reference 插槽或默认插槽 + const slotContent = slots.reference?.() || slots.default?.() || [] + let triggerSlot: VNode | null = null - return ( - <> - {triggerEl} - -