feat: add toast

This commit is contained in:
Nik 2023-10-16 00:10:40 +05:30
parent 2f9845efcd
commit 20306def54
17 changed files with 510 additions and 1 deletions

View File

@ -12,11 +12,11 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Button } from '@/lib/registry/default/ui/button'
import RadixIconsGithubLogo from '~icons/radix-icons/github-logo'
import TablerBrandX from '~icons/tabler/brand-x'
import RadixIconsMoon from '~icons/radix-icons/moon'
import RadixIconsSun from '~icons/radix-icons/sun'
import { useConfigStore } from '@/stores/config'
import { Dialog, DialogContent } from '@/lib/registry/default/ui/dialog'
import { Toaster } from '@/lib/registry/default/ui/toast'
import File from '~icons/radix-icons/file'
import Circle from '~icons/radix-icons/circle'
@ -276,5 +276,6 @@ watch(() => $route.path, (n) => {
</Command>
</DialogContent>
</Dialog>
<Toaster />
</div>
</template>

View File

@ -0,0 +1,93 @@
---
title: Toast
description: A succinct message that is displayed temporarily.
source: apps/www/src/lib/registry/default/ui/toast
primitive: https://www.radix-vue.com/components/toast.html
---
<ComponentPreview name="ToastDemo" />
## Installation
<Steps>
### Run the following command
```bash
npx shadcn-vue@latest add toast
```
### Add the Toaster component
Add the following `Toaster` component to your `App.vue` file:
```vue title="App.vue" {2,6}
<script setup lang="ts">
import Toaster from '@/components/ui/toast/Toaster.vue'
</script>
<template>
<Toaster />
</template>
```
</Steps>
## Usage
The `useToast` hook returns a `toast` function that you can use to display a toast.
```tsx
import { useToast } from '@/components/ui/toast/use-toast'
```
```vue
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/toast/use-toast'
const { toast } = useToast()
</script>
<template>
<Button
@click="() => {
toast({
title: 'Scheduled: Catch up',
description: 'Friday, February 10, 2023 at 5:57 PM',
});
}"
>
Add to calander
</Button>
</template>
```
<Callout>
To display multiple toasts at the same time, you can update the `TOAST_LIMIT` in `use-toast.ts`.
</Callout>
## Examples
### Simple
<ComponentPreview name="ToastSimple" />
### With Title
<ComponentPreview name="ToastWithTitle" />
### With Action
<ComponentPreview name="ToastWithAction" />
### Destructive
Use `toast({ variant: "destructive" })` to display a destructive toast.
<ComponentPreview name="ToastDestructive" />

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { Button } from '@/lib/registry/default/ui/button'
import { useToast } from '@/lib/registry/default/ui/toast/use-toast'
const { toast } = useToast()
</script>
<template>
<Button
variant="outline" @click="() => {
toast({
title: 'Scheduled: Catch up',
description: 'Friday, February 10, 2023 at 5:57 PM',
});
}"
>
Add to calander
</Button>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { h } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { useToast } from '@/lib/registry/default/ui/toast/use-toast'
import { ToastAction } from '@/lib/registry/default/ui/toast'
const { toast } = useToast()
</script>
<template>
<Button
variant="outline" @click="() => {
toast({
title: 'Uh oh! Something went wrong.',
description: 'There was a problem with your request.',
variant: 'destructive',
action: h(ToastAction, {
altText: 'Try again',
}, {
default: () => 'Try again',
}),
});
}"
>
Show Toast
</Button>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { Button } from '@/lib/registry/default/ui/button'
import { useToast } from '@/lib/registry/default/ui/toast/use-toast'
const { toast } = useToast()
</script>
<template>
<Button
variant="outline" @click="() => {
toast({
description: 'Your message has been sent.',
});
}"
>
Show Toast
</Button>
</template>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { h } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { useToast } from '@/lib/registry/default/ui/toast/use-toast'
import { ToastAction } from '@/lib/registry/default/ui/toast'
const { toast } = useToast()
</script>
<template>
<Button
variant="outline" @click="() => {
toast({
title: 'Uh oh! Something went wrong.',
description: 'There was a problem with your request.',
action: h(ToastAction, {
altText: 'Try again',
}, {
default: () => 'Try again',
}),
});
}"
>
Show Toast
</Button>
</template>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { Button } from '@/lib/registry/default/ui/button'
import { useToast } from '@/lib/registry/default/ui/toast/use-toast'
const { toast } = useToast()
</script>
<template>
<Button
variant="outline" @click="() => {
toast({
title: 'Uh oh! Something went wrong.',
description: 'There was a problem with your request.',
});
}"
>
Show Toast
</Button>
</template>

