@rivinia/toast 0.4.0 → 0.4.2

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.
Files changed (2) hide show
  1. package/README.md +216 -90
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -3,136 +3,262 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@rivinia/toast.svg)](https://www.npmjs.com/package/@rivinia/toast)
4
4
  [![license](https://img.shields.io/npm/l/@rivinia/toast.svg)](https://www.npmjs.com/package/@rivinia/toast)
5
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.
6
+ An Angular signal-based toast queue with timers, actions, deduplication, and
7
+ overflow handling. Core is headless; an optional standalone UI is available at
8
+ `@rivinia/toast/ui`.
10
9
 
11
- - Version: **0.4.0**
12
- - Requires: **Angular `^22.0.0`**, `tslib`
10
+ - Package version: **0.4.2**
11
+ - Peer dependency: **`@angular/core` `^22.0.0`**
12
+ - No `tslib`, router, or HTTP peer dependency.
13
13
 
14
- ## Install
14
+ ## Install and configure
15
15
 
16
16
  ```bash
17
17
  npm install @rivinia/toast
18
- ng add @rivinia/toast # wires provideRiviniaToast() into app.config.ts
18
+ ng add @rivinia/toast
19
19
  ```
20
20
 
21
+ `ng add` adds `provideRiviniaToast()` to a standalone app's `app.config.ts`; it
22
+ does not add the optional UI component. You can also configure the service
23
+ manually:
24
+
21
25
  ```ts
22
- // app.config.ts
26
+ // src/app/app.config.ts
27
+ import type { ApplicationConfig } from '@angular/core';
23
28
  import { provideRiviniaToast } from '@rivinia/toast';
24
29
 
25
- providers: [provideRiviniaToast({ duration: 5000, maxVisible: 5 })];
30
+ export const appConfig: ApplicationConfig = {
31
+ providers: [
32
+ provideRiviniaToast({
33
+ duration: 5000,
34
+ durations: { error: 8000 },
35
+ maxVisible: 5,
36
+ overflow: 'dismiss-oldest',
37
+ }),
38
+ ],
39
+ };
26
40
  ```
27
41
 
28
- ## Quick start
42
+ All provider fields are optional. Defaults are 5000 ms, five active toasts,
43
+ an unbounded queue, and `dismiss-oldest` overflow. `ng add` also accepts
44
+ `--duration` and `--max-visible`.
45
+
46
+ ## Use with the optional UI
47
+
48
+ The UI is a separate secondary entry point; import its component explicitly.
49
+ This standalone example keeps the injected manager in a class field (injection
50
+ must not be called from the template):
29
51
 
30
52
  ```ts
31
- export class Checkout {
32
- private toast = injectRiviniaToast();
53
+ // src/app/notifications.component.ts
54
+ import { Component } from '@angular/core';
55
+ import { injectRiviniaToast } from '@rivinia/toast';
56
+ import { RiviniaToastList } from '@rivinia/toast/ui';
57
+
58
+ @Component({
59
+ selector: 'app-notifications',
60
+ standalone: true,
61
+ imports: [RiviniaToastList],
62
+ templateUrl: './notifications.component.html',
63
+ })
64
+ export class NotificationsComponent {
65
+ readonly toast = injectRiviniaToast();
33
66
 
34
- pay() {
35
- const id = this.toast.show({ message: 'Payment sent', kind: 'success' });
36
- // id: number | null (null = rejected by the 'reject' overflow policy)
67
+ save(): void {
68
+ this.toast.show({
69
+ message: 'Changes saved',
70
+ kind: 'success',
71
+ dedupeKey: 'save',
72
+ });
37
73
  }
38
74
 
39
- saving() {
40
- // Same key updates in place instead of stacking duplicates:
41
- this.toast.show({ message: 'Saving…', dedupeKey: 'save' });
42
- this.toast.show({ message: 'Saved', kind: 'success', dedupeKey: 'save' });
75
+ saveWithRetry(retry: () => void): void {
76
+ this.toast.show({
77
+ message: 'Could not save',
78
+ kind: 'error',
79
+ duration: 0,
80
+ action: { label: 'Retry', onPress: retry },
81
+ });
43
82
  }
44
83
  }
45
84
  ```
46
85
 
47
86
  ```html
48
- <!-- your outlet (or the future @rivinia/toast/ui): -->
49
- @for (t of toast.active(); track t.id) {
50
- <div [attr.data-kind]="t.kind">{{ resolveToastText(t.message) }}</div>
51
- }
87
+ <!-- src/app/notifications.component.html -->
88
+ <button type="button" (click)="save()">Save</button>
89
+ <rv-toast-list [manager]="toast" position="bottom-end" />
52
90
  ```
53
91
 
54
- ## API
55
-
56
- | Export | Kind | Purpose |
57
- | ------------------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
58
- | `provideRiviniaToast(config?)` | provider | `{ duration?=5000, durations?, maxVisible?=5, maxQueue?=∞, overflow?='dismiss-oldest' }` |
59
- | `RiviniaToast` | `@Service()` | `show/dismiss/dismissAll/update/pause/resume/runAction`, `active` + `queue` signals |
60
- | `injectRiviniaToast()` | function | shorthand for `inject(RiviniaToast)` |
61
- | `resolveToastText(msg)` | util | reads `string \| Signal<string>` (reactive re-translation without i18n coupling) |
62
- | `clampDuration(v, fallback)` | util | normalizes durations (`0`/negative/`NaN`/infinite = persistent) |
63
- | types | types | `Toast`, `ToastKind`, `ToastMessage`, `ToastAction`, `ToastOptions`, `ToastChanges`, `ToastOverflowPolicy`, `RiviniaToastConfig` |
64
-
65
- `show()` returns the stable id, or `null` under `'reject'` (or a full finite
66
- queue). `dismiss/update/pause/resume/runAction` return `false` for unknown ids
67
- (and repeated dismissal) instead of throwing. `duration: 0` = persistent.
68
- `pause()` suspends only running timers (persistent toasts can never pause, so
69
- hover-leave cannot dismiss them); `resume()` on anything unpaused is a no-op;
70
- each toast exposes its `paused` flag for progress UI.
71
- Timers start at queue **promotion**, never while queued. Per-toast options
72
- always beat globals; a kind change re-resolves the per-kind default unless the
73
- toast carries an explicit duration. Overflow eviction prefers timed,
74
- actionless toasts — persistent errors with actions are last resort.
75
-
76
- ## Recipes (app-owned, deliberately not in the library)
92
+ The standalone list renders the manager's `active()` signal, pauses timed
93
+ notifications on hover and keyboard focus, runs actions, and provides a
94
+ dismiss button by default. Its defaults are `position="bottom-end"`,
95
+ `animation="slide"`, `richColors=false`, and `showCloseButton=true`.
96
+ `position` supports `top`/`bottom` × `start`/`center`/`end` (logical start/end
97
+ work in RTL); `animation` supports `slide`, `fade`, and `none`. Errors use an
98
+ assertive live region; other kinds use polite announcements.
99
+
100
+ For snapshot-driven UIs, provide the snapshots and handle the emitted IDs
101
+ yourself:
77
102
 
78
103
  ```ts
79
- import { filter } from 'rxjs';
80
- import { NavigationStart } from '@angular/router';
81
-
82
- // Clear timed toasts on navigation (BRT behavior, 3 lines, no library peer needed):
83
- router.events
84
- .pipe(filter((e) => e instanceof NavigationStart))
85
- .subscribe(() => toast.active().forEach((t) => t.duration > 0 && toast.dismiss(t.id)));
86
-
87
- // Backend-shaped errors stay in your error mapper, then:
88
- toast.show({
89
- message: 'Order failed',
90
- kind: 'error',
91
- action: {
92
- label: 'Retry',
93
- onPress: () => retry(),
94
- },
95
- }); // dismisses after press unless dismissAfterPress: false
104
+ // src/app/toast-snapshots.component.ts
105
+ import { Component } from '@angular/core';
106
+ import type { Toast } from '@rivinia/toast';
107
+ import { RiviniaToastList } from '@rivinia/toast/ui';
108
+
109
+ @Component({
110
+ selector: 'app-toast-snapshots',
111
+ standalone: true,
112
+ imports: [RiviniaToastList],
113
+ templateUrl: './toast-snapshots.component.html',
114
+ })
115
+ export class ToastSnapshotsComponent {
116
+ items: readonly Toast[] = [
117
+ {
118
+ id: 1,
119
+ kind: 'info',
120
+ message: 'A background task is ready',
121
+ duration: 0,
122
+ remaining: 0,
123
+ paused: false,
124
+ action: { label: 'Open', onPress: () => console.log('Open task') },
125
+ },
126
+ ];
127
+
128
+ remove(id: number): void {
129
+ this.items = this.items.filter((item) => item.id !== id);
130
+ }
131
+
132
+ run(id: number): void {
133
+ const item = this.items.find((candidate) => candidate.id === id);
134
+ if (!item?.action) return;
135
+ item.action.onPress();
136
+ if (item.action.dismissAfterPress ?? true) this.remove(id);
137
+ }
138
+ }
139
+ ```
140
+
141
+ ```html
142
+ <!-- src/app/toast-snapshots.component.html -->
143
+ <rv-toast-list [toasts]="items" (dismissed)="remove($event)" (actionPressed)="run($event)" />
96
144
  ```
97
145
 
98
- Future UI renders inside `role="region" aria-live="polite"`, pauses on
99
- hover **and** keyboard focus (WCAG 2.2.1 timing control), and offers a labelled
100
- dismiss button — and now it exists:
146
+ In this presentational mode your app owns timers, pause behavior, and action
147
+ handling. If both `manager` and `toasts` are supplied, the manager takes
148
+ precedence.
101
149
 
102
- ## Ready-made UI (`@rivinia/toast/ui`)
150
+ ## Use the headless service
103
151
 
104
- Optional secondary entry point. Zero new runtime dependencies, no design
105
- system. Headless consumers never load it (proven: core FESM has zero UI
106
- markers).
152
+ Core can be used without the UI. `active()` and `queue()` are readonly signals;
153
+ the active list is oldest-first. Timers begin only when a toast becomes active.
107
154
 
108
155
  ```ts
109
- import { RiviniaToastList } from '@rivinia/toast/ui';
156
+ // src/app/save-status.component.ts
157
+ import { Component, inject } from '@angular/core';
158
+ import { resolveToastText, RiviniaToast } from '@rivinia/toast';
159
+
160
+ @Component({
161
+ selector: 'app-save-status',
162
+ standalone: true,
163
+ templateUrl: './save-status.component.html',
164
+ })
165
+ export class SaveStatusComponent {
166
+ readonly toast = inject(RiviniaToast);
167
+ readonly resolveText = resolveToastText;
168
+
169
+ save(): void {
170
+ const id: number | null = this.toast.show({ message: 'Saving…' });
171
+ if (id === null) return; // possible with overflow: 'reject'
172
+
173
+ // A matching key updates the existing active or queued item.
174
+ this.toast.show({
175
+ message: 'Saved',
176
+ kind: 'success',
177
+ dedupeKey: 'save',
178
+ });
179
+ }
180
+ }
110
181
  ```
111
182
 
112
183
  ```html
113
- <!-- Managed: signals, hover-pause, actions, X button, exit animation -->
114
- <rv-toast-list [manager]="injectRiviniaToast()" [position]="'bottom-end'" />
115
-
116
- <!-- Presentational: you own the snapshots (no manager needed) -->
117
- <rv-toast-list [toasts]="items" (dismissed)="remove($event)" (actionPressed)="act($event)" />
184
+ <!-- src/app/save-status.component.html -->
185
+ <button type="button" (click)="save()">Save</button>
186
+ @for (item of toast.active(); track item.id) {
187
+ <p [attr.data-kind]="item.kind">{{ resolveText(item.message) }}</p>
188
+ }
118
189
  ```
119
190
 
120
- Positions (user-picked, RTL-aware): `top/bottom × start/center/end`
121
- (default `bottom-end`, sonner-compatible). `richColors` swaps the kind rail
122
- for full-bleed kind colors. Items are `role="status"` (`assertive` for
123
- errors); stack order is `active()` order; hover/focus pauses via
124
- `pause()/resume()`; throwing actions propagate to your `ErrorHandler`
125
- (never swallowed). Dismissed rows play a 150 ms exit from their last
126
- snapshot on the browser (SSR drops them — renders never hang). When both
127
- `manager` and `toasts` are given, the manager wins: the UI never races Core.
191
+ `message` and action `label` accept either a string or a reactive string
192
+ signal, so they can reflect language changes. For a custom outlet, use the exported
193
+ `resolveToastText(message)` utility to read either shape.
194
+
195
+ ## API and behavior
128
196
 
129
- ## Testing your app code
197
+ | Export | Kind | Purpose |
198
+ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------- |
199
+ | `provideRiviniaToast(config?)` | Provider | Configure defaults; see `RiviniaToastConfig`. |
200
+ | `RiviniaToast` | Service | `show`, `dismiss`, `dismissAll`, `update`, `pause`, `resume`, `runAction`; `active` and `queue` signals. |
201
+ | `injectRiviniaToast()` | Function | Shorthand for `inject(RiviniaToast)`. |
202
+ | UI secondary entry point | `@rivinia/toast/ui` | Optional managed or presentational standalone list and its UI types. |
203
+ | `Toast`, `ToastKind`, `ToastMessage`, `ToastAction`, `ToastOptions`, `ToastChanges`, `ToastOverflowPolicy`, `RiviniaToastConfig` | Types | Public service, configuration, and UI input types. |
204
+ | `resolveToastText`, `clampDuration` | Utilities | Resolve signal/string text; normalize a duration. |
205
+
206
+ Important details:
207
+
208
+ - `show()` returns a stable `number` ID, or `null` when rejected by the
209
+ `reject` policy or when a finite queue is full. `dismiss()` and `update()`
210
+ return `false` for unknown or dismissed IDs; `pause()`/`resume()` also return
211
+ `false` when the timer cannot be changed.
212
+ - A repeated `dedupeKey` reuses the active or queued toast ID and updates it
213
+ rather than stacking another toast. A matching `show()` without an `action`
214
+ clears the previous action; include it again if it should remain.
215
+ - A toast-specific `duration` overrides per-kind and global defaults.
216
+ `duration: 0`, negative values, not-a-number, and infinities mean persistent (no
217
+ timer). Per-kind defaults are set with `durations`, for example
218
+ `{ error: 8000 }`.
219
+ - `pause()` preserves the remaining time on an active timed toast. Persistent
220
+ or queued toasts cannot be paused. `resume()` restarts the saved time; if it
221
+ has already elapsed, the toast is dismissed.
222
+ - With `overflow: 'queue'`, new toasts wait without timers and are promoted
223
+ when a visible slot opens. Set `maxQueue` to bound that queue. With
224
+ `overflow: 'reject'`, `show()` returns `null`. `dismiss-oldest` replaces an
225
+ active toast, preferring timed, actionless toasts for eviction.
226
+ - `update(id, changes)` restarts an active timer; a queued item's timer starts
227
+ only on promotion. Changing `kind` re-applies that kind's default duration
228
+ unless the toast was given an explicit duration. `action: null` removes an
229
+ action.
230
+ - `runAction(id)` runs the callback, then dismisses by default.
231
+ `dismissAfterPress: false` keeps the toast visible. Callback errors propagate
232
+ to the caller (and, in managed UI use, Angular's `ErrorHandler`).
233
+ - No routing policy is built in. To clear timed notifications on navigation,
234
+ subscribe to your router in app code and call `dismiss(id)` as appropriate.
235
+
236
+ ## Testing app code
237
+
238
+ Use fake timers to make timer-dependent tests deterministic:
130
239
 
131
240
  ```ts
132
- vi.useFakeTimers();
133
- toast.show({ message: 'x' });
134
- vi.advanceTimersByTime(5000);
135
- expect(toast.active()).toEqual([]);
241
+ import { TestBed } from '@angular/core/testing';
242
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
243
+ import { provideRiviniaToast, RiviniaToast } from '@rivinia/toast';
244
+
245
+ beforeEach(() => {
246
+ TestBed.configureTestingModule({ providers: [provideRiviniaToast()] });
247
+ vi.useFakeTimers();
248
+ });
249
+
250
+ afterEach(() => {
251
+ TestBed.resetTestingModule();
252
+ vi.useRealTimers();
253
+ });
254
+
255
+ it('auto-dismisses after the configured duration', () => {
256
+ const toast = TestBed.inject(RiviniaToast);
257
+ const id = toast.show({ message: 'Saved', duration: 1000 });
258
+
259
+ vi.advanceTimersByTime(1000);
260
+ expect(toast.active().some((item) => item.id === id)).toBe(false);
261
+ });
136
262
  ```
137
263
 
138
264
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivinia/toast",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Headless toast queue for Angular: signals, pause-on-hover timers, actions.",
5
5
  "keywords": [
6
6
  "angular",