@rivinia/toast 0.4.0 → 0.4.1

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 +211 -96
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -3,136 +3,251 @@
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
+ - Prepared patch: **0.4.1** (latest published: **0.4.0**)
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';
33
57
 
34
- pay() {
35
- const id = this.toast.show({ message: 'Payment sent', kind: 'success' });
36
- // id: number | null (null = rejected by the 'reject' overflow policy)
58
+ @Component({
59
+ selector: 'app-notifications',
60
+ standalone: true,
61
+ imports: [RiviniaToastList],
62
+ template: `
63
+ <button type="button" (click)="save()">Save</button>
64
+ <rv-toast-list [manager]="toast" position="bottom-end" />
65
+ `,
66
+ })
67
+ export class NotificationsComponent {
68
+ readonly toast = injectRiviniaToast();
69
+
70
+ save(): void {
71
+ this.toast.show({
72
+ message: 'Changes saved',
73
+ kind: 'success',
74
+ dedupeKey: 'save',
75
+ });
37
76
  }
38
77
 
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' });
78
+ saveWithRetry(retry: () => void): void {
79
+ this.toast.show({
80
+ message: 'Could not save',
81
+ kind: 'error',
82
+ duration: 0,
83
+ action: { label: 'Retry', onPress: retry },
84
+ });
43
85
  }
44
86
  }
45
87
  ```
46
88
 
47
- ```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
- }
52
- ```
89
+ The standalone list renders the manager's `active()` signal, pauses timed
90
+ notifications on hover and keyboard focus, runs actions, and provides a
91
+ dismiss button by default. Its defaults are `position="bottom-end"`,
92
+ `animation="slide"`, `richColors=false`, and `showCloseButton=true`.
93
+ `position` supports `top`/`bottom` × `start`/`center`/`end` (logical start/end
94
+ work in RTL); `animation` supports `slide`, `fade`, and `none`. Errors use an
95
+ assertive live region; other kinds use polite announcements.
53
96
 
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)
97
+ For snapshot-driven UIs, provide the snapshots and handle the emitted IDs
98
+ yourself:
77
99
 
78
100
  ```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
96
- ```
101
+ import { Component } from '@angular/core';
102
+ import type { Toast } from '@rivinia/toast';
103
+ import { RiviniaToastList } from '@rivinia/toast/ui';
97
104
 
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:
105
+ @Component({
106
+ selector: 'app-toast-snapshots',
107
+ standalone: true,
108
+ imports: [RiviniaToastList],
109
+ template: `
110
+ <rv-toast-list [toasts]="items" (dismissed)="remove($event)" (actionPressed)="run($event)" />
111
+ `,
112
+ })
113
+ export class ToastSnapshotsComponent {
114
+ items: readonly Toast[] = [
115
+ {
116
+ id: 1,
117
+ kind: 'info',
118
+ message: 'A background task is ready',
119
+ duration: 0,
120
+ remaining: 0,
121
+ paused: false,
122
+ action: { label: 'Open', onPress: () => console.log('Open task') },
123
+ },
124
+ ];
125
+
126
+ remove(id: number): void {
127
+ this.items = this.items.filter((item) => item.id !== id);
128
+ }
101
129
 
102
- ## Ready-made UI (`@rivinia/toast/ui`)
130
+ run(id: number): void {
131
+ const item = this.items.find((candidate) => candidate.id === id);
132
+ if (!item?.action) return;
133
+ item.action.onPress();
134
+ if (item.action.dismissAfterPress ?? true) this.remove(id);
135
+ }
136
+ }
137
+ ```
103
138
 
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).
139
+ In this presentational mode your app owns timers, pause behavior, and action
140
+ handling. If both `manager` and `toasts` are supplied, the manager takes
141
+ precedence.
107
142
 
108
- ```ts
109
- import { RiviniaToastList } from '@rivinia/toast/ui';
110
- ```
143
+ ## Use the headless service
111
144
 
