@pyreon/toast 0.11.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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +362 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +109 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/index.ts +40 -0
- package/src/styles.ts +80 -0
- package/src/tests/toast.test.ts +247 -0
- package/src/toast.ts +210 -0
- package/src/toaster.tsx +155 -0
- package/src/types.ts +65 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { _reset, _toasts, toast } from "../toast"
|
|
3
|
+
|
|
4
|
+
/** Helper — get toast at index with non-null assertion (tests verify length first). */
|
|
5
|
+
function at(index: number) {
|
|
6
|
+
const t = _toasts()[index]
|
|
7
|
+
if (!t) throw new Error(`No toast at index ${index}`)
|
|
8
|
+
return t
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_reset()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
_reset()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("toast()", () => {
|
|
20
|
+
it("adds a toast to the stack", () => {
|
|
21
|
+
toast("Hello")
|
|
22
|
+
expect(_toasts().length).toBe(1)
|
|
23
|
+
expect(at(0).message).toBe("Hello")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("returns the toast id", () => {
|
|
27
|
+
const id = toast("Hello")
|
|
28
|
+
expect(typeof id).toBe("string")
|
|
29
|
+
expect(id).toMatch(/^pyreon-toast-/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("defaults to type info", () => {
|
|
33
|
+
toast("Hello")
|
|
34
|
+
expect(at(0).type).toBe("info")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("defaults dismissible to true", () => {
|
|
38
|
+
toast("Hello")
|
|
39
|
+
expect(at(0).dismissible).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("respects custom options", () => {
|
|
43
|
+
toast("Hello", { type: "error", duration: 0, dismissible: false })
|
|
44
|
+
const t = at(0)
|
|
45
|
+
expect(t.type).toBe("error")
|
|
46
|
+
expect(t.duration).toBe(0)
|
|
47
|
+
expect(t.dismissible).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("toast.success/error/warning/info", () => {
|
|
52
|
+
it("toast.success sets type to success", () => {
|
|
53
|
+
toast.success("Done")
|
|
54
|
+
expect(at(0).type).toBe("success")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("toast.error sets type to error", () => {
|
|
58
|
+
toast.error("Failed")
|
|
59
|
+
expect(at(0).type).toBe("error")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("toast.warning sets type to warning", () => {
|
|
63
|
+
toast.warning("Watch out")
|
|
64
|
+
expect(at(0).type).toBe("warning")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("toast.info sets type to info", () => {
|
|
68
|
+
toast.info("FYI")
|
|
69
|
+
expect(at(0).type).toBe("info")
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("toast.dismiss", () => {
|
|
74
|
+
it("removes a specific toast by id", () => {
|
|
75
|
+
const id1 = toast("First")
|
|
76
|
+
toast("Second")
|
|
77
|
+
expect(_toasts().length).toBe(2)
|
|
78
|
+
|
|
79
|
+
toast.dismiss(id1)
|
|
80
|
+
expect(_toasts().length).toBe(1)
|
|
81
|
+
expect(at(0).message).toBe("Second")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("clears all toasts when no id is given", () => {
|
|
85
|
+
toast("First")
|
|
86
|
+
toast("Second")
|
|
87
|
+
toast("Third")
|
|
88
|
+
expect(_toasts().length).toBe(3)
|
|
89
|
+
|
|
90
|
+
toast.dismiss()
|
|
91
|
+
expect(_toasts().length).toBe(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("is a no-op for unknown id", () => {
|
|
95
|
+
toast("Hello")
|
|
96
|
+
toast.dismiss("unknown-id")
|
|
97
|
+
expect(_toasts().length).toBe(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("calls onDismiss callback when dismissing by id", () => {
|
|
101
|
+
const onDismiss = vi.fn()
|
|
102
|
+
const id = toast("Hello", { onDismiss })
|
|
103
|
+
toast.dismiss(id)
|
|
104
|
+
expect(onDismiss).toHaveBeenCalledOnce()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("calls onDismiss for all toasts when dismissing all", () => {
|
|
108
|
+
const onDismiss1 = vi.fn()
|
|
109
|
+
const onDismiss2 = vi.fn()
|
|
110
|
+
toast("First", { onDismiss: onDismiss1 })
|
|
111
|
+
toast("Second", { onDismiss: onDismiss2 })
|
|
112
|
+
toast.dismiss()
|
|
113
|
+
expect(onDismiss1).toHaveBeenCalledOnce()
|
|
114
|
+
expect(onDismiss2).toHaveBeenCalledOnce()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe("auto-dismiss", () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
vi.useFakeTimers()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.useRealTimers()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("auto-dismisses after default duration (4000ms)", () => {
|
|
128
|
+
toast("Hello")
|
|
129
|
+
expect(_toasts().length).toBe(1)
|
|
130
|
+
|
|
131
|
+
vi.advanceTimersByTime(3999)
|
|
132
|
+
expect(_toasts().length).toBe(1)
|
|
133
|
+
|
|
134
|
+
vi.advanceTimersByTime(1)
|
|
135
|
+
expect(_toasts().length).toBe(0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("auto-dismisses after custom duration", () => {
|
|
139
|
+
toast("Hello", { duration: 2000 })
|
|
140
|
+
|
|
141
|
+
vi.advanceTimersByTime(1999)
|
|
142
|
+
expect(_toasts().length).toBe(1)
|
|
143
|
+
|
|
144
|
+
vi.advanceTimersByTime(1)
|
|
145
|
+
expect(_toasts().length).toBe(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("does not auto-dismiss when duration is 0", () => {
|
|
149
|
+
toast("Persistent", { duration: 0 })
|
|
150
|
+
|
|
151
|
+
vi.advanceTimersByTime(10000)
|
|
152
|
+
expect(_toasts().length).toBe(1)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("toast.promise", () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
vi.useFakeTimers()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
vi.useRealTimers()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("creates a loading toast that updates on resolve", async () => {
|
|
166
|
+
const promise = Promise.resolve("data")
|
|
167
|
+
|
|
168
|
+
toast.promise(promise, {
|
|
169
|
+
loading: "Loading...",
|
|
170
|
+
success: "Done!",
|
|
171
|
+
error: "Failed",
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(_toasts().length).toBe(1)
|
|
175
|
+
expect(at(0).message).toBe("Loading...")
|
|
176
|
+
expect(at(0).type).toBe("info")
|
|
177
|
+
expect(at(0).duration).toBe(0) // persistent while loading
|
|
178
|
+
|
|
179
|
+
await promise
|
|
180
|
+
// Flush microtasks
|
|
181
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
182
|
+
|
|
183
|
+
expect(_toasts().length).toBe(1)
|
|
184
|
+
expect(at(0).message).toBe("Done!")
|
|
185
|
+
expect(at(0).type).toBe("success")
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("creates a loading toast that updates on reject", async () => {
|
|
189
|
+
const promise = Promise.reject(new Error("oops"))
|
|
190
|
+
|
|
191
|
+
// Prevent unhandled rejection
|
|
192
|
+
toast
|
|
193
|
+
.promise(promise, {
|
|
194
|
+
loading: "Loading...",
|
|
195
|
+
success: "Done!",
|
|
196
|
+
error: "Failed",
|
|
197
|
+
})
|
|
198
|
+
.catch(() => {})
|
|
199
|
+
|
|
200
|
+
expect(at(0).message).toBe("Loading...")
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await promise
|
|
204
|
+
} catch {
|
|
205
|
+
// expected
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
209
|
+
|
|
210
|
+
expect(_toasts().length).toBe(1)
|
|
211
|
+
expect(at(0).message).toBe("Failed")
|
|
212
|
+
expect(at(0).type).toBe("error")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("supports function form for success/error messages", async () => {
|
|
216
|
+
const promise = Promise.resolve(42)
|
|
217
|
+
|
|
218
|
+
toast.promise(promise, {
|
|
219
|
+
loading: "Calculating...",
|
|
220
|
+
success: (data) => `Result: ${data}`,
|
|
221
|
+
error: (err) => `Error: ${err}`,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
await promise
|
|
225
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
226
|
+
|
|
227
|
+
expect(at(0).message).toBe("Result: 42")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("returns the original promise", async () => {
|
|
231
|
+
const promise = Promise.resolve("value")
|
|
232
|
+
const result = toast.promise(promise, {
|
|
233
|
+
loading: "Loading...",
|
|
234
|
+
success: "Done!",
|
|
235
|
+
error: "Failed",
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
expect(await result).toBe("value")
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe("Toaster renders", () => {
|
|
243
|
+
it("Toaster is a function component", async () => {
|
|
244
|
+
const { Toaster } = await import("../toaster")
|
|
245
|
+
expect(typeof Toaster).toBe("function")
|
|
246
|
+
})
|
|
247
|
+
})
|
package/src/toast.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { signal } from "@pyreon/reactivity"
|
|
3
|
+
import type { Toast, ToastOptions, ToastPromiseOptions, ToastType } from "./types"
|
|
4
|
+
|
|
5
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
let _idCounter = 0
|
|
8
|
+
const DEFAULT_DURATION = 4000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Module-level signal holding the active toast stack.
|
|
12
|
+
* Consumed by the `Toaster` component.
|
|
13
|
+
*/
|
|
14
|
+
export const _toasts = signal<Toast[]>([])
|
|
15
|
+
|
|
16
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function generateId(): string {
|
|
19
|
+
return `pyreon-toast-${++_idCounter}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function startTimer(t: Toast): void {
|
|
23
|
+
if (t.duration <= 0) return
|
|
24
|
+
t.timerStart = Date.now()
|
|
25
|
+
t.remaining = t.duration
|
|
26
|
+
t.timer = setTimeout(() => dismiss(t.id), t.duration)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addToast(message: string | VNodeChild, options: ToastOptions = {}): string {
|
|
30
|
+
const id = generateId()
|
|
31
|
+
const t: Toast = {
|
|
32
|
+
id,
|
|
33
|
+
message,
|
|
34
|
+
type: options.type ?? "info",
|
|
35
|
+
duration: options.duration ?? DEFAULT_DURATION,
|
|
36
|
+
dismissible: options.dismissible ?? true,
|
|
37
|
+
action: options.action,
|
|
38
|
+
onDismiss: options.onDismiss,
|
|
39
|
+
state: "entering",
|
|
40
|
+
timer: undefined,
|
|
41
|
+
remaining: 0,
|
|
42
|
+
timerStart: 0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
startTimer(t)
|
|
46
|
+
_toasts.set([..._toasts(), t])
|
|
47
|
+
|
|
48
|
+
return id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dismiss(id?: string): void {
|
|
52
|
+
const current = _toasts()
|
|
53
|
+
|
|
54
|
+
if (id === undefined) {
|
|
55
|
+
// Clear all
|
|
56
|
+
for (const t of current) {
|
|
57
|
+
if (t.timer !== undefined) clearTimeout(t.timer)
|
|
58
|
+
t.onDismiss?.()
|
|
59
|
+
}
|
|
60
|
+
_toasts.set([])
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match = current.find((item) => item.id === id)
|
|
65
|
+
if (!match) return
|
|
66
|
+
|
|
67
|
+
if (match.timer !== undefined) clearTimeout(match.timer)
|
|
68
|
+
match.onDismiss?.()
|
|
69
|
+
_toasts.set(current.filter((item) => item.id !== id))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function updateToast(
|
|
73
|
+
id: string,
|
|
74
|
+
updates: Partial<Pick<Toast, "message" | "type" | "duration">>,
|
|
75
|
+
): void {
|
|
76
|
+
const current = _toasts()
|
|
77
|
+
const idx = current.findIndex((item) => item.id === id)
|
|
78
|
+
if (idx === -1) return
|
|
79
|
+
|
|
80
|
+
const t = current[idx] as Toast
|
|
81
|
+
if (t.timer !== undefined) clearTimeout(t.timer)
|
|
82
|
+
|
|
83
|
+
const updated: Toast = {
|
|
84
|
+
...t,
|
|
85
|
+
message: updates.message ?? t.message,
|
|
86
|
+
type: updates.type ?? t.type,
|
|
87
|
+
duration: updates.duration ?? t.duration,
|
|
88
|
+
timer: undefined,
|
|
89
|
+
remaining: 0,
|
|
90
|
+
timerStart: 0,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const duration = updates.duration ?? t.duration
|
|
94
|
+
updated.duration = duration
|
|
95
|
+
startTimer(updated)
|
|
96
|
+
|
|
97
|
+
const next = [...current]
|
|
98
|
+
next[idx] = updated
|
|
99
|
+
_toasts.set(next)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Pause / resume (for hover) ─────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export function _pauseAll(): void {
|
|
105
|
+
for (const t of _toasts()) {
|
|
106
|
+
if (t.timer !== undefined) {
|
|
107
|
+
clearTimeout(t.timer)
|
|
108
|
+
t.remaining = Math.max(0, t.remaining - (Date.now() - t.timerStart))
|
|
109
|
+
t.timer = undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function _resumeAll(): void {
|
|
115
|
+
for (const t of _toasts()) {
|
|
116
|
+
if (t.duration > 0 && t.timer === undefined && t.remaining > 0) {
|
|
117
|
+
t.timerStart = Date.now()
|
|
118
|
+
t.timer = setTimeout(() => dismiss(t.id), t.remaining)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Public imperative API ───────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Show a toast notification.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* toast("Saved!")
|
|
130
|
+
* toast("Error occurred", { type: "error", duration: 6000 })
|
|
131
|
+
*
|
|
132
|
+
* @returns The toast id — pass to `toast.dismiss(id)` to remove it.
|
|
133
|
+
*/
|
|
134
|
+
export function toast(message: string | VNodeChild, options?: ToastOptions): string {
|
|
135
|
+
return addToast(message, options)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function shortcut(type: ToastType) {
|
|
139
|
+
return (message: string | VNodeChild, options?: Omit<ToastOptions, "type">): string =>
|
|
140
|
+
addToast(message, { ...options, type })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Show a success toast. */
|
|
144
|
+
toast.success = shortcut("success")
|
|
145
|
+
|
|
146
|
+
/** Show an error toast. */
|
|
147
|
+
toast.error = shortcut("error")
|
|
148
|
+
|
|
149
|
+
/** Show a warning toast. */
|
|
150
|
+
toast.warning = shortcut("warning")
|
|
151
|
+
|
|
152
|
+
/** Show an info toast. */
|
|
153
|
+
toast.info = shortcut("info")
|
|
154
|
+
|
|
155
|
+
/** Show a persistent loading toast. Returns id for later update/dismiss. */
|
|
156
|
+
toast.loading = (
|
|
157
|
+
message: string | VNodeChild,
|
|
158
|
+
options?: Omit<ToastOptions, "type" | "duration">,
|
|
159
|
+
): string => addToast(message, { ...options, type: "info", duration: 0 })
|
|
160
|
+
|
|
161
|
+
/** Update an existing toast (message, type, duration). */
|
|
162
|
+
toast.update = (
|
|
163
|
+
id: string,
|
|
164
|
+
updates: Partial<Pick<ToastOptions, "type" | "duration">> & { message?: string | VNodeChild },
|
|
165
|
+
): void => updateToast(id, updates)
|
|
166
|
+
|
|
167
|
+
/** Dismiss a specific toast by id, or all toasts if no id is given. */
|
|
168
|
+
toast.dismiss = dismiss
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Show a loading toast that updates on promise resolution or rejection.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* toast.promise(saveTodo(), {
|
|
175
|
+
* loading: "Saving...",
|
|
176
|
+
* success: "Saved!",
|
|
177
|
+
* error: "Failed to save",
|
|
178
|
+
* })
|
|
179
|
+
*/
|
|
180
|
+
toast.promise = function toastPromise<T>(
|
|
181
|
+
promise: Promise<T>,
|
|
182
|
+
opts: ToastPromiseOptions<T>,
|
|
183
|
+
): Promise<T> {
|
|
184
|
+
const id = addToast(opts.loading, { type: "info", duration: 0 })
|
|
185
|
+
|
|
186
|
+
promise.then(
|
|
187
|
+
(data) => {
|
|
188
|
+
const msg = typeof opts.success === "function" ? opts.success(data) : opts.success
|
|
189
|
+
updateToast(id, { message: msg, type: "success", duration: DEFAULT_DURATION })
|
|
190
|
+
},
|
|
191
|
+
(err: unknown) => {
|
|
192
|
+
const msg = typeof opts.error === "function" ? opts.error(err) : opts.error
|
|
193
|
+
updateToast(id, { message: msg, type: "error", duration: DEFAULT_DURATION })
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return promise
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Test utilities ──────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/** @internal Reset state for testing. */
|
|
203
|
+
export function _reset(): void {
|
|
204
|
+
const current = _toasts()
|
|
205
|
+
for (const t of current) {
|
|
206
|
+
if (t.timer !== undefined) clearTimeout(t.timer)
|
|
207
|
+
}
|
|
208
|
+
_toasts.set([])
|
|
209
|
+
_idCounter = 0
|
|
210
|
+
}
|
package/src/toaster.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { For, Portal } from "@pyreon/core"
|
|
3
|
+
import { computed, effect, onCleanup } from "@pyreon/reactivity"
|
|
4
|
+
import { toastStyles } from "./styles"
|
|
5
|
+
import { _pauseAll, _resumeAll, _toasts, toast } from "./toast"
|
|
6
|
+
import type { Toast, ToasterProps, ToastPosition } from "./types"
|
|
7
|
+
|
|
8
|
+
// ─── Style injection ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
let _styleInjected = false
|
|
11
|
+
|
|
12
|
+
function injectStyles(): void {
|
|
13
|
+
if (_styleInjected) return
|
|
14
|
+
_styleInjected = true
|
|
15
|
+
|
|
16
|
+
const style = document.createElement("style")
|
|
17
|
+
style.setAttribute("data-pyreon-toast", "")
|
|
18
|
+
style.textContent = toastStyles
|
|
19
|
+
document.head.appendChild(style)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Position helpers ────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function getContainerStyle(position: ToastPosition, gap: number, offset: number): string {
|
|
25
|
+
const [vertical, horizontal] = position.split("-") as [string, string]
|
|
26
|
+
|
|
27
|
+
let style = `gap: ${gap}px;`
|
|
28
|
+
|
|
29
|
+
if (vertical === "top") {
|
|
30
|
+
style += ` top: ${offset}px;`
|
|
31
|
+
} else {
|
|
32
|
+
style += ` bottom: ${offset}px;`
|
|
33
|
+
style += " flex-direction: column-reverse;"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (horizontal === "left") {
|
|
37
|
+
style += ` left: ${offset}px;`
|
|
38
|
+
} else if (horizontal === "center") {
|
|
39
|
+
style += " left: 50%; transform: translateX(-50%);"
|
|
40
|
+
} else {
|
|
41
|
+
style += ` right: ${offset}px;`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return style
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Toaster component ──────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render component for toast notifications. Place once at your app root.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* function App() {
|
|
55
|
+
* return (
|
|
56
|
+
* <>
|
|
57
|
+
* <Toaster position="bottom-right" />
|
|
58
|
+
* <MyApp />
|
|
59
|
+
* </>
|
|
60
|
+
* )
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function Toaster(props?: ToasterProps): VNodeChild {
|
|
65
|
+
const position = props?.position ?? "top-right"
|
|
66
|
+
const max = props?.max ?? 5
|
|
67
|
+
const gap = props?.gap ?? 8
|
|
68
|
+
const offset = props?.offset ?? 16
|
|
69
|
+
|
|
70
|
+
injectStyles()
|
|
71
|
+
|
|
72
|
+
// Promote "entering" toasts to "visible" on next frame.
|
|
73
|
+
// Only runs when there are actually entering toasts (early return guard).
|
|
74
|
+
effect(() => {
|
|
75
|
+
const toasts = _toasts()
|
|
76
|
+
const hasEntering = toasts.some((t) => t.state === "entering")
|
|
77
|
+
if (!hasEntering) return
|
|
78
|
+
|
|
79
|
+
const raf = requestAnimationFrame(() => {
|
|
80
|
+
const current = _toasts()
|
|
81
|
+
let changed = false
|
|
82
|
+
const next = current.map((t) => {
|
|
83
|
+
if (t.state === "entering") {
|
|
84
|
+
changed = true
|
|
85
|
+
return { ...t, state: "visible" as const }
|
|
86
|
+
}
|
|
87
|
+
return t
|
|
88
|
+
})
|
|
89
|
+
if (changed) _toasts.set(next)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
onCleanup(() => cancelAnimationFrame(raf))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Computed visible toasts — only the most recent `max` items
|
|
96
|
+
const visibleToasts = computed(() => _toasts().slice(-max))
|
|
97
|
+
|
|
98
|
+
const containerStyle = getContainerStyle(position, gap, offset)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Portal target={document.body}>
|
|
102
|
+
<section
|
|
103
|
+
class="pyreon-toast-container"
|
|
104
|
+
style={containerStyle}
|
|
105
|
+
aria-label="Notifications"
|
|
106
|
+
aria-live="polite"
|
|
107
|
+
onMouseEnter={_pauseAll}
|
|
108
|
+
onMouseLeave={_resumeAll}
|
|
109
|
+
>
|
|
110
|
+
<For each={visibleToasts} by={(t: Toast) => t.id}>
|
|
111
|
+
{(t: Toast) => <ToastItem toast={t} />}
|
|
112
|
+
</For>
|
|
113
|
+
</section>
|
|
114
|
+
</Portal>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Toast item ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function ToastItem({ toast: t }: { toast: Toast }): VNodeChild {
|
|
121
|
+
const stateClass =
|
|
122
|
+
t.state === "entering"
|
|
123
|
+
? " pyreon-toast--entering"
|
|
124
|
+
: t.state === "exiting"
|
|
125
|
+
? " pyreon-toast--exiting"
|
|
126
|
+
: ""
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
class={`pyreon-toast pyreon-toast--${t.type}${stateClass}`}
|
|
131
|
+
role="alert"
|
|
132
|
+
aria-atomic="true"
|
|
133
|
+
data-toast-id={t.id}
|
|
134
|
+
>
|
|
135
|
+
<div class="pyreon-toast__message">
|
|
136
|
+
{typeof t.message === "string" ? t.message : t.message}
|
|
137
|
+
</div>
|
|
138
|
+
{t.action && (
|
|
139
|
+
<button type="button" class="pyreon-toast__action" onClick={t.action.onClick}>
|
|
140
|
+
{t.action.label}
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
{t.dismissible && (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
class="pyreon-toast__dismiss"
|
|
147
|
+
onClick={() => toast.dismiss(t.id)}
|
|
148
|
+
aria-label="Dismiss"
|
|
149
|
+
>
|
|
150
|
+
×
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
|
|
3
|
+
// ─── Public types ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type ToastPosition =
|
|
6
|
+
| "top-left"
|
|
7
|
+
| "top-center"
|
|
8
|
+
| "top-right"
|
|
9
|
+
| "bottom-left"
|
|
10
|
+
| "bottom-center"
|
|
11
|
+
| "bottom-right"
|
|
12
|
+
|
|
13
|
+
export type ToastType = "info" | "success" | "warning" | "error"
|
|
14
|
+
|
|
15
|
+
export interface ToastOptions {
|
|
16
|
+
/** Toast variant — controls styling. */
|
|
17
|
+
type?: ToastType
|
|
18
|
+
/** Auto-dismiss delay in ms. Default: 4000. Set 0 for persistent. */
|
|
19
|
+
duration?: number
|
|
20
|
+
/** Screen position. Default: inherited from Toaster. */
|
|
21
|
+
position?: ToastPosition
|
|
22
|
+
/** Whether the toast shows a dismiss button. Default: true. */
|
|
23
|
+
dismissible?: boolean
|
|
24
|
+
/** Optional action button. */
|
|
25
|
+
action?: { label: string; onClick: () => void }
|
|
26
|
+
/** Called when the toast is dismissed (manually or by timeout). */
|
|
27
|
+
onDismiss?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ToasterProps {
|
|
31
|
+
/** Default position for all toasts. Default: "top-right". */
|
|
32
|
+
position?: ToastPosition
|
|
33
|
+
/** Maximum visible toasts. Default: 5. */
|
|
34
|
+
max?: number
|
|
35
|
+
/** Gap between toasts in px. Default: 8. */
|
|
36
|
+
gap?: number
|
|
37
|
+
/** Offset from viewport edge in px. Default: 16. */
|
|
38
|
+
offset?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToastPromiseOptions<T> {
|
|
42
|
+
loading: string | VNodeChild
|
|
43
|
+
success: string | VNodeChild | ((data: T) => string | VNodeChild)
|
|
44
|
+
error: string | VNodeChild | ((err: unknown) => string | VNodeChild)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Internal types ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export type ToastState = "entering" | "visible" | "exiting"
|
|
50
|
+
|
|
51
|
+
export interface Toast {
|
|
52
|
+
id: string
|
|
53
|
+
message: string | VNodeChild
|
|
54
|
+
type: ToastType
|
|
55
|
+
duration: number
|
|
56
|
+
dismissible: boolean
|
|
57
|
+
action: { label: string; onClick: () => void } | undefined
|
|
58
|
+
onDismiss: (() => void) | undefined
|
|
59
|
+
state: ToastState
|
|
60
|
+
timer: ReturnType<typeof setTimeout> | undefined
|
|
61
|
+
/** Remaining ms when timer was paused (hover). */
|
|
62
|
+
remaining: number
|
|
63
|
+
/** Timestamp when current timer started. */
|
|
64
|
+
timerStart: number
|
|
65
|
+
}
|