feat(Toast): new component (#50)

This commit is contained in:
Benjamin Canac
2024-04-10 18:22:09 +02:00
committed by GitHub
parent 90f18a3505
commit 3da1e1a518
13 changed files with 566 additions and 12 deletions

View File

@@ -1,33 +1,35 @@
<script lang="ts">
import type { ConfigProviderProps, ToastProviderProps, TooltipProviderProps } from 'radix-vue'
import type { ConfigProviderProps, TooltipProviderProps } from 'radix-vue'
import type { ToasterProps } from '#ui/types'
export interface ProviderProps extends ConfigProviderProps {
tooltip?: TooltipProviderProps
toast?: ToastProviderProps
toaster?: ToasterProps | null
}
</script>
<script setup lang="ts">
import { toRef } from 'vue'
import { ConfigProvider, ToastProvider, TooltipProvider, useForwardProps } from 'radix-vue'
import { ConfigProvider, TooltipProvider, useForwardProps } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useId } from '#imports'
import { UToaster } from '#components'
const props = withDefaults(defineProps<ProviderProps>(), {
useId: () => useId()
})
const configProps = useForwardProps(reactivePick(props, 'dir', 'scrollBody', 'useId'))
const tooltipProps = toRef(() => props.tooltip as TooltipProviderProps)
const toastProps = toRef(() => props.toast as ToastProviderProps)
const configProviderProps = useForwardProps(reactivePick(props, 'dir', 'scrollBody', 'useId'))
const tooltipProps = toRef(() => props.tooltip)
const toasterProps = toRef(() => props.toaster)
</script>
<template>
<ConfigProvider v-bind="configProps">
<ConfigProvider v-bind="configProviderProps">
<TooltipProvider v-bind="tooltipProps">
<ToastProvider v-bind="toastProps">
<slot />
</ToastProvider>
<slot />
<UToaster v-if="toaster !== null" v-bind="toasterProps" />
</TooltipProvider>
</ConfigProvider>
</template>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { isVNode, type VNode } from 'vue'
import { tv, type VariantProps } from 'tailwind-variants'
import type { ToastRootProps, ToastRootEmits } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/toast'
import type { AvatarProps, ButtonProps, IconProps } from '#ui/types'
const appConfig = _appConfig as AppConfig & { ui: { toast: Partial<typeof theme> } }
const toast = tv({ extend: tv(theme), ...(appConfig.ui?.toast || {}) })
type ToastVariants = VariantProps<typeof toast>
export interface ToastProps extends Omit<ToastRootProps, 'asChild' | 'forceMount'> {
title?: string
description?: string | VNode | (() => VNode)
icon?: IconProps['name']
avatar?: AvatarProps
color?: ToastVariants['color']
actions?: (ButtonProps & { click?: () => void })[]
close?: ButtonProps | null
class?: any
ui?: Partial<typeof toast.slots>
}
export interface ToastEmits extends ToastRootEmits { }
</script>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ToastRoot, ToastTitle, ToastDescription, ToastAction, ToastClose, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { UIcon, UAvatar } from '#components'
const props = defineProps<ToastProps>()
const emits = defineEmits<ToastEmits>()
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'duration', 'open', 'type'), emits)
const multiline = computed(() => !!props.title && !!props.description)
const ui = computed(() => tv({ extend: toast, slots: props.ui })({
color: props.color
}))
const el = ref()
const height = ref(0)
onMounted(() => {
if (!el.value) {
return
}
setTimeout(() => {
height.value = el.value.$el.getBoundingClientRect().height
}, 0)
})
defineExpose({
height
})
</script>
<template>
<ToastRoot ref="el" v-bind="rootProps" :class="ui.root({ class: props.class, multiline })" :style="{ '--height': height }">
<UAvatar v-if="avatar" size="2xl" v-bind="avatar" :class="ui.avatar()" />
<UIcon v-else-if="icon" :name="icon" :class="ui.icon()" />
<div :class="ui.wrapper()">
<ToastTitle v-if="title" :class="ui.title()">
{{ title }}
</ToastTitle>
<template v-if="description">
<ToastDescription v-if="isVNode(description)" :as="description" />
<ToastDescription v-else :class="ui.description()">
{{ description }}
</ToastDescription>
</template>
<div v-if="multiline && actions?.length" :class="ui.actions({ multiline: true })">
<ToastAction v-for="(action, index) in actions" :key="index" :alt-text="action.label || 'Action'" as-child @click.stop>
<UButton size="xs" color="white" v-bind="action" />
</ToastAction>
</div>
</div>
<div v-if="(!multiline && actions?.length) || close !== null" :class="ui.actions({ multiline: false })">
<template v-if="!multiline">
<ToastAction v-for="(action, index) in actions" :key="index" :alt-text="action.label || 'Action'" as-child @click.stop>
<UButton size="xs" color="white" v-bind="action" />
</ToastAction>
</template>
<ToastClose as-child>
<UButton v-if="close !== null" :icon="appConfig.ui.icons.close" size="sm" color="gray" variant="link" aria-label="Close" v-bind="close" :class="ui.close()" @click.stop />
</ToastClose>
</div>
<div :class="ui.progress()" />
<div :class="ui.mask()" />
</ToastRoot>
</template>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { ToastProviderProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/toaster'
const appConfig = _appConfig as AppConfig & { ui: { toaster: Partial<typeof theme> } }
const toaster = tv({ extend: tv(theme), ...(appConfig.ui?.toaster || {}) })
type ToasterVariants = VariantProps<typeof toaster>
export interface ToasterProps extends Omit<ToastProviderProps, 'swipeDirection'> {
position?: ToasterVariants['position']
expand?: boolean
class?: any
ui?: Partial<typeof toaster.slots>
}
</script>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ToastProvider, ToastViewport, useForwardProps } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useToast } from '#imports'
import { UToast } from '#components'
import { omit } from '#ui/utils'
const props = withDefaults(defineProps<ToasterProps>(), { expand: true })
const providerProps = useForwardProps(reactivePick(props, 'duration', 'label', 'swipeThreshold'))
const { toasts, remove } = useToast()
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({ extend: toaster, slots: props.ui })({
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) - 16)
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">
<UToast
v-for="(toast, index) of toasts"
:key="toast.id"
ref="refs"
v-bind="omit(toast, ['id'])"
: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.click
}]"
@update:open="onUpdateOpen($event, toast.id)"
@click="toast.click && toast.click(toast)"
/>
<ToastViewport
:data-expanded="expanded"
:class="ui.viewport({ class: props.class })"
: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"
/>
</ToastProvider>
</template>