vue-toastflow 0.0.5 → 0.0.7

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.
@@ -1,658 +1,658 @@
1
- <script setup lang="ts">
2
- import { computed, type CSSProperties, inject, ref, watch } from "vue";
3
- import ToastProgress from "./ToastProgress.vue";
4
- import type {
5
- ToastContext,
6
- ToastId,
7
- ToastInstance,
8
- ToastStore,
9
- ToastType,
10
- } from "toastflow-core";
11
- import { toastStoreKey } from "../symbols";
12
- import { getToastStore } from "../toast";
13
- import CheckCircle from "./icons/CheckCircle.vue";
14
- import XCircle from "./icons/XCircle.vue";
15
- import Bell from "./icons/Bell.vue";
16
- import InfoCircle from "./icons/InfoCircle.vue";
17
- import QuestionMarkCircle from "./icons/QuestionMarkCircle.vue";
18
- import ArrowPath from "./icons/ArrowPath.vue";
19
- import XMark from "./icons/XMark.vue";
20
-
21
- const {
22
- toast,
23
- progressResetKey,
24
- duplicateKey,
25
- updateKey,
26
- bumpAnimationClass,
27
- clearAllAnimationClass,
28
- updateAnimationClass,
29
- } = defineProps<{
30
- toast: ToastInstance;
31
- progressResetKey?: number;
32
- duplicateKey?: number;
33
- updateKey?: number;
34
- bumpAnimationClass?: string;
35
- clearAllAnimationClass?: string;
36
- updateAnimationClass?: string;
37
- }>();
38
-
39
- const emit = defineEmits<{
40
- (e: "dismiss", id: ToastId): void;
41
- }>();
42
-
43
- const injectedStore = inject<ToastStore | null>(toastStoreKey, null);
44
- const store: ToastStore = injectedStore ?? getToastStore();
45
-
46
- const isHovered = ref(false);
47
- const isBumped = ref(false);
48
- const isUpdated = ref(false);
49
-
50
- const typeMeta: Record<
51
- ToastType,
52
- {
53
- accent: string;
54
- icon: string;
55
- close: string;
56
- component: any;
57
- }
58
- > = {
59
- success: {
60
- accent: "tf-toast-accent--success",
61
- icon: "tf-toast-icon--success",
62
- close: "tf-toast-close--success",
63
- component: CheckCircle,
64
- },
65
- error: {
66
- accent: "tf-toast-accent--error",
67
- icon: "tf-toast-icon--error",
68
- close: "tf-toast-close--error",
69
- component: XCircle,
70
- },
71
- warning: {
72
- accent: "tf-toast-accent--warning",
73
- icon: "tf-toast-icon--warning",
74
- close: "tf-toast-close--warning",
75
- component: Bell,
76
- },
77
- info: {
78
- accent: "tf-toast-accent--info",
79
- icon: "tf-toast-icon--info",
80
- close: "tf-toast-close--info",
81
- component: InfoCircle,
82
- },
83
- default: {
84
- accent: "tf-toast-accent--default",
85
- icon: "tf-toast-icon--default",
86
- close: "tf-toast-close--default",
87
- component: QuestionMarkCircle,
88
- },
89
- loading: {
90
- accent: "tf-toast-accent--loading",
91
- icon: "tf-toast-icon--loading",
92
- close: "tf-toast-close--loading",
93
- component: ArrowPath,
94
- },
95
- };
96
-
97
- const role = computed(function () {
98
- return assertiveTypes.has(toast.type) ? "alert" : "status";
99
- });
100
-
101
- const ariaLive = computed(function () {
102
- return assertiveTypes.has(toast.type) ? "assertive" : "polite";
103
- });
104
-
105
- const accentClass = computed(function () {
106
- return typeMeta[toast.type].accent;
107
- });
108
-
109
- const iconWrapperClass = computed(function () {
110
- return typeMeta[toast.type].icon;
111
- });
112
-
113
- const closeWrapperClass = computed(function () {
114
- return typeMeta[toast.type].close;
115
- });
116
-
117
- const defaultIconComponent = computed(function () {
118
- return typeMeta[toast.type].component;
119
- });
120
-
121
- const assertiveTypes = new Set<ToastType>(["error", "warning"]);
122
-
123
- const progressStyle = computed(function (): CSSProperties {
124
- return {
125
- "--tf-toast-progress-duration": `${toast.duration}ms`,
126
- };
127
- });
128
-
129
- const defaultCreatedAtFormatter = function (createdAt: number): string {
130
- return new Date(createdAt).toLocaleTimeString([], {
131
- hour: "2-digit",
132
- minute: "2-digit",
133
- });
134
- };
135
-
136
- const hasCreatedAt = computed(function () {
137
- return Boolean(toast.showCreatedAt && Number.isFinite(toast.createdAt));
138
- });
139
-
140
- const createdAtText = computed(function () {
141
- if (!hasCreatedAt.value) {
142
- return "";
143
- }
144
-
145
- const formatter =
146
- typeof toast.createdAtFormatter === "function"
147
- ? toast.createdAtFormatter
148
- : defaultCreatedAtFormatter;
149
-
150
- try {
151
- return formatter(toast.createdAt);
152
- } catch (error) {
153
- return defaultCreatedAtFormatter(toast.createdAt);
154
- }
155
- });
156
-
157
- const createdAtAriaLabel = computed(function () {
158
- if (!createdAtText.value) {
159
- return "";
160
- }
161
- return `Sent at ${createdAtText.value}`;
162
- });
163
-
164
- const titleAriaLabel = computed(function () {
165
- return toAriaText(toast.title);
166
- });
167
-
168
- const descriptionAriaLabel = computed(function () {
169
- return toAriaText(toast.description);
170
- });
171
-
172
- const toastAriaLabel = computed(function () {
173
- const parts = [];
174
-
175
- if (titleAriaLabel.value) {
176
- parts.push(titleAriaLabel.value);
177
- }
178
-
179
- if (descriptionAriaLabel.value) {
180
- parts.push(descriptionAriaLabel.value);
181
- }
182
-
183
- if (hasCreatedAt.value && createdAtAriaLabel.value) {
184
- parts.push(createdAtAriaLabel.value);
185
- }
186
-
187
- if (!parts.length) {
188
- parts.push(`${toast.type} notification`);
189
- }
190
-
191
- return parts.join(". ");
192
- });
193
-
194
- function toAriaText(value: string): string {
195
- if (!value) {
196
- return "";
197
- }
198
- return stripHtmlToText(value);
199
- }
200
-
201
- function stripHtmlToText(value: string): string {
202
- const fallback = normalizeWhitespace(value.replace(/<[^>]*>/g, " "));
203
-
204
- if (typeof window === "undefined" || !window.document) {
205
- return fallback;
206
- }
207
-
208
- try {
209
- const container = window.document.createElement("div");
210
- container.innerHTML = value;
211
- return normalizeWhitespace(container.textContent ?? "");
212
- } catch (error) {
213
- return fallback;
214
- }
215
- }
216
-
217
- function normalizeWhitespace(value: string): string {
218
- return value.replace(/\s+/g, " ").trim();
219
- }
220
-
221
- function createContext(): ToastContext {
222
- return {
223
- id: toast.id,
224
- position: toast.position,
225
- type: toast.type,
226
- title: toast.title,
227
- description: toast.description,
228
- createdAt: toast.createdAt,
229
- };
230
- }
231
-
232
- function handleClick(event: MouseEvent) {
233
- const context = createContext();
234
-
235
- if (toast.onClick) {
236
- toast.onClick(context, event);
237
- }
238
-
239
- if (toast.closeOnClick) {
240
- emit("dismiss", toast.id);
241
- }
242
- }
243
-
244
- function handleCloseClick() {
245
- emit("dismiss", toast.id);
246
- }
247
-
248
- function handleMouseEnter() {
249
- if (!toast.pauseOnHover) {
250
- return;
251
- }
252
- isHovered.value = true;
253
- store.pause(toast.id);
254
- }
255
-
256
- function handleMouseLeave() {
257
- if (!toast.pauseOnHover) {
258
- return;
259
- }
260
- isHovered.value = false;
261
- store.resume(toast.id);
262
- }
263
-
264
- const progressKeyLocal = ref(0);
265
-
266
- watch(
267
- () => progressResetKey,
268
- function () {
269
- if (progressResetKey == null) {
270
- return;
271
- }
272
- progressKeyLocal.value += 1;
273
- },
274
- );
275
-
276
- watch(
277
- () => duplicateKey,
278
- function () {
279
- if (duplicateKey == null) {
280
- return;
281
- }
282
-
283
- progressKeyLocal.value += 1;
284
-
285
- triggerBump();
286
- },
287
- );
288
-
289
- watch(
290
- () => updateKey,
291
- function () {
292
- if (updateKey == null) {
293
- return;
294
- }
295
-
296
- triggerUpdate();
297
- },
298
- );
299
-
300
- function triggerBump() {
301
- isBumped.value = false;
302
- requestAnimationFrame(function () {
303
- isBumped.value = true;
304
- });
305
- }
306
-
307
- function triggerUpdate() {
308
- isUpdated.value = false;
309
- requestAnimationFrame(function () {
310
- isUpdated.value = true;
311
- });
312
- }
313
- </script>
314
-
315
- <template>
316
- <div :role="role" :aria-live="ariaLive" class="tf-toast-wrapper">
317
- <div
318
- class="tf-toast"
319
- :aria-label="toastAriaLabel || undefined"
320
- :class="[
321
- accentClass,
322
- isBumped &&
323
- toast.phase !== 'leaving' &&
324
- toast.phase !== 'clear-all' &&
325
- bumpAnimationClass,
326
- isUpdated &&
327
- toast.phase !== 'leaving' &&
328
- toast.phase !== 'clear-all' &&
329
- updateAnimationClass,
330
- toast.phase === 'clear-all' && clearAllAnimationClass,
331
- isHovered && 'tf-toast--paused',
332
- ]"
333
- @click="handleClick"
334
- @mouseenter="handleMouseEnter"
335
- @mouseleave="handleMouseLeave"
336
- >
337
- <div class="tf-toast-surface">
338
- <!-- main row -->
339
- <div class="tf-toast-main">
340
- <!-- icon (slot + lucide defaults) -->
341
- <div
342
- class="tf-toast-icon"
343
- :class="iconWrapperClass"
344
- aria-hidden="true"
345
- >
346
- <slot name="icon" :toast="toast">
347
- <component
348
- :is="defaultIconComponent"
349
- class="tf-toast-icon-svg"
350
- :class="[toast.type === 'loading' && 'tf-toast-icon-spin']"
351
- aria-hidden="true"
352
- />
353
- </slot>
354
- </div>
355
-
356
- <!-- content -->
357
- <div class="tf-toast-body">
358
- <div class="tf-toast-text">
359
- <p
360
- v-if="toast.title && !toast.supportHtml"
361
- :aria-label="titleAriaLabel || undefined"
362
- class="tf-toast-title"
363
- >
364
- {{ toast.title }}
365
- </p>
366
- <p
367
- v-else-if="toast.title && toast.supportHtml"
368
- class="tf-toast-title"
369
- :aria-label="titleAriaLabel || undefined"
370
- v-html="toast.title"
371
- ></p>
372
-
373
- <p
374
- v-if="toast.description && !toast.supportHtml"
375
- :aria-label="descriptionAriaLabel || undefined"
376
- class="tf-toast-description"
377
- >
378
- {{ toast.description }}
379
- </p>
380
- <p
381
- v-else-if="toast.description && toast.supportHtml"
382
- class="tf-toast-description"
383
- :aria-label="descriptionAriaLabel || undefined"
384
- v-html="toast.description"
385
- ></p>
386
- </div>
387
-
388
- <slot :toast="toast" />
389
-
390
- <div v-if="hasCreatedAt" class="tf-toast-created-at">
391
- <slot name="created-at" :toast="toast" :formatted="createdAtText">
392
- <span :aria-label="createdAtAriaLabel || undefined">
393
- {{ createdAtText }}
394
- </span>
395
- </slot>
396
- </div>
397
- </div>
398
- </div>
399
-
400
- <!-- bottom progress -->
401
- <div
402
- v-if="toast.progressBar"
403
- class="tf-toast-progress-wrapper"
404
- :style="progressStyle"
405
- >
406
- <slot name="progress" :toast="toast">
407
- <ToastProgress :key="progressKeyLocal" :type="toast.type" />
408
- </slot>
409
- </div>
410
- </div>
411
-
412
- <!-- close button floating top-right -->
413
- <button
414
- v-if="toast.closeButton"
415
- type="button"
416
- class="tf-toast-close"
417
- :class="closeWrapperClass"
418
- aria-label="Close notification"
419
- @click.stop="handleCloseClick"
420
- >
421
- <slot name="close-icon" :toast="toast">
422
- <XMark class="tf-toast-close-icon" aria-hidden="true" />
423
- </slot>
424
- </button>
425
- </div>
426
- </div>
427
- </template>
428
-
429
- <style scoped>
430
- .tf-toast-wrapper {
431
- pointer-events: auto;
432
- width: 100%;
433
- }
434
-
435
- .tf-toast {
436
- cursor: pointer;
437
- position: relative;
438
- width: 100%;
439
- --tf-toast-close-bg: var(--tf-toast-bg);
440
- --tf-toast-close-color: var(--tf-toast-color);
441
- --tf-toast-close-border-color: var(--tf-toast-border-color);
442
- }
443
-
444
- .tf-toast-accent--loading {
445
- --tf-toast-bg: var(--loading-bg);
446
- --tf-toast-border-color: var(--loading-border);
447
- --tf-toast-color: var(--loading-text);
448
- --tf-toast-description-color: var(--loading-text);
449
- --tf-toast-progress-bg: color-mix(
450
- in srgb,
451
- var(--loading-text) 20%,
452
- transparent
453
- );
454
- }
455
-
456
- .tf-toast-accent--default {
457
- --tf-toast-bg: var(--normal-bg);
458
- --tf-toast-border-color: var(--normal-border);
459
- --tf-toast-color: var(--normal-text);
460
- --tf-toast-description-color: var(--normal-text);
461
- --tf-toast-progress-bg: color-mix(
462
- in srgb,
463
- var(--normal-text) 20%,
464
- transparent
465
- );
466
- }
467
-
468
- .tf-toast-accent--success {
469
- --tf-toast-bg: var(--success-bg);
470
- --tf-toast-border-color: var(--success-border);
471
- --tf-toast-color: var(--success-text);
472
- --tf-toast-description-color: var(--success-text);
473
- --tf-toast-progress-bg: color-mix(
474
- in srgb,
475
- var(--success-text) 20%,
476
- transparent
477
- );
478
- }
479
-
480
- .tf-toast-accent--error {
481
- --tf-toast-bg: var(--error-bg);
482
- --tf-toast-border-color: var(--error-border);
483
- --tf-toast-color: var(--error-text);
484
- --tf-toast-description-color: var(--error-text);
485
- --tf-toast-progress-bg: color-mix(
486
- in srgb,
487
- var(--error-text) 20%,
488
- transparent
489
- );
490
- }
491
-
492
- .tf-toast-accent--warning {
493
- --tf-toast-bg: var(--warning-bg);
494
- --tf-toast-border-color: var(--warning-border);
495
- --tf-toast-color: var(--warning-text);
496
- --tf-toast-description-color: var(--warning-text);
497
- --tf-toast-progress-bg: color-mix(
498
- in srgb,
499
- var(--warning-text) 20%,
500
- transparent
501
- );
502
- }
503
-
504
- .tf-toast-accent--info {
505
- --tf-toast-bg: var(--info-bg);
506
- --tf-toast-border-color: var(--info-border);
507
- --tf-toast-color: var(--info-text);
508
- --tf-toast-description-color: var(--info-text);
509
- --tf-toast-progress-bg: color-mix(in srgb, var(--info-text) 20%, transparent);
510
- }
511
-
512
- /* card */
513
-
514
- .tf-toast-surface {
515
- position: relative;
516
- display: block;
517
- min-width: 0;
518
- padding: var(--tf-toast-padding);
519
- border-radius: var(--tf-toast-radius);
520
- background-color: var(--tf-toast-bg);
521
- color: var(--tf-toast-color);
522
- border: 1px solid var(--tf-toast-border-color);
523
- font-family: var(--tf-toast-font-family);
524
- overflow: hidden;
525
- }
526
-
527
- .tf-toast-main {
528
- display: flex;
529
- align-items: center;
530
- gap: var(--tf-toast-gap);
531
- }
532
-
533
- /* icon */
534
-
535
- .tf-toast-icon-spin {
536
- display: inline-block;
537
- animation: tf-toast-spin 0.8s linear infinite;
538
- transform-origin: center;
539
- }
540
-
541
- @keyframes tf-toast-spin {
542
- from {
543
- transform: rotate(0deg);
544
- }
545
- to {
546
- transform: rotate(360deg);
547
- }
548
- }
549
-
550
- .tf-toast-icon-svg {
551
- width: var(--tf-toast-icon-size);
552
- height: var(--tf-toast-icon-size);
553
- }
554
-
555
- /* per-type icon color */
556
-
557
- .tf-toast-icon--loading .tf-toast-icon-svg {
558
- color: var(--tf-toast-icon-loading);
559
- }
560
-
561
- .tf-toast-icon--default .tf-toast-icon-svg {
562
- color: var(--tf-toast-icon-default);
563
- }
564
-
565
- .tf-toast-icon--success .tf-toast-icon-svg {
566
- color: var(--tf-toast-icon-success);
567
- }
568
-
569
- .tf-toast-icon--error .tf-toast-icon-svg {
570
- color: var(--tf-toast-icon-error);
571
- }
572
-
573
- .tf-toast-icon--warning .tf-toast-icon-svg {
574
- color: var(--tf-toast-icon-warning);
575
- }
576
-
577
- .tf-toast-icon--info .tf-toast-icon-svg {
578
- color: var(--tf-toast-icon-info);
579
- }
580
-
581
- /* body */
582
-
583
- .tf-toast-body {
584
- position: relative;
585
- z-index: 1;
586
- min-width: 0;
587
- flex: 1 1 auto;
588
- display: flex;
589
- flex-direction: column;
590
- }
591
-
592
- .tf-toast-created-at {
593
- position: absolute;
594
- bottom: 0;
595
- right: 0;
596
- color: var(--tf-toast-description-color);
597
- font-size: var(--tf-toast-created-at-font-size);
598
- font-style: italic;
599
- }
600
-
601
- .tf-toast-title {
602
- margin: 0;
603
- font-size: var(--tf-toast-title-font-size);
604
- font-weight: var(--tf-toast-title-font-weight);
605
- line-height: 1.25;
606
- white-space: nowrap;
607
- text-overflow: ellipsis;
608
- overflow: hidden;
609
- }
610
-
611
- .tf-toast-description {
612
- margin: 0;
613
- font-size: var(--tf-toast-description-font-size);
614
- color: var(--tf-toast-description-color);
615
- }
616
-
617
- /* floating close button */
618
-
619
- .tf-toast-close {
620
- position: absolute;
621
- top: 0;
622
- right: 0;
623
- transform: translate(40%, -40%);
624
- height: var(--tf-toast-close-size);
625
- width: var(--tf-toast-close-size);
626
- border-radius: 999px;
627
- border: 1px solid var(--tf-toast-close-border-color);
628
- background: var(--tf-toast-close-bg);
629
- color: var(--tf-toast-close-color);
630
- font-size: 11px;
631
- display: inline-flex;
632
- align-items: center;
633
- justify-content: center;
634
- padding: 0;
635
- cursor: pointer;
636
- z-index: 2;
637
- }
638
-
639
- .tf-toast-close-icon {
640
- width: 12px;
641
- height: 12px;
642
- }
643
-
644
- .tf-toast-close:focus-visible {
645
- outline: 2px solid var(--tf-toast-close-ring-color);
646
- outline-offset: 2px;
647
- }
648
-
649
- /* bottom progress wrapper */
650
-
651
- .tf-toast-progress-wrapper {
652
- position: absolute;
653
- left: 0;
654
- right: 0;
655
- bottom: 0;
656
- margin: 0;
657
- }
658
- </style>
1
+ <script setup lang="ts">
2
+ import { computed, type CSSProperties, inject, ref, watch } from "vue";
3
+ import ToastProgress from "./ToastProgress.vue";
4
+ import type {
5
+ ToastContext,
6
+ ToastId,
7
+ ToastInstance,
8
+ ToastStore,
9
+ ToastType,
10
+ } from "toastflow-core";
11
+ import { toastStoreKey } from "../symbols";
12
+ import { getToastStore } from "../toast";
13
+ import CheckCircle from "./icons/CheckCircle.vue";
14
+ import XCircle from "./icons/XCircle.vue";
15
+ import Bell from "./icons/Bell.vue";
16
+ import InfoCircle from "./icons/InfoCircle.vue";
17
+ import QuestionMarkCircle from "./icons/QuestionMarkCircle.vue";
18
+ import ArrowPath from "./icons/ArrowPath.vue";
19
+ import XMark from "./icons/XMark.vue";
20
+
21
+ const {
22
+ toast,
23
+ progressResetKey,
24
+ duplicateKey,
25
+ updateKey,
26
+ bumpAnimationClass,
27
+ clearAllAnimationClass,
28
+ updateAnimationClass,
29
+ } = defineProps<{
30
+ toast: ToastInstance;
31
+ progressResetKey?: number;
32
+ duplicateKey?: number;
33
+ updateKey?: number;
34
+ bumpAnimationClass?: string;
35
+ clearAllAnimationClass?: string;
36
+ updateAnimationClass?: string;
37
+ }>();
38
+
39
+ const emit = defineEmits<{
40
+ (e: "dismiss", id: ToastId): void;
41
+ }>();
42
+
43
+ const injectedStore = inject<ToastStore | null>(toastStoreKey, null);
44
+ const store: ToastStore = injectedStore ?? getToastStore();
45
+
46
+ const isHovered = ref(false);
47
+ const isBumped = ref(false);
48
+ const isUpdated = ref(false);
49
+
50
+ const typeMeta: Record<
51
+ ToastType,
52
+ {
53
+ accent: string;
54
+ icon: string;
55
+ close: string;
56
+ component: any;
57
+ }
58
+ > = {
59
+ success: {
60
+ accent: "tf-toast-accent--success",
61
+ icon: "tf-toast-icon--success",
62
+ close: "tf-toast-close--success",
63
+ component: CheckCircle,
64
+ },
65
+ error: {
66
+ accent: "tf-toast-accent--error",
67
+ icon: "tf-toast-icon--error",
68
+ close: "tf-toast-close--error",
69
+ component: XCircle,
70
+ },
71
+ warning: {
72
+ accent: "tf-toast-accent--warning",
73
+ icon: "tf-toast-icon--warning",
74
+ close: "tf-toast-close--warning",
75
+ component: Bell,
76
+ },
77
+ info: {
78
+ accent: "tf-toast-accent--info",
79
+ icon: "tf-toast-icon--info",
80
+ close: "tf-toast-close--info",
81
+ component: InfoCircle,
82
+ },
83
+ default: {
84
+ accent: "tf-toast-accent--default",
85
+ icon: "tf-toast-icon--default",
86
+ close: "tf-toast-close--default",
87
+ component: QuestionMarkCircle,
88
+ },
89
+ loading: {
90
+ accent: "tf-toast-accent--loading",
91
+ icon: "tf-toast-icon--loading",
92
+ close: "tf-toast-close--loading",
93
+ component: ArrowPath,
94
+ },
95
+ };
96
+
97
+ const role = computed(function () {
98
+ return assertiveTypes.has(toast.type) ? "alert" : "status";
99
+ });
100
+
101
+ const ariaLive = computed(function () {
102
+ return assertiveTypes.has(toast.type) ? "assertive" : "polite";
103
+ });
104
+
105
+ const accentClass = computed(function () {
106
+ return typeMeta[toast.type].accent;
107
+ });
108
+
109
+ const iconWrapperClass = computed(function () {
110
+ return typeMeta[toast.type].icon;
111
+ });
112
+
113
+ const closeWrapperClass = computed(function () {
114
+ return typeMeta[toast.type].close;
115
+ });
116
+
117
+ const defaultIconComponent = computed(function () {
118
+ return typeMeta[toast.type].component;
119
+ });
120
+
121
+ const assertiveTypes = new Set<ToastType>(["error", "warning"]);
122
+
123
+ const progressStyle = computed(function (): CSSProperties {
124
+ return {
125
+ "--tf-toast-progress-duration": `${toast.duration}ms`,
126
+ };
127
+ });
128
+
129
+ const defaultCreatedAtFormatter = function (createdAt: number): string {
130
+ return new Date(createdAt).toLocaleTimeString([], {
131
+ hour: "2-digit",
132
+ minute: "2-digit",
133
+ });
134
+ };
135
+
136
+ const hasCreatedAt = computed(function () {
137
+ return Boolean(toast.showCreatedAt && Number.isFinite(toast.createdAt));
138
+ });
139
+
140
+ const createdAtText = computed(function () {
141
+ if (!hasCreatedAt.value) {
142
+ return "";
143
+ }
144
+
145
+ const formatter =
146
+ typeof toast.createdAtFormatter === "function"
147
+ ? toast.createdAtFormatter
148
+ : defaultCreatedAtFormatter;
149
+
150
+ try {
151
+ return formatter(toast.createdAt);
152
+ } catch (error) {
153
+ return defaultCreatedAtFormatter(toast.createdAt);
154
+ }
155
+ });
156
+
157
+ const createdAtAriaLabel = computed(function () {
158
+ if (!createdAtText.value) {
159
+ return "";
160
+ }
161
+ return `Sent at ${createdAtText.value}`;
162
+ });
163
+
164
+ const titleAriaLabel = computed(function () {
165
+ return toAriaText(toast.title);
166
+ });
167
+
168
+ const descriptionAriaLabel = computed(function () {
169
+ return toAriaText(toast.description);
170
+ });
171
+
172
+ const toastAriaLabel = computed(function () {
173
+ const parts = [];
174
+
175
+ if (titleAriaLabel.value) {
176
+ parts.push(titleAriaLabel.value);
177
+ }
178
+
179
+ if (descriptionAriaLabel.value) {
180
+ parts.push(descriptionAriaLabel.value);
181
+ }
182
+
183
+ if (hasCreatedAt.value && createdAtAriaLabel.value) {
184
+ parts.push(createdAtAriaLabel.value);
185
+ }
186
+
187
+ if (!parts.length) {
188
+ parts.push(`${toast.type} notification`);
189
+ }
190
+
191
+ return parts.join(". ");
192
+ });
193
+
194
+ function toAriaText(value: string): string {
195
+ if (!value) {
196
+ return "";
197
+ }
198
+ return stripHtmlToText(value);
199
+ }
200
+
201
+ function stripHtmlToText(value: string): string {
202
+ const fallback = normalizeWhitespace(value.replace(/<[^>]*>/g, " "));
203
+
204
+ if (typeof window === "undefined" || !window.document) {
205
+ return fallback;
206
+ }
207
+
208
+ try {
209
+ const container = window.document.createElement("div");
210
+ container.innerHTML = value;
211
+ return normalizeWhitespace(container.textContent ?? "");
212
+ } catch (error) {
213
+ return fallback;
214
+ }
215
+ }
216
+
217
+ function normalizeWhitespace(value: string): string {
218
+ return value.replace(/\s+/g, " ").trim();
219
+ }
220
+
221
+ function createContext(): ToastContext {
222
+ return {
223
+ id: toast.id,
224
+ position: toast.position,
225
+ type: toast.type,
226
+ title: toast.title,
227
+ description: toast.description,
228
+ createdAt: toast.createdAt,
229
+ };
230
+ }
231
+
232
+ function handleClick(event: MouseEvent) {
233
+ const context = createContext();
234
+
235
+ if (toast.onClick) {
236
+ toast.onClick(context, event);
237
+ }
238
+
239
+ if (toast.closeOnClick) {
240
+ emit("dismiss", toast.id);
241
+ }
242
+ }
243
+
244
+ function handleCloseClick() {
245
+ emit("dismiss", toast.id);
246
+ }
247
+
248
+ function handleMouseEnter() {
249
+ if (!toast.pauseOnHover) {
250
+ return;
251
+ }
252
+ isHovered.value = true;
253
+ store.pause(toast.id);
254
+ }
255
+
256
+ function handleMouseLeave() {
257
+ if (!toast.pauseOnHover) {
258
+ return;
259
+ }
260
+ isHovered.value = false;
261
+ store.resume(toast.id);
262
+ }
263
+
264
+ const progressKeyLocal = ref(0);
265
+
266
+ watch(
267
+ () => progressResetKey,
268
+ function () {
269
+ if (progressResetKey == null) {
270
+ return;
271
+ }
272
+ progressKeyLocal.value += 1;
273
+ },
274
+ );
275
+
276
+ watch(
277
+ () => duplicateKey,
278
+ function () {
279
+ if (duplicateKey == null) {
280
+ return;
281
+ }
282
+
283
+ progressKeyLocal.value += 1;
284
+
285
+ triggerBump();
286
+ },
287
+ );
288
+
289
+ watch(
290
+ () => updateKey,
291
+ function () {
292
+ if (updateKey == null) {
293
+ return;
294
+ }
295
+
296
+ triggerUpdate();
297
+ },
298
+ );
299
+
300
+ function triggerBump() {
301
+ isBumped.value = false;
302
+ requestAnimationFrame(function () {
303
+ isBumped.value = true;
304
+ });
305
+ }
306
+
307
+ function triggerUpdate() {
308
+ isUpdated.value = false;
309
+ requestAnimationFrame(function () {
310
+ isUpdated.value = true;
311
+ });
312
+ }
313
+ </script>
314
+
315
+ <template>
316
+ <div :role="role" :aria-live="ariaLive" class="tf-toast-wrapper">
317
+ <div
318
+ class="tf-toast"
319
+ :aria-label="toastAriaLabel || undefined"
320
+ :class="[
321
+ accentClass,
322
+ isBumped &&
323
+ toast.phase !== 'leaving' &&
324
+ toast.phase !== 'clear-all' &&
325
+ bumpAnimationClass,
326
+ isUpdated &&
327
+ toast.phase !== 'leaving' &&
328
+ toast.phase !== 'clear-all' &&
329
+ updateAnimationClass,
330
+ toast.phase === 'clear-all' && clearAllAnimationClass,
331
+ isHovered && 'tf-toast--paused',
332
+ ]"
333
+ @click="handleClick"
334
+ @mouseenter="handleMouseEnter"
335
+ @mouseleave="handleMouseLeave"
336
+ >
337
+ <div class="tf-toast-surface">
338
+ <!-- main row -->
339
+ <div class="tf-toast-main">
340
+ <!-- icon (slot + lucide defaults) -->
341
+ <div
342
+ class="tf-toast-icon"
343
+ :class="iconWrapperClass"
344
+ aria-hidden="true"
345
+ >
346
+ <slot name="icon" :toast="toast">
347
+ <component
348
+ :is="defaultIconComponent"
349
+ class="tf-toast-icon-svg"
350
+ :class="[toast.type === 'loading' && 'tf-toast-icon-spin']"
351
+ aria-hidden="true"
352
+ />
353
+ </slot>
354
+ </div>
355
+
356
+ <!-- content -->
357
+ <div class="tf-toast-body">
358
+ <div class="tf-toast-text">
359
+ <p
360
+ v-if="toast.title && !toast.supportHtml"
361
+ :aria-label="titleAriaLabel || undefined"
362
+ class="tf-toast-title"
363
+ >
364
+ {{ toast.title }}
365
+ </p>
366
+ <p
367
+ v-else-if="toast.title && toast.supportHtml"
368
+ class="tf-toast-title"
369
+ :aria-label="titleAriaLabel || undefined"
370
+ v-html="toast.title"
371
+ ></p>
372
+
373
+ <p
374
+ v-if="toast.description && !toast.supportHtml"
375
+ :aria-label="descriptionAriaLabel || undefined"
376
+ class="tf-toast-description"
377
+ >
378
+ {{ toast.description }}
379
+ </p>
380
+ <p
381
+ v-else-if="toast.description && toast.supportHtml"
382
+ class="tf-toast-description"
383
+ :aria-label="descriptionAriaLabel || undefined"
384
+ v-html="toast.description"
385
+ ></p>
386
+ </div>
387
+
388
+ <slot :toast="toast" />
389
+
390
+ <div v-if="hasCreatedAt" class="tf-toast-created-at">
391
+ <slot name="created-at" :toast="toast" :formatted="createdAtText">
392
+ <span :aria-label="createdAtAriaLabel || undefined">
393
+ {{ createdAtText }}
394
+ </span>
395
+ </slot>
396
+ </div>
397
+ </div>
398
+ </div>
399
+
400
+ <!-- bottom progress -->
401
+ <div
402
+ v-if="toast.progressBar"
403
+ class="tf-toast-progress-wrapper"
404
+ :style="progressStyle"
405
+ >
406
+ <slot name="progress" :toast="toast">
407
+ <ToastProgress :key="progressKeyLocal" :type="toast.type" />
408
+ </slot>
409
+ </div>
410
+ </div>
411
+
412
+ <!-- close button floating top-right -->
413
+ <button
414
+ v-if="toast.closeButton"
415
+ type="button"
416
+ class="tf-toast-close"
417
+ :class="closeWrapperClass"
418
+ aria-label="Close notification"
419
+ @click.stop="handleCloseClick"
420
+ >
421
+ <slot name="close-icon" :toast="toast">
422
+ <XMark class="tf-toast-close-icon" aria-hidden="true" />
423
+ </slot>
424
+ </button>
425
+ </div>
426
+ </div>
427
+ </template>
428
+
429
+ <style scoped>
430
+ .tf-toast-wrapper {
431
+ pointer-events: auto;
432
+ width: 100%;
433
+ }
434
+
435
+ .tf-toast {
436
+ cursor: pointer;
437
+ position: relative;
438
+ width: 100%;
439
+ --tf-toast-close-bg: var(--tf-toast-bg);
440
+ --tf-toast-close-color: var(--tf-toast-color);
441
+ --tf-toast-close-border-color: var(--tf-toast-border-color);
442
+ }
443
+
444
+ .tf-toast-accent--loading {
445
+ --tf-toast-bg: var(--loading-bg);
446
+ --tf-toast-border-color: var(--loading-border);
447
+ --tf-toast-color: var(--loading-text);
448
+ --tf-toast-description-color: var(--loading-text);
449
+ --tf-toast-progress-bg: color-mix(
450
+ in srgb,
451
+ var(--loading-text) 20%,
452
+ transparent
453
+ );
454
+ }
455
+
456
+ .tf-toast-accent--default {
457
+ --tf-toast-bg: var(--normal-bg);
458
+ --tf-toast-border-color: var(--normal-border);
459
+ --tf-toast-color: var(--normal-text);
460
+ --tf-toast-description-color: var(--normal-text);
461
+ --tf-toast-progress-bg: color-mix(
462
+ in srgb,
463
+ var(--normal-text) 20%,
464
+ transparent
465
+ );
466
+ }
467
+
468
+ .tf-toast-accent--success {
469
+ --tf-toast-bg: var(--success-bg);
470
+ --tf-toast-border-color: var(--success-border);
471
+ --tf-toast-color: var(--success-text);
472
+ --tf-toast-description-color: var(--success-text);
473
+ --tf-toast-progress-bg: color-mix(
474
+ in srgb,
475
+ var(--success-text) 20%,
476
+ transparent
477
+ );
478
+ }
479
+
480
+ .tf-toast-accent--error {
481
+ --tf-toast-bg: var(--error-bg);
482
+ --tf-toast-border-color: var(--error-border);
483
+ --tf-toast-color: var(--error-text);
484
+ --tf-toast-description-color: var(--error-text);
485
+ --tf-toast-progress-bg: color-mix(
486
+ in srgb,
487
+ var(--error-text) 20%,
488
+ transparent
489
+ );
490
+ }
491
+
492
+ .tf-toast-accent--warning {
493
+ --tf-toast-bg: var(--warning-bg);
494
+ --tf-toast-border-color: var(--warning-border);
495
+ --tf-toast-color: var(--warning-text);
496
+ --tf-toast-description-color: var(--warning-text);
497
+ --tf-toast-progress-bg: color-mix(
498
+ in srgb,
499
+ var(--warning-text) 20%,
500
+ transparent
501
+ );
502
+ }
503
+
504
+ .tf-toast-accent--info {
505
+ --tf-toast-bg: var(--info-bg);
506
+ --tf-toast-border-color: var(--info-border);
507
+ --tf-toast-color: var(--info-text);
508
+ --tf-toast-description-color: var(--info-text);
509
+ --tf-toast-progress-bg: color-mix(in srgb, var(--info-text) 20%, transparent);
510
+ }
511
+
512
+ /* card */
513
+
514
+ .tf-toast-surface {
515
+ position: relative;
516
+ display: block;
517
+ min-width: 0;
518
+ padding: var(--tf-toast-padding);
519
+ border-radius: var(--tf-toast-radius);
520
+ background-color: var(--tf-toast-bg);
521
+ color: var(--tf-toast-color);
522
+ border: 1px solid var(--tf-toast-border-color);
523
+ font-family: var(--tf-toast-font-family);
524
+ overflow: hidden;
525
+ }
526
+
527
+ .tf-toast-main {
528
+ display: flex;
529
+ align-items: center;
530
+ gap: var(--tf-toast-gap);
531
+ }
532
+
533
+ /* icon */
534
+
535
+ .tf-toast-icon-spin {
536
+ display: inline-block;
537
+ animation: tf-toast-spin 0.8s linear infinite;
538
+ transform-origin: center;
539
+ }
540
+
541
+ @keyframes tf-toast-spin {
542
+ from {
543
+ transform: rotate(0deg);
544
+ }
545
+ to {
546
+ transform: rotate(360deg);
547
+ }
548
+ }
549
+
550
+ .tf-toast-icon-svg {
551
+ width: var(--tf-toast-icon-size);
552
+ height: var(--tf-toast-icon-size);
553
+ }
554
+
555
+ /* per-type icon color */
556
+
557
+ .tf-toast-icon--loading .tf-toast-icon-svg {
558
+ color: var(--tf-toast-icon-loading);
559
+ }
560
+
561
+ .tf-toast-icon--default .tf-toast-icon-svg {
562
+ color: var(--tf-toast-icon-default);
563
+ }
564
+
565
+ .tf-toast-icon--success .tf-toast-icon-svg {
566
+ color: var(--tf-toast-icon-success);
567
+ }
568
+
569
+ .tf-toast-icon--error .tf-toast-icon-svg {
570
+ color: var(--tf-toast-icon-error);
571
+ }
572
+
573
+ .tf-toast-icon--warning .tf-toast-icon-svg {
574
+ color: var(--tf-toast-icon-warning);
575
+ }
576
+
577
+ .tf-toast-icon--info .tf-toast-icon-svg {
578
+ color: var(--tf-toast-icon-info);
579
+ }
580
+
581
+ /* body */
582
+
583
+ .tf-toast-body {
584
+ position: relative;
585
+ z-index: 1;
586
+ min-width: 0;
587
+ flex: 1 1 auto;
588
+ display: flex;
589
+ flex-direction: column;
590
+ }
591
+
592
+ .tf-toast-created-at {
593
+ position: absolute;
594
+ bottom: 0;
595
+ right: 0;
596
+ color: var(--tf-toast-description-color);
597
+ font-size: var(--tf-toast-created-at-font-size);
598
+ font-style: italic;
599
+ }
600
+
601
+ .tf-toast-title {
602
+ margin: 0;
603
+ font-size: var(--tf-toast-title-font-size);
604
+ font-weight: var(--tf-toast-title-font-weight);
605
+ line-height: 1.25;
606
+ white-space: nowrap;
607
+ text-overflow: ellipsis;
608
+ overflow: hidden;
609
+ }
610
+
611
+ .tf-toast-description {
612
+ margin: 0;
613
+ font-size: var(--tf-toast-description-font-size);
614
+ color: var(--tf-toast-description-color);
615
+ }
616
+
617
+ /* floating close button */
618
+
619
+ .tf-toast-close {
620
+ position: absolute;
621
+ top: 0;
622
+ right: 0;
623
+ transform: translate(40%, -40%);
624
+ height: var(--tf-toast-close-size);
625
+ width: var(--tf-toast-close-size);
626
+ border-radius: 999px;
627
+ border: 1px solid var(--tf-toast-close-border-color);
628
+ background: var(--tf-toast-close-bg);
629
+ color: var(--tf-toast-close-color);
630
+ font-size: 11px;
631
+ display: inline-flex;
632
+ align-items: center;
633
+ justify-content: center;
634
+ padding: 0;
635
+ cursor: pointer;
636
+ z-index: 2;
637
+ }
638
+
639
+ .tf-toast-close-icon {
640
+ width: 12px;
641
+ height: 12px;
642
+ }
643
+
644
+ .tf-toast-close:focus-visible {
645
+ outline: 2px solid var(--tf-toast-close-ring-color);
646
+ outline-offset: 2px;
647
+ }
648
+
649
+ /* bottom progress wrapper */
650
+
651
+ .tf-toast-progress-wrapper {
652
+ position: absolute;
653
+ left: 0;
654
+ right: 0;
655
+ bottom: 0;
656
+ margin: 0;
657
+ }
658
+ </style>