@rivinia/toast 0.0.0-stage → 0.1.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/README.md +100 -2
- package/fesm2022/rivinia-toast.mjs +267 -0
- package/fesm2022/rivinia-toast.mjs.map +1 -0
- package/package.json +29 -3
- package/types/rivinia-toast.d.ts +213 -0
package/README.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rivinia/toast
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@rivinia/toast)
|
|
4
|
+
[](https://www.npmjs.com/package/@rivinia/toast)
|
|
5
|
+
|
|
6
|
+
Headless toast queue for Angular 22+. Signals, pause-on-hover timers, actions,
|
|
7
|
+
dedupe, overflow policies — zero DOM, zero styles, zero router dependency.
|
|
8
|
+
A future `@rivinia/toast/ui` entry point will render this state; Core never
|
|
9
|
+
imports UI.
|
|
10
|
+
|
|
11
|
+
- Version: **0.1.0**
|
|
12
|
+
- Requires: **Angular `^22.0.0`**, `tslib`
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @rivinia/toast
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// app.config.ts
|
|
22
|
+
import { provideRiviniaToast } from '@rivinia/toast';
|
|
23
|
+
|
|
24
|
+
providers: [provideRiviniaToast({ duration: 5000, maxVisible: 5 })];
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
export class Checkout {
|
|
31
|
+
private toast = injectRiviniaToast();
|
|
32
|
+
|
|
33
|
+
pay() {
|
|
34
|
+
const id = this.toast.show({ message: 'Payment sent', kind: 'success' });
|
|
35
|
+
// id: number | null (null = rejected by the 'reject' overflow policy)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
saving() {
|
|
39
|
+
// Same key updates in place instead of stacking duplicates:
|
|
40
|
+
this.toast.show({ message: 'Saving…', dedupeKey: 'save' });
|
|
41
|
+
this.toast.show({ message: 'Saved', kind: 'success', dedupeKey: 'save' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<!-- your outlet (or the future @rivinia/toast/ui): -->
|
|
48
|
+
@for (t of toast.active(); track t.id) {
|
|
49
|
+
<div [attr.data-kind]="t.kind">{{ resolveToastText(t.message) }}</div>
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
| Export | Kind | Purpose |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `provideRiviniaToast(config?)` | provider | `{ duration?=5000, durations?, maxVisible?=5, maxQueue?=∞, overflow?='dismiss-oldest' }` |
|
|
58
|
+
| `RiviniaToast` | `@Service()` | `show/dismiss/dismissAll/update/pause/resume/runAction`, `active` + `queue` signals |
|
|
59
|
+
| `injectRiviniaToast()` | function | shorthand for `inject(RiviniaToast)` |
|
|
60
|
+
| `resolveToastText(msg)` | util | reads `string \| Signal<string>` (reactive re-translation without i18n coupling) |
|
|
61
|
+
| `clampDuration(v, fallback)` | util | normalizes durations (`0`/negative/`NaN`/infinite = persistent) |
|
|
62
|
+
| types | types | `Toast`, `ToastKind`, `ToastMessage`, `ToastAction`, `ToastOptions`, `ToastChanges`, `ToastOverflowPolicy`, `RiviniaToastConfig` |
|
|
63
|
+
|
|
64
|
+
`show()` returns the stable id, or `null` under `'reject'` (or a full finite
|
|
65
|
+
queue). `dismiss/update/pause/resume/runAction` return `false` for unknown ids
|
|
66
|
+
(and repeated dismissal) instead of throwing. `duration: 0` = persistent.
|
|
67
|
+
Timers start at queue **promotion**, never while queued. Per-toast options
|
|
68
|
+
always beat globals.
|
|
69
|
+
|
|
70
|
+
## Recipes (app-owned, deliberately not in the library)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { filter } from 'rxjs';
|
|
74
|
+
import { NavigationStart } from '@angular/router';
|
|
75
|
+
|
|
76
|
+
// Clear timed toasts on navigation (BRT behavior, 3 lines, no library peer needed):
|
|
77
|
+
router.events.pipe(filter((e) => e instanceof NavigationStart))
|
|
78
|
+
.subscribe(() => toast.active().forEach((t) => t.duration > 0 && toast.dismiss(t.id)));
|
|
79
|
+
|
|
80
|
+
// Backend-shaped errors stay in your error mapper, then:
|
|
81
|
+
toast.show({ message: 'Order failed', kind: 'error', action: {
|
|
82
|
+
label: 'Retry', onPress: () => retry(),
|
|
83
|
+
} }); // dismisses after press unless dismissAfterPress: false
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Future UI must render inside `role="region" aria-live="polite"`, pause on
|
|
87
|
+
hover **and** keyboard focus (WCAG 2.2.1 timing control), and offer a labelled
|
|
88
|
+
dismiss button — the Core API (`pause/resume/dismiss`) already supports all of it.
|
|
89
|
+
|
|
90
|
+
## Testing your app code
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
vi.useFakeTimers();
|
|
94
|
+
toast.show({ message: 'x' });
|
|
95
|
+
vi.advanceTimersByTime(5000);
|
|
96
|
+
expect(toast.active()).toEqual([]);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import * as i0 from "@angular/core";
|
|
2
|
+
import { DestroyRef, InjectionToken, Service, inject, makeEnvironmentProviders, signal } from "@angular/core";
|
|
3
|
+
function resolveToastText(message) {
|
|
4
|
+
return typeof message === "string" ? message : message();
|
|
5
|
+
}
|
|
6
|
+
function clampDuration(value, fallback) {
|
|
7
|
+
const candidate = value === void 0 ? fallback : value;
|
|
8
|
+
if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate <= 0) return 0;
|
|
9
|
+
return Math.floor(candidate);
|
|
10
|
+
}
|
|
11
|
+
const RIVINIA_TOAST_CONFIG = new InjectionToken("RIVINIA_TOAST_CONFIG");
|
|
12
|
+
const DEFAULT_DURATION = 5e3;
|
|
13
|
+
var RiviniaToast = class RiviniaToast {
|
|
14
|
+
config = inject(RIVINIA_TOAST_CONFIG, { optional: true });
|
|
15
|
+
_active = signal([], ...ngDevMode ? [{ debugName: "_active" }] : /* istanbul ignore next */ []);
|
|
16
|
+
_queue = signal([], ...ngDevMode ? [{ debugName: "_queue" }] : /* istanbul ignore next */ []);
|
|
17
|
+
active = this._active.asReadonly();
|
|
18
|
+
queue = this._queue.asReadonly();
|
|
19
|
+
_timers = /* @__PURE__ */ new Map();
|
|
20
|
+
_generations = /* @__PURE__ */ new Map();
|
|
21
|
+
_dedupeByKey = /* @__PURE__ */ new Map();
|
|
22
|
+
_dedupeKeyById = /* @__PURE__ */ new Map();
|
|
23
|
+
_nextId = 0;
|
|
24
|
+
constructor() {
|
|
25
|
+
inject(DestroyRef).onDestroy(() => this.clearTimers());
|
|
26
|
+
}
|
|
27
|
+
show(options) {
|
|
28
|
+
if (options.dedupeKey !== void 0) {
|
|
29
|
+
const existing = this.findByDedupeKey(options.dedupeKey);
|
|
30
|
+
if (existing !== null) {
|
|
31
|
+
this.update(existing, {
|
|
32
|
+
message: options.message,
|
|
33
|
+
kind: options.kind,
|
|
34
|
+
duration: options.duration,
|
|
35
|
+
action: options.action ?? null
|
|
36
|
+
});
|
|
37
|
+
return existing;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const id = ++this._nextId;
|
|
41
|
+
const kind = options.kind ?? "info";
|
|
42
|
+
const duration = this.resolveDuration(kind, options.duration);
|
|
43
|
+
const toast = {
|
|
44
|
+
id,
|
|
45
|
+
kind,
|
|
46
|
+
message: options.message,
|
|
47
|
+
duration,
|
|
48
|
+
remaining: duration,
|
|
49
|
+
action: options.action ?? null
|
|
50
|
+
};
|
|
51
|
+
if (options.dedupeKey !== void 0) {
|
|
52
|
+
this._dedupeByKey.set(options.dedupeKey, id);
|
|
53
|
+
this._dedupeKeyById.set(id, options.dedupeKey);
|
|
54
|
+
}
|
|
55
|
+
if (this._active().length < this.maxVisible()) {
|
|
56
|
+
this._active.update((list) => [...list, toast]);
|
|
57
|
+
this.arm(id, duration);
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
switch (this.config?.overflow ?? "dismiss-oldest") {
|
|
61
|
+
case "queue":
|
|
62
|
+
if (this._queue().length >= this.maxQueue()) {
|
|
63
|
+
this.forgetDedupe(id);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
this._queue.update((list) => [...list, toast]);
|
|
67
|
+
return id;
|
|
68
|
+
case "reject":
|
|
69
|
+
this.forgetDedupe(id);
|
|
70
|
+
return null;
|
|
71
|
+
default: {
|
|
72
|
+
const oldest = this._active()[0];
|
|
73
|
+
if (oldest) this.dismiss(oldest.id);
|
|
74
|
+
this._active.update((list) => [...list, toast]);
|
|
75
|
+
this.arm(id, duration);
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
update(id, changes) {
|
|
81
|
+
const inActive = this._active().some((t) => t.id === id);
|
|
82
|
+
const apply = (t) => ({
|
|
83
|
+
...t,
|
|
84
|
+
message: changes.message ?? t.message,
|
|
85
|
+
kind: changes.kind ?? t.kind,
|
|
86
|
+
duration: changes.duration !== void 0 ? clampDuration(changes.duration, t.duration) : t.duration,
|
|
87
|
+
remaining: changes.duration !== void 0 ? clampDuration(changes.duration, t.duration) : t.duration,
|
|
88
|
+
action: changes.action !== void 0 ? changes.action : t.action
|
|
89
|
+
});
|
|
90
|
+
if (inActive) {
|
|
91
|
+
this.disarm(id);
|
|
92
|
+
let next;
|
|
93
|
+
this._active.update((list) => list.map((t) => {
|
|
94
|
+
if (t.id !== id) return t;
|
|
95
|
+
next = apply(t);
|
|
96
|
+
return next;
|
|
97
|
+
}));
|
|
98
|
+
if (next) this.arm(id, next.duration);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
let found = false;
|
|
102
|
+
this._queue.update((list) => list.map((t) => {
|
|
103
|
+
if (t.id !== id) return t;
|
|
104
|
+
found = true;
|
|
105
|
+
return apply(t);
|
|
106
|
+
}));
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
dismiss(id) {
|
|
110
|
+
this.disarm(id);
|
|
111
|
+
let removed = false;
|
|
112
|
+
this._active.update((list) => {
|
|
113
|
+
const next = list.filter((t) => t.id !== id);
|
|
114
|
+
removed = next.length !== list.length;
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
if (!removed) this._queue.update((list) => {
|
|
118
|
+
const next = list.filter((t) => t.id !== id);
|
|
119
|
+
removed = next.length !== list.length;
|
|
120
|
+
return next;
|
|
121
|
+
});
|
|
122
|
+
if (removed) {
|
|
123
|
+
this.forgetDedupe(id);
|
|
124
|
+
this.promote();
|
|
125
|
+
}
|
|
126
|
+
return removed;
|
|
127
|
+
}
|
|
128
|
+
dismissAll() {
|
|
129
|
+
this.clearTimers();
|
|
130
|
+
this._active.set([]);
|
|
131
|
+
this._queue.set([]);
|
|
132
|
+
this._dedupeByKey.clear();
|
|
133
|
+
this._dedupeKeyById.clear();
|
|
134
|
+
}
|
|
135
|
+
pause(id) {
|
|
136
|
+
const record = this._timers.get(id);
|
|
137
|
+
if (!record) return false;
|
|
138
|
+
clearTimeout(record.handle);
|
|
139
|
+
this._timers.delete(id);
|
|
140
|
+
const elapsed = Date.now() - record.startedAt;
|
|
141
|
+
this._active.update((list) => list.map((t) => t.id === id ? {
|
|
142
|
+
...t,
|
|
143
|
+
remaining: Math.max(0, t.remaining - elapsed)
|
|
144
|
+
} : t));
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
resume(id) {
|
|
148
|
+
if (this._timers.has(id)) return false;
|
|
149
|
+
const toast = this._active().find((t) => t.id === id);
|
|
150
|
+
if (!toast) return false;
|
|
151
|
+
if (toast.remaining <= 0) {
|
|
152
|
+
this.dismiss(id);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
this.arm(id, toast.remaining);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
runAction(id) {
|
|
159
|
+
const toast = this._active().find((t) => t.id === id) ?? this._queue().find((t) => t.id === id);
|
|
160
|
+
if (!toast?.action) return false;
|
|
161
|
+
toast.action.onPress();
|
|
162
|
+
if (toast.action.dismissAfterPress ?? true) this.dismiss(id);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
maxVisible() {
|
|
166
|
+
const value = this.config?.maxVisible ?? 5;
|
|
167
|
+
return Math.max(1, Math.floor(value));
|
|
168
|
+
}
|
|
169
|
+
maxQueue() {
|
|
170
|
+
const value = this.config?.maxQueue ?? Number.POSITIVE_INFINITY;
|
|
171
|
+
return value < 0 ? 0 : value;
|
|
172
|
+
}
|
|
173
|
+
resolveDuration(kind, override) {
|
|
174
|
+
if (override !== void 0) return clampDuration(override, this.fallbackDuration(kind));
|
|
175
|
+
return this.fallbackDuration(kind);
|
|
176
|
+
}
|
|
177
|
+
fallbackDuration(kind) {
|
|
178
|
+
const perKind = this.config?.durations?.[kind];
|
|
179
|
+
if (perKind !== void 0) return clampDuration(perKind, DEFAULT_DURATION);
|
|
180
|
+
return clampDuration(this.config?.duration, DEFAULT_DURATION);
|
|
181
|
+
}
|
|
182
|
+
arm(id, delay) {
|
|
183
|
+
if (delay <= 0) return;
|
|
184
|
+
const generation = (this._generations.get(id) ?? 0) + 1;
|
|
185
|
+
this._generations.set(id, generation);
|
|
186
|
+
this._timers.set(id, {
|
|
187
|
+
handle: setTimeout(() => {
|
|
188
|
+
if (this._timers.get(id)?.generation === generation) this.dismiss(id);
|
|
189
|
+
}, delay),
|
|
190
|
+
startedAt: Date.now(),
|
|
191
|
+
generation
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
disarm(id) {
|
|
195
|
+
const record = this._timers.get(id);
|
|
196
|
+
if (record) {
|
|
197
|
+
clearTimeout(record.handle);
|
|
198
|
+
this._timers.delete(id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
clearTimers() {
|
|
202
|
+
for (const record of this._timers.values()) clearTimeout(record.handle);
|
|
203
|
+
this._timers.clear();
|
|
204
|
+
}
|
|
205
|
+
promote() {
|
|
206
|
+
while (this._active().length < this.maxVisible() && this._queue().length > 0) {
|
|
207
|
+
const head = this._queue()[0];
|
|
208
|
+
this._queue.update((list) => list.slice(1));
|
|
209
|
+
const fresh = {
|
|
210
|
+
...head,
|
|
211
|
+
remaining: head.duration
|
|
212
|
+
};
|
|
213
|
+
this._active.update((list) => [...list, fresh]);
|
|
214
|
+
this.arm(fresh.id, fresh.duration);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
findByDedupeKey(key) {
|
|
218
|
+
const id = this._dedupeByKey.get(key);
|
|
219
|
+
if (id === void 0) return null;
|
|
220
|
+
if (!(this._active().some((t) => t.id === id) || this._queue().some((t) => t.id === id))) {
|
|
221
|
+
this.forgetDedupe(id);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return id;
|
|
225
|
+
}
|
|
226
|
+
forgetDedupe(id) {
|
|
227
|
+
const key = this._dedupeKeyById.get(id);
|
|
228
|
+
if (key !== void 0) {
|
|
229
|
+
this._dedupeKeyById.delete(id);
|
|
230
|
+
if (this._dedupeByKey.get(key) === id) this._dedupeByKey.delete(key);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
static ɵfac = i0.ɵɵngDeclareFactory({
|
|
234
|
+
minVersion: "12.0.0",
|
|
235
|
+
version: "22.2.2",
|
|
236
|
+
ngImport: i0,
|
|
237
|
+
type: RiviniaToast,
|
|
238
|
+
deps: [],
|
|
239
|
+
target: i0.ɵɵFactoryTarget.Service
|
|
240
|
+
});
|
|
241
|
+
static ɵprov = i0.ɵɵngDeclareService({
|
|
242
|
+
minVersion: "22.0.0",
|
|
243
|
+
version: "22.2.2",
|
|
244
|
+
ngImport: i0,
|
|
245
|
+
type: RiviniaToast
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
i0.ɵɵngDeclareClassMetadata({
|
|
249
|
+
minVersion: "12.0.0",
|
|
250
|
+
version: "22.2.2",
|
|
251
|
+
ngImport: i0,
|
|
252
|
+
type: RiviniaToast,
|
|
253
|
+
decorators: [{ type: Service }],
|
|
254
|
+
ctorParameters: () => []
|
|
255
|
+
});
|
|
256
|
+
function provideRiviniaToast(config = {}) {
|
|
257
|
+
return makeEnvironmentProviders([{
|
|
258
|
+
provide: RIVINIA_TOAST_CONFIG,
|
|
259
|
+
useValue: config
|
|
260
|
+
}]);
|
|
261
|
+
}
|
|
262
|
+
function injectRiviniaToast() {
|
|
263
|
+
return inject(RiviniaToast);
|
|
264
|
+
}
|
|
265
|
+
export { RiviniaToast, clampDuration, injectRiviniaToast, provideRiviniaToast, resolveToastText };
|
|
266
|
+
|
|
267
|
+
//# sourceMappingURL=rivinia-toast.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rivinia-toast.mjs","names":[],"sources":["../../../projects/toast/src/lib/toast-utils.ts","../../../projects/toast/src/lib/toast-config.ts","../../../projects/toast/src/lib/rivinia-toast.ts","../../../projects/toast/src/lib/provide-toast.ts"],"sourcesContent":["import type { Signal } from '@angular/core';\nimport type { ToastMessage } from './toast-types';\n\n/**\n * Reads a toast message regardless of shape. Future UI entry points call this\n * in templates — one place to support `string | Signal<string>`.\n */\nexport function resolveToastText(message: ToastMessage): string {\n return typeof message === 'string' ? message : (message as Signal<string>)();\n}\n\n/**\n * Normalizes a duration into a safe non-negative integer.\n * `0`, negatives, `NaN` and non-finite values all mean persistent (no timer).\n * Rationale: a `setTimeout(Infinity)` silently overflows to ~1ms in browsers —\n * converting to persistent is the only non-surprising mapping.\n */\nexport function clampDuration(value: number | undefined, fallback: number): number {\n const candidate = value === undefined ? fallback : value;\n if (typeof candidate !== 'number' || !Number.isFinite(candidate) || candidate <= 0) {\n return 0;\n }\n return Math.floor(candidate);\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ToastKind, ToastOverflowPolicy } from './toast-types';\n\n/**\n * Global configuration for `@rivinia/toast`.\n *\n * Every field is optional. Per-toast options always win over globals.\n * URLs, icons, positions, styles: intentionally absent — presentation is\n * app-owned (a future `@rivinia/toast/ui` entry point will consume this Core).\n */\nexport interface RiviniaToastConfig {\n /**\n * Default auto-dismiss delay in milliseconds.\n * @default 5000\n */\n duration?: number;\n /**\n * Per-kind default delays. A per-toast `duration` beats both levels.\n * @default {} (every kind falls back to `duration`)\n */\n durations?: Partial<Record<ToastKind, number>>;\n /**\n * Maximum simultaneously visible (active) toasts. Clamped to ≥ 1.\n * @default 5 (industry standard: shadcn `TOAST_LIMIT = 3`, BRT `MAX_VISIBLE = 5`)\n */\n maxVisible?: number;\n /**\n * Maximum queued toasts when `overflow: 'queue'`. Further shows are rejected.\n * @default Infinity (bounded only if you bound it — an unbounded queue is a\n * documented leak risk for hot paths; prefer `'dismiss-oldest'` there)\n */\n maxQueue?: number;\n /**\n * What happens when a new toast arrives while `maxVisible` are active.\n * - `'dismiss-oldest'`: dismiss the oldest active toast, show the new one.\n * - `'queue'`: hold the new toast (no timer runs) until room frees up.\n * - `'reject'`: `show()` returns `null`; nothing changes.\n * @default 'dismiss-oldest'\n */\n overflow?: ToastOverflowPolicy;\n}\n\n/**\n * Internal configuration token. Consumers must use `provideRiviniaToast()`.\n * @internal\n */\nexport const RIVINIA_TOAST_CONFIG = new InjectionToken<RiviniaToastConfig>(\n 'RIVINIA_TOAST_CONFIG',\n);\n\nexport const DEFAULT_DURATION = 5000;\nexport const DEFAULT_MAX_VISIBLE = 5;\n","import { DestroyRef, inject, Service, signal } from '@angular/core';\nimport {\n DEFAULT_DURATION,\n DEFAULT_MAX_VISIBLE,\n RIVINIA_TOAST_CONFIG,\n type RiviniaToastConfig,\n} from './toast-config';\nimport type {\n Toast,\n ToastChanges,\n ToastKind,\n ToastOptions,\n} from './toast-types';\nimport { clampDuration } from './toast-utils';\n\ninterface TimerRecord {\n handle: ReturnType<typeof setTimeout>;\n startedAt: number;\n generation: number;\n}\n\n/**\n * Headless toast queue owner.\n *\n * State: two readonly signals — `active` (visible, capped) and `queue`\n * (overflow waiting, timerless). Who changes it: only the methods below.\n * Timers: one `setTimeout` per active timed toast, guarded by a per-toast\n * generation so a stale fire can never dismiss an updated toast. No DOM,\n * no styles, no router — presentation and navigation policy are app-owned\n * (see README recipes).\n */\n@Service()\nexport class RiviniaToast {\n private readonly config = inject<RiviniaToastConfig>(RIVINIA_TOAST_CONFIG, {\n optional: true,\n });\n\n private readonly _active = signal<Toast[]>([]);\n private readonly _queue = signal<Toast[]>([]);\n\n /** Visible toasts, oldest first. Never exceeds effective `maxVisible`. */\n readonly active = this._active.asReadonly();\n /** Overflow waiting for room. No timers run while queued. */\n readonly queue = this._queue.asReadonly();\n\n private readonly _timers = new Map<number, TimerRecord>();\n private readonly _generations = new Map<number, number>();\n private readonly _dedupeByKey = new Map<string, number>();\n private readonly _dedupeKeyById = new Map<number, string>();\n private _nextId = 0;\n\n constructor() {\n inject(DestroyRef).onDestroy(() => this.clearTimers());\n }\n\n /**\n * Shows a toast. Returns its stable id, or `null` when rejected by the\n * `'reject'` overflow policy (or a full finite queue).\n */\n show(options: ToastOptions): number | null {\n if (options.dedupeKey !== undefined) {\n const existing = this.findByDedupeKey(options.dedupeKey);\n if (existing !== null) {\n this.update(existing, {\n message: options.message,\n kind: options.kind,\n duration: options.duration,\n action: options.action ?? null,\n });\n return existing;\n }\n }\n\n const id = ++this._nextId;\n const kind = options.kind ?? 'info';\n const duration = this.resolveDuration(kind, options.duration);\n const toast: Toast = {\n id,\n kind,\n message: options.message,\n duration,\n remaining: duration,\n action: options.action ?? null,\n };\n if (options.dedupeKey !== undefined) {\n this._dedupeByKey.set(options.dedupeKey, id);\n this._dedupeKeyById.set(id, options.dedupeKey);\n }\n\n if (this._active().length < this.maxVisible()) {\n this._active.update((list) => [...list, toast]);\n this.arm(id, duration);\n return id;\n }\n\n switch (this.config?.overflow ?? 'dismiss-oldest') {\n case 'queue': {\n if (this._queue().length >= this.maxQueue()) {\n this.forgetDedupe(id);\n return null;\n }\n this._queue.update((list) => [...list, toast]);\n return id;\n }\n case 'reject':\n this.forgetDedupe(id);\n return null;\n case 'dismiss-oldest':\n default: {\n const oldest = this._active()[0];\n if (oldest) this.dismiss(oldest.id);\n this._active.update((list) => [...list, toast]);\n this.arm(id, duration);\n return id;\n }\n }\n }\n\n /**\n * Merges changes into an active or queued toast and restarts its timer\n * with the new duration (unchanged when `changes.duration` is absent).\n * Unknown ids (never shown or already dismissed): no-op, returns `false`.\n */\n update(id: number, changes: ToastChanges): boolean {\n const inActive = this._active().some((t) => t.id === id);\n const apply = (t: Toast): Toast => ({\n ...t,\n message: changes.message ?? t.message,\n kind: changes.kind ?? t.kind,\n duration: changes.duration !== undefined ? clampDuration(changes.duration, t.duration) : t.duration,\n remaining:\n changes.duration !== undefined ? clampDuration(changes.duration, t.duration) : t.duration,\n action: changes.action !== undefined ? changes.action : t.action,\n });\n if (inActive) {\n this.disarm(id);\n let next: Toast | undefined;\n this._active.update((list) =>\n list.map((t) => {\n if (t.id !== id) return t;\n next = apply(t);\n return next;\n }),\n );\n if (next) this.arm(id, next.duration);\n return true;\n }\n let found = false;\n this._queue.update((list) =>\n list.map((t) => {\n if (t.id !== id) return t;\n found = true;\n return apply(t);\n }),\n );\n return found;\n }\n\n /**\n * Removes a toast wherever it lives, clearing its timer. Unknown ids and\n * repeated calls: no-op, returns `false`. Removing an active toast promotes\n * the queue head (if any), whose timer starts at promotion.\n */\n dismiss(id: number): boolean {\n this.disarm(id);\n let removed = false;\n this._active.update((list) => {\n const next = list.filter((t) => t.id !== id);\n removed = next.length !== list.length;\n return next;\n });\n if (!removed) {\n this._queue.update((list) => {\n const next = list.filter((t) => t.id !== id);\n removed = next.length !== list.length;\n return next;\n });\n }\n if (removed) {\n this.forgetDedupe(id);\n this.promote();\n }\n return removed;\n }\n\n /** Clears timers, active and queue. Ids keep incrementing (never reused). */\n dismissAll(): void {\n this.clearTimers();\n this._active.set([]);\n this._queue.set([]);\n this._dedupeByKey.clear();\n this._dedupeKeyById.clear();\n }\n\n /**\n * Suspends the active timer, preserving `remaining`. UI binds this to\n * hover/focus (WCAG 2.2.1 timing control). Returns `false` for unknown ids,\n * queued/persistent toasts, or already-paused timers.\n */\n pause(id: number): boolean {\n const record = this._timers.get(id);\n if (!record) return false;\n clearTimeout(record.handle);\n this._timers.delete(id);\n const elapsed = Date.now() - record.startedAt;\n this._active.update((list) =>\n list.map((t) =>\n t.id === id ? { ...t, remaining: Math.max(0, t.remaining - elapsed) } : t,\n ),\n );\n return true;\n }\n\n /**\n * Restarts a paused timer with the preserved `remaining`. A toast paused at\n * zero remaining is dismissed instead (it logically already expired).\n */\n resume(id: number): boolean {\n if (this._timers.has(id)) return false;\n const toast = this._active().find((t) => t.id === id);\n if (!toast) return false;\n if (toast.remaining <= 0) {\n this.dismiss(id);\n return true;\n }\n this.arm(id, toast.remaining);\n return true;\n }\n\n /**\n * Runs the toast action callback, then dismisses unless the action opts out\n * via `dismissAfterPress: false`. Callback exceptions propagate to the caller.\n * Returns `false` when the toast or its action is missing.\n */\n runAction(id: number): boolean {\n const toast =\n this._active().find((t) => t.id === id) ?? this._queue().find((t) => t.id === id);\n if (!toast?.action) return false;\n toast.action.onPress();\n if (toast.action.dismissAfterPress ?? true) this.dismiss(id);\n return true;\n }\n\n private maxVisible(): number {\n const value = this.config?.maxVisible ?? DEFAULT_MAX_VISIBLE;\n return Math.max(1, Math.floor(value));\n }\n\n private maxQueue(): number {\n const value = this.config?.maxQueue ?? Number.POSITIVE_INFINITY;\n return value < 0 ? 0 : value;\n }\n\n private resolveDuration(kind: ToastKind, override?: number): number {\n if (override !== undefined) return clampDuration(override, this.fallbackDuration(kind));\n return this.fallbackDuration(kind);\n }\n\n private fallbackDuration(kind: ToastKind): number {\n const perKind = this.config?.durations?.[kind];\n if (perKind !== undefined) return clampDuration(perKind, DEFAULT_DURATION);\n return clampDuration(this.config?.duration, DEFAULT_DURATION);\n }\n\n private arm(id: number, delay: number): void {\n if (delay <= 0) return;\n const generation = (this._generations.get(id) ?? 0) + 1;\n this._generations.set(id, generation);\n this._timers.set(id, {\n handle: setTimeout(() => {\n // Stale-guard: only the latest generation may dismiss.\n if (this._timers.get(id)?.generation === generation) this.dismiss(id);\n }, delay),\n startedAt: Date.now(),\n generation,\n });\n }\n\n private disarm(id: number): void {\n const record = this._timers.get(id);\n if (record) {\n clearTimeout(record.handle);\n this._timers.delete(id);\n }\n }\n\n private clearTimers(): void {\n for (const record of this._timers.values()) clearTimeout(record.handle);\n this._timers.clear();\n }\n\n private promote(): void {\n while (this._active().length < this.maxVisible() && this._queue().length > 0) {\n const head = this._queue()[0];\n this._queue.update((list) => list.slice(1));\n const fresh: Toast = { ...head, remaining: head.duration };\n this._active.update((list) => [...list, fresh]);\n this.arm(fresh.id, fresh.duration);\n }\n }\n\n private findByDedupeKey(key: string): number | null {\n const id = this._dedupeByKey.get(key);\n if (id === undefined) return null;\n const present =\n this._active().some((t) => t.id === id) || this._queue().some((t) => t.id === id);\n if (!present) {\n this.forgetDedupe(id);\n return null;\n }\n return id;\n }\n\n private forgetDedupe(id: number): void {\n const key = this._dedupeKeyById.get(id);\n if (key !== undefined) {\n this._dedupeKeyById.delete(id);\n if (this._dedupeByKey.get(key) === id) this._dedupeByKey.delete(key);\n }\n }\n}\n","import { EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { RIVINIA_TOAST_CONFIG, type RiviniaToastConfig } from './toast-config';\nimport { RiviniaToast } from './rivinia-toast';\n\n/**\n * Configures `@rivinia/toast`. The queue is a root singleton; call once at\n * bootstrap. Presentation (outlet component, position, styles, icons,\n * translations) stays app-owned — a future `@rivinia/toast/ui` entry point\n * will consume the same `RiviniaToast` state.\n *\n * @example\n * ```ts\n * provideRiviniaToast({ duration: 5000, maxVisible: 5 })\n * ```\n */\nexport function provideRiviniaToast(config: RiviniaToastConfig = {}): EnvironmentProviders {\n return makeEnvironmentProviders([{ provide: RIVINIA_TOAST_CONFIG, useValue: config }]);\n}\n\n/** Shorthand for `inject(RiviniaToast)`. */\nexport function injectRiviniaToast(): RiviniaToast {\n return inject(RiviniaToast);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rivinia/toast",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless toast queue for Angular with optional UI outlet: signals, pause-on-hover timers, actions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"angular",
|
|
7
|
+
"toast",
|
|
8
|
+
"notification",
|
|
9
|
+
"signals",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@angular/core": "^22.0.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"tslib": "^2.3.0"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"module": "fesm2022/rivinia-toast.mjs",
|
|
21
|
+
"typings": "types/rivinia-toast.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
"./package.json": {
|
|
24
|
+
"default": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./types/rivinia-toast.d.ts",
|
|
28
|
+
"default": "./fesm2022/rivinia-toast.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"type": "module"
|
|
6
32
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as i0 from "@angular/core";
|
|
2
|
+
import { EnvironmentProviders, InjectionToken, Signal } from "@angular/core";
|
|
3
|
+
/**
|
|
4
|
+
* Semantic severity. Core treats this as data only (default durations,
|
|
5
|
+
* UI icon/role mapping) — never as styling.
|
|
6
|
+
*/
|
|
7
|
+
type ToastKind = 'success' | 'error' | 'info' | 'warning';
|
|
8
|
+
/**
|
|
9
|
+
* Toast text without i18n coupling: pass a plain string, or a `Signal<string>`
|
|
10
|
+
* (e.g. a `computed` over your language service) for reactive re-translation.
|
|
11
|
+
* The future UI reads it via `resolveToastText()`.
|
|
12
|
+
*/
|
|
13
|
+
type ToastMessage = string | Signal<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Optional call-to-action. DOM wiring (click handlers) lives in UI code,
|
|
16
|
+
* which calls `runAction(id)` — Core only owns the metadata + callback.
|
|
17
|
+
*/
|
|
18
|
+
interface ToastAction {
|
|
19
|
+
/** Button label: plain string or reactive signal. */
|
|
20
|
+
readonly label: ToastMessage;
|
|
21
|
+
/** Runs when the UI triggers the action. Exceptions propagate to the caller. */
|
|
22
|
+
readonly onPress: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Dismiss the toast after `onPress` runs.
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
readonly dismissAfterPress?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** A live notification. All fields readonly — mutate via service methods. */
|
|
30
|
+
interface Toast {
|
|
31
|
+
/** Stable monotonic identifier. Never reused, even after dismissal. */
|
|
32
|
+
readonly id: number;
|
|
33
|
+
readonly kind: ToastKind;
|
|
34
|
+
readonly message: ToastMessage;
|
|
35
|
+
/** Resolved auto-dismiss delay in ms. `0` = persistent (no timer). */
|
|
36
|
+
readonly duration: number;
|
|
37
|
+
/** Milliseconds left on the active timer. Equals `duration` when fresh/queued. */
|
|
38
|
+
readonly remaining: number;
|
|
39
|
+
readonly action: ToastAction | null;
|
|
40
|
+
}
|
|
41
|
+
/** Arguments to `show()`. */
|
|
42
|
+
interface ToastOptions {
|
|
43
|
+
readonly message: ToastMessage;
|
|
44
|
+
readonly kind?: ToastKind;
|
|
45
|
+
/** Overrides every configured default. `0` (or negative/NaN) = persistent. */
|
|
46
|
+
readonly duration?: number;
|
|
47
|
+
readonly action?: ToastAction;
|
|
48
|
+
/**
|
|
49
|
+
* Opt-in deduplication scope. A second `show()` with an equal key updates the
|
|
50
|
+
* existing active-or-queued toast in place (content replaced, timer restarted)
|
|
51
|
+
* and returns its id — instead of opening a duplicate. No key = no dedupe.
|
|
52
|
+
*/
|
|
53
|
+
readonly dedupeKey?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Partial changes for `update()`. Re-keying is not supported. */
|
|
56
|
+
interface ToastChanges {
|
|
57
|
+
readonly message?: ToastMessage;
|
|
58
|
+
readonly kind?: ToastKind;
|
|
59
|
+
/** When provided, the timer restarts with the new duration. */
|
|
60
|
+
readonly duration?: number;
|
|
61
|
+
/** `null` removes an existing action. */
|
|
62
|
+
readonly action?: ToastAction | null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Overflow behavior when showing past `maxVisible` active toasts.
|
|
66
|
+
* See `RiviniaToastConfig.overflow` for semantics.
|
|
67
|
+
*/
|
|
68
|
+
type ToastOverflowPolicy = 'dismiss-oldest' | 'queue' | 'reject';
|
|
69
|
+
/**
|
|
70
|
+
* Global configuration for `@rivinia/toast`.
|
|
71
|
+
*
|
|
72
|
+
* Every field is optional. Per-toast options always win over globals.
|
|
73
|
+
* URLs, icons, positions, styles: intentionally absent — presentation is
|
|
74
|
+
* app-owned (a future `@rivinia/toast/ui` entry point will consume this Core).
|
|
75
|
+
*/
|
|
76
|
+
interface RiviniaToastConfig {
|
|
77
|
+
/**
|
|
78
|
+
* Default auto-dismiss delay in milliseconds.
|
|
79
|
+
* @default 5000
|
|
80
|
+
*/
|
|
81
|
+
duration?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Per-kind default delays. A per-toast `duration` beats both levels.
|
|
84
|
+
* @default {} (every kind falls back to `duration`)
|
|
85
|
+
*/
|
|
86
|
+
durations?: Partial<Record<ToastKind, number>>;
|
|
87
|
+
/**
|
|
88
|
+
* Maximum simultaneously visible (active) toasts. Clamped to ≥ 1.
|
|
89
|
+
* @default 5 (industry standard: shadcn `TOAST_LIMIT = 3`, BRT `MAX_VISIBLE = 5`)
|
|
90
|
+
*/
|
|
91
|
+
maxVisible?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Maximum queued toasts when `overflow: 'queue'`. Further shows are rejected.
|
|
94
|
+
* @default Infinity (bounded only if you bound it — an unbounded queue is a
|
|
95
|
+
* documented leak risk for hot paths; prefer `'dismiss-oldest'` there)
|
|
96
|
+
*/
|
|
97
|
+
maxQueue?: number;
|
|
98
|
+
/**
|
|
99
|
+
* What happens when a new toast arrives while `maxVisible` are active.
|
|
100
|
+
* - `'dismiss-oldest'`: dismiss the oldest active toast, show the new one.
|
|
101
|
+
* - `'queue'`: hold the new toast (no timer runs) until room frees up.
|
|
102
|
+
* - `'reject'`: `show()` returns `null`; nothing changes.
|
|
103
|
+
* @default 'dismiss-oldest'
|
|
104
|
+
*/
|
|
105
|
+
overflow?: ToastOverflowPolicy;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Internal configuration token. Consumers must use `provideRiviniaToast()`.
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
declare const RIVINIA_TOAST_CONFIG: InjectionToken<RiviniaToastConfig>;
|
|
112
|
+
declare const DEFAULT_DURATION = 5000;
|
|
113
|
+
declare const DEFAULT_MAX_VISIBLE = 5;
|
|
114
|
+
/**
|
|
115
|
+
* Reads a toast message regardless of shape. Future UI entry points call this
|
|
116
|
+
* in templates — one place to support `string | Signal<string>`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function resolveToastText(message: ToastMessage): string;
|
|
119
|
+
/**
|
|
120
|
+
* Normalizes a duration into a safe non-negative integer.
|
|
121
|
+
* `0`, negatives, `NaN` and non-finite values all mean persistent (no timer).
|
|
122
|
+
* Rationale: a `setTimeout(Infinity)` silently overflows to ~1ms in browsers —
|
|
123
|
+
* converting to persistent is the only non-surprising mapping.
|
|
124
|
+
*/
|
|
125
|
+
export declare function clampDuration(value: number | undefined, fallback: number): number;
|
|
126
|
+
/**
|
|
127
|
+
* Headless toast queue owner.
|
|
128
|
+
*
|
|
129
|
+
* State: two readonly signals — `active` (visible, capped) and `queue`
|
|
130
|
+
* (overflow waiting, timerless). Who changes it: only the methods below.
|
|
131
|
+
* Timers: one `setTimeout` per active timed toast, guarded by a per-toast
|
|
132
|
+
* generation so a stale fire can never dismiss an updated toast. No DOM,
|
|
133
|
+
* no styles, no router — presentation and navigation policy are app-owned
|
|
134
|
+
* (see README recipes).
|
|
135
|
+
*/
|
|
136
|
+
export declare class RiviniaToast {
|
|
137
|
+
private readonly config;
|
|
138
|
+
private readonly _active;
|
|
139
|
+
private readonly _queue;
|
|
140
|
+
/** Visible toasts, oldest first. Never exceeds effective `maxVisible`. */
|
|
141
|
+
readonly active: import("@angular/core").Signal<Toast[]>;
|
|
142
|
+
/** Overflow waiting for room. No timers run while queued. */
|
|
143
|
+
readonly queue: import("@angular/core").Signal<Toast[]>;
|
|
144
|
+
private readonly _timers;
|
|
145
|
+
private readonly _generations;
|
|
146
|
+
private readonly _dedupeByKey;
|
|
147
|
+
private readonly _dedupeKeyById;
|
|
148
|
+
private _nextId;
|
|
149
|
+
constructor();
|
|
150
|
+
/**
|
|
151
|
+
* Shows a toast. Returns its stable id, or `null` when rejected by the
|
|
152
|
+
* `'reject'` overflow policy (or a full finite queue).
|
|
153
|
+
*/
|
|
154
|
+
show(options: ToastOptions): number | null;
|
|
155
|
+
/**
|
|
156
|
+
* Merges changes into an active or queued toast and restarts its timer
|
|
157
|
+
* with the new duration (unchanged when `changes.duration` is absent).
|
|
158
|
+
* Unknown ids (never shown or already dismissed): no-op, returns `false`.
|
|
159
|
+
*/
|
|
160
|
+
update(id: number, changes: ToastChanges): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Removes a toast wherever it lives, clearing its timer. Unknown ids and
|
|
163
|
+
* repeated calls: no-op, returns `false`. Removing an active toast promotes
|
|
164
|
+
* the queue head (if any), whose timer starts at promotion.
|
|
165
|
+
*/
|
|
166
|
+
dismiss(id: number): boolean;
|
|
167
|
+
/** Clears timers, active and queue. Ids keep incrementing (never reused). */
|
|
168
|
+
dismissAll(): void;
|
|
169
|
+
/**
|
|
170
|
+
* Suspends the active timer, preserving `remaining`. UI binds this to
|
|
171
|
+
* hover/focus (WCAG 2.2.1 timing control). Returns `false` for unknown ids,
|
|
172
|
+
* queued/persistent toasts, or already-paused timers.
|
|
173
|
+
*/
|
|
174
|
+
pause(id: number): boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Restarts a paused timer with the preserved `remaining`. A toast paused at
|
|
177
|
+
* zero remaining is dismissed instead (it logically already expired).
|
|
178
|
+
*/
|
|
179
|
+
resume(id: number): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Runs the toast action callback, then dismisses unless the action opts out
|
|
182
|
+
* via `dismissAfterPress: false`. Callback exceptions propagate to the caller.
|
|
183
|
+
* Returns `false` when the toast or its action is missing.
|
|
184
|
+
*/
|
|
185
|
+
runAction(id: number): boolean;
|
|
186
|
+
private maxVisible;
|
|
187
|
+
private maxQueue;
|
|
188
|
+
private resolveDuration;
|
|
189
|
+
private fallbackDuration;
|
|
190
|
+
private arm;
|
|
191
|
+
private disarm;
|
|
192
|
+
private clearTimers;
|
|
193
|
+
private promote;
|
|
194
|
+
private findByDedupeKey;
|
|
195
|
+
private forgetDedupe;
|
|
196
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<RiviniaToast, never>;
|
|
197
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<any>;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Configures `@rivinia/toast`. The queue is a root singleton; call once at
|
|
201
|
+
* bootstrap. Presentation (outlet component, position, styles, icons,
|
|
202
|
+
* translations) stays app-owned — a future `@rivinia/toast/ui` entry point
|
|
203
|
+
* will consume the same `RiviniaToast` state.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* provideRiviniaToast({ duration: 5000, maxVisible: 5 })
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
export declare function provideRiviniaToast(config?: RiviniaToastConfig): EnvironmentProviders;
|
|
211
|
+
/** Shorthand for `inject(RiviniaToast)`. */
|
|
212
|
+
export declare function injectRiviniaToast(): RiviniaToast;
|
|
213
|
+
export type { RiviniaToastConfig, Toast, ToastAction, ToastChanges, ToastKind, ToastMessage, ToastOptions, ToastOverflowPolicy };
|