Toaster
Demo
Related Components
This requires the following components to be installed:
Related Composables
This requires the following composables to be installed:
Related Utils
This requires the following utils to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { ComponentConfig } from '@/ui/utils/utils'
import type { ToastProviderProps } from 'reka-ui'
import Toast from '@/ui/components/Toast.vue'
import { usePortal } from '@/ui/composables/usePortal'
import { useToast } from '@/ui/composables/useToast'
import theme from '@/ui/theme/toaster'
import { omit } from '@/ui/utils/omit'
import { reactivePick } from '@vueuse/shared'
import { ToastPortal, ToastProvider, ToastViewport, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, ref, toRef } from 'vue'
type Toaster = ComponentConfig<typeof theme>
export interface ToasterProps extends Omit<ToastProviderProps, 'swipeDirection'> {
position?: Toaster['variants']['position']
expand?: boolean
portal?: boolean | string | HTMLElement
class?: any
ui?: Toaster['slots']
}
export interface ToasterSlots {
default: (props?: object) => any
}
export default {
name: 'Toaster',
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<ToasterProps>(), {
expand: true,
portal: true,
duration: 5000,
})
defineSlots<ToasterSlots>()
const { toasts, remove } = useToast()
const providerProps = useForwardProps(reactivePick(props, 'duration', 'label', 'swipeThreshold'))
const portalProps = usePortal(toRef(() => props.portal))
const swipeDirection = computed(() => {
switch (props.position) {
case 'top-center':
return 'up'
case 'top-right':
case 'bottom-right':
return 'right'
case 'bottom-center':
return 'down'
case 'top-left':
case 'bottom-left':
return 'left'
}
return 'right'
})
const ui = computed(() => tv(theme)({
position: props.position,
swipeDirection: swipeDirection.value,
}))
function onUpdateOpen(value: boolean, id: string | number) {
if (value) {
return
}
remove(id)
}
const hovered = ref(false)
const expanded = computed(() => props.expand || hovered.value)
const refs = ref<{ height: number }[]>([])
const height = computed(() => refs.value.reduce((acc, { height }) => acc + height + 16, 0))
const frontHeight = computed(() => refs.value[refs.value.length - 1]?.height || 0)
function getOffset(index: number) {
return refs.value.slice(index + 1).reduce((acc, { height }) => acc + height + 16, 0)
}
</script>
<template>
<ToastProvider :swipe-direction="swipeDirection" v-bind="providerProps">
<slot />
<Toast
v-for="(toast, index) of toasts"
:key="toast.id"
ref="refs"
v-bind="omit(toast, ['id', 'close'])"
:close="(toast.close as boolean)"
:data-expanded="expanded"
:data-front="!expanded && index === toasts.length - 1"
:style="{
'--index': (index - toasts.length) + toasts.length,
'--before': toasts.length - 1 - index,
'--offset': getOffset(index),
'--scale': expanded ? '1' : 'calc(1 - var(--before) * var(--scale-factor))',
'--translate': expanded ? 'calc(var(--offset) * var(--translate-factor))' : 'calc(var(--before) * var(--gap))',
'--transform': 'translateY(var(--translate)) scale(var(--scale))',
}"
:class="[ui.base(), {
'cursor-pointer': !!toast.onClick,
}]"
@update:open="onUpdateOpen($event, toast.id)"
@click="toast.onClick && toast.onClick(toast)"
/>
<ToastPortal v-bind="portalProps">
<ToastViewport
:data-expanded="expanded"
:class="ui.viewport({ class: [props.class, props.ui?.viewport] })"
:style="{
'--scale-factor': '0.05',
'--translate-factor': position?.startsWith('top') ? '1px' : '-1px',
'--gap': position?.startsWith('top') ? '16px' : '-16px',
'--front-height': `${frontHeight}px`,
'--height': `${height}px`,
}"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
/>
</ToastPortal>
</ToastProvider>
</template>vue
<script lang="ts">
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { ToastProviderProps } from 'reka-ui'
import Toast from '@/UI/Components/Toast.vue'
import { usePortal } from '@/UI/Composables/usePortal'
import { useToast } from '@/UI/Composables/useToast'
import theme from '@/UI/Theme/toaster'
import { omit } from '@/UI/Utils/omit'
import { reactivePick } from '@vueuse/shared'
import { ToastPortal, ToastProvider, ToastViewport, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, ref, toRef } from 'vue'
type Toaster = ComponentConfig<typeof theme>
export interface ToasterProps extends Omit<ToastProviderProps, 'swipeDirection'> {
position?: Toaster['variants']['position']
expand?: boolean
portal?: boolean | string | HTMLElement
class?: any
ui?: Toaster['slots']
}
export interface ToasterSlots {
default: (props?: object) => any
}
export default {
name: 'Toaster',
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<ToasterProps>(), {
expand: true,
portal: true,
duration: 5000,
})
defineSlots<ToasterSlots>()
const { toasts, remove } = useToast()
const providerProps = useForwardProps(reactivePick(props, 'duration', 'label', 'swipeThreshold'))
const portalProps = usePortal(toRef(() => props.portal))
const swipeDirection = computed(() => {
switch (props.position) {
case 'top-center':
return 'up'
case 'top-right':
case 'bottom-right':
return 'right'
case 'bottom-center':
return 'down'
case 'top-left':
case 'bottom-left':
return 'left'
}
return 'right'
})
const ui = computed(() => tv(theme)({
position: props.position,
swipeDirection: swipeDirection.value,
}))
function onUpdateOpen(value: boolean, id: string | number) {
if (value) {
return
}
remove(id)
}
const hovered = ref(false)
const expanded = computed(() => props.expand || hovered.value)
const refs = ref<{ height: number }[]>([])
const height = computed(() => refs.value.reduce((acc, { height }) => acc + height + 16, 0))
const frontHeight = computed(() => refs.value[refs.value.length - 1]?.height || 0)
function getOffset(index: number) {
return refs.value.slice(index + 1).reduce((acc, { height }) => acc + height + 16, 0)
}
</script>
<template>
<ToastProvider :swipe-direction="swipeDirection" v-bind="providerProps">
<slot />
<Toast
v-for="(toast, index) of toasts"
:key="toast.id"
ref="refs"
v-bind="omit(toast, ['id', 'close'])"
:close="(toast.close as boolean)"
:data-expanded="expanded"
:data-front="!expanded && index === toasts.length - 1"
:style="{
'--index': (index - toasts.length) + toasts.length,
'--before': toasts.length - 1 - index,
'--offset': getOffset(index),
'--scale': expanded ? '1' : 'calc(1 - var(--before) * var(--scale-factor))',
'--translate': expanded ? 'calc(var(--offset) * var(--translate-factor))' : 'calc(var(--before) * var(--gap))',
'--transform': 'translateY(var(--translate)) scale(var(--scale))',
}"
:class="[ui.base(), {
'cursor-pointer': !!toast.onClick,
}]"
@update:open="onUpdateOpen($event, toast.id)"
@click="toast.onClick && toast.onClick(toast)"
/>
<ToastPortal v-bind="portalProps">
<ToastViewport
:data-expanded="expanded"
:class="ui.viewport({ class: [props.class, props.ui?.viewport] })"
:style="{
'--scale-factor': '0.05',
'--translate-factor': position?.startsWith('top') ? '1px' : '-1px',
'--gap': position?.startsWith('top') ? '16px' : '-16px',
'--front-height': `${frontHeight}px`,
'--height': `${height}px`,
}"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
/>
</ToastPortal>
</ToastProvider>
</template>Theme
ts
export default {
slots: {
viewport: '',
base: '',
},
variants: {
position: {
'top-left': {
viewport: 'left-4',
},
'top-center': {
viewport: 'left-1/2 transform -translate-x-1/2',
},
'top-right': {
viewport: 'right-4',
},
'bottom-left': {
viewport: 'left-4',
},
'bottom-center': {
viewport: 'left-1/2 transform -translate-x-1/2',
},
'bottom-right': {
viewport: 'right-4',
},
},
swipeDirection: {
up: '',
right: '',
down: '',
left: '',
},
},
compoundVariants: [{
position: ['top-left', 'top-center', 'top-right'],
class: {
viewport: 'top-4',
base: 'top-0 data-[state=open]:animate-[slide-in-from-top_200ms_ease-in-out]',
},
}, {
position: ['bottom-left', 'bottom-center', 'bottom-right'],
class: {
viewport: 'bottom-4',
base: 'bottom-0 data-[state=open]:animate-[slide-in-from-bottom_200ms_ease-in-out]',
},
}, {
swipeDirection: ['left', 'right'],
class: 'data-[swipe=move]:translate-x-(--reka-toast-swipe-move-x) data-[swipe=end]:translate-x-(--reka-toast-swipe-end-x) data-[swipe=cancel]:translate-x-0',
}, {
swipeDirection: ['up', 'down'],
class: 'data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y) data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=cancel]:translate-y-0',
}] as any,
defaultVariants: {
position: 'bottom-right',
} as const,
}View Nuxt UI theme
ts
export default {
slots: {
viewport: 'fixed flex flex-col w-[calc(100%-2rem)] sm:w-96 z-[100] data-[expanded=true]:h-(--height) focus:outline-none',
base: 'pointer-events-auto absolute inset-x-0 z-(--index) transform-(--transform) data-[expanded=false]:data-[front=false]:h-(--front-height) data-[expanded=false]:data-[front=false]:*:invisible data-[state=closed]:animate-[toast-closed_200ms_ease-in-out] data-[state=closed]:data-[expanded=false]:data-[front=false]:animate-[toast-collapsed-closed_200ms_ease-in-out] data-[swipe=move]:transition-none transition-[transform,translate,height] duration-200 ease-out',
},
variants: {
position: {
'top-left': {
viewport: 'left-4',
},
'top-center': {
viewport: 'left-1/2 transform -translate-x-1/2',
},
'top-right': {
viewport: 'right-4',
},
'bottom-left': {
viewport: 'left-4',
},
'bottom-center': {
viewport: 'left-1/2 transform -translate-x-1/2',
},
'bottom-right': {
viewport: 'right-4',
},
},
swipeDirection: {
up: 'data-[swipe=end]:animate-[toast-slide-up_200ms_ease-out]',
right: 'data-[swipe=end]:animate-[toast-slide-right_200ms_ease-out]',
down: 'data-[swipe=end]:animate-[toast-slide-down_200ms_ease-out]',
left: 'data-[swipe=end]:animate-[toast-slide-left_200ms_ease-out]',
},
},
compoundVariants: [{
position: ['top-left', 'top-center', 'top-right'],
class: {
viewport: 'top-4',
base: 'top-0 data-[state=open]:animate-[slide-in-from-top_200ms_ease-in-out]',
},
}, {
position: ['bottom-left', 'bottom-center', 'bottom-right'],
class: {
viewport: 'bottom-4',
base: 'bottom-0 data-[state=open]:animate-[slide-in-from-bottom_200ms_ease-in-out]',
},
}, {
swipeDirection: ['left', 'right'],
class: 'data-[swipe=move]:translate-x-(--reka-toast-swipe-move-x) data-[swipe=end]:translate-x-(--reka-toast-swipe-end-x) data-[swipe=cancel]:translate-x-0',
}, {
swipeDirection: ['up', 'down'],
class: 'data-[swipe=move]:translate-y-(--reka-toast-swipe-move-y) data-[swipe=end]:translate-y-(--reka-toast-swipe-end-y) data-[swipe=cancel]:translate-y-0',
}] as any,
defaultVariants: {
position: 'bottom-right',
} as const,
}Test
To test this component, you can use the following test file:
ts
import { expect, it } from 'vitest'
// No tests for this component
it('passes', () => {
expect(true).toBe(true)
})ts
import { expect, it } from 'vitest'
// No tests for this component
it('passes', () => {
expect(true).toBe(true)
})