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>
This commit is contained in:
知晓同丶
2025-08-19 23:16:49 +08:00
committed by GitHub
parent bc71cba2d2
commit 98041055b0
13 changed files with 442 additions and 65 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.')
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div class="flex flex-wrap gap-1">
<el-button class="!ml-0" :plain="true" @click="openMsg()"> Top </el-button>
<el-button class="!ml-0" :plain="true" @click="openMsg('top-left')">
Top Left
</el-button>
<el-button class="!ml-0" :plain="true" @click="openMsg('top-right')">
Top Right
</el-button>
<el-button class="!ml-0" :plain="true" @click="openMsg('bottom')">
Bottom
</el-button>
<el-button class="!ml-0" :plain="true" @click="openMsg('bottom-left')">
Bottom Left
</el-button>
<el-button class="!ml-0" :plain="true" @click="openMsg('bottom-right')">
Bottom Right
</el-button>
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import type { MessagePlacement, MessageType } from 'element-plus'
let topCount = 0
let bottomCount = 0
let topLeftCount = 0
let topRightCount = 0
let bottomLeftCount = 0
let bottomRightCount = 0
const openMsg = (placement: MessagePlacement = 'top') => {
let count = 0
let type: MessageType = 'success'
switch (placement) {
case 'top':
count = ++topCount
type = 'success'
break
case 'bottom':
count = ++bottomCount
type = 'warning'
break
case 'top-left':
count = ++topLeftCount
type = 'info'
break
case 'top-right':
count = ++topRightCount
type = 'primary'
break
case 'bottom-left':
count = ++bottomLeftCount
type = 'warning'
break
case 'bottom-right':
count = ++bottomRightCount
type = 'error'
break
}
ElMessage({
message: `This is a message from the ${placement} ${count}`,
type,
placement,
})
}
</script>

View File

@@ -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)
})
})

View File

@@ -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',

View File

@@ -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()

View File

@@ -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')
})
})
})

View File

@@ -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<MessageProps>
}
export const instances: MessageContext[] = shallowReactive([])
export const placementInstances = shallowReactive(
{} as Record<MessagePlacement, MessageContext[]>
)
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
}

View File

@@ -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,

View File

@@ -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<CSSProperties>(() => ({
top: `${offset.value}px`,
[verticalProperty.value]: `${offset.value}px`,
zIndex: currentZIndex.value,
}))

View File

@@ -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<HTMLElement>(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

View File

@@ -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%);
}
}
}