From 98041055b07919cdfb6ce2ff14af1bd0f2216d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E6=99=93=E5=90=8C=E4=B8=B6?= Date: Tue, 19 Aug 2025 23:16:49 +0800 Subject: [PATCH] feat(components): [message] add `placement` option & method (#21747) * feat(components): [message] add `placement` option & method * fix: resolve test hanging issue caused by reactive circular dependency * test: add test * feat: add placement `top-left/top-right/bottom-left/bottom-right` * refactor: split large normalizeOptions function * docs: adjust description text * chore: remove unused height expose * refactor: simpify code * style: opt-in center style & simplified animations * style: replace :not(.center) with :is(.left,.right) for more explicit * feat: add `placement` to `config-provider` * fix: fix test warning when placement is undefined * refactor: remove useless style & simpify types * fix: avoid circular dependency * chore: types related * Update docs/examples/config-provider/message.vue Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com> * Update packages/components/config-provider/__tests__/config-provider.test.tsx Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com> * chore: make typecheck happy & format * style: add top/bottom transition * chore: format --------- Co-authored-by: zhixiaotong <947803089@qq.com> Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com> Co-authored-by: Dsaquel <291874700n@gmail.com> --- docs/en-US/component/config-provider.md | 17 ++-- docs/en-US/component/message.md | 43 +++++--- docs/examples/config-provider/message.vue | 4 +- docs/examples/message/placement.vue | 71 ++++++++++++++ .../__tests__/config-provider.test.tsx | 6 ++ .../config-provider/src/config-provider.ts | 4 +- .../__tests__/message-manager.test.tsx | 69 +++++++++++++ .../message/__tests__/message.test.ts | 75 ++++++++++++++ packages/components/message/src/instance.ts | 30 ++++-- packages/components/message/src/message.ts | 23 +++++ packages/components/message/src/message.vue | 32 ++++-- packages/components/message/src/method.ts | 97 ++++++++++++++----- packages/theme-chalk/src/message.scss | 36 ++++++- 13 files changed, 442 insertions(+), 65 deletions(-) create mode 100644 docs/examples/message/placement.vue diff --git a/docs/en-US/component/config-provider.md b/docs/en-US/component/config-provider.md index 420dd0f33c..596a5fed13 100644 --- a/docs/en-US/component/config-provider.md +++ b/docs/en-US/component/config-provider.md @@ -141,14 +141,15 @@ In this section, you can learn how to use Config Provider to provide experimenta ### Message Attribute -| Attribute | Description | Type | Default | -| ------------------ | ------------------------------------------------------------------------------ | ---------- | ------- | -| max | the maximum number of messages that can be displayed at the same time | ^[number] | — | -| grouping ^(2.8.2) | merge messages with the same content, type of VNode message is not supported | ^[boolean] | — | -| duration ^(2.8.2) | display duration, millisecond. If set to 0, it will not turn off automatically | ^[number] | — | -| showClose ^(2.8.2) | whether to show a close button | ^[boolean] | — | -| offset ^(2.8.2) | set the distance to the top of viewport | ^[number] | — | -| plain ^(2.9.11) | whether message is plain | ^[boolean] | — | +| Attribute | Description | Type | Default | +| ------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- | +| max | the maximum number of messages that can be displayed at the same time | ^[number] | — | +| grouping ^(2.8.2) | merge messages with the same content, type of VNode message is not supported | ^[boolean] | — | +| duration ^(2.8.2) | display duration, millisecond. If set to 0, it will not turn off automatically | ^[number] | — | +| showClose ^(2.8.2) | whether to show a close button | ^[boolean] | — | +| offset ^(2.8.2) | set the distance to the top of viewport | ^[number] | — | +| plain ^(2.9.11) | whether message is plain | ^[boolean] | — | +| placement ^(2.11.0) | message placement position | ^[enum]`'top' \| 'top-left' \| 'top-right' \| 'bottom' \| 'bottom-left' \| 'bottom-right'` | — | ### Config Provider Slots diff --git a/docs/en-US/component/message.md b/docs/en-US/component/message.md index 674d89eaab..c6c0463434 100644 --- a/docs/en-US/component/message.md +++ b/docs/en-US/component/message.md @@ -9,7 +9,7 @@ Used to show feedback after an activity. The difference with Notification is tha ## Basic usage -Displays at the top, and disappears after 3 seconds. +Displays at the top by default, and disappears after 3 seconds. You can control the position using the `placement` property. :::demo The setup of Message is very similar to notification, so parts of the options won't be explained in detail here. You can check the options table below combined with notification doc to understand it. Element Plus has registered a `$message` method for invoking. Message can take a string or a VNode as parameter, and it will be shown as the main body. @@ -73,6 +73,16 @@ message/grouping ::: +## Placement ^(2.11.0) + +Control the position where messages appear. Messages can be displayed at the top (default) or other placements of the viewport. + +:::demo + +message/placement + +::: + ## Global method Element Plus has added a global method `$message` for `app.config.globalProperties`. So in a vue instance you can call `Message` like what we did in this page. @@ -110,21 +120,22 @@ ElMessage({}, appContext) ### Options -| Name | Description | Type | Default | -| ------------------------ | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | -| message | message text | ^[string] / ^[VNode] / ^[Function]`() => VNode` | '' | -| type | message type | ^[enum]`'primary' (2.9.11) \| 'success' \| 'warning' \| 'info' \| 'error'` | info | -| plain ^(2.6.3) | whether message is plain | ^[boolean] | false | -| icon | custom icon component, overrides `type` | ^[string] / ^[Component] | — | -| dangerouslyUseHTMLString | whether `message` is treated as HTML string | ^[boolean] | false | -| customClass | custom class name for Message | ^[string] | '' | -| duration | display duration, millisecond. If set to 0, it will not turn off automatically | ^[number] | 3000 | -| showClose | whether to show a close button | ^[boolean] | false | -| onClose | callback function when closed with the message instance as the parameter | ^[Function]`() => void` | — | -| offset | set the distance to the top of viewport | ^[number] | 16 | -| appendTo | set the root element for the message, default to `document.body` | ^[CSSSelector] / ^[HTMLElement] | — | -| grouping | merge messages with the same content, type of VNode message is not supported | ^[boolean] | false | -| repeatNum | The number of repetitions, similar to badge, is used as the initial number when used with `grouping` | ^[number] | 1 | +| Name | Description | Type | Default | +| ------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- | +| message | message text | ^[string] / ^[VNode] / ^[Function]`() => VNode` | '' | +| type | message type | ^[enum]`'primary' (2.9.11) \| 'success' \| 'warning' \| 'info' \| 'error'` | info | +| plain ^(2.6.3) | whether message is plain | ^[boolean] | false | +| icon | custom icon component, overrides `type` | ^[string] / ^[Component] | — | +| dangerouslyUseHTMLString | whether `message` is treated as HTML string | ^[boolean] | false | +| customClass | custom class name for Message | ^[string] | '' | +| duration | display duration, millisecond. If set to 0, it will not turn off automatically | ^[number] | 3000 | +| showClose | whether to show a close button | ^[boolean] | false | +| onClose | callback function when closed with the message instance as the parameter | ^[Function]`() => void` | — | +| offset | set the distance to the viewport edge (top when placement is 'top', bottom when placement is 'bottom') | ^[number] | 16 | +| placement ^(2.11.0) | message placement position | ^[enum]`'top' \| 'top-left' \| 'top-right' \| 'bottom' \| 'bottom-left' \| 'bottom-right'` | top | +| appendTo | set the root element for the message, default to `document.body` | ^[CSSSelector] / ^[HTMLElement] | — | +| grouping | merge messages with the same content, type of VNode message is not supported | ^[boolean] | false | +| repeatNum | The number of repetitions, similar to badge, is used as the initial number when used with `grouping` | ^[number] | 1 | ### Methods diff --git a/docs/examples/config-provider/message.vue b/docs/examples/config-provider/message.vue index f7cbc64ed5..abfd734e3e 100644 --- a/docs/examples/config-provider/message.vue +++ b/docs/examples/config-provider/message.vue @@ -13,8 +13,10 @@ import { ElMessage } from 'element-plus' const config = reactive({ max: 3, plain: true, + placement: 'bottom', }) + const open = () => { - ElMessage('This is a message.') + ElMessage('This is a message from bottom.') } diff --git a/docs/examples/message/placement.vue b/docs/examples/message/placement.vue new file mode 100644 index 0000000000..e7f7a3d482 --- /dev/null +++ b/docs/examples/message/placement.vue @@ -0,0 +1,71 @@ + + + diff --git a/packages/components/config-provider/__tests__/config-provider.test.tsx b/packages/components/config-provider/__tests__/config-provider.test.tsx index 756e780df9..58ac5927ee 100644 --- a/packages/components/config-provider/__tests__/config-provider.test.tsx +++ b/packages/components/config-provider/__tests__/config-provider.test.tsx @@ -402,6 +402,7 @@ describe('config-provider', () => { }) const overrideConfig = reactive({ max: 1, + placement: 'bottom-left', }) const open = () => { ElMessage('this is a message.') @@ -420,7 +421,12 @@ describe('config-provider', () => { await wrapper.find('.el-button').trigger('click') await wrapper.find('.el-button').trigger('click') await nextTick() + const messages = document.querySelectorAll('.el-message') expect(document.querySelectorAll('.el-message').length).toBe(1) + + const classList = messages[0].classList + expect(classList.contains('is-left')).toBe(true) + expect(classList.contains('is-bottom')).toBe(true) }) }) diff --git a/packages/components/config-provider/src/config-provider.ts b/packages/components/config-provider/src/config-provider.ts index 63a7dd715e..38d16f3df4 100644 --- a/packages/components/config-provider/src/config-provider.ts +++ b/packages/components/config-provider/src/config-provider.ts @@ -4,7 +4,9 @@ import { configProviderProps } from './config-provider-props' import type { MessageConfigContext } from '@element-plus/components/message' -export const messageConfig: MessageConfigContext = {} +export const messageConfig: MessageConfigContext = { + placement: 'top', +} const ConfigProvider = defineComponent({ name: 'ElConfigProvider', diff --git a/packages/components/message/__tests__/message-manager.test.tsx b/packages/components/message/__tests__/message-manager.test.tsx index 1decabfd88..8615a237fb 100644 --- a/packages/components/message/__tests__/message-manager.test.tsx +++ b/packages/components/message/__tests__/message-manager.test.tsx @@ -51,6 +51,38 @@ describe('Message on command', () => { expect(document.querySelectorAll(selector).length).toBe(0) }) + test('it should close all messages with mixed placement', async () => { + const onClose = vi.fn() + const instances = [] + + for (let i = 0; i < 2; i++) { + const instance = Message({ + duration: 0, + placement: 'top', + onClose, + }) + instances.push(instance) + } + + for (let i = 0; i < 2; i++) { + const instance = Message({ + duration: 0, + placement: 'bottom', + onClose, + }) + instances.push(instance) + } + + await rAF() + const elements = document.querySelectorAll(selector) + expect(elements.length).toBe(4) + + Message.closeAll() + await rAF() + expect(onClose).toHaveBeenCalledTimes(4) + expect(document.querySelectorAll(selector).length).toBe(0) + }) + test('it should close all messages of the specified type', async () => { const onClose = vi.fn() const instances = [] @@ -84,6 +116,43 @@ describe('Message on command', () => { Message.closeAll() }) + test('it should close all messages by specified placement', async () => { + const onClose = vi.fn() + const instances = [] + + for (let i = 0; i < 3; i++) { + const instance = Message({ + duration: 0, + placement: 'top', + onClose, + }) + instances.push(instance) + } + + for (let i = 0; i < 2; i++) { + const instance = Message({ + duration: 0, + placement: 'bottom', + onClose, + }) + instances.push(instance) + } + + await rAF() + const elements = document.querySelectorAll(selector) + expect(elements.length).toBe(5) + + Message.closeAllByPlacement('top') + await rAF() + expect(onClose).toHaveBeenCalledTimes(3) + expect(document.querySelectorAll(selector).length).toBe(2) + + Message.closeAllByPlacement('bottom') + await rAF() + expect(onClose).toHaveBeenCalledTimes(5) + expect(document.querySelectorAll(selector).length).toBe(0) + }) + test('it should stack messages', async () => { const messages = [Message(), Message(), Message()] await rAF() diff --git a/packages/components/message/__tests__/message.test.ts b/packages/components/message/__tests__/message.test.ts index dd2cf3e05f..24d199581c 100644 --- a/packages/components/message/__tests__/message.test.ts +++ b/packages/components/message/__tests__/message.test.ts @@ -15,6 +15,8 @@ type MessageInstance = ComponentPublicInstance<{ visible: boolean iconComponent: string | Component customStyle: CSSProperties + placement?: 'top' | 'bottom' + offset: number }> const onClose = vi.fn() @@ -183,4 +185,77 @@ describe('Message.vue', () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + + describe('placement', () => { + test('should render with top placement by default', () => { + const wrapper = _mount({ + slots: { default: AXIOM }, + }) + const vm = wrapper.vm as MessageInstance + expect(vm.customStyle).toHaveProperty('top', '16px') + expect(wrapper.classes()).not.toContain('is-bottom') + }) + + test('should render with top-left placement', () => { + const wrapper = _mount({ + slots: { default: AXIOM }, + props: { + placement: 'top-left', + }, + }) + const vm = wrapper.vm as MessageInstance + expect(vm.customStyle).toHaveProperty('top', '16px') + expect(vm.placement).toBe('top-left') + }) + + test('should render with top-right placement', () => { + const wrapper = _mount({ + slots: { default: AXIOM }, + props: { + placement: 'top-right', + }, + }) + const vm = wrapper.vm as MessageInstance + expect(vm.customStyle).toHaveProperty('top', '16px') + expect(vm.placement).toBe('top-right') + }) + + test('should render with bottom placement', () => { + const wrapper = _mount({ + slots: { default: AXIOM }, + props: { + placement: 'bottom', + }, + }) + const vm = wrapper.vm as MessageInstance + expect(vm.customStyle).toHaveProperty('bottom', '16px') + expect(wrapper.classes()).toContain('is-bottom') + }) + + test('should render with bottom-left placement', () => { + const wrapper = _mount({ + slots: { default: AXIOM }, + props: { + placement: 'bottom-left', + }, + }) + const vm = wrapper.vm as MessageInstance + expect(vm.customStyle).toHaveProperty('bottom', '16px') + expect(vm.placement).toBe('bottom-left') + expect(wrapper.classes()).toContain('is-bottom') + }) + + test('should render with bottom-right placement', () => { + const wrapper = _mount({ + slots: { default: AXIOM }, + props: { + placement: 'bottom-right', + }, + }) + const vm = wrapper.vm as MessageInstance + expect(vm.customStyle).toHaveProperty('bottom', '16px') + expect(vm.placement).toBe('bottom-right') + expect(wrapper.classes()).toContain('is-bottom') + }) + }) }) diff --git a/packages/components/message/src/instance.ts b/packages/components/message/src/instance.ts index 9a6c90f78c..fd895596aa 100644 --- a/packages/components/message/src/instance.ts +++ b/packages/components/message/src/instance.ts @@ -2,7 +2,7 @@ import { shallowReactive } from 'vue' import type { ComponentInternalInstance, VNode } from 'vue' import type { Mutable } from '@element-plus/utils' -import type { MessageHandler, MessageProps } from './message' +import type { MessageHandler, MessagePlacement, MessageProps } from './message' export type MessageContext = { id: string @@ -12,9 +12,19 @@ export type MessageContext = { props: Mutable } -export const instances: MessageContext[] = shallowReactive([]) +export const placementInstances = shallowReactive( + {} as Record +) -export const getInstance = (id: string) => { +export const getOrCreatePlacementInstances = (placement: MessagePlacement) => { + if (!placementInstances[placement]) { + placementInstances[placement] = shallowReactive([]) + } + return placementInstances[placement] +} + +export const getInstance = (id: string, placement: MessagePlacement) => { + const instances = placementInstances[placement] || [] const idx = instances.findIndex((instance) => instance.id === id) const current = instances[idx] let prev: MessageContext | undefined @@ -24,13 +34,21 @@ export const getInstance = (id: string) => { return { current, prev } } -export const getLastOffset = (id: string): number => { - const { prev } = getInstance(id) +export const getLastOffset = ( + id: string, + placement: MessagePlacement +): number => { + const { prev } = getInstance(id, placement) if (!prev) return 0 return prev.vm.exposed!.bottom.value } -export const getOffsetOrSpace = (id: string, offset: number) => { +export const getOffsetOrSpace = ( + id: string, + offset: number, + placement: MessagePlacement +) => { + const instances = placementInstances[placement] || [] const idx = instances.findIndex((instance) => instance.id === id) return idx > 0 ? 16 : offset } diff --git a/packages/components/message/src/message.ts b/packages/components/message/src/message.ts index 98695df543..f6ac87a92c 100644 --- a/packages/components/message/src/message.ts +++ b/packages/components/message/src/message.ts @@ -23,7 +23,19 @@ export const messageTypes = [ 'error', ] as const +export const messagePlacement = [ + 'top', + 'top-left', + 'top-right', + 'bottom', + 'bottom-left', + 'bottom-right', +] as const + +export const MESSAGE_DEFAULT_PLACEMENT = 'top' + export type MessageType = typeof messageTypes[number] +export type MessagePlacement = typeof messagePlacement[number] /** @deprecated please use `MessageType` instead */ export type messageType = MessageType // will be removed in 3.0.0. @@ -34,6 +46,7 @@ export interface MessageConfigContext { offset?: number showClose?: boolean plain?: boolean + placement?: string } export const messageDefaults = mutable({ @@ -48,6 +61,7 @@ export const messageDefaults = mutable({ type: 'info', plain: false, offset: 16, + placement: undefined, zIndex: 0, grouping: false, repeatNum: 1, @@ -137,6 +151,14 @@ export const messageProps = buildProps({ type: Number, default: messageDefaults.offset, }, + /** + * @description message placement position + */ + placement: { + type: String, + values: messagePlacement, + default: messageDefaults.placement, + }, /** * @description input box size */ @@ -198,6 +220,7 @@ export interface MessageHandler { export type MessageFn = { (options?: MessageParams, appContext?: null | AppContext): MessageHandler closeAll(type?: MessageType): void + closeAllByPlacement(position: MessagePlacement): void } export type MessageTypedFn = ( options?: MessageParamsWithType, diff --git a/packages/components/message/src/message.vue b/packages/components/message/src/message.vue index d584167014..c594dabb71 100644 --- a/packages/components/message/src/message.vue +++ b/packages/components/message/src/message.vue @@ -14,6 +14,8 @@ { [ns.m(type)]: type }, ns.is('closable', showClose), ns.is('plain', plain), + ns.is('bottom', verticalProperty === 'bottom'), + horizontalClass, customClass, ]" :style="customStyle" @@ -52,7 +54,11 @@ import { EVENT_CODE } from '@element-plus/constants' import ElBadge from '@element-plus/components/badge' import { useGlobalComponentSettings } from '@element-plus/components/config-provider' import { ElIcon } from '@element-plus/components/icon' -import { messageEmits, messageProps } from './message' +import { + MESSAGE_DEFAULT_PLACEMENT, + messageEmits, + messageProps, +} from './message' import { getLastOffset, getOffsetOrSpace } from './instance' import type { BadgeProps } from '@element-plus/components/badge' @@ -89,13 +95,27 @@ const iconComponent = computed( () => props.icon || TypeComponentsMap[props.type] || '' ) -const lastOffset = computed(() => getLastOffset(props.id)) -const offset = computed( - () => getOffsetOrSpace(props.id, props.offset) + lastOffset.value -) +const placement = computed(() => props.placement || MESSAGE_DEFAULT_PLACEMENT) + +const lastOffset = computed(() => getLastOffset(props.id, placement.value)) +const offset = computed(() => { + return ( + getOffsetOrSpace(props.id, props.offset, placement.value) + lastOffset.value + ) +}) const bottom = computed(() => height.value + offset.value) +const horizontalClass = computed(() => { + if (placement.value.includes('left')) return ns.is('left') + if (placement.value.includes('right')) return ns.is('right') + return ns.is('center') +}) + +const verticalProperty = computed(() => + placement.value.startsWith('top') ? 'top' : 'bottom' +) + const customStyle = computed(() => ({ - top: `${offset.value}px`, + [verticalProperty.value]: `${offset.value}px`, zIndex: currentZIndex.value, })) diff --git a/packages/components/message/src/method.ts b/packages/components/message/src/method.ts index 7cb2173323..779d7cd8bc 100644 --- a/packages/components/message/src/method.ts +++ b/packages/components/message/src/method.ts @@ -1,6 +1,7 @@ import { createVNode, isVNode, render } from 'vue' import { debugWarn, + hasOwn, isBoolean, isClient, isElement, @@ -10,8 +11,13 @@ import { } from '@element-plus/utils' import { messageConfig } from '@element-plus/components/config-provider' import MessageConstructor from './message.vue' -import { messageDefaults, messageTypes } from './message' -import { instances } from './instance' +import { + MESSAGE_DEFAULT_PLACEMENT, + messageDefaults, + messagePlacement, + messageTypes, +} from './message' +import { getOrCreatePlacementInstances, placementInstances } from './instance' import type { MessageContext } from './instance' import type { AppContext } from 'vue' @@ -22,6 +28,7 @@ import type { MessageOptions, MessageParams, MessageParamsNormalized, + MessagePlacement, MessageType, } from './message' @@ -29,18 +36,9 @@ let seed = 1 // TODO: Since Notify.ts is basically the same like this file. So we could do some encapsulation against them to reduce code duplication. -const normalizeOptions = (params?: MessageParams) => { - const options: MessageOptions = - !params || isString(params) || isVNode(params) || isFunction(params) - ? { message: params } - : params - - const normalized = { - ...messageDefaults, - ...options, - } - - if (!normalized.appendTo) { +const normalizeAppendTo = (normalized: MessageOptions) => { + const appendTo = normalized.appendTo + if (!appendTo) { normalized.appendTo = document.body } else if (isString(normalized.appendTo)) { let appendTo = document.querySelector(normalized.appendTo) @@ -53,9 +51,48 @@ const normalizeOptions = (params?: MessageParams) => { ) appendTo = document.body } - normalized.appendTo = appendTo } +} + +const normalizePlacement = (normalized: MessageOptions) => { + // if placement is not passed and global has config, use global config + if ( + !normalized.placement && + isString(messageConfig.placement) && + messageConfig.placement + ) { + normalized.placement = messageConfig.placement as + | MessagePlacement + | undefined + } + // if placement is not passed and global has no config, use default config + if (!normalized.placement) { + normalized.placement = MESSAGE_DEFAULT_PLACEMENT + } + // if placement is not valid, use default config + if (!messagePlacement.includes(normalized.placement!)) { + debugWarn( + 'ElMessage', + `Invalid placement: ${normalized.placement}. Falling back to '${MESSAGE_DEFAULT_PLACEMENT}'.` + ) + normalized.placement = MESSAGE_DEFAULT_PLACEMENT + } +} + +const normalizeOptions = (params?: MessageParams) => { + const options: MessageOptions = + !params || isString(params) || isVNode(params) || isFunction(params) + ? { message: params } + : params + + const normalized: MessageOptions = { + ...messageDefaults, + ...options, + } + + normalizeAppendTo(normalized) + normalizePlacement(normalized) // When grouping is configured globally, // if grouping is manually set when calling message individually and it is not equal to the default value, @@ -80,9 +117,11 @@ const normalizeOptions = (params?: MessageParams) => { } const closeMessage = (instance: MessageContext) => { + const placement = instance.props.placement || MESSAGE_DEFAULT_PLACEMENT + const instances = placementInstances[placement] + const idx = instances.indexOf(instance) if (idx === -1) return - instances.splice(idx, 1) const { handler } = instance handler.close() @@ -161,6 +200,9 @@ const message: MessageFn & if (!isClient) return { close: () => undefined } const normalized = normalizeOptions(options) + const instances = getOrCreatePlacementInstances( + normalized.placement || MESSAGE_DEFAULT_PLACEMENT + ) if (normalized.grouping && instances.length) { const instance = instances.find( @@ -191,17 +233,28 @@ messageTypes.forEach((type) => { }) export function closeAll(type?: MessageType): void { - // Create a copy of instances to avoid modification during iteration - const instancesToClose = [...instances] - - for (const instance of instancesToClose) { - if (!type || type === instance.props.type) { - instance.handler.close() + for (const placement in placementInstances) { + if (hasOwn(placementInstances, placement)) { + // Create a copy of instances to avoid modification during iteration + const instances: MessageContext[] = [...placementInstances[placement]] + for (const instance of instances) { + if (!type || type === instance.props.type) { + instance.handler.close() + } + } } } } +export function closeAllByPlacement(placement: MessagePlacement) { + if (!placementInstances[placement]) return + // Create a copy of instances to avoid modification during iteration + const instances = [...placementInstances[placement]] + instances.forEach((instance) => instance.handler.close()) +} + message.closeAll = closeAll +message.closeAllByPlacement = closeAllByPlacement message._context = null export default message as Message diff --git a/packages/theme-chalk/src/message.scss b/packages/theme-chalk/src/message.scss index 9a8b0b7e75..eab8bf2655 100644 --- a/packages/theme-chalk/src/message.scss +++ b/packages/theme-chalk/src/message.scss @@ -15,16 +15,27 @@ border-style: getCssVar('border-style'); border-color: getCssVar('message', 'border-color'); position: fixed; - left: 50%; - top: 20px; - transform: translateX(-50%); background-color: getCssVar('message', 'bg-color'); - transition: opacity getCssVar('transition-duration'), transform 0.4s, top 0.4s; + transition: opacity getCssVar('transition-duration'), transform 0.4s, top 0.4s, + bottom 0.4s; padding: getCssVar('message', 'padding'); display: flex; align-items: center; gap: 8px; + &.is-left { + left: 16px; + } + + &.is-right { + right: 16px; + } + + &.is-center { + left: 50%; + transform: translateX(-50%); + } + @include when(plain) { background-color: getCssVar('bg-color', 'overlay'); border-color: getCssVar('bg-color', 'overlay'); @@ -90,5 +101,20 @@ .#{$namespace}-message-fade-enter-from, .#{$namespace}-message-fade-leave-to { opacity: 0; - transform: translate(-50%, -100%); + + &:is(.is-left, .is-right) { + transform: translateY(-100%); + + &.is-bottom { + transform: translateY(100%); + } + } + + &.is-center { + transform: translate(-50%, -100%); + + &.is-bottom { + transform: translate(-50%, 100%); + } + } }