@rivinia/modal 0.5.2 → 0.5.3
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 +220 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ policies, async guards, and optional ready-made UI. The core package owns
|
|
|
5
5
|
modal state and lifecycle but does not render or style dialogs.
|
|
6
6
|
|
|
7
7
|
- Install: `npm install @rivinia/modal`
|
|
8
|
-
-
|
|
8
|
+
- Package version: **0.5.3**
|
|
9
9
|
- Peer dependencies: `@angular/core ^22.0.0` and `@angular/common ^22.0.0`
|
|
10
10
|
- `@rivinia/modal` is the headless manager.
|
|
11
11
|
- `@rivinia/modal/ui` contains standalone dialog and host components.
|
|
@@ -37,8 +37,9 @@ described below. You can also configure the provider manually as above.
|
|
|
37
37
|
|
|
38
38
|
## Open a typed modal
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
The examples use standalone components and separate `.ts`/`.html` files. Mount
|
|
41
|
+
one shared `RiviniaModalHost` for the manager. This first version uses the
|
|
42
|
+
classic `@ViewChild` query, which is still supported in Angular 22:
|
|
42
43
|
|
|
43
44
|
```ts
|
|
44
45
|
import { Component, TemplateRef, ViewChild, signal } from '@angular/core';
|
|
@@ -59,19 +60,7 @@ interface DeleteData {
|
|
|
59
60
|
@Component({
|
|
60
61
|
standalone: true,
|
|
61
62
|
imports: [RiviniaModalHost],
|
|
62
|
-
|
|
63
|
-
@for (user of users(); track user.id) {
|
|
64
|
-
<button type="button" (click)="askToDelete(user)">Delete {{ user.name }}</button>
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
<ng-template #deleteConfirmation let-data>
|
|
68
|
-
<p>Delete {{ data.name }}?</p>
|
|
69
|
-
<button type="button" (click)="confirmDelete()">Delete user</button>
|
|
70
|
-
<button type="button" (click)="cancelDelete()">Cancel</button>
|
|
71
|
-
</ng-template>
|
|
72
|
-
|
|
73
|
-
<rv-modal-host [modal]="modals" size="sm" />
|
|
74
|
-
`,
|
|
63
|
+
templateUrl: './users-page.component.html',
|
|
75
64
|
})
|
|
76
65
|
export class UsersPage {
|
|
77
66
|
private readonly manager = injectRiviniaModal();
|
|
@@ -115,11 +104,90 @@ export class UsersPage {
|
|
|
115
104
|
}
|
|
116
105
|
```
|
|
117
106
|
|
|
107
|
+
```html
|
|
108
|
+
<!-- src/app/users-page.component.html -->
|
|
109
|
+
@for (user of users(); track user.id) {
|
|
110
|
+
<button type="button" (click)="askToDelete(user)">Delete {{ user.name }}</button>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
<ng-template #deleteConfirmation let-data>
|
|
114
|
+
<p>Delete {{ data.name }}?</p>
|
|
115
|
+
<button type="button" (click)="confirmDelete()">Delete user</button>
|
|
116
|
+
<button type="button" (click)="cancelDelete()">Cancel</button>
|
|
117
|
+
</ng-template>
|
|
118
|
+
|
|
119
|
+
<rv-modal-host [modal]="modals" size="sm" />
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Signal-based view query
|
|
123
|
+
|
|
124
|
+
For new code, Angular 22 also supports the `viewChild` signal query. Keep the
|
|
125
|
+
same HTML; call the query signal to get its `TemplateRef`:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// src/app/users-page.component.ts
|
|
129
|
+
import { Component, TemplateRef, signal, viewChild } from '@angular/core';
|
|
130
|
+
import type { ModalHandle, ModalTemplateContext } from '@rivinia/modal';
|
|
131
|
+
import { injectRiviniaModal } from '@rivinia/modal';
|
|
132
|
+
import { RiviniaModalHost } from '@rivinia/modal/ui';
|
|
133
|
+
|
|
134
|
+
interface User {
|
|
135
|
+
readonly id: string;
|
|
136
|
+
readonly name: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface DeleteData {
|
|
140
|
+
readonly id: string;
|
|
141
|
+
readonly name: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Component({
|
|
145
|
+
standalone: true,
|
|
146
|
+
imports: [RiviniaModalHost],
|
|
147
|
+
templateUrl: './users-page.component.html',
|
|
148
|
+
})
|
|
149
|
+
export class UsersPage {
|
|
150
|
+
private readonly manager = injectRiviniaModal();
|
|
151
|
+
protected readonly modals = this.manager;
|
|
152
|
+
readonly users = signal<User[]>([{ id: '42', name: 'Ada Lovelace' }]);
|
|
153
|
+
private readonly deleteTemplate =
|
|
154
|
+
viewChild.required<TemplateRef<ModalTemplateContext<DeleteData>>>('deleteConfirmation');
|
|
155
|
+
private pendingDelete: ModalHandle<boolean> | null = null;
|
|
156
|
+
|
|
157
|
+
async askToDelete(user: User): Promise<void> {
|
|
158
|
+
if (this.pendingDelete) return;
|
|
159
|
+
|
|
160
|
+
const handle = this.manager.openModal<DeleteData, boolean>(this.deleteTemplate(), {
|
|
161
|
+
data: { id: user.id, name: user.name },
|
|
162
|
+
ref: 'confirm-delete',
|
|
163
|
+
metadata: { title: 'Delete user' },
|
|
164
|
+
dismiss: { backdrop: 'static' },
|
|
165
|
+
});
|
|
166
|
+
this.pendingDelete = handle;
|
|
167
|
+
try {
|
|
168
|
+
const result = await handle.result;
|
|
169
|
+
if (result.status === 'confirmed' && result.value) {
|
|
170
|
+
this.users.update((users) => users.filter((entry) => entry.id !== user.id));
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
if (this.pendingDelete === handle) this.pendingDelete = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
confirmDelete(): void {
|
|
178
|
+
this.pendingDelete?.close(true);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
cancelDelete(): void {
|
|
182
|
+
this.pendingDelete?.cancel('user-cancelled');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
118
187
|
The UI host uses `metadata.title` by default, so the example renders
|
|
119
188
|
“Delete user” without custom title logic. Import `RiviniaModalHost` into the
|
|
120
|
-
standalone component that renders it.
|
|
121
|
-
standalone
|
|
122
|
-
Mount a single host for the manager you pass to it.
|
|
189
|
+
standalone component that renders it. All examples in this guide target
|
|
190
|
+
standalone Angular 22 applications.
|
|
123
191
|
|
|
124
192
|
`openModal<TData, TResult>()` accepts a `TemplateRef`, a component type, or a
|
|
125
193
|
`ModalContent<TData>` descriptor. Template content receives `data` as
|
|
@@ -129,6 +197,128 @@ and are not checked against the component's inputs at compile time. The
|
|
|
129
197
|
optional `RIVINIA_MODAL_CONTEXT` injection token supplies the same per-modal
|
|
130
198
|
context to a dynamically created component.
|
|
131
199
|
|
|
200
|
+
### Component content, modal context, and `NgComponentOutlet`
|
|
201
|
+
|
|
202
|
+
When the content is a component type, the host renders it with Angular's
|
|
203
|
+
`NgComponentOutlet` and creates an injector scoped to that modal instance.
|
|
204
|
+
Inject `RIVINIA_MODAL_CONTEXT` for that instance's `id`, `data`, `close()`, and
|
|
205
|
+
`dismiss()`; ordinary app services still resolve from the surrounding
|
|
206
|
+
injectors. The host supplies this context, so do not build a separate
|
|
207
|
+
`Injector` just to pass modal data or lifecycle operations.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// src/app/delete-confirmation.component.ts
|
|
211
|
+
import { Component, inject } from '@angular/core';
|
|
212
|
+
import { RIVINIA_MODAL_CONTEXT } from '@rivinia/modal';
|
|
213
|
+
|
|
214
|
+
@Component({
|
|
215
|
+
standalone: true,
|
|
216
|
+
templateUrl: './delete-confirmation.component.html',
|
|
217
|
+
})
|
|
218
|
+
export class DeleteConfirmationComponent {
|
|
219
|
+
readonly modal = inject(RIVINIA_MODAL_CONTEXT);
|
|
220
|
+
|
|
221
|
+
confirm(): void {
|
|
222
|
+
this.modal.close(true);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
cancel(): void {
|
|
226
|
+
this.modal.dismiss('user-cancelled');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```html
|
|
232
|
+
<!-- src/app/delete-confirmation.component.html -->
|
|
233
|
+
<h2>Delete this user?</h2>
|
|
234
|
+
<button type="button" (click)="confirm()">Delete</button>
|
|
235
|
+
<button type="button" (click)="cancel()">Cancel</button>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Open the component type through the same manager and host used for template
|
|
239
|
+
content:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const handle = this.manager.openModal<DeleteConfirmationComponent, DeleteData, boolean>(
|
|
243
|
+
DeleteConfirmationComponent,
|
|
244
|
+
{
|
|
245
|
+
data: { id: user.id, name: user.name },
|
|
246
|
+
metadata: { title: 'Delete user' },
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
const result = await handle.result;
|
|
250
|
+
if (result.status === 'confirmed' && result.value) {
|
|
251
|
+
this.removeUser(user.id);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The injection token is typed as `ModalContext<unknown>` because content may
|
|
256
|
+
come from different callers. Narrow or validate `modal.data` before reading
|
|
257
|
+
application-specific fields. The opener's `ModalHandle<boolean>` remains
|
|
258
|
+
typed, so `handle.close()` checks the result type. Component `inputs` can also
|
|
259
|
+
be supplied in `openModal` options, but their keys are not type-checked.
|
|
260
|
+
|
|
261
|
+
### Showing a toast inside an open modal
|
|
262
|
+
|
|
263
|
+
The toast list is fixed-position UI, not a popover. A native `<dialog>` is in
|
|
264
|
+
the browser's top layer, so a toast list rendered outside it can appear behind
|
|
265
|
+
the dialog regardless of its `z-index`. When a notification belongs to the
|
|
266
|
+
open modal, put its `RiviniaToastList` inside the modal template so it is
|
|
267
|
+
rendered inside the dialog:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// src/app/payment-page.component.ts
|
|
271
|
+
import { Component, TemplateRef, inject, viewChild } from '@angular/core';
|
|
272
|
+
import type { ModalTemplateContext } from '@rivinia/modal';
|
|
273
|
+
import { RiviniaModal } from '@rivinia/modal';
|
|
274
|
+
import { RiviniaModalHost } from '@rivinia/modal/ui';
|
|
275
|
+
import { RiviniaToast } from '@rivinia/toast';
|
|
276
|
+
import { RiviniaToastList } from '@rivinia/toast/ui';
|
|
277
|
+
|
|
278
|
+
@Component({
|
|
279
|
+
standalone: true,
|
|
280
|
+
imports: [RiviniaModalHost, RiviniaToastList],
|
|
281
|
+
templateUrl: './payment-page.component.html',
|
|
282
|
+
})
|
|
283
|
+
export class PaymentPageComponent {
|
|
284
|
+
readonly modal = inject(RiviniaModal);
|
|
285
|
+
readonly toast = inject(RiviniaToast);
|
|
286
|
+
private readonly paymentTemplate =
|
|
287
|
+
viewChild.required<TemplateRef<ModalTemplateContext<void>>>('paymentConfirmation');
|
|
288
|
+
|
|
289
|
+
openPayment(): void {
|
|
290
|
+
this.modal.openModal<void, void>(this.paymentTemplate(), {
|
|
291
|
+
data: undefined,
|
|
292
|
+
metadata: { title: 'Confirm payment' },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
showPaymentSaved(): void {
|
|
297
|
+
this.toast.show({ message: 'Payment saved', kind: 'success' });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```html
|
|
303
|
+
<!-- src/app/payment-page.component.html -->
|
|
304
|
+
<button type="button" (click)="openPayment()">Pay invoice</button>
|
|
305
|
+
|
|
306
|
+
<ng-template #paymentConfirmation let-modal="modal">
|
|
307
|
+
<p>Submit payment for invoice INV-2048?</p>
|
|
308
|
+
<button type="button" (click)="showPaymentSaved()">Show saved notice</button>
|
|
309
|
+
<button type="button" (click)="modal.close()">Done</button>
|
|
310
|
+
<rv-toast-list [manager]="toast" position="top-end" />
|
|
311
|
+
</ng-template>
|
|
312
|
+
|
|
313
|
+
<rv-modal-host [modal]="modal" size="sm" />
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Register `provideRiviniaToast()` as well as `provideRiviniaModal()` in the
|
|
317
|
+
application's root providers. Use one `RiviniaToastList` per toast manager at a
|
|
318
|
+
time: if the app already renders that same manager globally, move or gate that
|
|
319
|
+
list while this modal is open rather than mounting a second list and showing
|
|
320
|
+
every notification twice.
|
|
321
|
+
|
|
132
322
|
The result is a discriminated `ModalResult<TResult>`:
|
|
133
323
|
|
|
134
324
|
- `confirmed` carries the typed `value`.
|
|
@@ -211,20 +401,23 @@ import { RiviniaModalDialog } from '@rivinia/modal/ui';
|
|
|
211
401
|
@Component({
|
|
212
402
|
standalone: true,
|
|
213
403
|
imports: [RiviniaModalDialog],
|
|
214
|
-
|
|
215
|
-
<button type="button" (click)="open = true">Preview</button>
|
|
216
|
-
<rv-modal-dialog #dialog [(open)]="open" title="Preview">
|
|
217
|
-
<p>Dialog content.</p>
|
|
218
|
-
<button type="button" (click)="dialog.confirm(true)">Continue</button>
|
|
219
|
-
<button type="button" (click)="dialog.cancel('cancelled')">Cancel</button>
|
|
220
|
-
</rv-modal-dialog>
|
|
221
|
-
`,
|
|
404
|
+
templateUrl: './preview-dialog.component.html',
|
|
222
405
|
})
|
|
223
406
|
export class PreviewDialog {
|
|
224
407
|
open = false;
|
|
225
408
|
}
|
|
226
409
|
```
|
|
227
410
|
|
|
411
|
+
```html
|
|
412
|
+
<!-- src/app/preview-dialog.component.html -->
|
|
413
|
+
<button type="button" (click)="open = true">Preview</button>
|
|
414
|
+
<rv-modal-dialog #dialog [(open)]="open" title="Preview">
|
|
415
|
+
<p>Dialog content.</p>
|
|
416
|
+
<button type="button" (click)="dialog.confirm(true)">Continue</button>
|
|
417
|
+
<button type="button" (click)="dialog.cancel('cancelled')">Cancel</button>
|
|
418
|
+
</rv-modal-dialog>
|
|
419
|
+
```
|
|
420
|
+
|
|
228
421
|
`RiviniaModalDialog` accepts `open`, `title`, `description`, `ariaLabel`,
|
|
229
422
|
`size`, `position`, `animation`, `closeOnEscape`, `backdrop`, and
|
|
230
423
|
`showCloseButton`; its `open` model supports `[(open)]`. In UI-only mode it
|