Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/ui/src/components/message/MessageContainer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="ant-message" :style="containerStyle">
<div class="ant-message" :class="{ 'ant-message-rtl': rtl }" :style="containerStyle" :dir="rtl ? 'rtl' : undefined">
<TransitionGroup name="ant-move-up" tag="div">
<MessageItem
v-for="item in messages"
Expand All @@ -18,7 +18,10 @@ import type { InternalMessageItem } from './types'

const props = defineProps<{
messages: InternalMessageItem[]
top?: number | string
config: {
top?: number | string
rtl?: boolean
}
}>()

const emit = defineEmits<{
Expand All @@ -27,13 +30,15 @@ const emit = defineEmits<{

const containerStyle = computed(() => {
const style: Record<string, string> = {}
if (props.top != null) {
style.top = typeof props.top === 'number' ? `${props.top}px` : props.top
if (props.config.top != null) {
style.top = typeof props.config.top === 'number' ? `${props.config.top}px` : props.config.top
}
return style
})

function onClose(id: string) {
emit('close', id)
}

const rtl = computed(() => props.config.rtl === true)
</script>
16 changes: 14 additions & 2 deletions packages/ui/src/components/message/MessageItem.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div :class="itemClasses" :style="item.args.style">
<div :class="itemClasses" :style="item.args.style" @click="handleClick">
<div class="ant-message-notice-content">
<span v-if="iconNode" class="ant-message-icon">
<component :is="iconNode" />
Expand All @@ -14,7 +14,7 @@
</template>

<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, isVNode, type Component } from 'vue'
import { computed, onMounted, onBeforeUnmount, watch, isVNode, type Component } from 'vue'
import {
InfoCircleFilled,
CheckCircleFilled,
Expand Down Expand Up @@ -75,10 +75,22 @@ function clearTimer() {
}
}

function handleClick(event: MouseEvent) {
props.item.args.onClick?.(event)
}
Comment on lines +78 to +80
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onClick is now forwarded via handleClick, but there isn't a regression test covering that a click on the rendered message calls the provided callback. Adding a small test for onClick would help prevent this behavior from breaking later.

Copilot uses AI. Check for mistakes.

onMounted(() => {
startTimer()
})

watch(
() => props.item.args,
() => {
clearTimer()
startTimer()
},
)
Comment on lines +86 to +92
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer reset watcher only tracks props.item.args.duration. When updating an existing keyed message without changing duration (e.g. only content changes), the auto-close timer will not restart, so the message may close sooner than expected after an update. Consider watching props.item.args (object identity) or another update signal so any keyed update restarts the timer, not just duration changes.

Copilot uses AI. Check for mistakes.

onBeforeUnmount(() => {
clearTimer()
})
Expand Down
96 changes: 95 additions & 1 deletion packages/ui/src/components/message/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { message } from '@ant-design-vue/ui'
import { message } from '../index'

describe('message', () => {
beforeEach(() => {
message.config({
top: 8,
duration: 3,
maxCount: undefined,
getContainer: () => document.body,
rtl: false,
})
message.destroy()
})

afterEach(() => {
vi.useRealTimers()
message.config({
top: 8,
duration: 3,
maxCount: undefined,
getContainer: () => document.body,
rtl: false,
})
message.destroy()
})

Expand All @@ -26,6 +41,10 @@ describe('message', () => {
expect(typeof message.warning).toBe('function')
})

it('has warn method', () => {
expect(typeof message.warn).toBe('function')
})

it('has loading method', () => {
expect(typeof message.loading).toBe('function')
})
Expand All @@ -42,6 +61,10 @@ describe('message', () => {
expect(typeof message.config).toBe('function')
})

it('has useMessage method', () => {
expect(typeof message.useMessage).toBe('function')
})

it('returns a destroy function', () => {
const destroy = message.info('Test')
expect(typeof destroy).toBe('function')
Expand All @@ -51,4 +74,75 @@ describe('message', () => {
const result = message.info('Test')
expect(typeof result.then).toBe('function')
})

it('supports object overload on type methods', () => {
const destroy = message.success({
content: 'Object params',
duration: 0,
key: 'object-overload',
})
expect(typeof destroy).toBe('function')
message.destroy('object-overload')
})

it('applies getContainer config', async () => {
const host = document.createElement('div')
document.body.appendChild(host)

message.config({ getContainer: () => host })
message.info('Container test', 0)

await Promise.resolve()
expect(host.querySelector('.ant-message')).not.toBeNull()

host.remove()
})

it('calls onClose when destroy all', () => {
const onClose = vi.fn()
message.open({ content: 'Close me', duration: 0, onClose })

message.destroy()
expect(onClose).toHaveBeenCalledTimes(1)
})

it('resolves then after actual close', async () => {
const onResolved = vi.fn()
const close = message.open({ content: 'Thenable', duration: 0 })

close.then(onResolved)
expect(onResolved).not.toHaveBeenCalled()

close()
await Promise.resolve()
expect(onResolved).toHaveBeenCalledTimes(1)
})

it('resets auto-close timer when updating same key', async () => {
vi.useFakeTimers()

message.open({ key: 'same-key', content: 'Loading', type: 'loading', duration: 10 })
const updated = message.open({ key: 'same-key', content: 'Done', type: 'success', duration: 1 })
const onResolved = vi.fn()
updated.then(onResolved)

await Promise.resolve()
vi.advanceTimersByTime(999)
expect(onResolved).not.toHaveBeenCalled()

vi.advanceTimersByTime(1)
vi.advanceTimersByTime(1000)
await Promise.resolve()

expect(onResolved).toHaveBeenCalledTimes(1)
})

it('supports chained then calls', async () => {
const close = message.open({ content: 'Chain', duration: 0 })

const chained = close.then(() => 'step1').then((val) => `${val}-step2`)
close()

await expect(chained).resolves.toBe('step1-step2')
})
})
4 changes: 4 additions & 0 deletions packages/ui/src/components/message/style/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
gap: 8px;
}

:where(.ant-message.ant-message-rtl) {
direction: rtl;
}

/* Message notice */
:where(.ant-message-notice) {
@apply text-center;
Expand Down
21 changes: 14 additions & 7 deletions packages/ui/src/components/message/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ import type { VNode, CSSProperties } from 'vue'
import type { Slot } from '@/utils/types'

export type MessageType = 'info' | 'success' | 'error' | 'warning' | 'loading'
export type MessageContent = string | VNode | (() => VNode)

export interface MessageArgsProps {
/** Message content */
content: string | VNode | (() => VNode)
content: MessageContent
/** Message type */
type?: MessageType
/** Duration in seconds (0 = never auto-close) */
duration?: number
/** Callback when message closes */
onClose?: () => void
/** Click callback */
onClick?: (e: MouseEvent) => void
/** Custom icon */
icon?: VNode | (() => VNode)
/** Unique key for update/destroy */
Expand Down Expand Up @@ -40,21 +43,25 @@ export interface MessageInstance {
success: MessageFn
error: MessageFn
warning: MessageFn
warn: MessageFn
loading: MessageFn
open: (args: MessageArgsProps) => MessageReturn
destroy: (key?: string | number) => void
config: (options: MessageConfigOptions) => void
useMessage: () => readonly [MessageInstance, () => VNode | null]
}

export type MessageFn = (
content: string | VNode | (() => VNode),
duration?: number,
onClose?: () => void,
) => MessageReturn
export type MessageFn = {
(content: MessageContent, duration?: number, onClose?: () => void): MessageReturn
(args: MessageArgsProps): MessageReturn
}

export interface MessageReturn {
(): void // call to destroy
then: (resolve: () => void) => void
then: <TResult1 = void, TResult2 = never>(
onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
) => Promise<TResult1 | TResult2>
}

export interface InternalMessageItem {
Expand Down
Loading
Loading