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

+2 -2
View File
@@ -49,9 +49,9 @@
"@use '../_styles/_variables.scss' as *;",
"@use 'sass:color';",
"",
"$${1:component}-prefix: #{$prefix}-${1:component};",
"$${1:component}-prefix: '#{$$prefix}-${1:component}';",
"",
".#{${1:component}-prefix} {",
".#{$${1:component}-prefix} {",
"}"
]
}
@@ -1,3 +1,4 @@
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Icon from '../icon'
@@ -19,6 +20,7 @@ export default defineComponent({
iconPosition: { type: String as () => 'left' | 'right', default: 'left' },
size: { type: String as () => 'small' | 'medium' | 'large', default: 'medium' },
block: { type: Boolean, default: false },
onClick: { type: Function as PropType<(event: MouseEvent) => void> }, // 注意这里不要加 ?
},
setup(props, { slots }) {
const prefixCls = getPrefixCls('btn')
@@ -39,7 +41,11 @@ export default defineComponent({
return () => {
return (
<button class={cls.value} disabled={props.disabled || props.loading}>
<button
class={cls.value}
disabled={props.disabled || props.loading}
onClick={props.onClick}
>
{props.iconPosition === 'left' && (props.loading || props.icon) && (
<Icon
icon={props.loading ? 'mingcute:loading-line' : props.icon}
@@ -1,8 +1,10 @@
export { default as ConfigProvider } from './_config'
export { default as Button } from './button'
export { default as Drawer } from './drawer'
export { default as Group } from './group'
export { default as Icon } from './icon'
export { default as Input } from './input'
export { default as Popconfirm } from './popconfirm'
export { default as Popup } from './popup'
export { default as Spin } from './spin'
export { default as Textarea } from './textarea'
@@ -0,0 +1,143 @@
@use '../_styles/_variables.scss' as *;
$drawer-prefix: '#{$prefix}-drawer';
.#{$drawer-prefix} {
z-index: 1500;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
outline: none;
box-sizing: border-box;
margin: 0;
padding: 0;
&-mask {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
&-wrapper {
position: absolute;
background: #fff;
display: flex;
flex-direction: column;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition:
transform 0.25s ease,
opacity 0.25s ease;
}
&-header {
display: flex;
align-items: center;
min-height: 46px;
padding: 16px;
font-size: 16px;
font-weight: 600;
color: $text-color-base;
border-bottom: 1px solid #e8e8e8;
box-sizing: border-box;
.#{$drawer-prefix}-close {
flex-shrink: 0;
margin-left: auto;
color: $text-color-assist;
cursor: pointer;
border-radius: $border-radius;
width: 20px;
height: 20px;
&:hover {
background-color: $default-color-hover;
}
}
}
&-body {
flex: 1;
padding: 16px;
overflow: auto;
font-size: 14px;
}
&-footer {
width: 100%;
padding: 16px;
text-align: left;
border-top: 1px solid #e8e8e8;
box-sizing: border-box;
background-color: $bg-color-control;
display: flex;
gap: 8px;
}
&-right {
top: 0;
right: 0;
}
&-left {
top: 0;
left: 0;
}
&-top {
top: 0;
left: 0;
}
&-bottom {
bottom: 0;
left: 0;
}
&-open {
pointer-events: auto;
.#{$drawer-prefix}-mask {
opacity: 1;
pointer-events: auto;
}
&.#{$drawer-prefix}-right .#{$drawer-prefix}-wrapper {
transform: translateX(0);
}
&.#{$drawer-prefix}-left .#{$drawer-prefix}-wrapper {
transform: translateX(0);
}
&.#{$drawer-prefix}-top .#{$drawer-prefix}-wrapper {
transform: translateY(0);
}
&.#{$drawer-prefix}-bottom .#{$drawer-prefix}-wrapper {
transform: translateY(0);
}
}
&-right .#{$drawer-prefix}-wrapper {
top: 0;
right: 0;
height: 100%;
transform: translateX(100%);
}
&-left .#{$drawer-prefix}-wrapper {
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
}
&-top .#{$drawer-prefix}-wrapper {
top: 0;
left: 0;
width: 100%;
transform: translateY(-100%);
}
&-bottom .#{$drawer-prefix}-wrapper {
bottom: 0;
left: 0;
width: 100%;
transform: translateY(100%);
}
}
@@ -0,0 +1,182 @@
import type { PropType } from 'vue'
import { defineComponent, onMounted, ref, Teleport } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Button from '../button/index'
import Icon from '../icon/index'
type DrawerSize = 'small' | 'medium' | 'large'
type SizeType = number | string | DrawerSize
export default defineComponent({
name: 'Drawer',
props: {
visible: {
type: Boolean,
default: false,
},
placement: {
type: String as PropType<'top' | 'bottom' | 'left' | 'right'>,
default: 'right',
},
size: {
type: [Number, String] as PropType<SizeType>,
default: 'medium',
},
title: {
type: String,
default: '',
},
showClose: {
type: Boolean,
default: false,
},
confirmBtn: {
type: null,
default: () => ({ label: '确认', type: 'solid', status: 'primary' }),
},
cancelBtn: {
type: null,
default: () => ({ label: '取消', type: 'solid', status: 'default' }),
},
asyncConfirm: {
type: Boolean,
default: false,
},
maskClose: {
type: Boolean,
default: true,
},
appendTo: {
type: [String, Object] as PropType<string | HTMLElement>,
default: 'body',
},
},
emits: ['update:visible', 'confirm', 'cancel'],
setup(props, { slots, emit }) {
const prefixCls = getPrefixCls('drawer')
const confirmLoading = ref<boolean>(false)
const close = () => {
emit('update:visible', false)
confirmLoading.value = false
}
const handleMaskClick = () => {
if (props.maskClose) {
close()
}
}
const resolveSize = (value: SizeType, defaultPx = 300) => {
if (typeof value === 'number')
return `${value}px`
if (typeof value === 'string') {
const presetMap: Record<DrawerSize, string> = {
small: '300px',
medium: '500px',
large: '700px',
}
if (presetMap[value as DrawerSize])
return presetMap[value as DrawerSize]
return value
}
return `${defaultPx}px`
}
const handleConfirm = () => {
if (props.asyncConfirm) {
confirmLoading.value = true
emit('confirm', close, () => {
confirmLoading.value = false
})
}
else {
close()
emit('confirm')
}
}
const handleCancel = () => {
close()
emit('cancel')
}
// 挂载目标位置
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)
targetEl.value = el as HTMLElement
}
})
// 判断是否是 body
const isBody = props.appendTo === 'body'
return () => (
<Teleport to={targetEl.value}>
<div
class={[prefixCls, `${prefixCls}-${props.placement}`, props.visible && `${prefixCls}-open`]}
style={{
position: isBody ? 'fixed' : 'absolute',
}}
>
<div class={`${prefixCls}-mask`} onClick={handleMaskClick}></div>
<div
class={[`${prefixCls}-wrapper`]}
style={{
width: ['left', 'right'].includes(props.placement)
? resolveSize(props.size)
: '100%',
height: ['top', 'bottom'].includes(props.placement)
? resolveSize(props.size)
: '100%',
}}
>
<div class={`${prefixCls}-header`}>
{slots.header
? slots.header()
: (
<>
<span>{props.title}</span>
{props.showClose && (
<Icon
class={`${prefixCls}-close`}
icon="ix:close"
onClick={() => emit('update:visible', false)}
/>
)}
</>
)}
</div>
<div class={`${prefixCls}-body`}>
{slots.default ? slots.default() : null}
</div>
<div class={`${prefixCls}-footer`}>
{slots.footer
? slots.footer()
: (
<>
<Button {...props.cancelBtn} block onClick={handleCancel}>
{props.cancelBtn.label}
</Button>
<Button loading={confirmLoading.value} block {...props.confirmBtn} onClick={handleConfirm}>
{props.confirmBtn.label}
</Button>
</>
)}
</div>
</div>
</div>
</Teleport>
)
},
})
@@ -5,6 +5,8 @@
@use './textarea/index.scss' as *;
@use './group/index.scss' as *;
@use './popup/index.scss' as *;
@use './popconfirm/index.scss' as *;
@use './icon/index.scss' as *;
@use './message/index.scss' as *;
@use './notification/index.scss' as *;
@use './drawer/index.scss' as *;
@@ -3,29 +3,28 @@
$message-prefix: #{$prefix}-message;
.#{$message-prefix}-wrapper {
position: fixed;
z-index: 6000;
display: flex;
align-items: center;
gap: 8px;
}
.#{$message-prefix} {
&-wrapper {
position: fixed;
z-index: 6000;
display: flex;
align-items: center;
gap: 8px;
}
&-item {
display: inline-flex;
align-items: center;
outline: 0;
width: fit-content;
vertical-align: middle;
border-radius: $border-radius;
padding: 6px 12px;
background-color: $bg-color-container;
color: $text-color-base;
box-shadow: $shadow-overlay;
box-sizing: border-box;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
&-content {
display: inline-flex;
align-items: center;
outline: 0;
width: fit-content;
vertical-align: middle;
border-radius: $border-radius;
padding: 6px 12px;
background-color: $bg-color-container;
color: $text-color-base;
box-shadow: $shadow-overlay;
box-sizing: border-box;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
&-message {
font-size: 14px;
line-height: 1;
}
@@ -40,8 +39,8 @@ $message-prefix: #{$prefix}-message;
color: $text-color-assist;
cursor: pointer;
border-radius: 50%;
width: 16px;
height: 16px;
width: 18px;
height: 18px;
&:hover {
background-color: $default-color-hover;
}
@@ -1,3 +1,4 @@
import type { VNode } from 'vue'
import { createApp, h } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import MessageItem from './item'
@@ -16,7 +17,7 @@ type Placement
type MessageType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question'
interface MessageOptions {
message: string
message: string | VNode | (() => VNode)
type?: MessageType
placement?: Placement
duration?: number
@@ -1,4 +1,4 @@
import type { PropType } from 'vue'
import type { PropType, VNode } from 'vue'
import { defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Icon from '../icon/index'
@@ -9,7 +9,10 @@ export default defineComponent({
name: 'MessageItem',
props: {
type: { type: String, default: 'info' },
message: { type: String, required: true },
message: {
type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode),
required: true,
},
placement: { type: String, required: true },
showClose: { type: Boolean, default: false },
onClose: {
@@ -26,10 +29,14 @@ export default defineComponent({
question: 'ix:question-filled',
loading: 'mingcute:loading-line',
}
const renderMessage = (message: string | VNode | (() => VNode)) => {
if (typeof message === 'function')
return message()
return message
}
return () => (
<div
class={`${prefixCls}-item`}
class={`${prefixCls}`}
>
<Icon
icon={iconMap[props.type ?? 'info']}
@@ -37,7 +44,9 @@ export default defineComponent({
class={`${prefixCls}-icon ${prefixCls}-${props.type ?? 'info'}`}
spin={props.type === 'loading'}
/>
<span class={`${prefixCls}-content`}>{props.message}</span>
<span class={`${prefixCls}-message`}>
{renderMessage(props.message)}
</span>
{props.showClose
? (
<Icon
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useNotification } from '@ao/ao-design'
import { h } from 'vue'
const notification = useNotification()
function showCustomMessage() {
notification.success({
title: '自定义消息',
message: () =>
h('div', { style: { color: 'red', position: 'relative' } }, [
h('p', {}, '这里是自定义内容,可以是任意 VNode'),
h('strong', {}, '加粗文本'),
// 右下角按钮容器
h(
'div',
{
style: {
position: 'absolute',
bottom: '4px',
right: '4px',
display: 'flex',
gap: '4px',
},
},
[
h(
'button',
{
style: { fontSize: '12px', padding: '2px 6px' },
onClick: () => alert('Button A clicked'),
},
'A',
),
h(
'button',
{
style: { fontSize: '12px', padding: '2px 6px' },
onClick: () => alert('Button B clicked'),
},
'B',
),
],
),
]),
type: 'success',
showClose: true,
})
}
</script>
<template>
<div class="demo">
<AButton status="primary" @click="showCustomMessage">
自定义 h 函数消息
</AButton>
</div>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
margin-top: 40px;
}
</style>
@@ -8,7 +8,7 @@ $notification-prefix: #{$prefix}-notification;
z-index: 6000;
display: flex;
align-items: center;
gap: 8px;
gap: 16px;
}
.#{$notification-prefix} {
@@ -54,7 +54,7 @@ $notification-prefix: #{$prefix}-notification;
margin-left: 6px;
color: $text-color-assist;
cursor: pointer;
border-radius: 50%;
border-radius: $border-radius;
width: 20px;
height: 20px;
&:hover {
@@ -62,7 +62,7 @@ $notification-prefix: #{$prefix}-notification;
}
}
&-content {
&-message {
margin-top: 8px;
font-size: 14px;
color: $text-color-secondary;
@@ -12,7 +12,7 @@ type Placement
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'loading' | 'question'
interface NotificationOptions {
content: string | VNode | (() => VNode)
message: string | VNode | (() => VNode)
title: string
subTitle?: string
@@ -63,7 +63,7 @@ export function useNotification(): MessageFn {
opts: NotificationOptions,
defaultType: NotificationType = 'info',
): NotificationHandle => {
const { content, title, subTitle, type = defaultType, placement = 'top-end', duration = 3000, showClose } = opts
const { message, title, subTitle, type = defaultType, placement = 'top-end', duration = 3000, showClose } = opts
const container = getContainer(placement)
let timer: number | undefined
@@ -75,7 +75,7 @@ export function useNotification(): MessageFn {
type,
title,
subTitle,
content,
message,
placement,
showClose,
style: {
@@ -1,5 +1,5 @@
import type { PropType, VNode } from 'vue'
import { defineComponent, h } from 'vue'
import { defineComponent } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Icon from '../icon/index'
@@ -10,7 +10,7 @@ export default defineComponent({
props: {
type: { type: String, default: 'info' },
title: { type: String, required: true },
content: {
message: {
type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode),
required: true,
},
@@ -31,10 +31,10 @@ export default defineComponent({
question: 'ix:question-filled',
loading: 'mingcute:loading-line',
}
const renderContent = (content: string | VNode | (() => VNode)) => {
if (typeof content === 'function')
return content()
return content
const renderMessage = (message: string | VNode | (() => VNode)) => {
if (typeof message === 'function')
return message()
return message
}
return () => (
<div
@@ -67,8 +67,8 @@ export default defineComponent({
: null}
</div>
<div class={`${prefixCls}-content`}>
{renderContent(props.content)}
<div class={`${prefixCls}-message`}>
{renderMessage(props.message)}
</div>
</div>
@@ -0,0 +1,64 @@
@use '../_styles/_variables.scss' as *;
@use 'sass:color';
$popconfirm-prefix: '#{$prefix}-popconfirm';
.#{$popconfirm-prefix}-wrapper {
padding: 12px 12px;
}
.#{$popconfirm-prefix} {
&-main {
display: flex;
align-items: flex-start;
text-align: left;
}
&-icon {
display: inline-block;
margin-right: 8px;
}
&-title-wrapper {
display: inline-block;
vertical-align: top;
max-width: 320px;
color: $text-color-base;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
word-break: break-all;
}
&-title {
font-weight: 500;
font-size: 14px;
}
&-description {
margin-top: 8px;
font-size: 12px;
color: $text-color-secondary;
}
&-footer {
text-align: right;
margin-top: 16px;
> * + * {
margin-left: 8px;
}
}
&-info {
color: $primary-color;
}
&-success {
color: $success-color;
}
&-warning {
color: $warning-color;
}
&-error {
color: $danger-color;
}
&-question {
color: $primary-color;
}
&-loading {
color: $primary-color;
}
}
@@ -0,0 +1,159 @@
import type { PropType, VNode } from 'vue'
import { defineComponent, ref, watch } from 'vue'
import { getPrefixCls } from '../_utils/getPrefixCls'
import Button from '../button/index'
import Icon from '../icon/index'
import Popup from '../popup/index'
export default defineComponent({
name: 'Popconfirm',
props: {
type: {
type: String as PropType<'info' | 'success' | 'error' | 'warning' | 'question' | 'loading'>,
default: 'info',
},
title: {
type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode),
required: true,
},
description: {
type: [String, Object, Function] as unknown as () => string | VNode | (() => VNode),
required: false,
},
asyncConfirm: {
type: Boolean,
default: false, // 默认同步关闭,不显示 loading
},
confirmBtn: {
type: null,
default: () => ({ label: '确认', size: 'small', type: 'solid', status: 'primary' }),
},
cancelBtn: {
type: null,
default: () => ({ label: '取消', size: 'small', type: 'text', status: 'default' }),
},
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: 'top',
},
showArrow: {
type: Boolean,
default: true,
},
},
emits: ['confirm', 'cancel'],
setup(props, { slots, emit }) {
const prefixCls = getPrefixCls('popconfirm')
const popVisible = ref<boolean>(false)
const confirmLoading = ref<boolean>(false)
const iconMap: Record<string, string> = {
success: 'ix:success-filled',
error: 'ix:error-filled',
warning: 'ix:warning-filled',
info: 'ix:info-filled',
question: 'ix:question-filled',
loading: 'mingcute:loading-line',
}
const renderNode = (val: any) => {
if (typeof val === 'function')
return val()
return val
}
const handleConfirm = () => {
if (props.asyncConfirm) {
confirmLoading.value = true
emit('confirm', () => { // resolve
popVisible.value = false
confirmLoading.value = false
}, () => { // reject
confirmLoading.value = false
})
}
else {
popVisible.value = false
emit('confirm')
}
}
const handleCancel = () => {
popVisible.value = false
emit('cancel')
}
// 渲染内容块
const renderContent = () => {
return (
<div class={`${prefixCls}-wrapper`}>
<div class={`${prefixCls}-main`}>
{slots.icon
? (
<span class={`${prefixCls}-icon`}>{slots.icon()}</span>
)
: (
<Icon
icon={iconMap[props.type ?? 'info']}
size={22}
class={`${prefixCls}-icon ${prefixCls}-${props.type ?? 'info'}`}
spin={props.type === 'loading'}
/>
)}
<div class={`${prefixCls}-title-wrapper`}>
<span class={`${prefixCls}-title`}>
{slots.title ? slots.title() : renderNode(props.title)}
</span>
<span class={`${prefixCls}-description`}>
{slots.description
? (
<div class={`${prefixCls}-description`}>{slots.description()}</div>
)
: (
props.description && <div class={`${prefixCls}-description`}>{renderNode(props.description)}</div>
)}
</span>
</div>
</div>
{slots.footer
? (
slots.footer()
)
: (
<div class={`${prefixCls}-footer`}>
<Button {...props.cancelBtn} onClick={handleCancel}>
{props.cancelBtn.label}
</Button>
<Button loading={confirmLoading.value} {...props.confirmBtn} onClick={handleConfirm}>
{props.confirmBtn.label}
</Button>
</div>
)}
</div>
)
}
return () => (
<Popup
v-model:visible={popVisible.value}
placement={props.placement}
showArrow={props.showArrow}
content={renderContent}
trigger="click"
>
{{
default: () => slots.default?.(),
}}
</Popup>
)
},
})
@@ -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`}
+2 -20
View File
@@ -7,7 +7,7 @@ const notification = useNotification()
function showCustomMessage() {
notification.success({
title: '自定义消息',
content: () =>
message: () =>
h('div', { style: { color: 'red', position: 'relative' } }, [
h('p', {}, '这里是自定义内容,可以是任意 VNode'),
h('strong', {}, '加粗文本'),
@@ -23,29 +23,11 @@ function showCustomMessage() {
gap: '4px',
},
},
[
h(
'button',
{
style: { fontSize: '12px', padding: '2px 6px' },
onClick: () => alert('Button A clicked'),
},
'A',
),
h(
'button',
{
style: { fontSize: '12px', padding: '2px 6px' },
onClick: () => alert('Button B clicked'),
},
'B',
),
],
),
]),
type: 'success',
duration: 0,
showClose: true,
duration: 0,
})
}
</script>
+2
View File
@@ -10,11 +10,13 @@ declare module 'vue' {
export interface GlobalComponents {
AButton: typeof import('@ao/ao-design')['Button']
AConfigProvider: typeof import('@ao/ao-design')['ConfigProvider']
ADrawer: typeof import('@ao/ao-design')['Drawer']
AGroup: typeof import('@ao/ao-design')['Group']
AIcon: typeof import('@ao/ao-design')['Icon']
AInput: typeof import('@ao/ao-design')['Input']
ALoading: typeof import('@ao/ao-design')['Loading']
AMessage: typeof import('@ao/ao-design')['Message']
APopconfirm: typeof import('@ao/ao-design')['Popconfirm']
APopup: typeof import('@ao/ao-design')['Popup']
ASpin: typeof import('@ao/ao-design')['Spin']
ATextarea: typeof import('@ao/ao-design')['Textarea']