View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import { ToastRoot, type ToastRootEmits, type ToastRootProps, useEmitAsProps } from 'radix-vue'
import { toastVariants } from '.'
import { cn } from '@/lib/utils'
export interface ToastProps extends ToastRootProps {
class?: string
variant?: NonNullable<Parameters<typeof toastVariants>[0]>['variant']
};
const props = defineProps<ToastProps>()
const emits = defineEmits<ToastRootEmits>()
</script>
<template>
<ToastRoot
v-bind="{ ...props, ...useEmitAsProps(emits) }" :class="cn(toastVariants({
variant: props.variant,
}), props.class)"
>
<slot />
</ToastRoot>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import { ToastAction, type ToastActionProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps<ToastActionProps & { class?: string }>()
</script>
<template>
<ToastAction v-bind="props" :class="cn('inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', props.class)">
<slot />
</ToastAction>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import { ToastClose } from 'radix-vue'
import { XIcon } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<ToastClose v-bind="props" :class="cn('absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', props.class)">
<XIcon class="h-4 w-4" />
</ToastClose>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import { ToastDescription, type ToastDescriptionProps } from 'radix-vue'
import { cn } from '@/lib/utils.ts'
const props = defineProps<ToastDescriptionProps & { class?: string }>()
</script>
<template>
<ToastDescription :class="cn('text-sm opacity-90', props.class)" v-bind="props">
<slot />
</ToastDescription>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { ToastProvider, type ToastProviderProps } from 'radix-vue'
const props = defineProps<ToastProviderProps>()
</script>
<template>
<ToastProvider v-bind="props">
<slot />
</ToastProvider>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import { ToastTitle, type ToastTitleProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps<ToastTitleProps & { class?: string }>()
</script>
<template>
<ToastTitle v-bind="props" :class="cn('text-sm font-semibold', props.class)">
<slot />
</ToastTitle>
</template>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
import { ToastViewport, type ToastViewportProps } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps<ToastViewportProps & { class?: string }>()
</script>
<template>
<ToastViewport v-bind="props" :class="cn('fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', props.class)" />
</template>

View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import { useToast } from './use-toast'
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '.'
const { toasts } = useToast()
</script>
<template>
<ToastProvider>
<Toast v-for="toast in toasts" :key="toast.id" v-bind="toast">
<div class="grid gap-1">
<ToastTitle v-if="toast.title">
{{ toast.title }}
</ToastTitle>
<ToastDescription v-if="toast.description">
{{ toast.description }}
</ToastDescription>
<ToastClose />
</div>
<component :is="toast.action" />
</Toast>
<ToastViewport />
</ToastProvider>
</template>

View File

@ -0,0 +1,26 @@
export { default as Toaster } from './Toaster.vue'
export { default as Toast } from './Toast.vue'
export { default as ToastViewport } from './ToastViewport.vue'
export { default as ToastAction } from './ToastAction.vue'
export { default as ToastClose } from './ToastClose.vue'
export { default as ToastTitle } from './ToastTitle.vue'
export { default as ToastDescription } from './ToastDescription.vue'
export { default as ToastProvider } from './ToastProvider.vue'
import { cva } from 'class-variance-authority'
export const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)

View File

@ -0,0 +1,160 @@
import { computed, ref } from 'vue'
import type { Component } from 'vue'
import type { ToastProps } from './Toast.vue'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: string
description?: string
action?: Component
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
function addToRemoveQueue(toastId: string) {
if (toastTimeouts.has(toastId))
return
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: actionTypes.REMOVE_TOAST,
toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
const state = ref<State>({
toasts: [],
})
function dispatch(action: Action) {
switch (action.type) {
case actionTypes.ADD_TOAST:
state.value.toasts = [action.toast, ...state.value.toasts].slice(0, TOAST_LIMIT)
break
case actionTypes.UPDATE_TOAST:
state.value.toasts = state.value.toasts.map(t =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
)
break
case actionTypes.DISMISS_TOAST: {
const { toastId } = action
if (toastId) {
addToRemoveQueue(toastId)
}
else {
state.value.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
state.value.toasts = state.value.toasts.map(t =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
)
break
}
case actionTypes.REMOVE_TOAST:
if (action.toastId === undefined)
state.value.toasts = []
else
state.value.toasts = state.value.toasts.filter(t => t.id !== action.toastId)
break
}
}
function useToast() {
return {
toasts: computed(() => state.value.toasts),
toast,
dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
}
}
type Toast = Omit<ToasterToast, 'id'>
function toast(props: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: actionTypes.UPDATE_TOAST,
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })
dispatch({
type: actionTypes.ADD_TOAST,
toast: {
...props,
id,
'open': true,
'onUpdate:open': (open: boolean) => {
if (!open)
dismiss()
},
},
})
return {
id,
dismiss,
update,
}
}
export { toast, useToast }