@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.
@@ -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
+ }
@@ -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
+ }