Skip to content

Commit daaedce

Browse files
committed
feat: add info & warning type for toast
1 parent ea08a79 commit daaedce

File tree

8 files changed

+342
-59
lines changed

8 files changed

+342
-59
lines changed

packages/Toast.vue

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
<template v-if="props.closeButton && !isTitleComponent">
4141
<button
4242
aria-label="Close toast"
43-
:data-disabled="disabled"
4443
data-close-button
44+
:data-disabled="disabled"
4545
@click="handleCloseToast"
4646
>
4747
<CloseIcon />
@@ -50,11 +50,17 @@
5050

5151
<template v-if="toastType || toast.icon || toast.promise">
5252
<div data-icon="">
53-
<template v-if="typeof toast.promise === 'function'">
54-
<Loader :visible="promiseStatus === 'loading'" />
53+
<template
54+
v-if="typeof toast.promise === 'function' || toastType === 'loading'"
55+
>
56+
<Loader
57+
:visible="promiseStatus === 'loading' || toastType === 'loading'"
58+
/>
5559
</template>
5660
<SuccessIcon v-if="iconType === 'success'" />
57-
<ErrorIcon v-if="iconType === 'error'" />
61+
<InfoIcon v-else-if="iconType === 'info'" />
62+
<WarningIcon v-else-if="iconType === 'warning'" />
63+
<ErrorIcon v-else-if="iconType === 'error'" />
5864
</div>
5965
</template>
6066

