@okyrychenko-dev/react-modal-manager 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Oleksii Kyrychenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,625 @@
1
+ # @okyrychenko-dev/react-modal-manager
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@okyrychenko-dev/react-modal-manager.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-modal-manager)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@okyrychenko-dev/react-modal-manager.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-modal-manager)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ > Open modals from anywhere and `await` their result — fully typed, with state isolated per `ModalProvider`.
8
+
9
+ `react-modal-manager` turns modal flows into typed promises. You `open()` a modal, `await` it, and TypeScript infers both the **input** you pass in and the **result** you get back — no `Promise<any>`, no global singleton, no design system lock-in.
10
+
11
+ ```tsx
12
+ import { ModalProvider, useModalManager } from "@okyrychenko-dev/react-modal-manager";
13
+
14
+ function DeleteButton() {
15
+ const modal = useModalManager();
16
+
17
+ async function handleClick() {
18
+ const { confirmed } = await modal.confirm({ title: "Delete report?", variant: "danger" });
19
+
20
+ if (confirmed) {
21
+ await deleteReport();
22
+ }
23
+ }
24
+
25
+ return <button onClick={handleClick}>Delete</button>;
26
+ }
27
+
28
+ // Wrap the subtree once — that is the whole setup.
29
+ const app = (
30
+ <ModalProvider>
31
+ <DeleteButton />
32
+ </ModalProvider>
33
+ );
34
+ ```
35
+
36
+ ## Why This Library
37
+
38
+ - **Typed results, not `any`.** `open<TInput, TResult>(def, input)` returns a `Promise<TResult>`. Both sides of the call are checked.
39
+ - **Per-provider isolation.** Each `ModalProvider` owns its own Zustand store — no global singleton, so subtrees and tests never leak modal state into each other.
40
+ - **Open from non-React code.** A typed registry (or controller) lets event buses, command palettes, and action maps open modals while keeping full inference.
41
+ - **UI-agnostic core.** A single `renderer` boundary lets you plug in portals, overlays, animations, or any design system. The core never prescribes DOM or styling.
42
+ - **Built-in `confirm()`** with a typed, discriminated-union result — useful from day one, replaceable when you need your own design.
43
+ - **Promise-shaped lifecycle.** Dismissals reject with `ModalDismissError`; exit animations are supported through `closeDelayMs` + an `"open" | "closing"` status.
44
+
45
+ ### Compared to `nice-modal-react`
46
+
47
+ | | `react-modal-manager` | `nice-modal-react` |
48
+ | --- | --- | --- |
49
+ | Result typing | `Promise<TResult>`, fully inferred | result is effectively `unknown` / `any` |
50
+ | State scope | isolated per `ModalProvider` | single global singleton |
51
+ | Open from anywhere | typed registry / controller (LIFO provider stack) | global `NiceModal.show(id)` |
52
+ | Built-in confirm | typed `ConfirmModalResult` | none |
53
+ | UI coupling | UI-agnostic `renderer` boundary | you render it yourself |
54
+ | Concepts to first modal | 1 (`confirm`) — or define → register → open for custom modals | 1 (`show`) |
55
+
56
+ **Honest trade-off:** there is no "show a modal by string id from literally anywhere" without importing a typed `ModalDefinition` or a registry. That is the deliberate price of end-to-end type safety, not a missing feature.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ npm install @okyrychenko-dev/react-modal-manager zustand
62
+ # or
63
+ yarn add @okyrychenko-dev/react-modal-manager zustand
64
+ # or
65
+ pnpm add @okyrychenko-dev/react-modal-manager zustand
66
+ ```
67
+
68
+ Peer dependencies:
69
+
70
+ - [React](https://react.dev/) `^18.0.0 || ^19.0.0`
71
+ - [Zustand](https://zustand-demo.pmnd.rs/) `^5.0.0`
72
+
73
+ There are **no runtime dependencies** beyond these peers — the provider-scoped store is built into the package.
74
+
75
+ ## Quick Start
76
+
77
+ Wrap the part of your app that can open modals with `ModalProvider`, then call `useModalManager()` from any descendant.
78
+
79
+ ```tsx
80
+ import { ModalProvider, useModalManager } from "@okyrychenko-dev/react-modal-manager";
81
+
82
+ function App() {
83
+ return (
84
+ <ModalProvider>
85
+ <ReportsPage />
86
+ </ModalProvider>
87
+ );
88
+ }
89
+
90
+ function ReportsPage() {
91
+ const modal = useModalManager();
92
+
93
+ async function handleDelete() {
94
+ const result = await modal.confirm({
95
+ title: "Delete report?",
96
+ description: "This action cannot be undone.",
97
+ confirmText: "Delete",
98
+ cancelText: "Cancel",
99
+ variant: "danger",
100
+ });
101
+
102
+ if (!result.confirmed) {
103
+ return;
104
+ }
105
+
106
+ await deleteReport();
107
+ }
108
+
109
+ return <button onClick={handleDelete}>Delete</button>;
110
+ }
111
+ ```
112
+
113
+ ## Type Safety
114
+
115
+ This is where the library earns its place. Define a modal once and every call site is checked end to end.
116
+
117
+ ```tsx
118
+ import { createModal, type ModalComponentProps } from "@okyrychenko-dev/react-modal-manager";
119
+
120
+ interface RenameInput {
121
+ reportId: string;
122
+ currentName: string;
123
+ }
124
+
125
+ type RenameResult =
126
+ | { status: "renamed"; name: string }
127
+ | { status: "cancelled" };
128
+
129
+ function RenameModal({ close, input }: ModalComponentProps<RenameInput, RenameResult>) {
130
+ // `input` is RenameInput. `close` only accepts a RenameResult.
131
+ }
132
+
133
+ const renameModal = createModal({ component: RenameModal });
134
+
135
+ // At the call site, TypeScript infers everything:
136
+ const result = await modal.open(renameModal, { reportId: "1", currentName: "Q3" });
137
+ // ^? RenameResult — discriminated union, narrowed by `result.status`
138
+ // and `modal.open` rejects the wrong input shape at compile time.
139
+ ```
140
+
141
+ - `ModalComponentProps<TInput, TResult>` ties the component's `input` and `close` together.
142
+ - `modal.open(def, input)` rejects a mismatched `input` and returns `Promise<TResult>`.
143
+ - The registry infers input/result **from the key** (see below).
144
+ - `confirm()` returns a discriminated union, so `if (result.confirmed)` narrows the type.
145
+
146
+ ## Typed Modal Flow
147
+
148
+ Define a modal with explicit input and result types.
149
+
150
+ ```tsx
151
+ import { createModal, type ModalComponentProps } from "@okyrychenko-dev/react-modal-manager";
152
+ import { useState } from "react";
153
+
154
+ interface RenameReportInput {
155
+ reportId: string;
156
+ currentName: string;
157
+ }
158
+
159
+ interface RenameReportSuccessResult {
160
+ status: "renamed";
161
+ name: string;
162
+ }
163
+
164
+ interface RenameReportCancelledResult {
165
+ status: "cancelled";
166
+ }
167
+
168
+ type RenameReportResult =
169
+ | RenameReportSuccessResult
170
+ | RenameReportCancelledResult;
171
+
172
+ function RenameReportModal({
173
+ close,
174
+ input,
175
+ }: ModalComponentProps<RenameReportInput, RenameReportResult>) {
176
+ const [name, setName] = useState(input.currentName);
177
+
178
+ return (
179
+ <dialog open>
180
+ <h2>Rename report</h2>
181
+ <input value={name} onChange={(event) => setName(event.target.value)} />
182
+ <button onClick={() => close({ status: "cancelled" })}>Cancel</button>
183
+ <button onClick={() => close({ status: "renamed", name })}>Rename</button>
184
+ </dialog>
185
+ );
186
+ }
187
+
188
+ export const renameReportModal = createModal<RenameReportInput, RenameReportResult>({
189
+ component: RenameReportModal,
190
+ });
191
+ ```
192
+
193
+ Open it from any descendant of `ModalProvider`.
194
+
195
+ ```tsx
196
+ const result = await modal.open(renameReportModal, {
197
+ reportId: report.id,
198
+ currentName: report.name,
199
+ });
200
+
201
+ if (result.status === "renamed") {
202
+ await renameReport({ reportId: report.id, name: result.name });
203
+ }
204
+ ```
205
+
206
+ `modal.open()` rejects with `ModalDismissError` when the modal is dismissed, `closeAll()` is called, or the provider unmounts while the modal is still pending. Use `try/catch` or `.catch()` when a modal can be dismissed without resolving a result.
207
+
208
+ Keep the handle returned by `open()` when the caller needs to identify or dismiss the specific modal instance later:
209
+
210
+ ```tsx
211
+ const handle = modal.open(renameReportModal, {
212
+ reportId: report.id,
213
+ currentName: report.name,
214
+ });
215
+
216
+ handle.instanceId;
217
+ handle.dismiss();
218
+
219
+ const result = await handle;
220
+ ```
221
+
222
+ The handle's `dismiss()` stays bound to the provider that opened the modal.
223
+
224
+ ## Typed Modal Registry
225
+
226
+ Use `createModalRegistry()` when code needs to open modals by a stable key while keeping typed input and result contracts. This suits command palettes, event buses, action maps, and configuration-driven flows. Pass the registry straight to `ModalProvider` — there is no controller to wire up.
227
+
228
+ ```tsx
229
+ // modals.ts
230
+ import { createModal, createModalRegistry } from "@okyrychenko-dev/react-modal-manager";
231
+ import { RenameModal } from "./RenameModal";
232
+
233
+ export const modals = createModalRegistry({
234
+ rename: createModal({ component: RenameModal }),
235
+ });
236
+ ```
237
+
238
+ ```tsx
239
+ // App.tsx
240
+ import { ModalProvider } from "@okyrychenko-dev/react-modal-manager";
241
+ import { modals } from "./modals";
242
+
243
+ function App() {
244
+ return (
245
+ <ModalProvider registry={modals}>
246
+ <ReportsPage />
247
+ </ModalProvider>
248
+ );
249
+ }
250
+ ```
251
+
252
+ ```tsx
253
+ // anywhere — including non-React code
254
+ export async function renameFromAction(reportId: string, currentName: string) {
255
+ const result = await modals.open("rename", { reportId, currentName });
256
+
257
+ if (result.status === "renamed") {
258
+ await renameReport({ reportId, name: result.name });
259
+ }
260
+ }
261
+ ```
262
+
263
+ The registry key is type-checked, and TypeScript infers the required input and the returned result from the modal registered under that key. `modals.open` from outside the React tree targets the most recently mounted `ModalProvider` bound to that registry (providers form a LIFO stack and fall back on unmount).
264
+
265
+ ## Confirmation Modals
266
+
267
+ `modal.confirm()` (and `registry.confirm()`) opens the built-in confirmation modal and resolves to a typed, discriminated-union result.
268
+
269
+ ```tsx
270
+ const result = await modal.confirm({
271
+ title: "Discard changes?",
272
+ description: "Your edits will be lost.",
273
+ variant: "warning",
274
+ });
275
+
276
+ if (result.confirmed) {
277
+ discard();
278
+ } else {
279
+ // result.reason is "cancel" | "dismiss"
280
+ }
281
+ ```
282
+
283
+ The bundled `confirmModal` is an **accessible, unstyled reference implementation**:
284
+
285
+ - `role="dialog"` with `aria-modal="true"`, `aria-labelledby` (title) and `aria-describedby` (description)
286
+ - the confirm button receives focus on open (the cancel button for `variant: "danger"`, so a stray Enter never confirms a destructive action), and focus returns to the trigger on close
287
+ - `Tab` / `Shift+Tab` are trapped within the dialog
288
+ - `Escape` dismisses it (unless `dismissible: false`)
289
+
290
+ It ships no styling and no portal — those belong to your `renderer` or design system. It is ideal for tests and simple flows; production apps usually supply their own confirm modal — see [Custom Confirm Modal](#custom-confirm-modal).
291
+
292
+ ## Custom Renderer
293
+
294
+ Use `renderer` when your application needs portals, overlays, animation wrappers, or design-system primitives.
295
+
296
+ ```tsx
297
+ import type { ModalRendererProps } from "@okyrychenko-dev/react-modal-manager";
298
+
299
+ function AppModalRenderer({ children, modal }: ModalRendererProps) {
300
+ return (
301
+ <div data-modal-id={modal.instanceId} data-status={modal.status} role="presentation">
302
+ {children}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ function App() {
308
+ return (
309
+ <ModalProvider closeDelayMs={200} renderer={AppModalRenderer}>
310
+ <ReportsPage />
311
+ </ModalProvider>
312
+ );
313
+ }
314
+ ```
315
+
316
+ The core prescribes no DOM structure, focus management, or styling — adapters provide those while reusing the same lifecycle API. When `closeDelayMs` is greater than `0`, resolved or dismissed instances move from `modal.status === "open"` to `modal.status === "closing"` before removal, giving exit animations time to run.
317
+
318
+ ## Recipes
319
+
320
+ ### Next.js App Router (SSR)
321
+
322
+ The store is created lazily **per provider** (`useState(createModalStore)`) and lives in React context, so there is no module-level singleton and no shared state across requests — it is safe for the App Router and React Server Components. The provider uses hooks, so it must run in a Client Component. Wrap it once and render that wrapper from your server layout.
323
+
324
+ ```tsx
325
+ // app/providers/modal-provider.tsx
326
+ "use client";
327
+
328
+ import { ModalProvider } from "@okyrychenko-dev/react-modal-manager";
329
+ import type { ReactNode } from "react";
330
+
331
+ export function AppModalProvider({ children }: { children: ReactNode }) {
332
+ return <ModalProvider>{children}</ModalProvider>;
333
+ }
334
+ ```
335
+
336
+ ```tsx
337
+ // app/layout.tsx (Server Component)
338
+ import { AppModalProvider } from "./providers/modal-provider";
339
+
340
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
341
+ return (
342
+ <html lang="en">
343
+ <body>
344
+ <AppModalProvider>{children}</AppModalProvider>
345
+ </body>
346
+ </html>
347
+ );
348
+ }
349
+ ```
350
+
351
+ Modal components and any component calling `useModalManager()` must also be Client Components (`"use client"`).
352
+
353
+ ### Tailwind CSS
354
+
355
+ Provide the overlay and centering through the `renderer`, and style modal components with Tailwind utilities.
356
+
357
+ ```tsx
358
+ import type { ModalRendererProps } from "@okyrychenko-dev/react-modal-manager";
359
+
360
+ function TailwindRenderer({ children, modal }: ModalRendererProps) {
361
+ return (
362
+ <div
363
+ data-status={modal.status}
364
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50
365
+ transition-opacity data-[status=closing]:opacity-0"
366
+ >
367
+ <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">{children}</div>
368
+ </div>
369
+ );
370
+ }
371
+
372
+ <ModalProvider closeDelayMs={150} renderer={TailwindRenderer}>
373
+ {children}
374
+ </ModalProvider>;
375
+ ```
376
+
377
+ ### shadcn/ui
378
+
379
+ Use a shadcn `Dialog` as the renderer shell, so every opened modal is wrapped in the design system's overlay and animations while your modal components stay focused on content.
380
+
381
+ ```tsx
382
+ import { Dialog, DialogContent } from "@/components/ui/dialog";
383
+ import type { ModalRendererProps } from "@okyrychenko-dev/react-modal-manager";
384
+
385
+ function ShadcnRenderer({ children, modal }: ModalRendererProps) {
386
+ // `open` stays true while mounted; the library removes the instance after closeDelayMs.
387
+ return (
388
+ <Dialog open={modal.status === "open"}>
389
+ <DialogContent>{children}</DialogContent>
390
+ </Dialog>
391
+ );
392
+ }
393
+
394
+ <ModalProvider closeDelayMs={200} renderer={ShadcnRenderer}>
395
+ {children}
396
+ </ModalProvider>;
397
+ ```
398
+
399
+ You can also build a fully custom confirm modal on shadcn's `AlertDialog` and pass it via the `confirmModal` prop — see below.
400
+
401
+ ### React Hook Form inside a modal
402
+
403
+ A modal component is just a React component, so any form library works. Resolve the typed result by calling `close()` from the submit handler.
404
+
405
+ ```tsx
406
+ import { useForm } from "react-hook-form";
407
+ import type { ModalComponentProps } from "@okyrychenko-dev/react-modal-manager";
408
+
409
+ interface RenameValues {
410
+ name: string;
411
+ }
412
+
413
+ function RenameForm({ input, close }: ModalComponentProps<{ currentName: string }, RenameValues>) {
414
+ const { register, handleSubmit } = useForm<RenameValues>({
415
+ defaultValues: { name: input.currentName },
416
+ });
417
+
418
+ return (
419
+ <form onSubmit={handleSubmit((values) => close(values))}>
420
+ <input {...register("name", { required: true })} />
421
+ <button type="submit">Save</button>
422
+ </form>
423
+ );
424
+ }
425
+ ```
426
+
427
+ ### Custom Confirm Modal
428
+
429
+ Supply your own confirm implementation (design-system markup, a11y, focus management) and pass it to `confirmModal`. `modal.confirm()` then renders yours instead of the built-in reference.
430
+
431
+ ```tsx
432
+ import { createModal, type ModalComponentProps } from "@okyrychenko-dev/react-modal-manager";
433
+ import type { ConfirmModalParams, ConfirmModalResult } from "@okyrychenko-dev/react-modal-manager";
434
+
435
+ function MyConfirm(props: ModalComponentProps<ConfirmModalParams, ConfirmModalResult>) {
436
+ const { input, close } = props;
437
+ // render with your design system, then:
438
+ // close({ confirmed: true })
439
+ // close({ confirmed: false, reason: "cancel" })
440
+ }
441
+
442
+ const confirmModal = createModal({ id: "confirm", component: MyConfirm });
443
+
444
+ <ModalProvider confirmModal={confirmModal}>{children}</ModalProvider>;
445
+ ```
446
+
447
+ ## API Reference
448
+
449
+ ### Public Exports
450
+
451
+ Runtime exports:
452
+
453
+ - `ModalProvider`
454
+ - `ModalViewport`
455
+ - `confirmModal`
456
+ - `createModal`
457
+ - `createModalRegistry`
458
+ - `useModalManager`
459
+ - `ModalDismissError`
460
+ - `ModalRejectError`
461
+
462
+ Type exports:
463
+
464
+ - `ConfirmModalParams`
465
+ - `ConfirmModalResult`
466
+ - `ConfirmModalVariant`
467
+ - `ModalComponent`
468
+ - `ModalComponentProps`
469
+ - `ModalDefinition`
470
+ - `ModalDismissReason`
471
+ - `ModalHandle`
472
+ - `ModalId`
473
+ - `ModalInstanceId`
474
+ - `ModalInstanceStatus`
475
+ - `ModalManager`
476
+ - `ModalOptions`
477
+ - `ModalProviderProps`
478
+ - `ModalRegistry`
479
+ - `ModalRegistryDefinitions`
480
+ - `ModalRegistryEntry`
481
+ - `ModalRegistryInput`
482
+ - `ModalRegistryResult`
483
+ - `ModalRenderer`
484
+ - `ModalRendererProps`
485
+ - `ModalRuntimeConfig`
486
+ - `ModalView`
487
+ - `ModalViewportProps`
488
+ - `RegisteredModalDefinition`
489
+
490
+ ### `<ModalProvider>`
491
+
492
+ Creates an isolated modal manager for a React subtree and renders active modals.
493
+
494
+ **Props:**
495
+
496
+ - `children: ReactNode` — Application subtree that can access the modal manager
497
+ - `renderer?: ModalRenderer` — Optional wrapper for each rendered modal instance
498
+ - `confirmModal?: ModalDefinition<ConfirmModalParams, ConfirmModalResult>` — Optional custom confirm modal implementation
499
+ - `registry?: ModalRegistry` — Optional typed modal registry bound to this provider while it is mounted
500
+ - `closeDelayMs?: number` — Delay before removing a closing modal from the store. Defaults to `0`
501
+
502
+ ### `useModalManager()`
503
+
504
+ Returns the modal manager from the nearest `ModalProvider`.
505
+
506
+ **Returns:**
507
+
508
+ - `open(modal, input): ModalHandle<TResult>`
509
+ - `confirm(params): Promise<ConfirmModalResult>`
510
+ - `dismiss(instanceId, reason?): void`
511
+ - `closeAll(reason?): void`
512
+
513
+ ### `createModal(options)`
514
+
515
+ Creates a typed modal definition.
516
+
517
+ **Options:**
518
+
519
+ - `id?: string` — Optional stable modal definition id. An internal debug id is generated when omitted
520
+ - `component: ModalComponent<TInput, TResult>` — React component that receives typed input and completion callbacks
521
+
522
+ ### `createModalRegistry(definitions)`
523
+
524
+ Creates a typed registry for opening modals by key. Bind it directly with `<ModalProvider registry={registry}>`.
525
+
526
+ **Returns:**
527
+
528
+ - `open(key, input): ModalHandle<TResult>`
529
+ - `confirm(params): Promise<ConfirmModalResult>`
530
+ - `dismiss(instanceId, reason?): void`
531
+ - `closeAll(reason?): void`
532
+ - `isReady(): boolean`
533
+
534
+ ### `ModalComponentProps<TInput, TResult>`
535
+
536
+ Props passed to custom modal components.
537
+
538
+ - `input: TInput` — Input supplied to `modal.open()`
539
+ - `instanceId: string` — Runtime modal instance id
540
+ - `close(result: TResult): void` — Resolve the modal promise and remove the instance
541
+ - `dismiss(reason?): void` — Reject with `ModalDismissError` and remove the instance
542
+ - `reject(error): void` — Reject with an error and remove the instance
543
+
544
+ When `closeDelayMs` is configured, `close`, `dismiss`, and `reject` settle the promise immediately, mark the modal as `"closing"`, and remove it after the delay.
545
+
546
+ ### `ModalRendererProps`
547
+
548
+ Props passed to the `renderer` boundary.
549
+
550
+ - `children: ReactNode` — Rendered modal component
551
+ - `modal.definitionId: string` — Stable modal definition id
552
+ - `modal.instanceId: string` — Runtime modal instance id
553
+ - `modal.status: "open" | "closing"` — Lifecycle status for entry/exit rendering
554
+
555
+ ### `confirm(params)`
556
+
557
+ Opens the built-in (or provided) confirmation modal.
558
+
559
+ **Parameters:**
560
+
561
+ - `title: ReactNode`
562
+ - `description?: ReactNode`
563
+ - `confirmText?: string`
564
+ - `cancelText?: string`
565
+ - `variant?: "default" | "danger" | "warning" | "success"`
566
+ - `dismissible?: boolean`
567
+
568
+ **Returns:**
569
+
570
+ ```ts
571
+ export type ConfirmationModalRejectReason = "cancel" | "dismiss";
572
+
573
+ export interface ConfirmationModalConfirmedResult {
574
+ confirmed: true;
575
+ }
576
+
577
+ export interface ConfirmationModalRejectedResult {
578
+ confirmed: false;
579
+ reason: ConfirmationModalRejectReason;
580
+ }
581
+
582
+ export type ConfirmModalResult =
583
+ | ConfirmationModalConfirmedResult
584
+ | ConfirmationModalRejectedResult;
585
+ ```
586
+
587
+ ### `ModalDismissError`
588
+
589
+ Thrown when a modal is dismissed by `dismiss()`, `closeAll()`, or provider unmount.
590
+
591
+ **Properties:**
592
+
593
+ - `reason: "dismiss" | "close-all" | "provider-unmount"`
594
+
595
+ ### `ModalRejectError`
596
+
597
+ Thrown when a modal calls `reject()` with a non-`Error` value. The original value is available as `error.value`.
598
+
599
+ ## Package Boundary
600
+
601
+ This package intentionally does not know about guarded actions, permissions, pending state, or action execution. It is the base modal/dialog lifecycle layer.
602
+
603
+ Action-aware flows should be built in a separate integration package on top of this API:
604
+
605
+ ```txt
606
+ react-modal-manager
607
+ -> typed modal opening, confirmation, dismissal, lifecycle
608
+
609
+ react-action-guard-dialog
610
+ -> confirm and run guarded actions through react-modal-manager
611
+ ```
612
+
613
+ ## Development
614
+
615
+ ```bash
616
+ npm install
617
+ npm run typecheck
618
+ npm run lint
619
+ npm run test:run
620
+ npm run build
621
+ ```
622
+
623
+ ## License
624
+
625
+ MIT © [Oleksii Kyrychenko](https://github.com/okyrychenko-dev)