@rivinia/modal 0.5.2 → 0.6.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 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
- - Prepared patch: **0.5.2** (latest published: **0.5.1**)
8
+ - Package version: **0.6.0**
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
- This standalone component uses a typed template, mounts one shared modal host,
41
- and waits for the result before changing application state:
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';
@@ -57,21 +58,8 @@ interface DeleteData {
57
58
  }
58
59
 
59
60
  @Component({
60
- standalone: true,
61
61
  imports: [RiviniaModalHost],
62
- template: `
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
- `,
62
+ templateUrl: './users-page.component.html',
75
63
  })
76
64
  export class UsersPage {
77
65
  private readonly manager = injectRiviniaModal();
@@ -115,11 +103,89 @@ export class UsersPage {
115
103
  }
116
104
  ```
117
105
 
106
+ ```html
107
+ <!-- src/app/users-page.component.html -->
108
+ @for (user of users(); track user.id) {
109
+ <button type="button" (click)="askToDelete(user)">Delete {{ user.name }}</button>
110
+ }
111
+
112
+ <ng-template #deleteConfirmation let-data>
113
+ <p>Delete {{ data.name }}?</p>
114
+ <button type="button" (click)="confirmDelete()">Delete user</button>
115
+ <button type="button" (click)="cancelDelete()">Cancel</button>
116
+ </ng-template>
117
+
118
+ <rv-modal-host [modal]="modals" size="sm" />
119
+ ```
120
+
121
+ ### Signal-based view query
122
+
123
+ For new code, Angular 22 also supports the `viewChild` signal query. Keep the
124
+ same HTML; call the query signal to get its `TemplateRef`:
125
+
126
+ ```ts
127
+ // src/app/users-page.component.ts
128
+ import { Component, TemplateRef, signal, viewChild } from '@angular/core';
129
+ import type { ModalHandle, ModalTemplateContext } from '@rivinia/modal';
130
+ import { injectRiviniaModal } from '@rivinia/modal';
131
+ import { RiviniaModalHost } from '@rivinia/modal/ui';
132
+
133
+ interface User {
134
+ readonly id: string;
135
+ readonly name: string;
136
+ }
137
+
138
+ interface DeleteData {
139
+ readonly id: string;
140
+ readonly name: string;
141
+ }
142
+
143
+ @Component({
144
+ imports: [RiviniaModalHost],
145
+ templateUrl: './users-page.component.html',
146
+ })
147
+ export class UsersPage {
148
+ private readonly manager = injectRiviniaModal();
149
+ protected readonly modals = this.manager;
150
+ readonly users = signal<User[]>([{ id: '42', name: 'Ada Lovelace' }]);
151
+ private readonly deleteTemplate =
152
+ viewChild.required<TemplateRef<ModalTemplateContext<DeleteData>>>('deleteConfirmation');
153
+ private pendingDelete: ModalHandle<boolean> | null = null;
154
+
155
+ async askToDelete(user: User): Promise<void> {
156
+ if (this.pendingDelete) return;
157
+
158
+ const handle = this.manager.openModal<DeleteData, boolean>(this.deleteTemplate(), {
159
+ data: { id: user.id, name: user.name },
160
+ ref: 'confirm-delete',
161
+ metadata: { title: 'Delete user' },
162
+ dismiss: { backdrop: 'static' },
163
+ });
164
+ this.pendingDelete = handle;
165
+ try {
166
+ const result = await handle.result;
167
+ if (result.status === 'confirmed' && result.value) {
168
+ this.users.update((users) => users.filter((entry) => entry.id !== user.id));
169
+ }
170
+ } finally {
171
+ if (this.pendingDelete === handle) this.pendingDelete = null;
172
+ }
173
+ }
174
+
175
+ confirmDelete(): void {
176
+ this.pendingDelete?.close(true);
177
+ }
178
+
179
+ cancelDelete(): void {
180
+ this.pendingDelete?.cancel('user-cancelled');
181
+ }
182
+ }
183
+ ```
184
+
118
185
  The UI host uses `metadata.title` by default, so the example renders
119
186
  “Delete user” without custom title logic. Import `RiviniaModalHost` into the
120
- standalone component that renders it. For an NgModule-based app, import this
121
- standalone component in the module's `imports` array, not `declarations`.
122
- Mount a single host for the manager you pass to it.
187
+ standalone component that renders it. All examples in this guide target
188
+ standalone Angular 22 applications.
123
189
 
124
190
  `openModal<TData, TResult>()` accepts a `TemplateRef`, a component type, or a
125
191
  `ModalContent<TData>` descriptor. Template content receives `data` as
@@ -129,6 +195,126 @@ and are not checked against the component's inputs at compile time. The
129
195
  optional `RIVINIA_MODAL_CONTEXT` injection token supplies the same per-modal
130
196
  context to a dynamically created component.
131
197
 
198
+ ### Component content, modal context, and `NgComponentOutlet`
199
+
200
+ When the content is a component type, the host renders it with Angular's
201
+ `NgComponentOutlet` and creates an injector scoped to that modal instance.
202
+ Inject `RIVINIA_MODAL_CONTEXT` for that instance's `id`, `data`, `close()`, and
203
+ `dismiss()`; ordinary app services still resolve from the surrounding
204
+ injectors. The host supplies this context, so do not build a separate
205
+ `Injector` just to pass modal data or lifecycle operations.
206
+
207
+ ```ts
208
+ // src/app/delete-confirmation.component.ts
209
+ import { Component, inject } from '@angular/core';
210
+ import { RIVINIA_MODAL_CONTEXT } from '@rivinia/modal';
211
+
212
+ @Component({
213
+ templateUrl: './delete-confirmation.component.html',
214
+ })
215
+ export class DeleteConfirmationComponent {
216
+ readonly modal = inject(RIVINIA_MODAL_CONTEXT);
217
+
218
+ confirm(): void {
219
+ this.modal.close(true);
220
+ }
221
+
222
+ cancel(): void {
223
+ this.modal.dismiss('user-cancelled');
224
+ }
225
+ }
226
+ ```
227
+
228
+ ```html
229
+ <!-- src/app/delete-confirmation.component.html -->
230
+ <h2>Delete this user?</h2>
231
+ <button type="button" (click)="confirm()">Delete</button>
232
+ <button type="button" (click)="cancel()">Cancel</button>
233
+ ```
234
+
235
+ Open the component type through the same manager and host used for template
236
+ content:
237
+
238
+ ```ts
239
+ const handle = this.manager.openModal<DeleteConfirmationComponent, DeleteData, boolean>(
240
+ DeleteConfirmationComponent,
241
+ {
242
+ data: { id: user.id, name: user.name },
243
+ metadata: { title: 'Delete user' },
244
+ },
245
+ );
246
+ const result = await handle.result;
247
+ if (result.status === 'confirmed' && result.value) {
248
+ this.removeUser(user.id);
249
+ }
250
+ ```
251
+
252
+ The injection token is typed as `ModalContext<unknown>` because content may
253
+ come from different callers. Narrow or validate `modal.data` before reading
254
+ application-specific fields. The opener's `ModalHandle<boolean>` remains
255
+ typed, so `handle.close()` checks the result type. Component `inputs` can also
256
+ be supplied in `openModal` options, but their keys are not type-checked.
257
+
258
+ ### Showing a toast inside an open modal
259
+
260
+ The toast list is fixed-position UI, not a popover. A native `<dialog>` is in
261
+ the browser's top layer, so a toast list rendered outside it can appear behind
262
+ the dialog regardless of its `z-index`. When a notification belongs to the
263
+ open modal, put its `RiviniaToastList` inside the modal template so it is
264
+ rendered inside the dialog:
265
+
266
+ ```ts
267
+ // src/app/payment-page.component.ts
268
+ import { Component, TemplateRef, inject, viewChild } from '@angular/core';
269
+ import type { ModalTemplateContext } from '@rivinia/modal';
270
+ import { RiviniaModal } from '@rivinia/modal';
271
+ import { RiviniaModalHost } from '@rivinia/modal/ui';
272
+ import { RiviniaToast } from '@rivinia/toast';
273
+ import { RiviniaToastList } from '@rivinia/toast/ui';
274
+
275
+ @Component({
276
+ imports: [RiviniaModalHost, RiviniaToastList],
277
+ templateUrl: './payment-page.component.html',
278
+ })
279
+ export class PaymentPageComponent {
280
+ readonly modal = inject(RiviniaModal);
281
+ readonly toast = inject(RiviniaToast);
282
+ private readonly paymentTemplate =
283
+ viewChild.required<TemplateRef<ModalTemplateContext<void>>>('paymentConfirmation');
284
+
285
+ openPayment(): void {
286
+ this.modal.openModal<void, void>(this.paymentTemplate(), {
287
+ data: undefined,
288
+ metadata: { title: 'Confirm payment' },
289
+ });
290
+ }
291
+
292
+ showPaymentSaved(): void {
293
+ this.toast.show({ message: 'Payment saved', kind: 'success' });
294
+ }
295
+ }
296
+ ```
297
+
298
+ ```html
299
+ <!-- src/app/payment-page.component.html -->
300
+ <button type="button" (click)="openPayment()">Pay invoice</button>
301
+
302
+ <ng-template #paymentConfirmation let-modal="modal">
303
+ <p>Submit payment for invoice INV-2048?</p>
304
+ <button type="button" (click)="showPaymentSaved()">Show saved notice</button>
305
+ <button type="button" (click)="modal.close()">Done</button>
306
+ <rv-toast-list [manager]="toast" position="top-end" />
307
+ </ng-template>
308
+
309
+ <rv-modal-host [modal]="modal" size="sm" />
310
+ ```
311
+
312
+ Register `provideRiviniaToast()` as well as `provideRiviniaModal()` in the
313
+ application's root providers. Use one `RiviniaToastList` per toast manager at a
314
+ time: if the app already renders that same manager globally, move or gate that
315
+ list while this modal is open rather than mounting a second list and showing
316
+ every notification twice.
317
+
132
318
  The result is a discriminated `ModalResult<TResult>`:
133
319
 
134
320
  - `confirmed` carries the typed `value`.
@@ -209,22 +395,24 @@ import { Component } from '@angular/core';
209
395
  import { RiviniaModalDialog } from '@rivinia/modal/ui';
210
396
 
211
397
  @Component({
212
- standalone: true,
213
398
  imports: [RiviniaModalDialog],
214
- template: `
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
- `,
399
+ templateUrl: './preview-dialog.component.html',
222
400
  })
223
401
  export class PreviewDialog {
224
402
  open = false;
225
403
  }
226
404
  ```
227
405
 
406
+ ```html
407
+ <!-- src/app/preview-dialog.component.html -->
408
+ <button type="button" (click)="open = true">Preview</button>
409
+ <rv-modal-dialog #dialog [(open)]="open" title="Preview">
410
+ <p>Dialog content.</p>
411
+ <button type="button" (click)="dialog.confirm(true)">Continue</button>
412
+ <button type="button" (click)="dialog.cancel('cancelled')">Cancel</button>
413
+ </rv-modal-dialog>
414
+ ```
415
+
228
416
  `RiviniaModalDialog` accepts `open`, `title`, `description`, `ariaLabel`,
229
417
  `size`, `position`, `animation`, `closeOnEscape`, `backdrop`, and
230
418
  `showCloseButton`; its `open` model supports `[(open)]`. In UI-only mode it
@@ -247,6 +435,135 @@ closed and opens on the client after hydration. Theme it with CSS custom
247
435
  properties such as `--rv-modal-bg`, `--rv-modal-fg`, `--rv-modal-border`,
248
436
  `--rv-modal-radius`, and `--rv-modal-width-md`.
249
437
 
438
+ ## Theming
439
+
440
+ Every component reads **its own token → the shared semantic layer → its
441
+ built-in default**:
442
+
443
+ ```css
444
+ --rv-modal-bg → --rv-bg → #ffffff
445
+ ```
446
+
447
+ | Level | Token | Scope |
448
+ | -------------- | --------------- | ----------------------------------- |
449
+ | 1. per package | `--rv-modal-bg` | that dialog only |
450
+ | 2. global | `--rv-bg` | every Rivinia component, everywhere |
451
+ | 3. default | — | what you get if you set nothing |
452
+
453
+ ```css
454
+ @import '@rivinia/theme';
455
+
456
+ :root {
457
+ --rv-bg: #ffffff;
458
+ --rv-fg: #111111;
459
+ --rv-border: #e0e0e0;
460
+ }
461
+ ```
462
+
463
+ Its defaults sit in `@layer rivinia-theme`, so your unlayered `:root` rules win
464
+ wherever they appear. **Map it on `:root`, never on a component element** — an
465
+ element-level declaration shadows the root value and the override silently
466
+ stops working.
467
+
468
+ ### The library's own tokens
469
+
470
+ | Token | Default | Applies to |
471
+ | --------------------- | ----------------------------- | -------------- |
472
+ | `--rv-modal-bg` | `#ffffff` | the dialog |
473
+ | `--rv-modal-fg` | `#111111` | the dialog |
474
+ | `--rv-modal-muted` | `#555555` | secondary text |
475
+ | `--rv-modal-border` | `#e0e0e0` | the dialog |
476
+ | `--rv-modal-radius` | `12px` | the dialog |
477
+ | `--rv-modal-shadow` | `0 24px 64px rgba(0,0,0,.28)` | the dialog |
478
+ | `--rv-modal-backdrop` | `rgba(0,0,0,.5)` | the scrim |
479
+ | `--rv-modal-pad` | `20px` | the dialog |
480
+ | `--rv-modal-width-md` | `32rem` | the dialog |
481
+
482
+ **No forced dark mode** — there is no `prefers-color-scheme` block in this
483
+ package, so a dialog never overrides your explicit choice.
484
+
485
+ ### Which styling stack?
486
+
487
+ | Stack | Import |
488
+ | --------------- | -------------------------------------------- |
489
+ | **Plain CSS** | `@import '@rivinia/theme';` |
490
+ | **Tailwind v4** | `@import '@rivinia/theme/tailwind.css';` |
491
+ | **Bootstrap** | `@import '@rivinia/theme/bootstrap.css';` |
492
+ | **Sass / SCSS** | `@use '@rivinia/theme/scss';` |
493
+ | **Less** | `@import (less) '@rivinia/theme/theme.css';` |
494
+
495
+ ## Sizing, stacking and who opened it
496
+
497
+ **A size per dialog.** The host's `size` is the default for every instance; a
498
+ dialog may override it when it is opened:
499
+
500
+ ```ts
501
+ this.modal.openModal(this.confirmTpl, { ref: 'confirm', size: 'sm' });
502
+ ```
503
+
504
+ ```html
505
+ <rv-modal-host [modal]="modal" size="lg" />
506
+ ```
507
+
508
+ The presets are **responsive** — each is a `clamp()`, so it scales with the
509
+ viewport between a floor and a ceiling instead of being a fixed width that
510
+ either overflows or gets capped:
511
+
512
+ | Preset | Width |
513
+ | ------ | --------------------------- |
514
+ | `sm` | `clamp(16rem, 40vw, 24rem)` |
515
+ | `md` | `clamp(20rem, 55vw, 32rem)` |
516
+ | `lg` | `clamp(24rem, 70vw, 48rem)` |
517
+ | `xl` | `clamp(28rem, 85vw, 64rem)` |
518
+ | `full` | `calc(100dvw - 2rem)` |
519
+ | `auto` | sized to its content |
520
+
521
+ Every one is a plain custom property, so the consumer overrides the preset in
522
+ CSS without touching the library:
523
+
524
+ ```css
525
+ my-scope {
526
+ --rv-modal-width-lg: clamp(30rem, 80vw, 60rem);
527
+ }
528
+ ```
529
+
530
+ **One dialog can cover the ones below it.** A stack shows every dialog, so the
531
+ ones below stay visible behind the top one — right for a wizard, wrong for a
532
+ flow where the detail view REPLACES the list view:
533
+
534
+ ```ts
535
+ this.modal.openModal(this.detailTpl, { ref: 'detail', coversOthers: true });
536
+ ```
537
+
538
+ The dialogs below are not rendered while the cover is up, and they come back
539
+ when it closes. They stay in the manager the whole time.
540
+
541
+ **The opener is attributable.** `openModal` is normally called from an event
542
+ handler, which is not an injection context, so the manager cannot discover the
543
+ caller. Naming it puts `data-caller` on the dialog, which the DevTools
544
+ inspector shows — so a dialog that appears unexpectedly points at its source:
545
+
546
+ ```ts
547
+ export class UsersScreen {
548
+ open() {
549
+ this.modal.openModal(this.detailTpl, { caller: UsersScreen });
550
+ }
551
+ }
552
+ ```
553
+
554
+ ```html
555
+ <dialog class="rv-modal" data-caller="UsersScreen">…</dialog>
556
+ ```
557
+
558
+ **Smoothness.** The dialog transitions its own box — width, height and the
559
+ offsets — so a size change or a dialog opening on top of another is a
560
+ movement rather than a jump. The stack change is additionally wrapped in a
561
+ **view transition** where the browser supports it, so the lower dialog is
562
+ painted continuously instead of flickering. During a viewport resize the
563
+ transition is switched off (`data-resizing="true"`): the dialog's size is
564
+ derived from the viewport, so animating towards a moving target lags behind
565
+ the pointer.
566
+
250
567
  ## Public core exports
251
568
 
252
569
  The core entry point exports: