@rivinia/modal 0.5.1 → 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.
Files changed (2) hide show
  1. package/README.md +410 -153
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,213 +1,470 @@
1
1
  # @rivinia/modal
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@rivinia/modal.svg)](https://www.npmjs.com/package/@rivinia/modal)
4
- [![license](https://img.shields.io/npm/l/@rivinia/modal.svg)](https://www.npmjs.com/package/@rivinia/modal)
3
+ An Angular modal manager with typed results, stack/queue admission, dismissal
4
+ policies, async guards, and optional ready-made UI. The core package owns
5
+ modal state and lifecycle but does not render or style dialogs.
5
6
 
6
- Headless modal manager for Angular 22+. Stack/Queue strategies, typed
7
- `Promise` results, dismissal policies, async guards, ownership — zero DOM,
8
- zero styles, zero router dependency. A future `@rivinia/modal/ui` entry point
9
- will render this state; Core never imports UI.
7
+ - Install: `npm install @rivinia/modal`
8
+ - Package version: **0.5.3**
9
+ - Peer dependencies: `@angular/core ^22.0.0` and `@angular/common ^22.0.0`
10
+ - `@rivinia/modal` is the headless manager.
11
+ - `@rivinia/modal/ui` contains standalone dialog and host components.
10
12
 
11
- - Version: **0.5.0**
12
- - Requires: **Angular `^22.0.0`**, `tslib`
13
+ ## Configure the manager
13
14
 
14
- ## Install
15
-
16
- ```bash
17
- npm install @rivinia/modal
18
- ng add @rivinia/modal # wires provideRiviniaModal() into app.config.ts
19
- ```
15
+ Register the provider once in the application's root environment providers.
16
+ The defaults use a stack with up to five visible modals and queue overflow:
20
17
 
21
18
  ```ts
22
19
  // app.config.ts
20
+ import type { ApplicationConfig } from '@angular/core';
23
21
  import { provideRiviniaModal } from '@rivinia/modal';
24
22
 
25
- providers: [provideRiviniaModal({ strategy: 'stack', maxVisible: 3 })];
23
+ export const appConfig: ApplicationConfig = {
24
+ providers: [
25
+ provideRiviniaModal({
26
+ strategy: 'stack',
27
+ maxVisible: 3,
28
+ closingTimeout: 300,
29
+ }),
30
+ ],
31
+ };
26
32
  ```
27
33
 
28
- ## Quick start
34
+ `ng add @rivinia/modal` can insert `provideRiviniaModal()` into a detected
35
+ standalone app configuration. It does not add a rendering host; add one as
36
+ described below. You can also configure the provider manually as above.
37
+
38
+ ## Open a typed modal
39
+
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:
29
43
 
30
44
  ```ts
31
- export class Users {
32
- private modals = injectRiviniaModal();
45
+ import { Component, TemplateRef, ViewChild, signal } from '@angular/core';
46
+ import type { ModalHandle, ModalTemplateContext } from '@rivinia/modal';
47
+ import { injectRiviniaModal } from '@rivinia/modal';
48
+ import { RiviniaModalHost } from '@rivinia/modal/ui';
49
+
50
+ interface User {
51
+ readonly id: string;
52
+ readonly name: string;
53
+ }
54
+
55
+ interface DeleteData {
56
+ readonly id: string;
57
+ readonly name: string;
58
+ }
59
+
60
+ @Component({
61
+ standalone: true,
62
+ imports: [RiviniaModalHost],
63
+ templateUrl: './users-page.component.html',
64
+ })
65
+ export class UsersPage {
66
+ private readonly manager = injectRiviniaModal();
67
+ protected readonly modals = this.manager;
68
+ readonly users = signal<User[]>([{ id: '42', name: 'Ada Lovelace' }]);
33
69
 
34
- async remove(user: User) {
35
- const handle = this.modals.openModal<DeleteData, boolean>(confirmTemplate, {
36
- data: { name: user.name },
70
+ @ViewChild('deleteConfirmation', { static: true })
71
+ private deleteTemplate!: TemplateRef<ModalTemplateContext<DeleteData>>;
72
+
73
+ private pendingDelete: ModalHandle<boolean> | null = null;
74
+
75
+ async askToDelete(user: User): Promise<void> {
76
+ if (this.pendingDelete) return;
77
+
78
+ const handle = this.manager.openModal<DeleteData, boolean>(this.deleteTemplate, {
79
+ data: { id: user.id, name: user.name },
37
80
  ref: 'confirm-delete',
81
+ metadata: { title: 'Delete user' },
82
+ dismiss: { backdrop: 'static' },
38
83
  });
39
- const result = await handle.result;
40
- if (result.status === 'confirmed' && result.value) {
41
- this.delete(user);
84
+ this.pendingDelete = handle;
85
+
86
+ try {
87
+ const result = await handle.result;
88
+ if (result.status === 'confirmed' && result.value) {
89
+ this.users.update((users) => users.filter((entry) => entry.id !== user.id));
90
+ }
91
+ } finally {
92
+ if (this.pendingDelete === handle) this.pendingDelete = null;
42
93
  }
43
- // 'cancelled' | 'dismissed' | 'queued-cancelled' | 'owner-destroyed' | 'rejected'
94
+ }
95
+
96
+ confirmDelete(): void {
97
+ // ModalHandle<boolean> checks that the confirmed value is a boolean.
98
+ this.pendingDelete?.close(true);
99
+ }
100
+
101
+ cancelDelete(): void {
102
+ this.pendingDelete?.cancel('user-cancelled');
44
103
  }
45
104
  }
46
105
  ```
47
106
 
48
- ## API
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
+ }
49
112
 
50
- | Export | Kind | Purpose |
51
- | --------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
- | `provideRiviniaModal(config?)` | provider | `{ strategy?, dismiss?, maxVisible?, maxQueue?, overflow?, descendants?, presets?, closingTimeout?, diagnostics? }` |
53
- | `RiviniaModal` | `@Service()` | `openModal/close/dismiss/cancel/requestDismiss/update/confirmClosed/dismissAll/findByRef/useStrategy/debugState` + `active/queued/closing/visible/top` signals |
54
- | `injectRiviniaModal()` | function | shorthand for `inject(RiviniaModal)` |
55
- | `RIVINIA_MODAL_CONTEXT` | token | per-instance context for dynamic components |
56
- | `StackStrategy` / `QueueStrategy` | classes | built-in ordering policies (`ModalStrategy` for custom) |
57
- | types | types | `ModalHandle`, `ModalResult`, `ModalContent`, `ModalOpenOptions`, `ModalDismissPolicy`, `ModalGuard`, … |
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>
58
118
 
59
- Results settle exactly once, at dismissal initiation — exit animations never
60
- delay callers. `close/dismiss/cancel` are guard-free primitives (booleans);
61
- `requestDismiss(id, origin)` runs async guards for user intents. Forced paths
62
- (owner destroy, overflow eviction, `dismissAll`) bypass guards. Render failures
63
- reject the promise instead of becoming variants.
119
+ <rv-modal-host [modal]="modals" size="sm" />
120
+ ```
64
121
 
65
- `openModal` accepts a raw `TemplateRef`, a component `Type`, or a full
66
- `ModalContent` descriptor (component inputs via `options.inputs`). Dismissal
67
- policy merges field-by-field: built-in defaults < global `dismiss` < named
68
- preset < per-call options — absent keys never clobber.
122
+ ### Signal-based view query
69
123
 
70
- ## Ownership (who cleans up)
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`:
71
126
 
72
127
  ```ts
73
- // Owned by a short-lived component: dies with it (active → owner-destroyed)
74
- this.modals.openModal(tpl, { owner: this.destroyRef });
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
+ }
75
180
 
76
- // Independent (default): lives until closed — for services and app flows
77
- this.modals.openModal(tpl);
181
+ cancelDelete(): void {
182
+ this.pendingDelete?.cancel('user-cancelled');
183
+ }
184
+ }
78
185
  ```
79
186
 
80
- Owner teardown frees capacity like any other removal: queued requests promote
81
- normally (a queued request sharing the dead owner is still torn down by its own
82
- callback, settling `owner-destroyed`). Opening with an already-destroyed owner
83
- settles `owner-destroyed` immediately without adding anything.
187
+ The UI host uses `metadata.title` by default, so the example renders
188
+ “Delete user” without custom title logic. Import `RiviniaModalHost` into the
189
+ standalone component that renders it. All examples in this guide target
190
+ standalone Angular 22 applications.
84
191
 
85
- Closing a parent cascades to descendants (`dismissed: parent-closed`) unless
86
- `descendants: 'prevent-parent-close'` blocks it. Swapping strategies with live
87
- modals throws — close everything first.
192
+ `openModal<TData, TResult>()` accepts a `TemplateRef`, a component type, or a
193
+ `ModalContent<TData>` descriptor. Template content receives `data` as
194
+ `let-data` and the per-modal operations as `let-modal="modal"`. For component
195
+ content, pass `inputs` in the options; those keys are `Record<string, unknown>`
196
+ and are not checked against the component's inputs at compile time. The
197
+ optional `RIVINIA_MODAL_CONTEXT` injection token supplies the same per-modal
198
+ context to a dynamically created component.
88
199
 
89
- ## Ready-made UI (`@rivinia/modal/ui`)
200
+ ### Component content, modal context, and `NgComponentOutlet`
90
201
 
91
- Optional secondary entry point. Zero new runtime dependencies (platform
92
- `<dialog>` + Angular only), no design system, no CSS framework. Headless
93
- consumers never load it (proven: core FESM contains zero UI markers).
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.
94
208
 
95
209
  ```ts
96
- import { RiviniaModalDialog } from '@rivinia/modal/ui'; // UI-only
97
- import { RiviniaModalHost } from '@rivinia/modal/ui'; // managed adapter
98
- ```
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
+ }
99
224
 
100
- **Mode A — UI-only** (no controller; owns its `open` state):
225
+ cancel(): void {
226
+ this.modal.dismiss('user-cancelled');
227
+ }
228
+ }
229
+ ```
101
230
 
102
231
  ```html
