@soft-toast/vue 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +210 -0
  3. package/dist/animations/gsapConfig.d.ts +42 -0
  4. package/dist/animations/gsapConfig.d.ts.map +1 -0
  5. package/dist/composables/useFlash.d.ts +41 -0
  6. package/dist/composables/useFlash.d.ts.map +1 -0
  7. package/dist/composables/useFlash.test.d.ts +2 -0
  8. package/dist/composables/useFlash.test.d.ts.map +1 -0
  9. package/dist/composables/useToast.d.ts +53 -0
  10. package/dist/composables/useToast.d.ts.map +1 -0
  11. package/dist/composables/useToast.test.d.ts +2 -0
  12. package/dist/composables/useToast.test.d.ts.map +1 -0
  13. package/dist/exports.test.d.ts +2 -0
  14. package/dist/exports.test.d.ts.map +1 -0
  15. package/dist/icons.d.ts +2 -0
  16. package/dist/icons.d.ts.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +2100 -0
  20. package/dist/plugin.d.ts +7 -0
  21. package/dist/plugin.d.ts.map +1 -0
  22. package/dist/stores/toastStore.d.ts +25 -0
  23. package/dist/stores/toastStore.d.ts.map +1 -0
  24. package/dist/stores/toastStore.test.d.ts +2 -0
  25. package/dist/stores/toastStore.test.d.ts.map +1 -0
  26. package/dist/style.css +1 -0
  27. package/dist/types/index.d.ts +107 -0
  28. package/dist/types/index.d.ts.map +1 -0
  29. package/dist/utils/sound.d.ts +9 -0
  30. package/dist/utils/sound.d.ts.map +1 -0
  31. package/package.json +70 -0
  32. package/src/animations/gsapConfig.ts +303 -0
  33. package/src/components/ToastContainer.vue +36 -0
  34. package/src/components/ToastIcon.vue +33 -0
  35. package/src/components/ToastItem.vue +342 -0
  36. package/src/components/ToastProgress.vue +50 -0
  37. package/src/components/ToastRegion.vue +381 -0
  38. package/src/composables/useFlash.test.ts +164 -0
  39. package/src/composables/useFlash.ts +118 -0
  40. package/src/composables/useToast.test.ts +230 -0
  41. package/src/composables/useToast.ts +95 -0
  42. package/src/exports.test.ts +72 -0
  43. package/src/icons.ts +38 -0
  44. package/src/index.ts +25 -0
  45. package/src/plugin.ts +85 -0
  46. package/src/stores/toastStore.test.ts +129 -0
  47. package/src/stores/toastStore.ts +288 -0
  48. package/src/styles/toast.css +353 -0
  49. package/src/styles/variables.css +83 -0
  50. package/src/types/index.ts +115 -0
  51. package/src/utils/sound.ts +140 -0
