@rivinia/modal 0.0.0-stage → 0.2.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 +80 -2
- package/fesm2022/rivinia-modal.mjs +420 -0
- package/fesm2022/rivinia-modal.mjs.map +1 -0
- package/package.json +30 -3
- package/schematics/collection.json +10 -0
- package/schematics/ng-add/index.cjs +101 -0
- package/schematics/ng-add/schema.json +19 -0
- package/types/rivinia-modal.d.ts +340 -0
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rivinia/modal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@rivinia/modal)
|
|
4
|
+
[](https://www.npmjs.com/package/@rivinia/modal)
|
|
5
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
- Version: **0.2.0**
|
|
12
|
+
- Requires: **Angular `^22.0.0`**, `tslib`
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @rivinia/modal
|
|
18
|
+
ng add @rivinia/modal # wires provideRiviniaModal() into app.config.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// app.config.ts
|
|
23
|
+
import { provideRiviniaModal } from '@rivinia/modal';
|
|
24
|
+
|
|
25
|
+
providers: [provideRiviniaModal({ strategy: 'stack', maxVisible: 3 })];
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
export class Users {
|
|
32
|
+
private modals = injectRiviniaModal();
|
|
33
|
+
|
|
34
|
+
async remove(user: User) {
|
|
35
|
+
const handle = this.modals.openModal<DeleteData, boolean>(confirmTemplate, {
|
|
36
|
+
data: { name: user.name },
|
|
37
|
+
ref: 'confirm-delete',
|
|
38
|
+
});
|
|
39
|
+
const result = await handle.result;
|
|
40
|
+
if (result.status === 'confirmed' && result.value) {
|
|
41
|
+
this.delete(user);
|
|
42
|
+
}
|
|
43
|
+
// 'cancelled' | 'dismissed' | 'queued-cancelled' | 'owner-destroyed' | 'rejected'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
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/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`, … |
|
|
58
|
+
|
|
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.
|
|
64
|
+
|
|
65
|
+
## Ownership (who cleans up)
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// Owned by a short-lived component: dies with it (active → owner-destroyed)
|
|
69
|
+
this.modals.openModal(tpl, { owner: this.destroyRef });
|
|
70
|
+
|
|
71
|
+
// Independent (default): lives until closed — for services and app flows
|
|
72
|
+
this.modals.openModal(tpl);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Closing a parent cascades to descendants (`dismissed: parent-closed`) unless
|
|
76
|
+
`descendants: 'prevent-parent-close'` blocks it. Swapping strategies with live
|
|
77
|
+
modals throws — close everything first.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT.
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import * as i0 from "@angular/core";
|
|
2
|
+
import { DestroyRef, InjectionToken, Service, computed, inject, makeEnvironmentProviders, signal } from "@angular/core";
|
|
3
|
+
var StackStrategy = class {
|
|
4
|
+
name = "stack";
|
|
5
|
+
maxActive(maxVisible) {
|
|
6
|
+
return Math.max(1, Math.floor(maxVisible));
|
|
7
|
+
}
|
|
8
|
+
admit(visibleCount, _queuedCount, maxVisible) {
|
|
9
|
+
return visibleCount < Math.max(1, Math.floor(maxVisible)) ? "activate" : "queue";
|
|
10
|
+
}
|
|
11
|
+
order(visible) {
|
|
12
|
+
return [...visible].sort((a, b) => a.openedSeq - b.openedSeq);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var QueueStrategy = class {
|
|
16
|
+
name = "queue";
|
|
17
|
+
maxActive(_maxVisible) {
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
admit(visibleCount, _queuedCount, _maxVisible) {
|
|
21
|
+
return visibleCount < 1 ? "activate" : "queue";
|
|
22
|
+
}
|
|
23
|
+
order(visible) {
|
|
24
|
+
return [...visible].sort((a, b) => a.openedSeq - b.openedSeq);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const RIVINIA_MODAL_CONTEXT = new InjectionToken("RIVINIA_MODAL_CONTEXT");
|
|
28
|
+
const RIVINIA_MODAL_CONFIG = new InjectionToken("RIVINIA_MODAL_CONFIG");
|
|
29
|
+
var RiviniaModal = class RiviniaModal {
|
|
30
|
+
config = inject(RIVINIA_MODAL_CONFIG, { optional: true });
|
|
31
|
+
_instances = signal([], ...ngDevMode ? [{ debugName: "_instances" }] : /* istanbul ignore next */ []);
|
|
32
|
+
_results = /* @__PURE__ */ new Map();
|
|
33
|
+
_strategy = this.resolveStrategy(this.config?.strategy);
|
|
34
|
+
_seq = 0;
|
|
35
|
+
_diagSeq = 0;
|
|
36
|
+
_diagnostics = [];
|
|
37
|
+
active = computed(() => this._strategy.order(this._instances().filter((i) => i.status === "active")), ...ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []);
|
|
38
|
+
queued = computed(() => this._instances().filter((i) => i.status === "queued"), ...ngDevMode ? [{ debugName: "queued" }] : /* istanbul ignore next */ []);
|
|
39
|
+
top = computed(() => {
|
|
40
|
+
const list = this.active();
|
|
41
|
+
return list.length > 0 ? list[list.length - 1] : null;
|
|
42
|
+
}, ...ngDevMode ? [{ debugName: "top" }] : /* istanbul ignore next */ []);
|
|
43
|
+
constructor() {
|
|
44
|
+
inject(DestroyRef).onDestroy(() => {
|
|
45
|
+
for (const record of [...this._instances()]) if (record.status === "queued") {
|
|
46
|
+
this.remove(record.id);
|
|
47
|
+
this.settle(record.id, { status: "queued-cancelled" });
|
|
48
|
+
} else if (record.status !== "closed") {
|
|
49
|
+
this.clearClosingTimer(record);
|
|
50
|
+
this.remove(record.id);
|
|
51
|
+
this.settle(record.id, {
|
|
52
|
+
status: "dismissed",
|
|
53
|
+
reason: "manager-destroyed"
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
openModal(content, options = {}) {
|
|
59
|
+
const id = `modal-${++this._seq}`;
|
|
60
|
+
const pending = this.deferred();
|
|
61
|
+
this._results.set(id, pending);
|
|
62
|
+
const handle = {
|
|
63
|
+
id,
|
|
64
|
+
result: pending.promise,
|
|
65
|
+
close: (value) => this.close(id, value),
|
|
66
|
+
dismiss: (reason) => this.dismiss(id, reason),
|
|
67
|
+
cancel: (reason) => this.cancel(id, reason)
|
|
68
|
+
};
|
|
69
|
+
const visibleCount = this._instances().filter((i) => i.status !== "queued").length;
|
|
70
|
+
const intent = this._strategy.admit(visibleCount, this.queuedCount(), this.maxVisible());
|
|
71
|
+
let status = intent === "activate" ? "active" : "queued";
|
|
72
|
+
if (intent === "queue") {
|
|
73
|
+
const outcome = this.admitQueued(id);
|
|
74
|
+
if (outcome === "active" || outcome === "queued") status = outcome;
|
|
75
|
+
else {
|
|
76
|
+
this.settle(id, {
|
|
77
|
+
status: "rejected",
|
|
78
|
+
reason: outcome
|
|
79
|
+
});
|
|
80
|
+
this.diagnose("rejected", id, options.ref ?? null, outcome);
|
|
81
|
+
return handle;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const record = {
|
|
85
|
+
id,
|
|
86
|
+
ref: options.ref ?? null,
|
|
87
|
+
caller: options.caller ?? null,
|
|
88
|
+
parentId: this.knownParent(options.parentId) ? options.parentId : null,
|
|
89
|
+
data: options.data,
|
|
90
|
+
content,
|
|
91
|
+
status,
|
|
92
|
+
dismiss: {
|
|
93
|
+
closeOnEscape: true,
|
|
94
|
+
backdrop: "close",
|
|
95
|
+
...this.presetDismiss(options.preset),
|
|
96
|
+
...options.dismiss
|
|
97
|
+
},
|
|
98
|
+
metadata: { ...options.metadata ?? {} },
|
|
99
|
+
openedSeq: this._seq,
|
|
100
|
+
generation: 0,
|
|
101
|
+
guards: [...options.guards ?? []],
|
|
102
|
+
closingTimer: null,
|
|
103
|
+
ownerCleanup: null
|
|
104
|
+
};
|
|
105
|
+
if (options.parentId !== void 0 && record.parentId === null) this.diagnose("rejected", id, record.ref, "stale-parent");
|
|
106
|
+
this._instances.update((list) => [...list, record]);
|
|
107
|
+
if (options.owner) {
|
|
108
|
+
const off = options.owner.onDestroy(() => this.teardownOwned(id));
|
|
109
|
+
this.patch(id, { ownerCleanup: off });
|
|
110
|
+
}
|
|
111
|
+
this.diagnose(intent === "activate" ? "activated" : "queued", id, record.ref);
|
|
112
|
+
return handle;
|
|
113
|
+
}
|
|
114
|
+
close(id, value) {
|
|
115
|
+
const record = this.get(id);
|
|
116
|
+
if (!record || record.status !== "active" || this.blockedByDescendants(id)) return false;
|
|
117
|
+
this.beginClosing(id, "confirmed", void 0, value);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
dismiss(id, reason) {
|
|
121
|
+
const record = this.get(id);
|
|
122
|
+
if (!record || this.blockedByDescendants(id)) return false;
|
|
123
|
+
if (record.status === "active") {
|
|
124
|
+
this.beginClosing(id, "dismissed", reason);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (record.status === "queued") {
|
|
128
|
+
this.closeDescendants(id, true);
|
|
129
|
+
this.remove(id);
|
|
130
|
+
this.settle(id, {
|
|
131
|
+
status: "dismissed",
|
|
132
|
+
reason
|
|
133
|
+
});
|
|
134
|
+
this.diagnose("dismissed", id, record.ref, reason);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
cancel(id, reason) {
|
|
140
|
+
const record = this.get(id);
|
|
141
|
+
if (!record || this.blockedByDescendants(id)) return false;
|
|
142
|
+
if (record.status === "queued") {
|
|
143
|
+
this.closeDescendants(id, true);
|
|
144
|
+
this.remove(id);
|
|
145
|
+
this.settle(id, { status: "queued-cancelled" });
|
|
146
|
+
this.diagnose("dismissed", id, record.ref, reason ?? "cancelled");
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (record.status !== "active") return false;
|
|
150
|
+
this.beginClosing(id, "cancelled", reason);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
async requestDismiss(id, origin, reason) {
|
|
154
|
+
const record = this.get(id);
|
|
155
|
+
if (!record || record.status !== "active" || this.blockedByDescendants(id)) return false;
|
|
156
|
+
const generation = record.generation;
|
|
157
|
+
for (const guard of record.guards) {
|
|
158
|
+
let allowed;
|
|
159
|
+
try {
|
|
160
|
+
allowed = await guard({
|
|
161
|
+
id,
|
|
162
|
+
origin
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
allowed = false;
|
|
166
|
+
}
|
|
167
|
+
const current = this.get(id);
|
|
168
|
+
if (!current || current.status !== "active" || current.generation !== generation) return false;
|
|
169
|
+
if (!allowed) {
|
|
170
|
+
this.diagnose("guard-denied", id, record.ref, origin);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return this.dismiss(id, reason ?? origin);
|
|
175
|
+
}
|
|
176
|
+
update(id, changes) {
|
|
177
|
+
const record = this.get(id);
|
|
178
|
+
if (!record || record.status !== "active" && record.status !== "queued") return false;
|
|
179
|
+
this._instances.update((list) => list.map((t) => t.id === id ? {
|
|
180
|
+
...t,
|
|
181
|
+
data: changes.data !== void 0 ? changes.data : t.data,
|
|
182
|
+
metadata: changes.metadata !== void 0 ? { ...changes.metadata } : t.metadata
|
|
183
|
+
} : t));
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
confirmClosed(id) {
|
|
187
|
+
const record = this.get(id);
|
|
188
|
+
if (!record || record.status !== "closing") return false;
|
|
189
|
+
this.finalize(id);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
dismissAll(reason = "dismiss-all") {
|
|
193
|
+
for (const record of [...this._instances()]) if (record.status === "queued") {
|
|
194
|
+
this.remove(record.id);
|
|
195
|
+
this.settle(record.id, { status: "queued-cancelled" });
|
|
196
|
+
} else if (record.status === "active") this.beginClosing(record.id, "dismissed", reason);
|
|
197
|
+
else if (record.status === "closing") this.finalize(record.id);
|
|
198
|
+
}
|
|
199
|
+
findByRef(ref) {
|
|
200
|
+
return this._instances().filter((i) => i.ref === ref && i.status !== "closed");
|
|
201
|
+
}
|
|
202
|
+
useStrategy(strategy) {
|
|
203
|
+
if (this._instances().some((i) => i.status !== "closed")) throw new Error("[rivinia/modal] useStrategy() rejected: active, queued or closing modals exist. Close everything first — live migration is not supported.");
|
|
204
|
+
this._strategy = strategy;
|
|
205
|
+
}
|
|
206
|
+
debugState() {
|
|
207
|
+
const all = this._instances();
|
|
208
|
+
return {
|
|
209
|
+
strategy: this._strategy.name,
|
|
210
|
+
active: this.orderedActive().filter((i) => i.status === "active").map((i) => i.id),
|
|
211
|
+
closing: all.filter((i) => i.status === "closing").map((i) => i.id),
|
|
212
|
+
queued: all.filter((i) => i.status === "queued").map((i) => i.id)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
getDiagnostics() {
|
|
216
|
+
return [...this._diagnostics];
|
|
217
|
+
}
|
|
218
|
+
clearDiagnostics() {
|
|
219
|
+
this._diagnostics.length = 0;
|
|
220
|
+
}
|
|
221
|
+
get(id) {
|
|
222
|
+
return this._instances().find((i) => i.id === id);
|
|
223
|
+
}
|
|
224
|
+
queuedCount() {
|
|
225
|
+
return this._instances().filter((i) => i.status === "queued").length;
|
|
226
|
+
}
|
|
227
|
+
orderedActive() {
|
|
228
|
+
return [...this._strategy.order(this._instances().filter((i) => i.status === "active" || i.status === "closing"))];
|
|
229
|
+
}
|
|
230
|
+
maxVisible() {
|
|
231
|
+
return Math.max(1, Math.floor(this.config?.maxVisible ?? 5));
|
|
232
|
+
}
|
|
233
|
+
maxQueue() {
|
|
234
|
+
const value = this.config?.maxQueue ?? Number.POSITIVE_INFINITY;
|
|
235
|
+
return value < 0 ? 0 : value;
|
|
236
|
+
}
|
|
237
|
+
presetDismiss(preset) {
|
|
238
|
+
if (!preset) return {};
|
|
239
|
+
return { ...this.config?.presets?.[preset]?.dismiss ?? {} };
|
|
240
|
+
}
|
|
241
|
+
knownParent(parentId) {
|
|
242
|
+
if (parentId === void 0) return false;
|
|
243
|
+
return this._instances().some((i) => i.id === parentId && i.status !== "closed");
|
|
244
|
+
}
|
|
245
|
+
admitQueued(id) {
|
|
246
|
+
switch (this.config?.overflow ?? "queue") {
|
|
247
|
+
case "reject": return "overflow";
|
|
248
|
+
case "dismiss-oldest": {
|
|
249
|
+
const oldest = this.orderedActive().find((i) => i.status === "active");
|
|
250
|
+
if (oldest) this.beginClosing(oldest.id, "dismissed", "overflow-evicted");
|
|
251
|
+
return "active";
|
|
252
|
+
}
|
|
253
|
+
default: return this.queuedCount() >= this.maxQueue() ? "queue-full" : "queued";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
blockedByDescendants(id) {
|
|
257
|
+
if ((this.config?.descendants ?? "close-with-parent") !== "prevent-parent-close") return false;
|
|
258
|
+
const blocked = this._instances().some((i) => i.parentId === id && i.status !== "closed" && i.status !== "closing");
|
|
259
|
+
if (blocked) this.diagnose("strategy-rejected", id, this.get(id)?.ref ?? null, "prevent-parent-close");
|
|
260
|
+
return blocked;
|
|
261
|
+
}
|
|
262
|
+
beginClosing(id, status, reason, value) {
|
|
263
|
+
const record = this.get(id);
|
|
264
|
+
if (!record || record.status !== "active") return;
|
|
265
|
+
record.generation++;
|
|
266
|
+
this.closeDescendants(id, false);
|
|
267
|
+
this.patch(id, { status: "closing" });
|
|
268
|
+
if (status === "confirmed") this.settle(id, {
|
|
269
|
+
status,
|
|
270
|
+
value
|
|
271
|
+
});
|
|
272
|
+
else this.settle(id, {
|
|
273
|
+
status,
|
|
274
|
+
reason
|
|
275
|
+
});
|
|
276
|
+
this.diagnose("closing", id, record.ref, reason);
|
|
277
|
+
const timeout = Math.max(0, this.config?.closingTimeout ?? 300);
|
|
278
|
+
if (timeout === 0) return;
|
|
279
|
+
const generation = record.generation;
|
|
280
|
+
record.closingTimer = setTimeout(() => {
|
|
281
|
+
const current = this.get(id);
|
|
282
|
+
if (current && current.status === "closing" && current.generation === generation) this.finalize(id);
|
|
283
|
+
}, timeout);
|
|
284
|
+
this.patch(id, { closingTimer: record.closingTimer });
|
|
285
|
+
}
|
|
286
|
+
closeDescendants(parentId, force) {
|
|
287
|
+
if (!force && (this.config?.descendants ?? "close-with-parent") !== "close-with-parent") return;
|
|
288
|
+
for (const child of this._instances().filter((i) => i.parentId === parentId && (i.status === "active" || i.status === "queued"))) if (child.status === "queued") {
|
|
289
|
+
this.remove(child.id);
|
|
290
|
+
this.settle(child.id, { status: "queued-cancelled" });
|
|
291
|
+
this.diagnose("dismissed", child.id, child.ref, "parent-closed");
|
|
292
|
+
} else {
|
|
293
|
+
this.closeDescendants(child.id, force);
|
|
294
|
+
this.beginClosing(child.id, "dismissed", "parent-closed");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
finalize(id, promoteAfter = true) {
|
|
298
|
+
const record = this.get(id);
|
|
299
|
+
if (!record) return;
|
|
300
|
+
this.clearClosingTimer(record);
|
|
301
|
+
this.patch(id, { status: "closed" });
|
|
302
|
+
record.ownerCleanup?.();
|
|
303
|
+
this._instances.update((list) => list.filter((i) => i.id !== id));
|
|
304
|
+
this._results.delete(id);
|
|
305
|
+
this.diagnose("closed", id, record.ref);
|
|
306
|
+
if (promoteAfter) this.promote();
|
|
307
|
+
}
|
|
308
|
+
promote() {
|
|
309
|
+
const width = this._strategy.maxActive(this.maxVisible());
|
|
310
|
+
const visible = () => this._instances().filter((i) => i.status === "active" || i.status === "closing").length;
|
|
311
|
+
while (visible() < width) {
|
|
312
|
+
const head = this._instances().find((i) => i.status === "queued");
|
|
313
|
+
if (!head) break;
|
|
314
|
+
this.patch(head.id, { status: "active" });
|
|
315
|
+
this.diagnose("promoted", head.id, head.ref);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
remove(id) {
|
|
319
|
+
const record = this.get(id);
|
|
320
|
+
if (record) this.clearClosingTimer(record);
|
|
321
|
+
this._instances.update((list) => list.filter((i) => i.id !== id));
|
|
322
|
+
}
|
|
323
|
+
clearClosingTimer(record) {
|
|
324
|
+
if (record.closingTimer) {
|
|
325
|
+
clearTimeout(record.closingTimer);
|
|
326
|
+
record.closingTimer = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
teardownOwned(id) {
|
|
330
|
+
const record = this.get(id);
|
|
331
|
+
if (!record || record.status === "closed" || record.status === "closing") return;
|
|
332
|
+
if (record.status === "queued") {
|
|
333
|
+
this.closeDescendants(id, true);
|
|
334
|
+
this.remove(id);
|
|
335
|
+
this.settle(id, { status: "queued-cancelled" });
|
|
336
|
+
this.diagnose("dismissed", id, record.ref, "owner-destroyed");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
record.generation++;
|
|
340
|
+
this.closeDescendants(id, true);
|
|
341
|
+
this.patch(id, { status: "closing" });
|
|
342
|
+
this.settle(id, { status: "owner-destroyed" });
|
|
343
|
+
this.diagnose("closing", id, record.ref, "owner-destroyed");
|
|
344
|
+
this.finalize(id, false);
|
|
345
|
+
}
|
|
346
|
+
patch(id, changes) {
|
|
347
|
+
this._instances.update((list) => list.map((t) => t.id === id ? {
|
|
348
|
+
...t,
|
|
349
|
+
...changes
|
|
350
|
+
} : t));
|
|
351
|
+
}
|
|
352
|
+
deferred() {
|
|
353
|
+
let resolve;
|
|
354
|
+
return {
|
|
355
|
+
promise: new Promise((res) => {
|
|
356
|
+
resolve = res;
|
|
357
|
+
}),
|
|
358
|
+
resolve: (value) => {
|
|
359
|
+
resolve(value);
|
|
360
|
+
},
|
|
361
|
+
settled: false
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
settle(id, value) {
|
|
365
|
+
const pending = this._results.get(id);
|
|
366
|
+
if (!pending || pending.settled) return;
|
|
367
|
+
pending.settled = true;
|
|
368
|
+
pending.resolve(value);
|
|
369
|
+
}
|
|
370
|
+
diagnose(type, id, ref, reason) {
|
|
371
|
+
if (!this.config?.diagnostics) return;
|
|
372
|
+
this._diagnostics.push({
|
|
373
|
+
seq: ++this._diagSeq,
|
|
374
|
+
type,
|
|
375
|
+
id,
|
|
376
|
+
ref,
|
|
377
|
+
reason
|
|
378
|
+
});
|
|
379
|
+
while (this._diagnostics.length > 100) this._diagnostics.shift();
|
|
380
|
+
}
|
|
381
|
+
resolveStrategy(configured) {
|
|
382
|
+
if (!configured || configured === "stack") return new StackStrategy();
|
|
383
|
+
if (configured === "queue") return new QueueStrategy();
|
|
384
|
+
return configured;
|
|
385
|
+
}
|
|
386
|
+
static ɵfac = i0.ɵɵngDeclareFactory({
|
|
387
|
+
minVersion: "12.0.0",
|
|
388
|
+
version: "22.2.2",
|
|
389
|
+
ngImport: i0,
|
|
390
|
+
type: RiviniaModal,
|
|
391
|
+
deps: [],
|
|
392
|
+
target: i0.ɵɵFactoryTarget.Service
|
|
393
|
+
});
|
|
394
|
+
static ɵprov = i0.ɵɵngDeclareService({
|
|
395
|
+
minVersion: "22.0.0",
|
|
396
|
+
version: "22.2.2",
|
|
397
|
+
ngImport: i0,
|
|
398
|
+
type: RiviniaModal
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
i0.ɵɵngDeclareClassMetadata({
|
|
402
|
+
minVersion: "12.0.0",
|
|
403
|
+
version: "22.2.2",
|
|
404
|
+
ngImport: i0,
|
|
405
|
+
type: RiviniaModal,
|
|
406
|
+
decorators: [{ type: Service }],
|
|
407
|
+
ctorParameters: () => []
|
|
408
|
+
});
|
|
409
|
+
function provideRiviniaModal(config = {}) {
|
|
410
|
+
return makeEnvironmentProviders([{
|
|
411
|
+
provide: RIVINIA_MODAL_CONFIG,
|
|
412
|
+
useValue: config
|
|
413
|
+
}]);
|
|
414
|
+
}
|
|
415
|
+
function injectRiviniaModal() {
|
|
416
|
+
return inject(RiviniaModal);
|
|
417
|
+
}
|
|
418
|
+
export { QueueStrategy, RIVINIA_MODAL_CONTEXT, RiviniaModal, StackStrategy, injectRiviniaModal, provideRiviniaModal };
|
|
419
|
+
|
|
420
|
+
//# sourceMappingURL=rivinia-modal.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rivinia-modal.mjs","names":[],"sources":["../../../projects/modal/src/lib/modal-strategy.ts","../../../projects/modal/src/lib/modal-context.ts","../../../projects/modal/src/lib/modal-config.ts","../../../projects/modal/src/lib/rivinia-modal.ts","../../../projects/modal/src/lib/provide-modal.ts"],"sourcesContent":["/**\n * Ordering/admission policy. Strategies are pure with respect to lifecycle:\n * the manager owns identity, results, timers and disposal; strategies own\n * ordering and activation width. No mode-dependent `if/else` in the manager.\n */\nexport interface ModalStrategy {\n readonly name: string;\n /** Maximum simultaneously active instances under this strategy. */\n maxActive(maxVisible: number): number;\n /**\n * Admission of a new request given current visible/queued sets.\n * Returns the action; the manager executes it (and settles results).\n */\n admit(visibleCount: number, queuedCount: number, maxVisible: number): 'activate' | 'queue';\n /** Display order of visible instances, bottom-first. */\n order<T extends { readonly openedSeq: number }>(visible: readonly T[]): readonly T[];\n}\n\n/** Multiple concurrent modals, topmost (last opened) interactive. */\nexport class StackStrategy implements ModalStrategy {\n readonly name = 'stack';\n maxActive(maxVisible: number): number {\n return Math.max(1, Math.floor(maxVisible));\n }\n admit(visibleCount: number, _queuedCount: number, maxVisible: number): 'activate' | 'queue' {\n return visibleCount < Math.max(1, Math.floor(maxVisible)) ? 'activate' : 'queue';\n }\n order<T extends { readonly openedSeq: number }>(visible: readonly T[]): readonly T[] {\n return [...visible].sort((a, b) => a.openedSeq - b.openedSeq);\n }\n}\n\n/** Strictly serial: one active modal, FIFO activation of the rest. */\nexport class QueueStrategy implements ModalStrategy {\n readonly name = 'queue';\n maxActive(_maxVisible: number): number {\n return 1;\n }\n admit(visibleCount: number, _queuedCount: number, _maxVisible: number): 'activate' | 'queue' {\n return visibleCount < 1 ? 'activate' : 'queue';\n }\n order<T extends { readonly openedSeq: number }>(visible: readonly T[]): readonly T[] {\n return [...visible].sort((a, b) => a.openedSeq - b.openedSeq);\n }\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ModalContext } from './modal-types';\n\n/**\n * Injection token providing the `ModalContext` of the enclosing modal to\n * dynamically created components. Template content receives the same object\n * as `let-modal=\"modal\"`. Never global — one value per modal instance.\n */\nexport const RIVINIA_MODAL_CONTEXT = new InjectionToken<ModalContext<unknown>>(\n 'RIVINIA_MODAL_CONTEXT',\n);\n","import { InjectionToken } from '@angular/core';\nimport type {\n DescendantPolicy,\n ModalDismissPolicy,\n ModalOverflowPolicy,\n} from './modal-types';\n\n/** Named preset: a reusable dismissal shape (explicit options always win). */\nexport interface ModalPreset {\n readonly dismiss?: Partial<ModalDismissPolicy>;\n}\n\n/**\n * Global configuration for `@rivinia/modal`. Everything optional; per-open\n * options win. No styling, positions, icons, or translations here — ever.\n */\nexport interface RiviniaModalConfig {\n /**\n * Ordering/activation policy. `'stack'` = up to `maxVisible` concurrent,\n * topmost interactive. `'queue'` = strictly serial (one active, FIFO rest).\n * A custom `ModalStrategy` instance is also accepted.\n * @default 'stack'\n */\n readonly strategy?: 'stack' | 'queue' | import('./modal-strategy').ModalStrategy;\n /** Global dismissal defaults. @default { closeOnEscape: true, backdrop: 'close' } */\n readonly dismiss?: ModalDismissPolicy;\n /** Capacity of simultaneously active modals. Clamped to ≥ 1. @default 5 */\n readonly maxVisible?: number;\n /** Bound for queued requests (queue policy). @default Infinity */\n readonly maxQueue?: number;\n /** Overflow behavior when full. @default 'queue' (lossless — never drops input) */\n readonly overflow?: ModalOverflowPolicy;\n /** Parent closure with remaining descendants. @default 'close-with-parent' */\n readonly descendants?: DescendantPolicy;\n /** Named dismissal presets referenced by `openModal({ preset })`. */\n readonly presets?: Record<string, ModalPreset>;\n /**\n * Time the manager waits for UI teardown confirmation (`confirmClosed`)\n * before auto-finalizing a `closing` instance. `0` = manual only.\n * @default 300\n */\n readonly closingTimeout?: number;\n /**\n * Bounded transition log for debugging (ring buffer, no payloads).\n * Off by default to stay production-light.\n * @default false\n */\n readonly diagnostics?: boolean;\n}\n\n/**\n * Internal configuration token. Consumers must use `provideRiviniaModal()`.\n * @internal\n */\nexport const RIVINIA_MODAL_CONFIG = new InjectionToken<RiviniaModalConfig>(\n 'RIVINIA_MODAL_CONFIG',\n);\n\nexport const DEFAULT_MAX_VISIBLE = 5;\nexport const DEFAULT_CLOSING_TIMEOUT = 300;\nexport const DIAGNOSTIC_CAP = 100;\n","import { DestroyRef, computed, inject, Service, signal } from '@angular/core';\nimport {\n DEFAULT_CLOSING_TIMEOUT,\n DEFAULT_MAX_VISIBLE,\n DIAGNOSTIC_CAP,\n RIVINIA_MODAL_CONFIG,\n type RiviniaModalConfig,\n} from './modal-config';\nimport type {\n ModalDiagnostic,\n ModalDismissOrigin,\n ModalGuard,\n ModalHandle,\n ModalInstance,\n ModalOpenOptions,\n ModalResult,\n ModalUpdate,\n} from './modal-types';\nimport { QueueStrategy, StackStrategy, type ModalStrategy } from './modal-strategy';\n\ninterface ModalRecord extends ModalInstance<unknown> {\n openedSeq: number;\n generation: number;\n guards: readonly ModalGuard[];\n closingTimer: ReturnType<typeof setTimeout> | null;\n ownerCleanup: (() => void) | null;\n}\n\ninterface PendingResult {\n promise: Promise<ModalResult<unknown>>;\n resolve: (value: ModalResult<unknown>) => void;\n settled: boolean;\n}\n\n/**\n * Headless modal manager. Owns identity, lifecycle coordination, result\n * settlement and diagnostics. Ordering/admission policy is delegated to a\n * `ModalStrategy` (Stack/Queue/custom) — the manager never branches on mode.\n *\n * Settlement contract (each path settles exactly once, never hangs):\n * - `close()` on active → `confirmed` · `dismiss()` on active → `dismissed`\n * - `cancel()` on active → `cancelled` · on queued → `queued-cancelled`\n * - `dismiss()` on queued → `dismissed` with the reason (pre-activation)\n * - overflow/guard refusal → `rejected` · owner gone → `owner-destroyed`\n * - forced paths (owner, overflow eviction, `dismissAll`) bypass guards\n * - `close/dismiss/cancel` are guard-free primitives; `requestDismiss` runs\n * guards for user intents (escape/backdrop/button) or explicit review\n * - infrastructure failures reject the promise — never a result variant\n *\n * Results settle at dismissal initiation, not after UI teardown: animations\n * never delay callers. The `closing` → `closed` handshake (`confirmClosed`\n * or `closingTimeout` auto-finalize) exists only for exit animations.\n */\n@Service()\nexport class RiviniaModal {\n private readonly config = inject<RiviniaModalConfig>(RIVINIA_MODAL_CONFIG, {\n optional: true,\n });\n\n private readonly _instances = signal<readonly ModalRecord[]>([]);\n private readonly _results = new Map<string, PendingResult>();\n private _strategy: ModalStrategy = this.resolveStrategy(this.config?.strategy);\n private _seq = 0;\n private _diagSeq = 0;\n private readonly _diagnostics: ModalDiagnostic[] = [];\n\n /** Active instances in strategy display order (bottom-first). */\n readonly active = computed<readonly ModalInstance[]>(() =>\n this._strategy.order(this._instances().filter((i) => i.status === 'active')),\n );\n /** Queued instances, FIFO admission order. */\n readonly queued = computed<readonly ModalInstance[]>(() =>\n this._instances().filter((i) => i.status === 'queued'),\n );\n /** Topmost active instance (the only Escape-eligible one), if any. */\n readonly top = computed<ModalInstance | null>(() => {\n const list = this.active();\n return list.length > 0 ? list[list.length - 1] : null;\n });\n\n constructor() {\n inject(DestroyRef).onDestroy(() => {\n for (const record of [...this._instances()]) {\n if (record.status === 'queued') {\n this.remove(record.id);\n this.settle(record.id, { status: 'queued-cancelled' });\n } else if (record.status !== 'closed') {\n this.clearClosingTimer(record);\n this.remove(record.id);\n this.settle(record.id, { status: 'dismissed', reason: 'manager-destroyed' });\n }\n }\n });\n }\n\n openModal<TData = unknown, TResult = unknown>(\n content: import('./modal-types').ModalContent<TData>,\n options: ModalOpenOptions<TData> = {},\n ): ModalHandle<TResult> {\n const id = `modal-${++this._seq}`;\n const pending = this.deferred();\n this._results.set(id, pending);\n const handle: ModalHandle<TResult> = {\n id,\n result: pending.promise as Promise<ModalResult<TResult>>,\n close: (value?: TResult) => this.close(id, value),\n dismiss: (reason?: string) => this.dismiss(id, reason),\n cancel: (reason?: string) => this.cancel(id, reason),\n };\n\n const visibleCount = this._instances().filter((i) => i.status !== 'queued').length;\n const intent = this._strategy.admit(visibleCount, this.queuedCount(), this.maxVisible());\n let status: 'active' | 'queued' = intent === 'activate' ? 'active' : 'queued';\n if (intent === 'queue') {\n const outcome = this.admitQueued(id);\n if (outcome === 'active' || outcome === 'queued') {\n // 'dismiss-oldest' evicted room: the newcomer activates immediately.\n // 'queue' keeps it queued.\n status = outcome;\n } else {\n this.settle(id, { status: 'rejected', reason: outcome });\n this.diagnose('rejected', id, options.ref ?? null, outcome);\n return handle;\n }\n }\n\n const record: ModalRecord = {\n id,\n ref: options.ref ?? null,\n caller: options.caller ?? null,\n parentId: this.knownParent(options.parentId) ? (options.parentId as string) : null,\n data: options.data as unknown,\n content: content as ModalRecord['content'],\n status,\n dismiss: {\n closeOnEscape: true,\n backdrop: 'close',\n ...this.presetDismiss(options.preset),\n ...options.dismiss,\n },\n metadata: { ...(options.metadata ?? {}) },\n openedSeq: this._seq,\n generation: 0,\n guards: [...(options.guards ?? [])],\n closingTimer: null,\n ownerCleanup: null,\n };\n if (options.parentId !== undefined && record.parentId === null) {\n this.diagnose('rejected', id, record.ref, 'stale-parent');\n }\n this._instances.update((list) => [...list, record]);\n\n if (options.owner) {\n const off = options.owner.onDestroy(() => this.teardownOwned(id));\n this.patch(id, { ownerCleanup: off });\n }\n this.diagnose(intent === 'activate' ? 'activated' : 'queued', id, record.ref);\n return handle;\n }\n\n /**\n * Confirms with a value. Active instances only; settles `confirmed`.\n * Blocked (returns `false`) by the prevent-parent-close policy.\n */\n close(id: string, value?: unknown): boolean {\n const record = this.get(id);\n if (!record || record.status !== 'active' || this.blockedByDescendants(id)) return false;\n this.beginClosing(id, 'confirmed', undefined, value);\n return true;\n }\n\n /**\n * Guard-free dismissal primitive. Active → `dismissed`; queued → removed as\n * `dismissed` with the reason. Blocked by prevent-parent-close.\n */\n dismiss(id: string, reason?: string): boolean {\n const record = this.get(id);\n if (!record || this.blockedByDescendants(id)) return false;\n if (record.status === 'active') {\n this.beginClosing(id, 'dismissed', reason);\n return true;\n }\n if (record.status === 'queued') {\n this.closeDescendants(id, true);\n this.remove(id);\n this.settle(id, { status: 'dismissed', reason });\n this.diagnose('dismissed', id, record.ref, reason);\n return true;\n }\n return false;\n }\n\n /**\n * Cancels: active → `cancelled`, queued → removed as `queued-cancelled`.\n * Blocked by prevent-parent-close. Closing/closed/unknown → `false`.\n */\n cancel(id: string, reason?: string): boolean {\n const record = this.get(id);\n if (!record || this.blockedByDescendants(id)) return false;\n if (record.status === 'queued') {\n this.closeDescendants(id, true);\n this.remove(id);\n this.settle(id, { status: 'queued-cancelled' });\n this.diagnose('dismissed', id, record.ref, reason ?? 'cancelled');\n return true;\n }\n if (record.status !== 'active') return false;\n this.beginClosing(id, 'cancelled', reason);\n return true;\n }\n\n /**\n * Guarded dismissal for user intents (escape/backdrop/button) or explicit\n * programmatic review. Guards run sequentially; any `false` or throw denies\n * (stays open + diagnostic). Stale guard completions are ignored.\n */\n async requestDismiss(\n id: string,\n origin: ModalDismissOrigin,\n reason?: string,\n ): Promise<boolean> {\n const record = this.get(id);\n if (!record || record.status !== 'active' || this.blockedByDescendants(id)) return false;\n const generation = record.generation;\n for (const guard of record.guards) {\n let allowed: boolean;\n try {\n allowed = await guard({ id, origin });\n } catch {\n allowed = false;\n }\n const current = this.get(id);\n if (!current || current.status !== 'active' || current.generation !== generation) {\n return false; // Resolved stale: something else already moved it.\n }\n if (!allowed) {\n this.diagnose('guard-denied', id, record.ref, origin);\n return false;\n }\n }\n return this.dismiss(id, reason ?? origin);\n }\n\n /** Merges data/metadata (new object identity so UI re-renders). */\n update<TData>(id: string, changes: import('./modal-types').ModalUpdate<TData>): boolean {\n const record = this.get(id);\n if (!record || (record.status !== 'active' && record.status !== 'queued')) return false;\n this._instances.update((list) =>\n list.map((t) =>\n t.id === id\n ? {\n ...t,\n data: (changes.data !== undefined ? changes.data : t.data) as unknown,\n metadata: changes.metadata !== undefined ? { ...changes.metadata } : t.metadata,\n }\n : t,\n ),\n );\n return true;\n }\n\n /** UI teardown confirmation after exit animations. Auto-finalizes via timeout. */\n confirmClosed(id: string): boolean {\n const record = this.get(id);\n if (!record || record.status !== 'closing') return false;\n this.finalize(id);\n return true;\n }\n\n /** Forced cleanup (bypasses guards): active → `dismissed`, queued → `queued-cancelled`. */\n dismissAll(reason = 'dismiss-all'): void {\n for (const record of [...this._instances()]) {\n if (record.status === 'queued') {\n this.remove(record.id);\n this.settle(record.id, { status: 'queued-cancelled' });\n } else if (record.status === 'active') {\n this.beginClosing(record.id, 'dismissed', reason);\n } else if (record.status === 'closing') {\n this.finalize(record.id);\n }\n }\n }\n\n /** Non-closed snapshots for a definition ref (e.g. avoid duplicate confirms). */\n findByRef(ref: string): ModalInstance[] {\n return this._instances().filter((i) => i.ref === ref && i.status !== 'closed');\n }\n\n /**\n * Swaps the strategy. Rejected with an explicit error while any instance is\n * not closed — silent live migration is never attempted.\n */\n useStrategy(strategy: ModalStrategy): void {\n if (this._instances().some((i) => i.status !== 'closed')) {\n throw new Error(\n '[rivinia/modal] useStrategy() rejected: active, queued or closing modals exist. ' +\n 'Close everything first — live migration is not supported.',\n );\n }\n this._strategy = strategy;\n }\n\n /** Introspection snapshot. Active means interactive; closing instances are\n * still rendered (exit animations) until `confirmClosed`/timeout finalizes. */\n debugState(): { strategy: string; active: string[]; closing: string[]; queued: string[] } {\n const all = this._instances();\n return {\n strategy: this._strategy.name,\n active: this.orderedActive()\n .filter((i) => i.status === 'active')\n .map((i) => i.id),\n closing: all.filter((i) => i.status === 'closing').map((i) => i.id),\n queued: all.filter((i) => i.status === 'queued').map((i) => i.id),\n };\n }\n\n /** Bounded transition log (only when `diagnostics: true`). */\n getDiagnostics(): readonly import('./modal-types').ModalDiagnostic[] {\n return [...this._diagnostics];\n }\n\n clearDiagnostics(): void {\n this._diagnostics.length = 0;\n }\n\n // ------------------------------------------------------------------ internals\n\n private get(id: string): ModalRecord | undefined {\n return this._instances().find((i) => i.id === id);\n }\n\n private queuedCount(): number {\n return this._instances().filter((i) => i.status === 'queued').length;\n }\n\n private orderedActive(): ModalRecord[] {\n return [\n ...this._strategy.order(\n this._instances().filter((i) => i.status === 'active' || i.status === 'closing'),\n ),\n ];\n }\n\n private maxVisible(): number {\n return Math.max(1, Math.floor(this.config?.maxVisible ?? DEFAULT_MAX_VISIBLE));\n }\n\n private maxQueue(): number {\n const value = this.config?.maxQueue ?? Number.POSITIVE_INFINITY;\n return value < 0 ? 0 : value;\n }\n\n private presetDismiss(preset?: string): Record<string, unknown> {\n if (!preset) return {};\n return { ...(this.config?.presets?.[preset]?.dismiss ?? {}) };\n }\n\n private knownParent(parentId: string | undefined): parentId is string {\n if (parentId === undefined) return false;\n return this._instances().some((i) => i.id === parentId && i.status !== 'closed');\n }\n\n /** Overflow handling for queue-intent admissions. Returns rejection reason, or the resulting status. */\n private admitQueued(id: string): string | 'active' | 'queued' {\n switch (this.config?.overflow ?? 'queue') {\n case 'reject':\n return 'overflow';\n case 'dismiss-oldest': {\n // Preempt: evict oldest pure-active instance (system path, no guards).\n const oldest = this.orderedActive().find((i) => i.status === 'active');\n if (oldest) this.beginClosing(oldest.id, 'dismissed', 'overflow-evicted');\n return 'active';\n }\n case 'queue':\n default:\n return this.queuedCount() >= this.maxQueue() ? 'queue-full' : 'queued';\n }\n }\n\n private blockedByDescendants(id: string): boolean {\n if ((this.config?.descendants ?? 'close-with-parent') !== 'prevent-parent-close') {\n return false;\n }\n const blocked = this._instances().some(\n (i) => i.parentId === id && i.status !== 'closed' && i.status !== 'closing',\n );\n if (blocked) this.diagnose('strategy-rejected', id, this.get(id)?.ref ?? null, 'prevent-parent-close');\n return blocked;\n }\n\n private beginClosing(\n id: string,\n status: 'confirmed' | 'dismissed' | 'cancelled',\n reason?: string,\n value?: unknown,\n ): void {\n const record = this.get(id);\n if (!record || record.status !== 'active') return;\n record.generation++;\n this.closeDescendants(id, false);\n this.patch(id, { status: 'closing' });\n if (status === 'confirmed') {\n this.settle(id, { status, value } as ModalResult<unknown>);\n } else {\n this.settle(id, { status, reason } as ModalResult<unknown>);\n }\n this.diagnose('closing', id, record.ref, reason);\n const timeout = Math.max(0, this.config?.closingTimeout ?? DEFAULT_CLOSING_TIMEOUT);\n if (timeout === 0) return; // Manual teardown: UI must call confirmClosed().\n const generation = record.generation;\n record.closingTimer = setTimeout(() => {\n const current = this.get(id);\n if (current && current.status === 'closing' && current.generation === generation) {\n this.finalize(id);\n }\n }, timeout);\n // Patch the timer into the stored record (same object identity held above).\n this.patch(id, { closingTimer: record.closingTimer });\n }\n\n private closeDescendants(parentId: string, force: boolean): void {\n if (!force && (this.config?.descendants ?? 'close-with-parent') !== 'close-with-parent') {\n return;\n }\n for (const child of this._instances().filter(\n (i) => i.parentId === parentId && (i.status === 'active' || i.status === 'queued'),\n )) {\n if (child.status === 'queued') {\n this.remove(child.id);\n this.settle(child.id, { status: 'queued-cancelled' });\n this.diagnose('dismissed', child.id, child.ref, 'parent-closed');\n } else {\n this.closeDescendants(child.id, force);\n this.beginClosing(child.id, 'dismissed', 'parent-closed');\n }\n }\n }\n\n private finalize(id: string, promoteAfter = true): void {\n const record = this.get(id);\n if (!record) return;\n this.clearClosingTimer(record);\n this.patch(id, { status: 'closed' });\n record.ownerCleanup?.();\n this._instances.update((list) => list.filter((i) => i.id !== id));\n this._results.delete(id);\n this.diagnose('closed', id, record.ref);\n if (promoteAfter) this.promote();\n }\n\n private promote(): void {\n const width = this._strategy.maxActive(this.maxVisible());\n const visible = (): number =>\n this._instances().filter((i) => i.status === 'active' || i.status === 'closing').length;\n while (visible() < width) {\n const head = this._instances().find((i) => i.status === 'queued');\n if (!head) break;\n this.patch(head.id, { status: 'active' });\n this.diagnose('promoted', head.id, head.ref);\n }\n }\n\n private remove(id: string): void {\n const record = this.get(id);\n if (record) this.clearClosingTimer(record);\n this._instances.update((list) => list.filter((i) => i.id !== id));\n }\n\n private clearClosingTimer(record: ModalRecord): void {\n if (record.closingTimer) {\n clearTimeout(record.closingTimer);\n record.closingTimer = null;\n }\n }\n\n private teardownOwned(id: string): void {\n const record = this.get(id);\n if (!record || record.status === 'closed' || record.status === 'closing') return;\n if (record.status === 'queued') {\n this.closeDescendants(id, true);\n this.remove(id);\n this.settle(id, { status: 'queued-cancelled' });\n this.diagnose('dismissed', id, record.ref, 'owner-destroyed');\n return;\n }\n record.generation++;\n this.closeDescendants(id, true);\n this.patch(id, { status: 'closing' });\n this.settle(id, { status: 'owner-destroyed' });\n this.diagnose('closing', id, record.ref, 'owner-destroyed');\n // No promotion: tearing down must not activate queued requests\n // only to destroy them a moment later (changes their settlement).\n this.finalize(id, false);\n }\n\n private patch(id: string, changes: Partial<ModalRecord>): void {\n this._instances.update((list) => list.map((t) => (t.id === id ? { ...t, ...changes } : t)));\n }\n\n private deferred<TResult>(): PendingResult & { promise: Promise<ModalResult<TResult>> } {\n let resolve!: (value: ModalResult<unknown>) => void;\n const promise = new Promise<ModalResult<unknown>>((res) => {\n resolve = res;\n });\n return {\n promise: promise as Promise<ModalResult<TResult>>,\n resolve: (value) => {\n resolve(value);\n },\n settled: false,\n };\n }\n\n private settle(id: string, value: ModalResult<unknown>): void {\n const pending = this._results.get(id);\n if (!pending || pending.settled) return; // Exactly-once, even under reentrancy.\n pending.settled = true;\n pending.resolve(value);\n }\n\n private diagnose(\n type: import('./modal-types').ModalDiagnostic['type'],\n id: string,\n ref: string | null,\n reason?: string,\n ): void {\n if (!this.config?.diagnostics) return;\n this._diagnostics.push({ seq: ++this._diagSeq, type, id, ref, reason });\n while (this._diagnostics.length > DIAGNOSTIC_CAP) this._diagnostics.shift();\n }\n\n private resolveStrategy(configured: RiviniaModalConfig['strategy']): ModalStrategy {\n if (!configured || configured === 'stack') return new StackStrategy();\n if (configured === 'queue') return new QueueStrategy();\n return configured;\n }\n}\n","import { EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { RIVINIA_MODAL_CONFIG, type RiviniaModalConfig } from './modal-config';\nimport { RiviniaModal } from './rivinia-modal';\n\n/**\n * Configures `@rivinia/modal`. The manager is a root singleton; call once at\n * bootstrap. Per-open options always win over these globals. No styling,\n * positions, or icons here — ever.\n *\n * @example\n * ```ts\n * provideRiviniaModal({ strategy: 'stack', maxVisible: 3 })\n * ```\n */\nexport function provideRiviniaModal(config: RiviniaModalConfig = {}): EnvironmentProviders {\n return makeEnvironmentProviders([{ provide: RIVINIA_MODAL_CONFIG, useValue: config }]);\n}\n\n/** Shorthand for `inject(RiviniaModal)`. */\nexport function injectRiviniaModal(): RiviniaModal {\n return inject(RiviniaModal);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rivinia/modal",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Headless modal manager for Angular: stack/queue strategies, typed results, guards, ownership.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"angular",
|
|
7
|
+
"modal",
|
|
8
|
+
"dialog",
|
|
9
|
+
"signals",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@angular/core": "^22.0.0"
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"schematics": "./schematics/collection.json",
|
|
18
|
+
"module": "fesm2022/rivinia-modal.mjs",
|
|
19
|
+
"typings": "types/rivinia-modal.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
"./package.json": {
|
|
22
|
+
"default": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./types/rivinia-modal.d.ts",
|
|
26
|
+
"default": "./fesm2022/rivinia-modal.mjs"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"tslib": "^2.3.0"
|
|
32
|
+
}
|
|
6
33
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json",
|
|
3
|
+
"schematics": {
|
|
4
|
+
"ng-add": {
|
|
5
|
+
"description": "Add provideRiviniaModal() to the application config.",
|
|
6
|
+
"factory": "./ng-add/index.cjs#ngAdd",
|
|
7
|
+
"schema": "./ng-add/schema.json"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ngAdd = ngAdd;
|
|
4
|
+
const schematics_1 = require("@angular-devkit/schematics");
|
|
5
|
+
const PROVIDE_IMPORT = `import { provideRiviniaModal } from '@rivinia/modal';`;
|
|
6
|
+
/**
|
|
7
|
+
* `ng add @rivinia/modal` — wires the manager into a standalone app config.
|
|
8
|
+
*
|
|
9
|
+
* - Never overwrites user files; only inserts one import + one provider.
|
|
10
|
+
* - Idempotent: safe to run twice. No prompts by design (strategy is a
|
|
11
|
+
* one-line edit; pass `--strategy queue` or edit afterwards).
|
|
12
|
+
* - Rendering outlet (`<rv-modal-outlet/>` in a future `/ui` entry) is a
|
|
13
|
+
* layout decision the schematic never makes for you.
|
|
14
|
+
*/
|
|
15
|
+
function ngAdd(options) {
|
|
16
|
+
return (tree, context) => {
|
|
17
|
+
let configPath;
|
|
18
|
+
try {
|
|
19
|
+
configPath = findAppConfig(tree, options.project);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
context.logger.warn(`[rivinia/modal] workspace lookup failed (${error instanceof Error ? error.message : error}) — skipping. ` +
|
|
23
|
+
`Add provideRiviniaModal() manually (see README).`);
|
|
24
|
+
return tree;
|
|
25
|
+
}
|
|
26
|
+
if (!configPath || !tree.exists(configPath)) {
|
|
27
|
+
context.logger.warn(`[rivinia/modal] app.config.ts not found — skipping. ` +
|
|
28
|
+
`Add provideRiviniaModal() manually (see README).`);
|
|
29
|
+
return tree;
|
|
30
|
+
}
|
|
31
|
+
let content = tree.read(configPath)?.toString('utf-8') ?? '';
|
|
32
|
+
if (!content) {
|
|
33
|
+
throw new schematics_1.SchematicsException(`[rivinia/modal] ${configPath} is empty.`);
|
|
34
|
+
}
|
|
35
|
+
const provideCall = options.strategy !== undefined ? `provideRiviniaModal({ strategy: '${options.strategy}' })` : 'provideRiviniaModal()';
|
|
36
|
+
let changed = false;
|
|
37
|
+
if (!content.includes(PROVIDE_IMPORT)) {
|
|
38
|
+
content = insertAfterLastImport(content, PROVIDE_IMPORT);
|
|
39
|
+
changed = true;
|
|
40
|
+
}
|
|
41
|
+
if (!content.includes('provideRiviniaModal(')) {
|
|
42
|
+
content = insertProvider(content, provideCall);
|
|
43
|
+
changed = true;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
context.logger.info('[rivinia/modal] provideRiviniaModal() already present — skipped.');
|
|
47
|
+
}
|
|
48
|
+
if (changed) {
|
|
49
|
+
tree.overwrite(configPath, content);
|
|
50
|
+
context.logger.info(`[rivinia/modal] wired into ${configPath}.`);
|
|
51
|
+
}
|
|
52
|
+
return tree;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function defaultProjectName(tree, requested) {
|
|
56
|
+
if (requested)
|
|
57
|
+
return requested;
|
|
58
|
+
const angularJson = tree.read('/angular.json')?.toString('utf-8');
|
|
59
|
+
if (!angularJson) {
|
|
60
|
+
throw new schematics_1.SchematicsException('[rivinia/modal] angular.json not found.');
|
|
61
|
+
}
|
|
62
|
+
const workspace = JSON.parse(angularJson);
|
|
63
|
+
if (workspace.defaultProject && typeof workspace.defaultProject === 'string') {
|
|
64
|
+
return workspace.defaultProject;
|
|
65
|
+
}
|
|
66
|
+
const names = Object.keys(workspace.projects ?? {});
|
|
67
|
+
if (names.length === 0) {
|
|
68
|
+
throw new schematics_1.SchematicsException('[rivinia/modal] no projects in angular.json.');
|
|
69
|
+
}
|
|
70
|
+
return names[0];
|
|
71
|
+
}
|
|
72
|
+
function findAppConfig(tree, requestedProject) {
|
|
73
|
+
const angularJson = tree.read('/angular.json')?.toString('utf-8');
|
|
74
|
+
if (!angularJson)
|
|
75
|
+
return null;
|
|
76
|
+
const workspace = JSON.parse(angularJson);
|
|
77
|
+
const name = defaultProjectName(tree, requestedProject);
|
|
78
|
+
const project = workspace.projects?.[name];
|
|
79
|
+
if (!project)
|
|
80
|
+
return null;
|
|
81
|
+
const sourceRoot = project.sourceRoot ?? (project.root ? `${project.root}/src` : 'src');
|
|
82
|
+
return `/${sourceRoot}/app/app.config.ts`;
|
|
83
|
+
}
|
|
84
|
+
function insertAfterLastImport(content, line) {
|
|
85
|
+
const lines = content.split('\n');
|
|
86
|
+
let idx = -1;
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
if (/^\s*import\s/.test(lines[i]))
|
|
89
|
+
idx = i;
|
|
90
|
+
}
|
|
91
|
+
lines.splice(idx + 1, 0, line);
|
|
92
|
+
return lines.join('\n');
|
|
93
|
+
}
|
|
94
|
+
function insertProvider(content, call) {
|
|
95
|
+
const match = /providers\s*:\s*\[/.exec(content);
|
|
96
|
+
if (!match) {
|
|
97
|
+
throw new schematics_1.SchematicsException('[rivinia/modal] no `providers: [` array found in app.config.ts — add providers manually.');
|
|
98
|
+
}
|
|
99
|
+
const at = match.index + match[0].length;
|
|
100
|
+
return `${content.slice(0, at)}\n ${call},${content.slice(at)}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "rivinia-modal-ng-add",
|
|
4
|
+
"title": "Rivinia Modal ng-add options",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"strategy": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "Ordering policy for provideRiviniaModal({ strategy }). Omit for the 'stack' default.",
|
|
10
|
+
"enum": ["stack", "queue"]
|
|
11
|
+
},
|
|
12
|
+
"project": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "Angular CLI project name — only for multi-app workspaces. Defaults to the workspace default project.",
|
|
15
|
+
"default": ""
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"required": []
|
|
19
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import * as i0 from "@angular/core";
|
|
2
|
+
import { DestroyRef, EnvironmentProviders, InjectionToken, TemplateRef, Type } from "@angular/core";
|
|
3
|
+
/**
|
|
4
|
+
* Ordering/admission policy. Strategies are pure with respect to lifecycle:
|
|
5
|
+
* the manager owns identity, results, timers and disposal; strategies own
|
|
6
|
+
* ordering and activation width. No mode-dependent `if/else` in the manager.
|
|
7
|
+
*/
|
|
8
|
+
interface ModalStrategy {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** Maximum simultaneously active instances under this strategy. */
|
|
11
|
+
maxActive(maxVisible: number): number;
|
|
12
|
+
/**
|
|
13
|
+
* Admission of a new request given current visible/queued sets.
|
|
14
|
+
* Returns the action; the manager executes it (and settles results).
|
|
15
|
+
*/
|
|
16
|
+
admit(visibleCount: number, queuedCount: number, maxVisible: number): 'activate' | 'queue';
|
|
17
|
+
/** Display order of visible instances, bottom-first. */
|
|
18
|
+
order<T extends {
|
|
19
|
+
readonly openedSeq: number;
|
|
20
|
+
}>(visible: readonly T[]): readonly T[];
|
|
21
|
+
}
|
|
22
|
+
/** Multiple concurrent modals, topmost (last opened) interactive. */
|
|
23
|
+
export declare class StackStrategy implements ModalStrategy {
|
|
24
|
+
readonly name = "stack";
|
|
25
|
+
maxActive(maxVisible: number): number;
|
|
26
|
+
admit(visibleCount: number, _queuedCount: number, maxVisible: number): 'activate' | 'queue';
|
|
27
|
+
order<T extends {
|
|
28
|
+
readonly openedSeq: number;
|
|
29
|
+
}>(visible: readonly T[]): readonly T[];
|
|
30
|
+
}
|
|
31
|
+
/** Strictly serial: one active modal, FIFO activation of the rest. */
|
|
32
|
+
export declare class QueueStrategy implements ModalStrategy {
|
|
33
|
+
readonly name = "queue";
|
|
34
|
+
maxActive(_maxVisible: number): number;
|
|
35
|
+
admit(visibleCount: number, _queuedCount: number, _maxVisible: number): 'activate' | 'queue';
|
|
36
|
+
order<T extends {
|
|
37
|
+
readonly openedSeq: number;
|
|
38
|
+
}>(visible: readonly T[]): readonly T[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Discriminated result of a modal request. Every terminal path settles exactly
|
|
42
|
+
* once — infrastructure failures reject the promise instead of becoming variants.
|
|
43
|
+
*/
|
|
44
|
+
type ModalResult<T> = {
|
|
45
|
+
readonly status: 'confirmed';
|
|
46
|
+
readonly value: T;
|
|
47
|
+
} | {
|
|
48
|
+
readonly status: 'cancelled';
|
|
49
|
+
readonly reason?: string;
|
|
50
|
+
} | {
|
|
51
|
+
readonly status: 'dismissed';
|
|
52
|
+
readonly reason?: string;
|
|
53
|
+
} | {
|
|
54
|
+
readonly status: 'owner-destroyed';
|
|
55
|
+
} | {
|
|
56
|
+
readonly status: 'queued-cancelled';
|
|
57
|
+
} | {
|
|
58
|
+
readonly status: 'rejected';
|
|
59
|
+
readonly reason?: string;
|
|
60
|
+
};
|
|
61
|
+
/** Lifecycle state of one modal instance. Single source of truth: the manager. */
|
|
62
|
+
type ModalStatus = 'queued' | 'active' | 'closing' | 'closed';
|
|
63
|
+
/** Origin of a dismissal intent. Guards see it; forced paths bypass guards. */
|
|
64
|
+
type ModalDismissOrigin = 'escape' | 'backdrop' | 'button' | 'program' | 'system';
|
|
65
|
+
/** Content a modal renders. Core stores the descriptor; UI interprets it. */
|
|
66
|
+
type ModalContent<TData> = {
|
|
67
|
+
readonly kind: 'template';
|
|
68
|
+
readonly template: TemplateRef<ModalTemplateContext<TData>>;
|
|
69
|
+
} | {
|
|
70
|
+
readonly kind: 'component';
|
|
71
|
+
readonly component: Type<unknown>;
|
|
72
|
+
readonly inputs?: Record<string, unknown>;
|
|
73
|
+
};
|
|
74
|
+
/** Template context for `TemplateRef` content. */
|
|
75
|
+
interface ModalTemplateContext<TData> {
|
|
76
|
+
/** Typed input data (`let-data`). */
|
|
77
|
+
readonly $implicit: TData;
|
|
78
|
+
/** Modal operations (`let-modal="modal"`). */
|
|
79
|
+
readonly modal: ModalContext<TData>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Operations exposed to modal content (injected token value for components,
|
|
83
|
+
* template context for templates). Scoped to ONE instance — never global.
|
|
84
|
+
*/
|
|
85
|
+
interface ModalContext<TData> {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly data: TData;
|
|
88
|
+
readonly parentId: string | null;
|
|
89
|
+
close(result?: unknown): boolean;
|
|
90
|
+
dismiss(reason?: string): boolean;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Guard hook run by `requestDismiss` (user intents: escape/backdrop/button,
|
|
94
|
+
* or explicit programmatic review). Return `false` (or a rejecting promise)
|
|
95
|
+
* to deny. Throwing denies safely. Never blocks forced disposal.
|
|
96
|
+
*/
|
|
97
|
+
type ModalGuard = (context: {
|
|
98
|
+
readonly id: string;
|
|
99
|
+
readonly origin: ModalDismissOrigin;
|
|
100
|
+
}) => boolean | Promise<boolean>;
|
|
101
|
+
/** Dismissal policy: global defaults, per-open overrides win. */
|
|
102
|
+
interface ModalDismissPolicy {
|
|
103
|
+
/** Escape closes the topmost eligible modal. @default true */
|
|
104
|
+
readonly closeOnEscape?: boolean;
|
|
105
|
+
/** Backdrop click behavior. @default 'close' */
|
|
106
|
+
readonly backdrop?: 'close' | 'static' | 'disabled';
|
|
107
|
+
}
|
|
108
|
+
/** Overflow behavior when capacity is full. @default 'queue' (lossless). */
|
|
109
|
+
type ModalOverflowPolicy = 'dismiss-oldest' | 'queue' | 'reject';
|
|
110
|
+
/** What closing a parent does to its descendants. */
|
|
111
|
+
type DescendantPolicy = 'close-with-parent' | 'prevent-parent-close';
|
|
112
|
+
/** Public, UI-consumable snapshot of one modal instance. */
|
|
113
|
+
interface ModalInstance<TData = unknown> {
|
|
114
|
+
readonly id: string;
|
|
115
|
+
/** Stable key for a modal definition (e.g. `'confirm-delete'`). */
|
|
116
|
+
readonly ref: string | null;
|
|
117
|
+
/** Request origin label for diagnostics (e.g. component/feature name). */
|
|
118
|
+
readonly caller: string | null;
|
|
119
|
+
readonly parentId: string | null;
|
|
120
|
+
readonly data: TData;
|
|
121
|
+
readonly content: ModalContent<TData>;
|
|
122
|
+
readonly status: ModalStatus;
|
|
123
|
+
readonly dismiss: ModalDismissPolicy;
|
|
124
|
+
readonly metadata: Readonly<Record<string, unknown>>;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Caller-side handle. `result` settles exactly once per the manager contract;
|
|
128
|
+
* repeated terminal calls are safe no-ops returning `false`.
|
|
129
|
+
*/
|
|
130
|
+
interface ModalHandle<TResult = unknown> {
|
|
131
|
+
readonly id: string;
|
|
132
|
+
readonly result: Promise<ModalResult<TResult>>;
|
|
133
|
+
close(result?: TResult): boolean;
|
|
134
|
+
dismiss(reason?: string): boolean;
|
|
135
|
+
cancel(reason?: string): boolean;
|
|
136
|
+
}
|
|
137
|
+
/** Arguments to `openModal()`. */
|
|
138
|
+
interface ModalOpenOptions<TData = unknown> {
|
|
139
|
+
readonly data?: TData;
|
|
140
|
+
readonly ref?: string;
|
|
141
|
+
readonly caller?: string;
|
|
142
|
+
readonly parentId?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Lifecycle owner. Destruction settles active modals as `owner-destroyed`
|
|
145
|
+
* and queued ones as `queued-cancelled`. Default: independent lifetime.
|
|
146
|
+
*/
|
|
147
|
+
readonly owner?: DestroyRef;
|
|
148
|
+
/** Named preset from global config (explicit options always win). */
|
|
149
|
+
readonly preset?: string;
|
|
150
|
+
readonly dismiss?: Partial<ModalDismissPolicy>;
|
|
151
|
+
readonly guards?: readonly ModalGuard[];
|
|
152
|
+
readonly metadata?: Record<string, unknown>;
|
|
153
|
+
}
|
|
154
|
+
/** Partial changes for `update()` (content shape itself is immutable). */
|
|
155
|
+
interface ModalUpdate<TData = unknown> {
|
|
156
|
+
readonly data?: TData;
|
|
157
|
+
readonly metadata?: Record<string, unknown>;
|
|
158
|
+
}
|
|
159
|
+
/** Bounded diagnostic record. Never carries payloads. */
|
|
160
|
+
interface ModalDiagnostic {
|
|
161
|
+
readonly seq: number;
|
|
162
|
+
readonly type: 'opened' | 'activated' | 'queued' | 'promoted' | 'closing' | 'closed' | 'dismissed' | 'rejected' | 'guard-denied' | 'strategy-rejected';
|
|
163
|
+
readonly id: string;
|
|
164
|
+
readonly ref: string | null;
|
|
165
|
+
readonly reason?: string;
|
|
166
|
+
}
|
|
167
|
+
/** Named preset: a reusable dismissal shape (explicit options always win). */
|
|
168
|
+
interface ModalPreset {
|
|
169
|
+
readonly dismiss?: Partial<ModalDismissPolicy>;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Global configuration for `@rivinia/modal`. Everything optional; per-open
|
|
173
|
+
* options win. No styling, positions, icons, or translations here — ever.
|
|
174
|
+
*/
|
|
175
|
+
interface RiviniaModalConfig {
|
|
176
|
+
/**
|
|
177
|
+
* Ordering/activation policy. `'stack'` = up to `maxVisible` concurrent,
|
|
178
|
+
* topmost interactive. `'queue'` = strictly serial (one active, FIFO rest).
|
|
179
|
+
* A custom `ModalStrategy` instance is also accepted.
|
|
180
|
+
* @default 'stack'
|
|
181
|
+
*/
|
|
182
|
+
readonly strategy?: 'stack' | 'queue' | ModalStrategy;
|
|
183
|
+
/** Global dismissal defaults. @default { closeOnEscape: true, backdrop: 'close' } */
|
|
184
|
+
readonly dismiss?: ModalDismissPolicy;
|
|
185
|
+
/** Capacity of simultaneously active modals. Clamped to ≥ 1. @default 5 */
|
|
186
|
+
readonly maxVisible?: number;
|
|
187
|
+
/** Bound for queued requests (queue policy). @default Infinity */
|
|
188
|
+
readonly maxQueue?: number;
|
|
189
|
+
/** Overflow behavior when full. @default 'queue' (lossless — never drops input) */
|
|
190
|
+
readonly overflow?: ModalOverflowPolicy;
|
|
191
|
+
/** Parent closure with remaining descendants. @default 'close-with-parent' */
|
|
192
|
+
readonly descendants?: DescendantPolicy;
|
|
193
|
+
/** Named dismissal presets referenced by `openModal({ preset })`. */
|
|
194
|
+
readonly presets?: Record<string, ModalPreset>;
|
|
195
|
+
/**
|
|
196
|
+
* Time the manager waits for UI teardown confirmation (`confirmClosed`)
|
|
197
|
+
* before auto-finalizing a `closing` instance. `0` = manual only.
|
|
198
|
+
* @default 300
|
|
199
|
+
*/
|
|
200
|
+
readonly closingTimeout?: number;
|
|
201
|
+
/**
|
|
202
|
+
* Bounded transition log for debugging (ring buffer, no payloads).
|
|
203
|
+
* Off by default to stay production-light.
|
|
204
|
+
* @default false
|
|
205
|
+
*/
|
|
206
|
+
readonly diagnostics?: boolean;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Internal configuration token. Consumers must use `provideRiviniaModal()`.
|
|
210
|
+
* @internal
|
|
211
|
+
*/
|
|
212
|
+
declare const RIVINIA_MODAL_CONFIG: InjectionToken<RiviniaModalConfig>;
|
|
213
|
+
declare const DEFAULT_MAX_VISIBLE = 5;
|
|
214
|
+
declare const DEFAULT_CLOSING_TIMEOUT = 300;
|
|
215
|
+
declare const DIAGNOSTIC_CAP = 100;
|
|
216
|
+
/**
|
|
217
|
+
* Injection token providing the `ModalContext` of the enclosing modal to
|
|
218
|
+
* dynamically created components. Template content receives the same object
|
|
219
|
+
* as `let-modal="modal"`. Never global — one value per modal instance.
|
|
220
|
+
*/
|
|
221
|
+
export declare const RIVINIA_MODAL_CONTEXT: InjectionToken<ModalContext<unknown>>;
|
|
222
|
+
/**
|
|
223
|
+
* Headless modal manager. Owns identity, lifecycle coordination, result
|
|
224
|
+
* settlement and diagnostics. Ordering/admission policy is delegated to a
|
|
225
|
+
* `ModalStrategy` (Stack/Queue/custom) — the manager never branches on mode.
|
|
226
|
+
*
|
|
227
|
+
* Settlement contract (each path settles exactly once, never hangs):
|
|
228
|
+
* - `close()` on active → `confirmed` · `dismiss()` on active → `dismissed`
|
|
229
|
+
* - `cancel()` on active → `cancelled` · on queued → `queued-cancelled`
|
|
230
|
+
* - `dismiss()` on queued → `dismissed` with the reason (pre-activation)
|
|
231
|
+
* - overflow/guard refusal → `rejected` · owner gone → `owner-destroyed`
|
|
232
|
+
* - forced paths (owner, overflow eviction, `dismissAll`) bypass guards
|
|
233
|
+
* - `close/dismiss/cancel` are guard-free primitives; `requestDismiss` runs
|
|
234
|
+
* guards for user intents (escape/backdrop/button) or explicit review
|
|
235
|
+
* - infrastructure failures reject the promise — never a result variant
|
|
236
|
+
*
|
|
237
|
+
* Results settle at dismissal initiation, not after UI teardown: animations
|
|
238
|
+
* never delay callers. The `closing` → `closed` handshake (`confirmClosed`
|
|
239
|
+
* or `closingTimeout` auto-finalize) exists only for exit animations.
|
|
240
|
+
*/
|
|
241
|
+
export declare class RiviniaModal {
|
|
242
|
+
private readonly config;
|
|
243
|
+
private readonly _instances;
|
|
244
|
+
private readonly _results;
|
|
245
|
+
private _strategy;
|
|
246
|
+
private _seq;
|
|
247
|
+
private _diagSeq;
|
|
248
|
+
private readonly _diagnostics;
|
|
249
|
+
/** Active instances in strategy display order (bottom-first). */
|
|
250
|
+
readonly active: import("@angular/core").Signal<readonly ModalInstance<unknown>[]>;
|
|
251
|
+
/** Queued instances, FIFO admission order. */
|
|
252
|
+
readonly queued: import("@angular/core").Signal<readonly ModalInstance<unknown>[]>;
|
|
253
|
+
/** Topmost active instance (the only Escape-eligible one), if any. */
|
|
254
|
+
readonly top: import("@angular/core").Signal<ModalInstance<unknown> | null>;
|
|
255
|
+
constructor();
|
|
256
|
+
openModal<TData = unknown, TResult = unknown>(content: ModalContent<TData>, options?: ModalOpenOptions<TData>): ModalHandle<TResult>;
|
|
257
|
+
/**
|
|
258
|
+
* Confirms with a value. Active instances only; settles `confirmed`.
|
|
259
|
+
* Blocked (returns `false`) by the prevent-parent-close policy.
|
|
260
|
+
*/
|
|
261
|
+
close(id: string, value?: unknown): boolean;
|
|
262
|
+
/**
|
|
263
|
+
* Guard-free dismissal primitive. Active → `dismissed`; queued → removed as
|
|
264
|
+
* `dismissed` with the reason. Blocked by prevent-parent-close.
|
|
265
|
+
*/
|
|
266
|
+
dismiss(id: string, reason?: string): boolean;
|
|
267
|
+
/**
|
|
268
|
+
* Cancels: active → `cancelled`, queued → removed as `queued-cancelled`.
|
|
269
|
+
* Blocked by prevent-parent-close. Closing/closed/unknown → `false`.
|
|
270
|
+
*/
|
|
271
|
+
cancel(id: string, reason?: string): boolean;
|
|
272
|
+
/**
|
|
273
|
+
* Guarded dismissal for user intents (escape/backdrop/button) or explicit
|
|
274
|
+
* programmatic review. Guards run sequentially; any `false` or throw denies
|
|
275
|
+
* (stays open + diagnostic). Stale guard completions are ignored.
|
|
276
|
+
*/
|
|
277
|
+
requestDismiss(id: string, origin: ModalDismissOrigin, reason?: string): Promise<boolean>;
|
|
278
|
+
/** Merges data/metadata (new object identity so UI re-renders). */
|
|
279
|
+
update<TData>(id: string, changes: ModalUpdate<TData>): boolean;
|
|
280
|
+
/** UI teardown confirmation after exit animations. Auto-finalizes via timeout. */
|
|
281
|
+
confirmClosed(id: string): boolean;
|
|
282
|
+
/** Forced cleanup (bypasses guards): active → `dismissed`, queued → `queued-cancelled`. */
|
|
283
|
+
dismissAll(reason?: string): void;
|
|
284
|
+
/** Non-closed snapshots for a definition ref (e.g. avoid duplicate confirms). */
|
|
285
|
+
findByRef(ref: string): ModalInstance[];
|
|
286
|
+
/**
|
|
287
|
+
* Swaps the strategy. Rejected with an explicit error while any instance is
|
|
288
|
+
* not closed — silent live migration is never attempted.
|
|
289
|
+
*/
|
|
290
|
+
useStrategy(strategy: ModalStrategy): void;
|
|
291
|
+
/** Introspection snapshot. Active means interactive; closing instances are
|
|
292
|
+
* still rendered (exit animations) until `confirmClosed`/timeout finalizes. */
|
|
293
|
+
debugState(): {
|
|
294
|
+
strategy: string;
|
|
295
|
+
active: string[];
|
|
296
|
+
closing: string[];
|
|
297
|
+
queued: string[];
|
|
298
|
+
};
|
|
299
|
+
/** Bounded transition log (only when `diagnostics: true`). */
|
|
300
|
+
getDiagnostics(): readonly ModalDiagnostic[];
|
|
301
|
+
clearDiagnostics(): void;
|
|
302
|
+
private get;
|
|
303
|
+
private queuedCount;
|
|
304
|
+
private orderedActive;
|
|
305
|
+
private maxVisible;
|
|
306
|
+
private maxQueue;
|
|
307
|
+
private presetDismiss;
|
|
308
|
+
private knownParent;
|
|
309
|
+
/** Overflow handling for queue-intent admissions. Returns rejection reason, or the resulting status. */
|
|
310
|
+
private admitQueued;
|
|
311
|
+
private blockedByDescendants;
|
|
312
|
+
private beginClosing;
|
|
313
|
+
private closeDescendants;
|
|
314
|
+
private finalize;
|
|
315
|
+
private promote;
|
|
316
|
+
private remove;
|
|
317
|
+
private clearClosingTimer;
|
|
318
|
+
private teardownOwned;
|
|
319
|
+
private patch;
|
|
320
|
+
private deferred;
|
|
321
|
+
private settle;
|
|
322
|
+
private diagnose;
|
|
323
|
+
private resolveStrategy;
|
|
324
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<RiviniaModal, never>;
|
|
325
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<any>;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Configures `@rivinia/modal`. The manager is a root singleton; call once at
|
|
329
|
+
* bootstrap. Per-open options always win over these globals. No styling,
|
|
330
|
+
* positions, or icons here — ever.
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```ts
|
|
334
|
+
* provideRiviniaModal({ strategy: 'stack', maxVisible: 3 })
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export declare function provideRiviniaModal(config?: RiviniaModalConfig): EnvironmentProviders;
|
|
338
|
+
/** Shorthand for `inject(RiviniaModal)`. */
|
|
339
|
+
export declare function injectRiviniaModal(): RiviniaModal;
|
|
340
|
+
export type { DescendantPolicy, ModalContent, ModalContext, ModalDiagnostic, ModalDismissOrigin, ModalDismissPolicy, ModalGuard, ModalHandle, ModalInstance, ModalOpenOptions, ModalOverflowPolicy, ModalPreset, ModalResult, ModalStatus, ModalStrategy, ModalTemplateContext, ModalUpdate, RiviniaModalConfig };
|