1<script lang="ts">
2 import { onMount, onDestroy } from 'svelte';
3
4 interface ToastProps {
5 id: number;
6 message: string;
7 duration?: number;
8 type?: 'info' | 'success' | 'error';
9 onDismiss: (id: number) => void;
10 }
11
12 let { id, message, duration = 2500, type = 'info', onDismiss }: ToastProps = $props();
13
14 let isExiting = $state(false);
15 let isDarkMode = $state(false);
16 let themeObserver: MutationObserver | undefined;
17 let timeoutId: number | undefined;
18
19 onMount(() => {
20 timeoutId = window.setTimeout(() => {
21 dismiss();
22 }, duration);
23
24 syncTheme();
25
26 if (typeof window !== 'undefined' && 'MutationObserver' in window) {
27 themeObserver = new MutationObserver(syncTheme);
28 themeObserver.observe(document.documentElement, {
29 attributes: true,
30 attributeFilter: ['class']
31 });
32 }
33 });
34
35 onDestroy(() => {
36 if (timeoutId) {
37 window.clearTimeout(timeoutId);
38 timeoutId = undefined;
39 }
40
41 if (themeObserver) {
42 themeObserver.disconnect();
43 themeObserver = undefined;
44 }
45 });
46
47 function dismiss() {
48 if (timeoutId) {
49 window.clearTimeout(timeoutId);
50 timeoutId = undefined;
51 }
52 isExiting = true;
53 // Allow time for exit animation before removing from parent
54 setTimeout(() => {
55 onDismiss(id);
56 }, 250);
57 }
58
59 function handleClick() {
60 dismiss();
61 }
62
63 function syncTheme() {
64 if (typeof document === 'undefined') {
65 return;
66 }
67
68 const prefersDark = document.documentElement.classList.contains('dark');
69
70 if (prefersDark) {
71 isDarkMode = true;
72 return;
73 }
74
75 try {
76 isDarkMode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
77 } catch {
78 isDarkMode = false;
79 }
80 }
81</script>
82
83<button
84 class={`toast ${isDarkMode ? 'toast-dark' : 'toast-light'} toast-${type} ${isExiting ? 'toast-exit' : 'toast-enter'}`}
85 onclick={handleClick}
86 data-toast-id={id}
87 type="button"
88 aria-label="Dismiss notification"
89 title="Click to dismiss"
90>
91 <div class="toast-message">
92 {message || 'Notification'}
93 </div>
94</button>
95
96<style>
97 .toast {
98 --toast-bg: color-mix(in srgb, var(--dynamic-surface) 88%, var(--dynamic-text) 12%);
99 --toast-border: color-mix(in srgb, var(--dynamic-border) 70%, var(--dynamic-text) 30%);
100 --toast-text: var(--dynamic-text);
101 background-color: var(--toast-bg);
102 border: 1px solid var(--toast-border);
103 border-radius: 0;
104 box-shadow: 0 8px 14px rgba(0, 0, 0, 0.08);
105 cursor: pointer;
106 display: inline-flex;
107 margin: 0;
108 pointer-events: auto;
109 padding: 0;
110 transition: transform 0.2s ease, opacity 0.2s ease;
111 }
112
113 .toast-enter {
114 opacity: 1;
115 transform: translateY(0);
116 }
117
118 .toast-exit {
119 opacity: 0;
120 transform: translateY(-16px);
121 }
122
123 .toast-message {
124 color: var(--toast-text);
125 font-size: 0.875rem;
126 font-weight: 600;
127 min-width: 9rem;
128 padding: 0.75rem 1rem;
129 text-align: center;
130 }
131
132 .toast.toast-info {
133 --toast-bg: color-mix(in srgb, var(--dynamic-surface) 82%, var(--dynamic-text) 18%);
134 --toast-border: color-mix(in srgb, var(--dynamic-border) 55%, var(--dynamic-text) 45%);
135 }
136
137 .toast.toast-success {
138 --toast-bg: color-mix(in srgb, var(--dynamic-surface) 78%, var(--dynamic-text) 22%);
139 --toast-border: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
140 }
141
142 .toast.toast-error {
143 --toast-bg: color-mix(in srgb, var(--dynamic-surface) 74%, var(--dynamic-text) 26%);
144 --toast-border: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
145 --toast-text: color-mix(in srgb, var(--dynamic-text) 80%, var(--dynamic-muted) 20%);
146 }
147</style>