@@ -128,9 +134,11 @@ import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
128134
import Loader from './assets/Loader.vue'
129135
import { HeightT, Position, PromiseData, ToastT } from './types'
130136
import SuccessIcon from './assets/SuccessIcon.vue'
131-
// import InfoIcon from '../assets/InfoIcon.vue'
137+
import InfoIcon from './assets/InfoIcon.vue'
138+
import WarningIcon from './assets/WarningIcon.vue'
132139
import ErrorIcon from './assets/ErrorIcon.vue'
133140
import CloseIcon from './assets/CloseIcon.vue'
141+
134142
// Default lifetime of a toasts (in ms)
135143
const TOAST_LIFETIME = 4000
136144
@@ -216,6 +224,7 @@ const toastRef = ref<HTMLLIElement | null>(null)
216224
const isFront = computed(() => props.index === 0)
217225
const isVisible = computed(() => props.index + 1 <= props.visibleToasts)
218226
const toastType = computed(() => props.toast.type)
227+
const dismissible = computed(() => props.toast.dismissible)
219228
const toastClass = props.toast.className || ''
220229
const toastDescriptionClass = props.toast.descriptionClassName || ''
221230
const toastStyle = props.toast.style || {}
@@ -323,7 +332,7 @@ watchEffect(() => {
323332
})
324333
325334
function handleCloseToast() {
326-
if (!disabled.value) {
335+
if (!disabled.value || dismissible.value) {
327336
deleteToast()
328337
props.toast.onDismiss?.(props.toast)
329338
}
@@ -333,7 +342,9 @@ function deleteToast() {
333342
// Save the offset for the exit swipe animation
334343
removed.value = true
335344
offsetBeforeRemove.value = offset.value
336-
const newHeights = props.heights.filter((height) => height.toastId !== props.toast.id)
345+
const newHeights = props.heights.filter(
346+
(height) => height.toastId !== props.toast.id
347+
)
337348
emit('update:heights', newHeights)
338349
339350
setTimeout(() => {
@@ -443,7 +454,9 @@ onMounted(() => {
443454
444455
onUnmounted(() => {
445456
if (toastRef.value) {
446-
const newHeights = props.heights.filter((height) => height.toastId !== props.toast.id)
457+
const newHeights = props.heights.filter(
458+
(height) => height.toastId !== props.toast.id
459+
)
447460
emit('update:heights', newHeights)
448461
}
449462
})

packages/Toaster.vue

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
<section :aria-label="`Notifications ${hotkeyLabel}`" :tabIndex="-1">
44
<ol
55
ref="listRef"
6-
:tabIndex="-1"
76
data-sonner-toaster
7+
:dir="dir === 'auto' ? getDocumentDirection() : dir"
8+
:tabIndex="-1"
89
:data-theme="theme"
910
:data-rich-colors="richColors"
1011
:data-y-position="coords[0]"
@@ -35,7 +36,7 @@
3536
<Toast
3637
:index="index"
3738
:toast="toast"
38-
:duration="duration"
39+
:duration="toastOptions?.duration ?? duration"
3940
:className="toastOptions?.className"
4041
:descriptionClassName="toastOptions?.descriptionClassName"
4142
:invert="invert"
@@ -46,6 +47,7 @@
4647
:style="toastOptions?.style"
4748
:toasts="toasts"
4849
:expandByDefault="expand"
50+
:gap="gap"
4951
:expanded="expanded"
5052
v-model:heights="heights"
5153
@removeToast="removeToast"
@@ -61,8 +63,10 @@ import {
6163
onMounted,
6264
onUnmounted,
6365
ref,
66+
watch,
6467
watchEffect,
65-
useAttrs
68+
useAttrs,
69+
CSSProperties
6670
} from 'vue'
6771
import {
6872
HeightT,
@@ -88,19 +92,25 @@ export interface ToasterProps {
8892
richColors?: boolean
8993
expand?: boolean
9094
duration?: number
95+
gap?: number
9196
visibleToasts?: number
9297
closeButton?: boolean
9398
toastOptions?: ToastOptions
9499
className?: string
95-
style?: Record<string, any>
100+
style?: CSSProperties
96101
offset?: string | number
102+
dir?: 'rtl' | 'ltr' | 'auto'
97103
}
98104
99105
// Visible toasts amount
100106
const VISIBLE_TOASTS_AMOUNT = 3
101107
108+
// Viewport padding
102109
const VIEWPORT_OFFSET = '32px'
103110
111+
// Default lifetime of a toasts (in ms)
112+
const TOAST_LIFETIME = 4000
113+
104114
// Default toast width
105115
const TOAST_WIDTH = 356
106116
@@ -109,21 +119,37 @@ const GAP = 14
109119
110120
const props = withDefaults(defineProps<ToasterProps>(), {
111121
invert: false,
112-
theme: 'light',
113122
position: 'bottom-right',
114123
hotkey: () => ['altKey', 'KeyT'],
115-
richColors: false,
116124
expand: false,
117-
visibleToasts: VISIBLE_TOASTS_AMOUNT,
118125
closeButton: false,
126+
className: '',
127+
offset: VIEWPORT_OFFSET,
128+
theme: 'light',
129+
richColors: false,
130+
duration: TOAST_LIFETIME,
131+
style: () => ({}),
132+
visibleToasts: VISIBLE_TOASTS_AMOUNT,
119133
toastOptions: () => ({}),
134+
dir: 'auto',
135+
gap: GAP
120136
})
121137
122138
const attrs = useAttrs()
123139
const toasts = ref<ToastT[]>([])
124140
const heights = ref<HeightT[]>([])
125141
const expanded = ref(false)
126142
const interacting = ref(false)
143+
const actualTheme = ref(
144+
props.theme !== 'system'
145+
? props.theme
146+
: typeof window !== 'undefined'
147+
? window.matchMedia &&
148+
window.matchMedia('(prefers-color-scheme: dark)').matches
149+
? 'dark'
150+
: 'light'
151+
: 'light'
152+
)
127153
const coords = computed(() => props.position.split('-'))
128154
const listRef = ref<HTMLOListElement | null>(null)
129155
const hotkeyLabel = props.hotkey
@@ -135,6 +161,19 @@ function removeToast(toast: ToastT) {
135161
toasts.value = toasts.value.filter(({ id }) => id !== toast.id)
136162
}
137163
164+
function getDocumentDirection(): ToasterProps['dir'] {
165+
if (typeof window === 'undefined') return 'ltr'
166+
167+
const dirAttribute = document.documentElement.getAttribute('dir')
168+
169+
if (dirAttribute === 'auto' || !dirAttribute) {
170+
return window.getComputedStyle(document.documentElement)
171+
.direction as ToasterProps['dir']
172+
}
173+
174+
return dirAttribute as ToasterProps['dir']
175+
}
176+
138177
onMounted(() => {
139178
const unsubscribe = ToastState.subscribe((toast) => {
140179
if ((toast as ToastToDismiss).dismiss) {
@@ -152,6 +191,42 @@ onMounted(() => {
152191
})
153192
})
154193
194+
watch(
195+
() => props.theme,
196+
(newTheme) => {
197+
if (newTheme !== 'system') {
198+
actualTheme.value = newTheme
199+
return
200+
}
201+
202+
if (newTheme === 'system') {
203+
// check if current preference is dark
204+
if (
205+
window.matchMedia &&
206+
window.matchMedia('(prefers-color-scheme: dark)').matches
207+
) {
208+
// it's currently dark
209+
actualTheme.value = 'dark'
210+
} else {
211+
// it's not dark
212+
actualTheme.value = 'light'
213+
}
214+
}
215+
216+
if (typeof window === 'undefined') return
217+
218+
window
219+
.matchMedia('(prefers-color-scheme: dark)')
220+
.addEventListener('change', ({ matches }) => {
221+
if (matches) {
222+
actualTheme.value = 'dark'
223+
} else {
224+
actualTheme.value = 'light'
225+
}
226+
})
227+
}
228+
)
229+
155230
watchEffect(() => {
156231
// Ensure expanded is always false when no toasts are present / only one left
157232
if (toasts.value.length <= 1) {

packages/assets/WarningIcon.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<template>
2+
<svg
3+
xmlns="http://www.w3.org/2000/svg"
4+
viewBox="0 0 24 24"
5+
fill="currentColor"
6+
height="20"
7+
width="20"
8+
>
9+
<path
10+
fill-rule="evenodd"
11+
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
12+
clip-rule="evenodd"
13+
></path>
14+
</svg>
15+
</template>

packages/state.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {
44
ToastT,
55
PromiseData,
66
PromiseT,
7-
ToastToDismiss
7+
ToastToDismiss,
8+
ToastTypes
89
} from './types'
910

10-
let toastsCounter = 0
11+
let toastsCounter = 1
1112

1213
class Observer {
1314
subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>
@@ -33,6 +34,50 @@ class Observer {
3334
this.toasts = [...this.toasts, data]
3435
}
3536

37+
addToast = (data: ToastT) => {
38+
this.publish(data)
39+
this.toasts = [...this.toasts, data]
40+
}
41+
42+
create = (
43+
data: ExternalToast & {
44+
message?: string | Component
45+
type?: ToastTypes
46+
promise?: PromiseT
47+
}
48+
) => {
49+
const { message, ...rest } = data
50+
const id =
51+
typeof data?.id === 'number' || data.id?.length! > 0
52+
? data.id!
53+
: toastsCounter++
54+
const alreadyExists = this.toasts.find((toast) => {
55+
return toast.id === id
56+
})
57+
const dismissible = data.dismissible === undefined ? true : data.dismissible
58+
59+
if (alreadyExists) {
60+
this.toasts = this.toasts.map((toast) => {
61+
if (toast.id === id) {
62+
this.publish({ ...toast, ...data, id, title: message })
63+
return {
64+
...toast,
65+
...data,
66+
id,
67+
dismissible,
68+
title: message
69+
}
70+
}
71+
72+
return toast
73+
})
74+
} else {
75+
this.addToast({ title: message, ...rest, dismissible, id })
76+
}
77+
78+
return id
79+
}
80+
3681
dismiss = (id?: number | string) => {
3782
if (!id) {
3883
this.toasts.forEach((toast) => {
@@ -64,6 +109,18 @@ class Observer {
64109
return id
65110
}
66111

112+
info = (message: string | Component, data?: ExternalToast) => {
113+
return this.create({ ...data, type: 'info', message })
114+
}
115+
116+
warning = (message: string | Component, data?: ExternalToast) => {
117+
return this.create({ ...data, type: 'warning', message })
118+
}
119+
120+
loading = (message: string | Component, data?: ExternalToast) => {
121+
return this.create({ ...data, type: 'loading', message })
122+
}
123+
67124
promise = (promise: PromiseT, data?: PromiseData) => {
68125
const id = data?.id || toastsCounter++
69126
this.publish({ ...data, promise, id })
@@ -74,6 +131,7 @@ class Observer {
74131
custom = (component: Component, data?: ExternalToast) => {
75132
const id = data?.id || toastsCounter++
76133
this.publish({ ...data, id, title: component })
134+
return id
77135
}
78136
}
79137

@@ -96,9 +154,12 @@ const basicToast = toastFunction
96154
// We use `Object.assign` to maintain the correct types as we would lose them otherwise
97155
export const toast = Object.assign(basicToast, {
98156
success: ToastState.success,
157+
info: ToastState.info,
158+
warning: ToastState.warning,
99159
error: ToastState.error,
100160
custom: ToastState.custom,
101161
message: ToastState.message,
102162
promise: ToastState.promise,
103-
dismiss: ToastState.dismiss
163+
dismiss: ToastState.dismiss,
164+
loading: ToastState.loading
104165
})

0 commit comments

Comments
 (0)