@@ -0,0 +1,288 @@
1
+ import { ref, computed } from 'vue'
2
+ import type { Toast, ToastOptions, ToastType, ToastPosition, ToastPromiseMessages } from '../types'
3
+ import { playToastSound } from '../utils/sound'
4
+
5
+ // ─── ID generation ───────────────────────────────────────────────────────────
6
+
7
+ const generateId = () => `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
8
+
9
+ // ─── Default options ─────────────────────────────────────────────────────────
10
+
11
+ const defaultOptions: Required<Pick<
12
+ ToastOptions,
13
+ 'type' | 'duration' | 'position' | 'preset' | 'bounce' | 'spring' | 'showTimestamp' | 'showProgress'
14
+ >> = {
15
+ type: 'default',
16
+ duration: 4000,
17
+ position: 'top-right',
18
+ preset: 'smooth',
19
+ bounce: 0.4,
20
+ spring: true,
21
+ showTimestamp: false,
22
+ showProgress: false,
23
+ }
24
+
25
+ // ─── Reactive state ───────────────────────────────────────────────────────────
26
+
27
+ const toasts = ref<Toast[]>([])
28
+
29
+ // ─── Computed ─────────────────────────────────────────────────────────────────
30
+
31
+ const getToastsByPosition = (position: ToastPosition) =>
32
+ computed(() => toasts.value.filter((t) => t.position === position))
33
+
34
+ const allToasts = computed(() => toasts.value)
35
+
36
+ // ─── Actions ──────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Add a toast — or UPDATE an existing one if the same `id` is already visible.
40
+ * This automatic deduplication means calling toast.error('Oops', { id: 'network-error' })
41
+ * five times in a row results in ONE toast that refreshes its content each time.
42
+ */
43
+ const add = (options: ToastOptions): string => {
44
+ const id = options.id || generateId()
45
+
46
+ // ── Smart Dedup: same id already exists → update instead of creating ──
47
+ const existingIndex = toasts.value.findIndex((t) => t.id === id)
48
+ if (existingIndex !== -1 && !toasts.value[existingIndex].isLeaving) {
49
+ const existing = toasts.value[existingIndex]
50
+ // Patch the existing toast with new content
51
+ toasts.value[existingIndex] = {
52
+ ...existing,
53
+ ...options,
54
+ id,
55
+ // Reset timer so user has time to read the updated content
56
+ remainingTime: options.duration ?? existing.duration,
57
+ isPaused: existing.isPaused,
58
+ isLeaving: false,
59
+ }
60
+ // Play sound again if configured (content changed)
61
+ const sound = options.sound
62
+ const vol = options.soundVolume ?? 0.5
63
+ if (sound) playToastSound(options.type ?? existing.type, sound, vol)
64
+ return id
65
+ }
66
+
67
+ // ── New toast ──
68
+ const toast: Toast = {
69
+ ...defaultOptions,
70
+ ...options,
71
+ id,
72
+ createdAt: Date.now(),
73
+ remainingTime: options.duration ?? defaultOptions.duration,
74
+ isPaused: false,
75
+ isExpanded: true,
76
+ isLeaving: false,
77
+ preset: options.preset ?? defaultOptions.preset,
78
+ bounce: options.bounce ?? defaultOptions.bounce,
79
+ spring: options.spring ?? defaultOptions.spring,
80
+ showTimestamp: options.showTimestamp ?? defaultOptions.showTimestamp,
81
+ showProgress: options.showProgress ?? defaultOptions.showProgress,
82
+ }
83
+
84
+ toasts.value.unshift(toast)
85
+ startTickLoop()
86
+
87
+ // Play sound if configured
88
+ const sound = options.sound
89
+ const vol = options.soundVolume ?? 0.5
90
+ if (sound) playToastSound(toast.type, sound, vol)
91
+
92
+ return id
93
+ }
94
+
95
+ const update = (id: string, options: Partial<ToastOptions>) => {
96
+ const index = toasts.value.findIndex((t) => t.id === id)
97
+ if (index !== -1) {
98
+ toasts.value[index] = { ...toasts.value[index], ...options }
99
+ }
100
+ }
101
+
102
+ const dismiss = (id?: string | { type?: ToastType | ToastType[] }) => {
103
+ if (!id) {
104
+ toasts.value.forEach((t) => { t.isLeaving = true })
105
+ setTimeout(() => { toasts.value = [] }, 400)
106
+ return
107
+ }
108
+
109
+ if (typeof id === 'string') {
110
+ const toast = toasts.value.find((t) => t.id === id)
111
+ if (toast) {
112
+ toast.isLeaving = true
113
+ toast.onDismiss?.(id)
114
+ setTimeout(() => {
115
+ toasts.value = toasts.value.filter((t) => t.id !== id)
116
+ }, 400)
117
+ }
118
+ } else {
119
+ const types = Array.isArray(id.type) ? id.type : [id.type]
120
+ toasts.value.forEach((t) => {
121
+ if (types.includes(t.type)) {
122
+ t.isLeaving = true
123
+ t.onDismiss?.(t.id)
124
+ }
125
+ })
126
+ setTimeout(() => {
127
+ toasts.value = toasts.value.filter((t) => !types.includes(t.type))
128
+ }, 400)
129
+ }
130
+ }
131
+
132
+ const pause = (id: string) => {
133
+ const toast = toasts.value.find((t) => t.id === id)
134
+ if (toast) toast.isPaused = true
135
+ }
136
+
137
+ const resume = (id: string) => {
138
+ const toast = toasts.value.find((t) => t.id === id)
139
+ if (toast) toast.isPaused = false
140
+ }
141
+
142
+ const expand = (id: string) => {
143
+ const toast = toasts.value.find((t) => t.id === id)
144
+ if (toast) toast.isExpanded = true
145
+ }
146
+
147
+ const collapse = (id: string) => {
148
+ const toast = toasts.value.find((t) => t.id === id)
149
+ if (toast) toast.isExpanded = false
150
+ }
151
+
152
+ // ─── Global RAF tick loop ─────────────────────────────────────────────────────
153
+
154
+ let lastTime = 0
155
+ let rafId: number | null = null
156
+
157
+ const startTickLoop = () => {
158
+ if (rafId !== null) return
159
+ if (typeof window === 'undefined') return
160
+
161
+ const loop = (currentTime: number) => {
162
+ if (lastTime === 0) lastTime = currentTime
163
+ const delta = currentTime - lastTime
164
+ lastTime = currentTime
165
+
166
+ toasts.value.forEach((toast) => {
167
+ if (!toast.isPaused && !toast.isLeaving && toast.remainingTime > 0 && toast.duration !== Infinity) {
168
+ toast.remainingTime -= delta
169
+ if (toast.remainingTime <= 0) {
170
+ toast.isLeaving = true
171
+ toast.onAutoClose?.(toast.id)
172
+ setTimeout(() => {
173
+ toasts.value = toasts.value.filter((t) => t.id !== toast.id)
174
+ }, 400)
175
+ }
176
+ }
177
+ })
178
+
179
+ if (toasts.value.length > 0) {
180
+ rafId = requestAnimationFrame(loop)
181
+ } else {
182
+ rafId = null
183
+ lastTime = 0
184
+ }
185
+ }
186
+
187
+ rafId = requestAnimationFrame(loop)
188
+ }
189
+
190
+ // ─── Type helpers ─────────────────────────────────────────────────────────────
191
+
192
+ const success = (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
193
+ add({ ...options, type: 'success', title })
194
+
195
+ const error = (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
196
+ add({ ...options, type: 'error', title })
197
+
198
+ const warning = (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
199
+ add({ ...options, type: 'warning', title })
200
+
201
+ const info = (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
202
+ add({ ...options, type: 'info', title })
203
+
204
+ const loading = (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
205
+ add({ ...options, type: 'promise', title, duration: Infinity })
206
+
207
+ const promise = async <T>(
208
+ promiseFn: Promise<T>,
209
+ messages: ToastPromiseMessages,
210
+ options?: Omit<ToastOptions, 'type' | 'promise' | 'promiseMessages'>
211
+ ): Promise<T> => {
212
+ const id = add({
213
+ ...options,
214
+ type: 'promise',
215
+ title: messages.loading,
216
+ duration: Infinity,
217
+ })
218
+
219
+ try {
220
+ const result = await promiseFn
221
+ update(id, {
222
+ type: 'success',
223
+ title: messages.success,
224
+ description: messages.description?.success,
225
+ duration: 4000,
226
+ })
227
+ return result
228
+ } catch (err) {
229
+ update(id, {
230
+ type: 'error',
231
+ title: messages.error,
232
+ description: messages.description?.error,
233
+ action: messages.action?.error,
234
+ duration: 6000,
235
+ })
236
+ throw err
237
+ }
238
+ }
239
+
240
+ const clearAll = () => { toasts.value = [] }
241
+
242
+ const remove = (id: string) => {
243
+ toasts.value = toasts.value.filter((t) => t.id !== id)
244
+ }
245
+
246
+ // ─── Store interface + export ─────────────────────────────────────────────────
247
+
248
+ import type { ComputedRef } from 'vue'
249
+
250
+ export interface ToastStore {
251
+ toasts: ComputedRef<Toast[]>
252
+ getToastsByPosition: (position: ToastPosition) => ComputedRef<Toast[]>
253
+ add: (options: ToastOptions) => string
254
+ update: (id: string, options: Partial<ToastOptions>) => void
255
+ dismiss: (id?: string | { type?: ToastType | ToastType[] }) => void
256
+ pause: (id: string) => void
257
+ resume: (id: string) => void
258
+ expand: (id: string) => void
259
+ collapse: (id: string) => void
260
+ success: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) => string
261
+ error: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) => string
262
+ warning: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) => string
263
+ info: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) => string
264
+ loading: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) => string
265
+ promise: <T>(promiseFn: Promise<T>, messages: ToastPromiseMessages, options?: Omit<ToastOptions, 'type' | 'promise' | 'promiseMessages'>) => Promise<T>
266
+ clearAll: () => void
267
+ remove: (id: string) => void
268
+ }
269
+
270
+ export const toastStore: ToastStore = {
271
+ toasts: allToasts,
272
+ getToastsByPosition,
273
+ add,
274
+ update,
275
+ dismiss,
276
+ pause,
277
+ resume,
278
+ expand,
279
+ collapse,
280
+ success,
281
+ error,
282
+ warning,
283
+ info,
284
+ loading,
285
+ promise,
286
+ clearAll,
287
+ remove,
288
+ }
@@ -0,0 +1,353 @@
1
+ @import './variables.css';
2
+
3
+ /* =============================================
4
+ Soft Toast — Modern Design System
5
+ ============================================= */
6
+
7
+ /* Container */
8
+ .soft-toast-container {
9
+ position: fixed;
10
+ z-index: var(--st-z-index);
11
+ pointer-events: none;
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: 10px;
15
+ width: min(var(--st-width, 356px), calc(100vw - 64px));
16
+ max-width: calc(100vw - 64px);
17
+ }
18
+
19
+ .soft-toast-container[data-position="top-left"] { top: 32px; left: 32px; }
20
+ .soft-toast-container[data-position="top-center"] { top: 32px; left: 50%; transform: translateX(-50%); }
21
+ .soft-toast-container[data-position="top-right"] { top: 32px; right: 32px; }
22
+ .soft-toast-container[data-position="bottom-left"] { bottom: 32px; left: 32px; }
23
+ .soft-toast-container[data-position="bottom-center"]{ bottom: 32px; left: 50%; transform: translateX(-50%); }
24
+ .soft-toast-container[data-position="bottom-right"] { bottom: 32px; right: 32px; }
25
+
26
+ /* New basic positions */
27
+ .soft-toast-container[data-position="top"] { top: 32px; left: 50%; transform: translateX(-50%); }
28
+ .soft-toast-container[data-position="bottom"] { bottom: 32px; left: 50%; transform: translateX(-50%); }
29
+ .soft-toast-container[data-position="left"] { top: 50%; left: 32px; transform: translateY(-50%); }
30
+ .soft-toast-container[data-position="right"] { top: 50%; right: 32px; transform: translateY(-50%); }
31
+ .soft-toast-container[data-position="center"] { top: 50%; left: 50%; transform: translate(-50%, -50%); }
32
+
33
+ /* ---- Toast Item ---- */
34
+ .soft-toast-item {
35
+ pointer-events: auto;
36
+ position: relative;
37
+ box-sizing: border-box;
38
+ width: 100%;
39
+ min-width: 260px;
40
+ max-width: 360px;
41
+ padding: 12px 16px;
42
+ border-radius: 18px;
43
+ font-family: var(--st-font-family);
44
+ font-size: 13.5px;
45
+ line-height: 1.45;
46
+ cursor: default;
47
+ user-select: none;
48
+ will-change: transform, opacity;
49
+ touch-action: pan-y; /* allow vertical scroll, intercept horizontal for swipe */
50
+
51
+ /* Clean modern white card */
52
+ background: rgba(255, 255, 255, 0.98);
53
+ border: 1px solid rgba(226, 232, 240, 0.8);
54
+ box-shadow:
55
+ 0 10px 30px -5px rgba(0, 0, 0, 0.08),
56
+ inset 0 1px 0 rgba(255, 255, 255, 1);
57
+ }
58
+
59
+ /* Show grab cursor on swipeable toasts to hint at the gesture */
60
+ .soft-toast-item--swipeable {
61
+ cursor: grab;
62
+ }
63
+ .soft-toast-item--swipeable:active {
64
+ cursor: grabbing;
65
+ }
66
+
67
+ /* Asymmetrical Chat Bubble Corners */
68
+ .soft-toast-container[data-position="top-left"] .soft-toast-item { border-top-left-radius: 4px; }
69
+ .soft-toast-container[data-position="left"] .soft-toast-item { border-top-left-radius: 4px; }
70
+ .soft-toast-container[data-position="bottom-left"] .soft-toast-item { border-bottom-left-radius: 4px; }
71
+
72
+ .soft-toast-container[data-position="top-right"] .soft-toast-item { border-top-right-radius: 4px; }
73
+ .soft-toast-container[data-position="right"] .soft-toast-item { border-top-right-radius: 4px; }
74
+ .soft-toast-container[data-position="bottom-right"] .soft-toast-item { border-bottom-right-radius: 4px; }
75
+
76
+ /* ---- Modern Clean Palette ---- */
77
+ .soft-toast-item[data-type="default"] {
78
+ --st-accent: #9ca3af;
79
+ --st-icon-color: #64748b;
80
+ }
81
+ .soft-toast-item[data-type="success"] {
82
+ --st-accent: #34d399;
83
+ --st-icon-color: #059669;
84
+ }
85
+ .soft-toast-item[data-type="error"] {
86
+ --st-accent: #fb7185;
87
+ --st-icon-color: #e11d48;
88
+ }
89
+ .soft-toast-item[data-type="warning"] {
90
+ --st-accent: #fbbf24;
91
+ --st-icon-color: #d97706;
92
+ }
93
+ .soft-toast-item[data-type="info"] {
94
+ --st-accent: #60a5fa;
95
+ --st-icon-color: #3b82f6;
96
+ }
97
+ .soft-toast-item[data-type="promise"] {
98
+ --st-accent: #a78bfa;
99
+ --st-icon-color: #7c3aed;
100
+ }
101
+
102
+ /* ---- Content layout ---- */
103
+ .soft-toast-content {
104
+ display: flex;
105
+ align-items: flex-start;
106
+ gap: 11px;
107
+ }
108
+
109
+ /* Icon */
110
+ .soft-toast-icon {
111
+ flex-shrink: 0;
112
+ width: 24px;
113
+ height: 24px;
114
+ margin-top: 1px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ color: var(--st-icon-color, #64748b);
119
+ }
120
+ .soft-toast-icon svg,
121
+ .soft-toast-icon .iconify,
122
+ .soft-toast-icon-svg {
123
+ display: block;
124
+ flex: none;
125
+ width: 18px !important;
126
+ height: 18px !important;
127
+ max-width: 18px;
128
+ max-height: 18px;
129
+ filter: drop-shadow(0 1px 2px rgba(0,0,0,0.12));
130
+ }
131
+ .soft-toast-item[data-type="promise"] .soft-toast-icon-svg {
132
+ animation: st-spin 0.8s linear infinite;
133
+ }
134
+
135
+ /* Loading spinner for promise */
136
+ .soft-toast-spinner {
137
+ width: 18px;
138
+ height: 18px;
139
+ border: 2px solid rgba(139, 92, 246, 0.2);
140
+ border-top-color: #8b5cf6;
141
+ border-radius: 50%;
142
+ animation: st-spin 0.7s linear infinite;
143
+ }
144
+ @keyframes st-spin {
145
+ to { transform: rotate(360deg); }
146
+ }
147
+
148
+ /* Body */
149
+ .soft-toast-body {
150
+ flex: 1;
151
+ min-width: 0;
152
+ }
153
+
154
+ .soft-toast-header-row {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 12px;
158
+ }
159
+
160
+ .soft-toast-title {
161
+ font-weight: 600;
162
+ font-size: 14px;
163
+ color: #111827;
164
+ margin: 0;
165
+ line-height: 1.4;
166
+ letter-spacing: -0.01em;
167
+ /* Slight right padding by default so text doesn't bump against close button on hover */
168
+ padding-right: 4px;
169
+ }
170
+
171
+ /* When the close button is displayed at top-right, push the title text further left */
172
+ .soft-toast-title--has-close {
173
+ padding-right: 24px;
174
+ }
175
+
176
+ .soft-toast-description {
177
+ font-size: 13.5px;
178
+ font-weight: 400;
179
+ color: #4b5563;
180
+ margin: 4px 0 0;
181
+ line-height: 1.5;
182
+ max-height: 120px;
183
+ overflow-y: auto;
184
+ padding-right: 4px;
185
+ }
186
+
187
+ /* Custom Scrollbar for long descriptions */
188
+ .soft-toast-description::-webkit-scrollbar { width: 4px; }
189
+ .soft-toast-description::-webkit-scrollbar-track { background: transparent; }
190
+ .soft-toast-description::-webkit-scrollbar-thumb {
191
+ background: rgba(0, 0, 0, 0.1);
192
+ border-radius: 4px;
193
+ }
194
+ .soft-toast-description::-webkit-scrollbar-thumb:hover {
195
+ background: rgba(0, 0, 0, 0.2);
196
+ }
197
+
198
+ /* Timestamp — lives BELOW all content, never overlaps close button */
199
+ .soft-toast-timestamp {
200
+ display: inline-block;
201
+ font-size: 11px;
202
+ color: #9ca3af;
203
+ letter-spacing: 0.01em;
204
+ white-space: nowrap;
205
+ margin-top: 6px;
206
+ }
207
+
208
+ /* Action */
209
+ .soft-toast-action {
210
+ display: flex;
211
+ gap: 8px;
212
+ margin-top: 12px;
213
+ }
214
+
215
+ .soft-toast-action-button {
216
+ flex: 1;
217
+ width: 100%;
218
+ padding: 8px 16px;
219
+ border-radius: 12px;
220
+ background: color-mix(in srgb, var(--st-accent) 15%, transparent);
221
+ color: var(--st-icon-color);
222
+ border: none;
223
+ font-size: 13.5px;
224
+ font-weight: 600;
225
+ cursor: pointer;
226
+ font-family: inherit;
227
+ transition: all 0.18s ease;
228
+ letter-spacing: 0.01em;
229
+ text-align: center;
230
+ }
231
+ .soft-toast-action-button:hover {
232
+ background: color-mix(in srgb, var(--st-accent) 25%, transparent);
233
+ transform: scale(1.02);
234
+ }
235
+ .soft-toast-action-button:active {
236
+ transform: scale(0.98);
237
+ }
238
+
239
+ .soft-toast-action-primary {
240
+ background: var(--st-icon-color);
241
+ color: #fff;
242
+ }
243
+ .soft-toast-action-primary:hover {
244
+ background: color-mix(in srgb, var(--st-icon-color) 85%, #000);
245
+ }
246
+
247
+ /* Close Button */
248
+ .soft-toast-close {
249
+ position: absolute;
250
+ top: 10px;
251
+ right: 10px;
252
+ width: 22px;
253
+ height: 22px;
254
+ border-radius: 50%;
255
+ border: none;
256
+ background: rgba(0,0,0,0.04);
257
+ color: #9ca3af;
258
+ cursor: pointer;
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ opacity: 0;
263
+ transition: opacity 0.18s ease, background 0.18s ease, color 0.18s ease, transform 0.18s ease;
264
+ padding: 0;
265
+ }
266
+ .soft-toast-close:hover {
267
+ background: rgba(0,0,0,0.1);
268
+ color: #374151;
269
+ transform: scale(1.1);
270
+ }
271
+ .soft-toast-item:hover .soft-toast-close { opacity: 1; }
272
+ .soft-toast-close[data-position="top-left"] { right: auto; left: 10px; }
273
+
274
+ /* Morph X to Minus on Hover */
275
+ .st-close-line-1, .st-close-line-2 {
276
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
277
+ transform-origin: center;
278
+ }
279
+ .soft-toast-close:hover .st-close-line-1 {
280
+ transform: rotate(-45deg);
281
+ }
282
+ .soft-toast-close:hover .st-close-line-2 {
283
+ transform: rotate(45deg) scaleX(0);
284
+ opacity: 0;
285
+ }
286
+
287
+ /* Progress Bar - Soft Background Fill */
288
+ .soft-toast-progress {
289
+ position: absolute;
290
+ top: 0;
291
+ left: 0;
292
+ right: 0;
293
+ bottom: 0;
294
+ height: 100%;
295
+ background: transparent;
296
+ z-index: 0;
297
+ pointer-events: none;
298
+ border-radius: inherit;
299
+ overflow: hidden;
300
+ }
301
+ .soft-toast-progress-bar {
302
+ height: 100%;
303
+ background: var(--st-accent);
304
+ opacity: 0.12;
305
+ transform-origin: left;
306
+ }
307
+
308
+ /* ---- Dark Theme ---- */
309
+ [data-soft-toast-theme="dark"] .soft-toast-item {
310
+ background: #1c1c1e;
311
+ border-color: rgba(255, 255, 255, 0.08);
312
+ box-shadow:
313
+ 0 4px 24px -4px rgba(0,0,0,0.5),
314
+ 0 1px 4px rgba(0,0,0,0.3);
315
+ }
316
+ [data-soft-toast-theme="dark"] .soft-toast-title { color: #f9fafb; }
317
+ [data-soft-toast-theme="dark"] .soft-toast-description { color: #9ca3af; }
318
+ [data-soft-toast-theme="dark"] .soft-toast-timestamp { color: #6b7280; }
319
+
320
+ [data-soft-toast-theme="dark"] .soft-toast-item[data-type] {
321
+ background: #1c1c1e;
322
+ border-color: rgba(255, 255, 255, 0.08);
323
+ }
324
+
325
+ /* ---- RTL ---- */
326
+ [data-soft-toast-dir="rtl"] .soft-toast-item::before { left: auto; right: 0; border-radius: 4px 0 0 4px; }
327
+ [data-soft-toast-dir="rtl"] .soft-toast-content { flex-direction: row-reverse; padding-left: 0; padding-right: 10px; }
328
+ [data-soft-toast-dir="rtl"] .soft-toast-body { align-items: flex-end; text-align: right; }
329
+ [data-soft-toast-dir="rtl"] .soft-toast-close { right: auto; left: 10px; }
330
+
331
+ /* ---- Mobile ---- */
332
+ @media (max-width: 640px) {
333
+ .soft-toast-container {
334
+ left: 12px !important;
335
+ right: 12px !important;
336
+ width: auto;
337
+ max-width: none;
338
+ transform: none !important;
339
+ align-items: stretch !important;
340
+ }
341
+ .soft-toast-container[data-position^="top"] { top: 12px; }
342
+ .soft-toast-container[data-position^="bottom"] { bottom: 12px; }
343
+ .soft-toast-item { min-width: auto; max-width: none; }
344
+ .soft-toast-close { opacity: 1; }
345
+ }
346
+
347
+ /* ---- Reduced Motion ---- */
348
+ @media (prefers-reduced-motion: reduce) {
349
+ .soft-toast-item,
350
+ .soft-toast-action-button,
351
+ .soft-toast-close,
352
+ .soft-toast-spinner { transition: none !important; animation: none !important; }
353
+ }