112
- ```html
113
- <!-- Managed: signals, hover-pause, actions, X button, exit animation -->
114
- <rv-toast-list [manager]="injectRiviniaToast()" [position]="'bottom-end'" />
145
+ Core can be used without the UI. `active()` and `queue()` are readonly signals;
146
+ the active list is oldest-first. Timers begin only when a toast becomes active.
115
147
 
116
- <!-- Presentational: you own the snapshots (no manager needed) -->
117
- <rv-toast-list [toasts]="items" (dismissed)="remove($event)" (actionPressed)="act($event)" />
148
+ ```ts
149
+ import { Component, inject } from '@angular/core';
150
+ import { resolveToastText, RiviniaToast } from '@rivinia/toast';
151
+
152
+ @Component({
153
+ selector: 'app-save-status',
154
+ standalone: true,
155
+ template: `
156
+ <button type="button" (click)="save()">Save</button>
157
+ @for (item of toast.active(); track item.id) {
158
+ <p [attr.data-kind]="item.kind">{{ resolveText(item.message) }}</p>
159
+ }
160
+ `,
161
+ })
162
+ export class SaveStatusComponent {
163
+ readonly toast = inject(RiviniaToast);
164
+ readonly resolveText = resolveToastText;
165
+
166
+ save(): void {
167
+ const id: number | null = this.toast.show({ message: 'Saving…' });
168
+ if (id === null) return; // possible with overflow: 'reject'
169
+
170
+ // A matching key updates the existing active or queued item.
171
+ this.toast.show({
172
+ message: 'Saved',
173
+ kind: 'success',
174
+ dedupeKey: 'save',
175
+ });
176
+ }
177
+ }
118
178
  ```
119
179
 
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.
128
-
129
- ## Testing your app code
180
+ `message` and action `label` accept either a string or a reactive string
181
+ signal, so they can reflect language changes. For a custom outlet, use the exported
182
+ `resolveToastText(message)` utility to read either shape.
183
+
184
+ ## API and behavior
185
+
186
+ | Export | Kind | Purpose |
187
+ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------- |
188
+ | `provideRiviniaToast(config?)` | Provider | Configure defaults; see `RiviniaToastConfig`. |
189
+ | `RiviniaToast` | Service | `show`, `dismiss`, `dismissAll`, `update`, `pause`, `resume`, `runAction`; `active` and `queue` signals. |
190
+ | `injectRiviniaToast()` | Function | Shorthand for `inject(RiviniaToast)`. |
191
+ | UI secondary entry point | `@rivinia/toast/ui` | Optional managed or presentational standalone list and its UI types. |
192
+ | `Toast`, `ToastKind`, `ToastMessage`, `ToastAction`, `ToastOptions`, `ToastChanges`, `ToastOverflowPolicy`, `RiviniaToastConfig` | Types | Public service, configuration, and UI input types. |
193
+ | `resolveToastText`, `clampDuration` | Utilities | Resolve signal/string text; normalize a duration. |
194
+
195
+ Important details:
196
+
197
+ - `show()` returns a stable `number` ID, or `null` when rejected by the
198
+ `reject` policy or when a finite queue is full. `dismiss()` and `update()`
199
+ return `false` for unknown or dismissed IDs; `pause()`/`resume()` also return
200
+ `false` when the timer cannot be changed.
201
+ - A repeated `dedupeKey` reuses the active or queued toast ID and updates it
202
+ rather than stacking another toast. A matching `show()` without an `action`
203
+ clears the previous action; include it again if it should remain.
204
+ - A toast-specific `duration` overrides per-kind and global defaults.
205
+ `duration: 0`, negative values, not-a-number, and infinities mean persistent (no
206
+ timer). Per-kind defaults are set with `durations`, for example
207
+ `{ error: 8000 }`.
208
+ - `pause()` preserves the remaining time on an active timed toast. Persistent
209
+ or queued toasts cannot be paused. `resume()` restarts the saved time; if it
210
+ has already elapsed, the toast is dismissed.
211
+ - With `overflow: 'queue'`, new toasts wait without timers and are promoted
212
+ when a visible slot opens. Set `maxQueue` to bound that queue. With
213
+ `overflow: 'reject'`, `show()` returns `null`. `dismiss-oldest` replaces an
214
+ active toast, preferring timed, actionless toasts for eviction.
215
+ - `update(id, changes)` restarts an active timer; a queued item's timer starts
216
+ only on promotion. Changing `kind` re-applies that kind's default duration
217
+ unless the toast was given an explicit duration. `action: null` removes an
218
+ action.
219
+ - `runAction(id)` runs the callback, then dismisses by default.
220
+ `dismissAfterPress: false` keeps the toast visible. Callback errors propagate
221
+ to the caller (and, in managed UI use, Angular's `ErrorHandler`).
222
+ - No routing policy is built in. To clear timed notifications on navigation,
223
+ subscribe to your router in app code and call `dismiss(id)` as appropriate.
224
+
225
+ ## Testing app code
226
+
227
+ Use fake timers to make timer-dependent tests deterministic:
130
228
 
131
229
  ```ts
132
- vi.useFakeTimers();
133
- toast.show({ message: 'x' });
134
- vi.advanceTimersByTime(5000);
135
- expect(toast.active()).toEqual([]);
230
+ import { TestBed } from '@angular/core/testing';
231
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
232
+ import { provideRiviniaToast, RiviniaToast } from '@rivinia/toast';
233
+
234
+ beforeEach(() => {
235
+ TestBed.configureTestingModule({ providers: [provideRiviniaToast()] });
236
+ vi.useFakeTimers();
237
+ });
238
+
239
+ afterEach(() => {
240
+ TestBed.resetTestingModule();
241
+ vi.useRealTimers();
242
+ });
243
+
244
+ it('auto-dismisses after the configured duration', () => {
245
+ const toast = TestBed.inject(RiviniaToast);
246
+ const id = toast.show({ message: 'Saved', duration: 1000 });
247
+
248
+ vi.advanceTimersByTime(1000);
249
+ expect(toast.active().some((item) => item.id === id)).toBe(false);
250
+ });
136
251
  ```
137
252
 
138
253
  ## 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.1",
4
4
  "description": "Headless toast queue for Angular: signals, pause-on-hover timers, actions.",
5
5
  "keywords": [
6
6
  "angular",