103
- <rv-modal-dialog [(open)]="open" [title]="'Delete?'" [size]="'sm'" (confirmed)="remove()">
104
- <p>Delete {{ user.name }}?</p>
105
- </rv-modal-dialog>
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
+ }
106
253
  ```
107
254
 
108
- **Mode B — headless + custom UI:** keep using `@rivinia/modal` alone.
109
- Nothing here loads, no styles leak.
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
+ }
110
295
 
111
- **Mode C — headless + ready-made UI** (single source of truth stays Core):
296
+ showPaymentSaved(): void {
297
+ this.toast.show({ message: 'Payment saved', kind: 'success' });
298
+ }
299
+ }
300
+ ```
112
301
 
113
302
  ```html
114
- <rv-modal-host [modal]="injectRiviniaModal()" />
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" />
115
314
  ```
116
315
 
117
- Intents (X, Escape, backdrop) route through `requestDismiss` (guards run);
118
- exits play, then the host calls `confirmClosed()` (match `closingTimeout`
119
- to the animation duration; default 300 ms covers the 150 ms default).
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.
120
321
 
121
- Custom chrome (no X button, custom header/footer, own actions in content):
322
+ The result is a discriminated `ModalResult<TResult>`:
122
323
 
123
- ```html
124
- <rv-modal-dialog [(open)]="open">
125
- <div rv-modal-header rvModalHeader>My header, no X</div>
126
- <p>Body.</p>
127
- <div rv-modal-footer rvModalFooter><button (click)="save()">Save</button></div>
128
- </rv-modal-dialog>
324
+ - `confirmed` carries the typed `value`.
325
+ - `cancelled` and `dismissed` may carry a `reason`.
326
+ - `owner-destroyed` and `queued-cancelled` identify lifecycle cleanup.
327
+ - `rejected` identifies a request refused by the configured admission or
328
+ overflow policy.
329
+
330
+ Narrow on `result.status` before reading `result.value`. Results settle once
331
+ when closing starts; callers do not wait for the exit animation. A component
332
+ that owns a handle may use `close(value)`, `cancel(reason)`, or
333
+ `dismiss(reason)`. `ModalHandle.close()` enforces `TResult`; the `close()`
334
+ method on template/component context accepts `unknown`, so prefer the handle
335
+ when the result type matters.
336
+
337
+ ## Dismissal, guards, and lifecycle
338
+
339
+ The default dismissal policy allows Escape and backdrop click. Configure
340
+ global defaults, named presets, or per-modal overrides:
341
+
342
+ ```ts
343
+ provideRiviniaModal({
344
+ dismiss: { closeOnEscape: true, backdrop: 'static' },
345
+ presets: {
346
+ critical: { dismiss: { closeOnEscape: false, backdrop: 'static' } },
347
+ },
348
+ });
129
349
  ```
130
350
 
131
- No header at all: omit the title (no custom header either). Header rules:
132
- title + optional `description` (wired as `aria-describedby`), top-aligned
133
- with a shrink-proof X; an empty heading collapses (no dead space); a custom
134
- header renders inside the same padded row. Focus goes to the title when
135
- there is no close button, to the dialog otherwise. No footer:
136
- omit the footer slot (renders nothing). Positions: `center` (default),
137
- `top/bottom`, `-start/-end` variants (RTL-aware), `side-start/side-end`
138
- full-height panels, `fullscreen`. Under 640px viewports, center/top/bottom
139
- shapes become bottom sheets automatically (top-only radius, slide-up
140
- entrance); panels and fullscreen keep their shape.
141
- Sizes `sm/md/lg/xl/full/auto` are ceilings via `--rv-modal-width-*` vars.
142
-
143
- Theming (no theme service; consumer owns it):
144
-
145
- ```css
146
- :root {
147
- --rv-modal-bg: #1c1c1e;
148
- --rv-modal-fg: #f5f5f7;
149
- }
150
- @media (prefers-color-scheme: dark) {
151
- .rv-modal {
152
- /* built-in dark defaults already apply; override vars to brand it */
153
- }
351
+ Dismissal policy fields merge independently in this order: built-in defaults,
352
+ global config, named preset, and per-open options. A missing key at a later
353
+ layer does not erase an earlier setting. `backdrop` may be `close`, `static`,
354
+ or `disabled`.
355
+
356
+ `RiviniaModalHost` routes Escape, backdrop, and close-button intents through
357
+ `requestDismiss()`, which runs any async `guards` supplied when opening. A
358
+ guard that returns `false` or throws keeps the modal open. The direct
359
+ `close()`, `dismiss()`, and `cancel()` methods on a handle are guard-free;
360
+ use `requestDismiss(id, origin)` when programmatic code should also be checked.
361
+ Forced cleanup such as owner destruction and `dismissAll()` bypasses guards.
362
+
363
+ The manager supports `strategy: 'stack' | 'queue'` (or a custom
364
+ `ModalStrategy`). Stack allows up to `maxVisible` active modals, with the
365
+ latest on top; queue activates one at a time in FIFO order. A modal in its
366
+ exit animation normally occupies a visible slot until the host confirms
367
+ teardown or the timeout finalizes it; `dismiss-oldest` preempts an active
368
+ instance to make room while its exit runs. When capacity is full, `overflow`
369
+ may queue (default), dismiss the oldest active modal, or reject the new request.
370
+ `maxQueue` bounds queued requests when queue overflow is enabled.
371
+
372
+ An optional `owner: DestroyRef` ties a modal to a component/service lifetime.
373
+ Destroying the owner settles an active request as `owner-destroyed`; a queued
374
+ request settles as `queued-cancelled`. Without an owner, the modal is
375
+ independent of the caller's lifetime. Set `parentId` to associate a child
376
+ modal with a parent; by default, closing a parent closes its descendants.
377
+ `descendants: 'prevent-parent-close'` instead blocks parent closure while a
378
+ descendant remains open.
379
+
380
+ When a modal enters `closing`, its result has already settled but it remains
381
+ visible while the UI exits. The UI host calls `confirmClosed()` after its exit
382
+ animation. The default `closingTimeout` is 300 ms; set it to cover the
383
+ renderer’s exit duration, or set it to `0` when a custom renderer will always
384
+ acknowledge closure manually. Changing strategy with active, queued, or
385
+ closing modals throws; close them first.
386
+
387
+ ## Ready-made UI
388
+
389
+ `@rivinia/modal/ui` is optional. It provides a native `<dialog>` component
390
+ (`RiviniaModalDialog`) for UI-only use and `RiviniaModalHost` to render
391
+ manager-owned modals. The host passes dismissal intents to the manager before
392
+ closing, so guards and promise results remain coordinated.
393
+
394
+ For a UI-only dialog, import the standalone component and bind its `open`
395
+ model. This mode has no manager, guards, or `ModalHandle` promise:
396
+
397
+ ```ts
398
+ import { Component } from '@angular/core';
399
+ import { RiviniaModalDialog } from '@rivinia/modal/ui';
400
+
401
+ @Component({
402
+ standalone: true,
403
+ imports: [RiviniaModalDialog],
404
+ templateUrl: './preview-dialog.component.html',
405
+ })
406
+ export class PreviewDialog {
407
+ open = false;
154
408
  }
155
409
  ```
156
410
 
157
- Plus: `prefers-reduced-motion` disables animation; `animation: 'none'`
158
- opt-out; `backdrop: 'close' | 'static' | 'disabled'`; Escape/backdrop/X
159
- policies stay independent (no `disableClose` bundle); long content scrolls
160
- inside (`rv-modal-body`); `scrollbar-gutter: stable` while open;
161
- reference-counted scroll lock restores previous styles; focus trap + focus
162
- restore + `aria-labelledby`/`aria-label`; `dir=rtl` works via logical
163
- properties; SSR renders closed and opens after hydration; lazy-load the
164
- entry (`loadComponent`) to split it into its own chunk (~16.7 kB raw,
165
- ~4.9 kB transfer, measured).
166
-
167
- ## Toasts above an open modal (measured pattern)
168
-
169
- A toast host outside the dialog can never paint above it — neither a high
170
- `z-index` nor `popover="manual"` escapes the modal top layer (verified in
171
- Chrome via `elementFromPoint`). When an error toast must appear over an open
172
- modal (e.g. a data error inside the form), host the list **inside** the
173
- dialog content instead: as a descendant of the top-layer element it paints
174
- above the dialog background with zero extra code and zero coupling:
175
-
176
411
  ```html
177
- <rv-modal-dialog [(open)]="open" [title]="'Edit user'">
178
- <!-- paints above this dialog; positions are still viewport-anchored -->
179
- <rv-toast-list [manager]="injectRiviniaToast()" [position]="'top-end'" />
180
- <form>…</form>
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>
181
418
  </rv-modal-dialog>
182
419
  ```
183
420
 
184
- Notes: positions stay viewport-anchored (`fixed`), so `top-end` means the
185
- viewport corner in both LTR and RTL (logical properties). Toasts fired
186
- while no dialog is open still need a page-level list — the two lists share
187
- the same manager, so nothing is lost or duplicated. Toasts: MEASURED (Chrome, `elementFromPoint`) —
188
- neither a high `z-index` nor `popover="manual"` paints above an open modal
189
- dialog; toasts queue behind it. No modal→toast dependency by design.
190
-
191
- ## Type holes (honest, by design)
192
-
193
- Core checks `ModalHandle<TResult>.close(result?: TResult)` and
194
- `ModalResult`/`ModalContext.data` typing (see `modal-type-safety.spec.ts`
195
- `@ts-expect-error` tests). It does NOT check:
196
-
197
- - `ModalContext.close(result?: unknown)` — any payload compiles; the mismatch
198
- reaches the opener's awaited `ModalResult` unchecked.
199
- - `inputs?: Record<string, unknown>` (options + component content) — any shape
200
- compiles; misspelled keys fail silently at render. The UI owns binding.
201
- - The internal deferred-promise cast to `Promise<ModalResult<TResult>>` —
202
- `close()` settles whatever it receives, never validated.
203
-
204
- ## Browser back button (non-goal)
205
-
206
- Native `<dialog>.showModal()` does not integrate with history: the back
207
- button neither closes the dialog nor is blocked by it (platform behavior,
208
- not a Rivinia choice). If your app needs back-to-dismiss, wire it at the
209
- app layer (router guard / `popstate` → `dismiss()`); the manager's
210
- `dismissRequested` + guards flow stays the single teardown path.
421
+ `RiviniaModalDialog` accepts `open`, `title`, `description`, `ariaLabel`,
422
+ `size`, `position`, `animation`, `closeOnEscape`, `backdrop`, and
423
+ `showCloseButton`; its `open` model supports `[(open)]`. In UI-only mode it
424
+ emits `confirmed`, `dismissed`, and `cancelled`. In managed use, the host
425
+ accepts the required `modal` manager input plus `size`, `showCloseButton`,
426
+ and `titleOf` overrides; observe the manager's `ModalHandle.result` instead
427
+ of the dialog outputs.
428
+ `RvModalHeader` and `RvModalFooter` are marker directives for custom header
429
+ and footer projection; import the marker used by the template and apply it
430
+ alongside `rv-modal-header` or `rv-modal-footer`. A custom header replaces the
431
+ default title and close button. Without a footer slot, no footer region is
432
+ rendered.
433
+
434
+ The native dialog provides focus containment and restores focus to its
435
+ invoker. The UI includes scroll locking, reduced-motion support, RTL-aware
436
+ logical positions, and responsive bottom-sheet styling below 640px for
437
+ center/top/bottom placements. The UI exposes size, position, animation,
438
+ backdrop, Escape, and close-button inputs. During server rendering it remains
439
+ closed and opens on the client after hydration. Theme it with CSS custom
440
+ properties such as `--rv-modal-bg`, `--rv-modal-fg`, `--rv-modal-border`,
441
+ `--rv-modal-radius`, and `--rv-modal-width-md`.
442
+
443
+ ## Public core exports
444
+
445
+ The core entry point exports:
446
+
447
+ - `provideRiviniaModal()` and `injectRiviniaModal()` for configuration and
448
+ injection, plus `RiviniaModal` for direct manager APIs.
449
+ - `StackStrategy`, `QueueStrategy`, and the `ModalStrategy` contract.
450
+ - `RiviniaModalConfig`, `ModalPreset`, `ModalHandle`, `ModalResult`,
451
+ `ModalContent`, `ModalOpenOptions`, `ModalUpdate`, `ModalInstance`,
452
+ `ModalTemplateContext`, `ModalContext`, `ModalGuard`,
453
+ `ModalDismissPolicy`, `ModalDismissOrigin`, `ModalOverflowPolicy`,
454
+ `ModalStatus`, `ModalDiagnostic`, and `DescendantPolicy`.
455
+ - `RIVINIA_MODAL_CONTEXT` for dynamic modal components.
456
+
457
+ `RiviniaModal` exposes readonly `active`, `queued`, `closing`, `visible`, and
458
+ `top` signals, along with `openModal`, `close`, `dismiss`, `cancel`,
459
+ `requestDismiss`, `update`, `confirmClosed`, `dismissAll`, `findByRef`,
460
+ `useStrategy`, and diagnostics helpers. Inspect the signal values by calling
461
+ them, for example `manager.top()`.
462
+
463
+ The `@rivinia/modal/ui` entry point exports `RiviniaModalDialog`,
464
+ `RiviniaModalHost`, `RiviniaModalHostManager`, `RiviniaModalScrollLock`,
465
+ `RvModalHeader`, and `RvModalFooter`, plus the `RiviniaModalAnimation`,
466
+ `RiviniaModalBackdrop`, `RiviniaModalPosition`, `RiviniaModalSize`, and
467
+ `RiviniaModalUiOptions` types.
211
468
 
212
469
  ## License
213
470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivinia/modal",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Headless modal manager for Angular: stack/queue strategies, typed results, guards, ownership.",
5
5
  "keywords": [
6
6
  "angular",