Skip to content

Commit 5b53670

Browse files
tom00502Parsonsm0ksem
authored
feat(VaToast #4373): add bottom-center, top-center position options (#4377)
* feat(VaToast #4373): add bottom-center, top-center position options * fix: correctly handle multiple toasts offset * chore(toast): fix paddings * chore: remove redundant * fix(toast): improve appear and close animations --------- Co-authored-by: Parsons <gongyingparsons@gmail.com> Co-authored-by: Maksim Nedoshev <m0ksem1337@gmail.com>
1 parent 280534b commit 5b53670

File tree

9 files changed

+160
-90
lines changed

9 files changed

+160
-90
lines changed

packages/docs/page-config/ui-elements/toast/examples/Position.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
>
66
top-right
77
</VaButton>
8+
<VaButton
9+
class="mr-2 mb-2"
10+
@click="$vaToast.init({ message: 'Top-center', position: 'top-center' })"
11+
>
12+
top-center
13+
</VaButton>
814
<VaButton
915
class="mr-2 mb-2"
1016
@click="$vaToast.init({ message: 'Top-left', position: 'top-left' })"
@@ -19,6 +25,14 @@
1925
>
2026
bottom-right
2127
</VaButton>
28+
<VaButton
29+
class="mr-2 mb-2"
30+
@click="
31+
$vaToast.init({ message: 'Bottom-center', position: 'bottom-center' })
32+
"
33+
>
34+
bottom-center
35+
</VaButton>
2236
<VaButton
2337
class="mr-2 mb-2"
2438
@click="$vaToast.init({ message: 'Bottom-left', position: 'bottom-left' })"

packages/docs/page-config/ui-elements/toast/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default definePageConfig({
7979
}),
8080
block.example("Position", {
8181
title: "Position",
82-
description: "Use `position` property to set the custom position of the toast. Available are `top-right`, `top-left`, `bottom-right`, `bottom-left`."
82+
description: "Use `position` property to set the custom position of the toast. Available are `top-right`, `top-center`, `top-left`, `bottom-right`, `bottom-center`, `bottom-left`."
8383
}),
8484
block.example("Close", {
8585
title: "Close",

packages/ui/.stylelintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
'value-list-max-empty-lines': 1,
1212
'function-calc-no-unspaced-operator': null,
1313
'value-keyword-case': null,
14+
'length-zero-no-unit': null,
1415
'selector-pseudo-class-no-unknown': [
1516
true,
1617
{

packages/ui/src/components/va-toast/VaToast.stories.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,35 @@ export const Color: StoryFn = () => ({
3232
`,
3333
})
3434

35-
export const PositionTopenter: StoryFn = () => ({
35+
export const PositionBottomLeft: StoryFn = () => ({
36+
components: { VaToast: VaToast },
37+
38+
setup () {
39+
const { notify } = useToast()
40+
41+
return {
42+
notify,
43+
}
44+
},
45+
46+
template: '<button @click="notify({ message: \'Test\', position: \'bottom-left\' })">Show</button>',
47+
})
48+
49+
export const PositionTopLeft: StoryFn = () => ({
50+
components: { VaToast: VaToast },
51+
52+
setup () {
53+
const { notify } = useToast()
54+
55+
return {
56+
notify,
57+
}
58+
},
59+
60+
template: '<button @click="notify({ message: \'Test\', position: \'top-left\' })">Show</button>',
61+
})
62+
63+
export const PositionTopCenter: StoryFn = () => ({
3664
components: { VaToast: VaToast },
3765

3866
setup () {

packages/ui/src/components/va-toast/VaToast.vue

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<transition name="va-toast-fade">
2+
<Transition name="va-toast-fade" @after-leave="onHidden">
33
<div
44
v-show="visible"
55
ref="rootElement"
@@ -38,7 +38,7 @@
3838
/>
3939
</div>
4040
</div>
41-
</transition>
41+
</Transition>
4242
</template>
4343

4444
<script lang="ts">
@@ -47,6 +47,7 @@ import { PropType, ref, computed, onMounted, shallowRef, defineComponent, Comput
4747
import { useComponentPresetProp, useColors, useTimer, useTextColor, useTranslation, useTranslationProp, useNumericProp } from '../../composables'
4848
4949
import { ToastPosition } from './types'
50+
import { useToastService } from './hooks/useToastService'
5051
5152
import { StringWithAutocomplete } from '../../utils/types/prop-type'
5253
</script>
@@ -78,15 +79,15 @@ const props = defineProps({
7879
icon: { type: String, default: 'close' },
7980
customClass: { type: String, default: '' },
8081
duration: { type: [Number, String], default: 5000 },
81-
color: { type: String, default: '' },
82+
color: { type: String, default: 'primary' },
8283
closeable: { type: Boolean, default: true },
8384
onClose: { type: Function },
8485
onClick: { type: Function },
8586
multiLine: { type: Boolean, default: false },
8687
position: {
8788
type: String as PropType<ToastPosition>,
8889
default: 'top-right',
89-
validator: (value: string) => ['top-right', 'top-left', 'bottom-right', 'bottom-left'].includes(value),
90+
validator: (value: string) => ['top-right', 'top-center', 'top-left', 'bottom-right', 'bottom-center', 'bottom-left'].includes(value),
9091
},
9192
render: { type: Function },
9293
ariaCloseLabel: useTranslationProp('$t:close'),
@@ -107,20 +108,30 @@ const durationComputed = useNumericProp('duration') as ComputedRef<number>
107108
108109
const visible = ref(false)
109110
111+
const {
112+
yOffset,
113+
updateYOffset,
114+
} = useToastService(props)
115+
116+
const positionObject = computed(() => ({
117+
vertical: props.position.includes('top') ? 'top' : 'bottom',
118+
horizontal: props.position.includes('center') ? 'center' : props.position.includes('right') ? 'right' : 'left',
119+
}))
120+
110121
const getPositionStyle = () => {
111-
const vertical = props.position.includes('top') ? 'top' : 'bottom'
112-
const horizontal = props.position.includes('center') ? 'center' : props.position.includes('right') ? 'right' : 'left'
122+
const vertical = positionObject.value.vertical
123+
const horizontal = positionObject.value.horizontal
113124
114125
if (horizontal === 'center') {
115126
return {
116-
[vertical]: `${offsetYComputed.value}px`,
127+
[vertical]: `${offsetYComputed.value + yOffset.value}px`,
117128
left: '50%',
118-
transform: 'translateX(-50%)',
129+
'--va-toast-x-shift': '-50%',
119130
}
120131
}
121132
122133
return {
123-
[vertical]: `${offsetYComputed.value}px`,
134+
[vertical]: `${offsetYComputed.value + yOffset.value}px`,
124135
[horizontal]: `${offsetXComputed.value}px`,
125136
}
126137
}
@@ -129,6 +140,7 @@ const toastClasses = computed(() => [
129140
props.customClass,
130141
props.multiLine ? 'va-toast--multiline' : '',
131142
props.inline ? 'va-toast--inline' : '',
143+
[`va-toast--${props.position}`],
132144
])
133145
134146
const toastStyles = computed(() => ({
@@ -163,14 +175,16 @@ const onToastClick = () => {
163175
164176
const onToastClose = () => {
165177
visible.value = false
178+
updateYOffset()
179+
}
166180
167-
rootElement.value?.addEventListener('transitionend', destroyElement)
168-
181+
const onHidden = () => {
169182
if (typeof props.onClose === 'function') {
170183
props.onClose()
171184
} else {
172185
emit('on-close')
173186
}
187+
destroyElement()
174188
}
175189
176190
const timer = useTimer()
@@ -193,6 +207,10 @@ onMounted(() => {
193207
@import "variables";
194208
195209
.va-toast {
210+
--va-toast-x-shift: 0px;
211+
--va-toast-animation-x-shift: 0px;
212+
--va-toast-animation-y-shift: 100%;
213+
196214
position: fixed;
197215
box-sizing: border-box;
198216
width: var(--va-toast-width);
@@ -207,26 +225,30 @@ onMounted(() => {
207225
overflow: hidden;
208226
z-index: var(--va-toast-z-index);
209227
font-family: var(--va-font-family);
228+
transform: translateX(var(--va-toast-x-shift));
210229
211-
&--inline {
212-
position: static;
230+
&--top-right,
231+
&--bottom-right {
232+
--va-toast-animation-x-shift: 100%;
213233
}
214234
215-
&--multiline {
216-
min-height: 70px;
235+
&--top-left,
236+
&--bottom-left {
237+
--va-toast-animation-x-shift: -100%;
217238
}
218239
219-
&--right {
220-
right: 16px;
240+
&--top-left,
241+
&--top-center,
242+
&--top-right {
243+
--va-toast-animation-y-shift: -100%;
221244
}
222245
223-
&--left {
224-
left: 16px;
246+
&--inline {
247+
position: static;
225248
}
226249
227-
&__group {
228-
margin-left: var(--va-toast-group-margin-left);
229-
margin-right: var(--va-toast-group-margin-right);
250+
&--multiline {
251+
min-height: 70px;
230252
}
231253
232254
&__title {
@@ -269,19 +291,14 @@ onMounted(() => {
269291
}
270292
}
271293
272-
.va-toast-fade-enter {
273-
&.right {
274-
right: 0;
275-
transform: translateX(100%);
294+
.va-toast-fade {
295+
&-enter-from {
296+
transform: translateX(calc(var(--va-toast-animation-x-shift) + var(--va-toast-x-shift)));
276297
}
277298
278-
&.left {
279-
left: 0;
280-
transform: translateX(-100%);
299+
&-leave-to {
300+
transform: translateY(var(--va-toast-animation-y-shift));
301+
opacity: 0;
281302
}
282303
}
283-
284-
.va-toast-fade-leave-active {
285-
opacity: 0;
286-
}
287304
</style>

packages/ui/src/components/va-toast/_variables.scss

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
:host {
33
--va-toast-display: flex;
44
--va-toast-width: 330px;
5-
--va-toast-padding: 14px 26px 14px 13px;
5+
--va-toast-padding: 14px 1.25rem 14px 1.25rem;
66
--va-toast-border-radius: 8px;
77
--va-toast-border-color: transparent;
88
--va-toast-border: 1px solid var(--va-toast-border-color);
@@ -11,10 +11,6 @@
1111
--va-toast-transition: opacity 0.3s, transform 0.3s, left 0.3s, right 0.3s, top 0.4s, bottom 0.3s;
1212
--va-toast-z-index: calc(var(--va-z-index-teleport-overlay) + 100);
1313

14-
/* Group */
15-
--va-toast-group-margin-left: 13px;
16-
--va-toast-group-margin-right: 8px;
17-
1814
/* Title */
1915
--va-toast-title-font-weight: bold;
2016
--va-toast-title-font-size: 1rem;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, Ref, ref, VNode } from 'vue'
2+
import { ToastOptions } from '../types'
3+
4+
const GAP = 5
5+
6+
// Expect as client-side used only
7+
const toastInstances = ref([]) as Ref<VNode[]>
8+
9+
type OptionKeys = keyof ToastOptions;
10+
11+
const getNodeProps = (vNode: VNode): Record<OptionKeys, any> => {
12+
return (vNode.component?.props as Record<OptionKeys, any>) || {}
13+
}
14+
15+
const getTranslateValue = (item: VNode) => {
16+
if (item.el) {
17+
return (item.el.offsetHeight + GAP)
18+
}
19+
return 0
20+
}
21+
22+
export const useToastService = (props: {
23+
position: NonNullable<ToastOptions['position']>,
24+
}) => {
25+
const currentInstance = getCurrentInstance()!
26+
27+
const yOffset = computed(() => {
28+
const currentIndex = toastInstances.value.findIndex((instance) => instance === currentInstance.vnode)
29+
30+
if (currentIndex === -1) { return 0 }
31+
32+
return toastInstances.value.slice(currentIndex + 1).reduce((acc, instance) => {
33+
const {
34+
position: itemPosition,
35+
} = getNodeProps(instance)
36+
37+
const { position } = props
38+
39+
if (position === itemPosition) {
40+
return getTranslateValue(instance) + acc
41+
}
42+
43+
return acc
44+
}, 0)
45+
})
46+
47+
onMounted(() => {
48+
toastInstances.value.unshift(currentInstance.vnode)
49+
})
50+
51+
onBeforeUnmount(() => {
52+
toastInstances.value = toastInstances.value.filter((item) => item !== currentInstance.vnode)
53+
})
54+
55+
return {
56+
yOffset,
57+
updateYOffset: () => {
58+
toastInstances.value = toastInstances.value.filter((item) => item !== currentInstance.vnode)
59+
},
60+
}
61+
}

0 commit comments

Comments
 (0)