@okyrychenko-dev/react-modal-manager 0.1.0 → 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/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-09-14
9
+
10
+ ### Added
11
+
12
+ - Allow modal definitions whose input is `void` or `undefined` to be opened without an input argument through the modal manager, registered definitions, and typed registries.
13
+ - Add verified React 18 and React 19 compatibility, including Strict Mode and independent-root coverage.
14
+ - Add tested SSR, hydration, and Next.js App Router/React Server Components guidance.
15
+ - Add compile-checked adoption examples, packed-package contract checks, lifecycle benchmarks, and reproducible competitive package checks.
16
+ - Add automated pnpm-based CI and provenance-enabled npm release workflows.
17
+
18
+ ### Changed
19
+
20
+ - Move modal lifecycle ownership entirely into each `ModalProvider`, with React observing the lifecycle state directly.
21
+ - Simplify imperative modal access: pass a typed registry directly to `ModalProvider`; external registry calls route to the most recently mounted matching provider and fall back when it unmounts.
22
+ - Remove Zustand from the public installation contract. React is now the only peer dependency; `@okyrychenko-dev/type-utils` is the sole runtime dependency.
23
+ - Migrate repository development, validation, package inspection, and release workflows from npm to pnpm.
24
+ - Expand the README with provider-scope, lifecycle, registry, SSR/RSC, custom-renderer, accessibility, adoption, and troubleshooting guidance.
25
+
26
+ ### Fixed
27
+
28
+ - Preserve provider lifecycle state during React Strict Mode effect replay.
29
+ - Preserve registry routing across nested providers, adjacent providers, and independent React roots.
30
+ - Make lifecycle Storybook examples repeatable after modal settlement.
31
+
32
+ [0.2.0]: https://github.com/okyrychenko-dev/react-modal-manager/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -35,42 +35,51 @@ const app = (
35
35
 
36
36
  ## Why This Library
37
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.
38
+ - **Typed results, not `any`.** `open<TInput, TResult>(def, input)` returns a `Promise<TResult>`. Both sides of the call are checked, and inputless modals can omit the argument.
39
+ - **Per-provider isolation.** Each `ModalProvider` owns an independent lifecycle whose authoritative state React observes directly — no global lifecycle singleton, so subtrees and tests never leak modal state into each other.
40
+ - **Open from non-React code.** A typed registry lets event buses, command palettes, and action maps open modals while keeping full inference.
41
+ - **UI-agnostic core.** A single `renderer` seam lets you plug in portals, overlays, animations, or any design system. The core never prescribes DOM or styling.
42
42
  - **Built-in `confirm()`** with a typed, discriminated-union result — useful from day one, replaceable when you need your own design.
43
43
  - **Promise-shaped lifecycle.** Dismissals reject with `ModalDismissError`; exit animations are supported through `closeDelayMs` + an `"open" | "closing"` status.
44
44
 
45
- ### Compared to `nice-modal-react`
45
+ ### Compared to [`@ebay/nice-modal-react`](https://github.com/eBay/nice-modal-react)
46
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`) |
47
+ Revalidated **2026-09-13** for this package's **0.2.0** release line (through `3a98aa8`) and the current stable [`@ebay/nice-modal-react` 1.2.13](https://www.npmjs.com/package/@ebay/nice-modal-react/v/1.2.13). “Verified behavior” below means an executable public-surface check; “architecture” describes source structure and is not itself a consumer guarantee.
55
48
 
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.
49
+ | Area | `react-modal-manager` | `nice-modal-react` | Evidence kind |
50
+ | --- | --- | --- | --- |
51
+ | Input and result typing | A definition or registry key couples input to `Promise<TResult>` | Component props are inferred; `show()` result is caller-selected and handlers expose `Promise<unknown>` | Verified declarations: [local packed-consumer fixture](scripts/package-consumer.typecheck.ts), [Nice Modal 1.2.13 declarations](https://unpkg.com/@ebay/nice-modal-react@1.2.13/lib/esm/index.d.ts) |
52
+ | Lifecycle correctness | Per-instance handles; resolve, reject, and dismiss settle once; optional closing phase precedes removal | Promise-based `show`; separate `hide` and `remove`; UI-library helpers connect removal to exit callbacks | Verified local behavior: [lifecycle tests](src/lifecycle/__tests__/modalLifecycle.test.ts); documented competitor behavior: [Nice Modal usage and helpers](https://github.com/eBay/nice-modal-react/tree/1.2.13#usage) |
53
+ | Provider isolation | Nested, adjacent, and independent roots own isolated lifecycle state | Each provider creates reducer state, while imperative dispatch, registrations, and promise callbacks are module-level | Verified local behavior: [root tests](src/provider/__tests__/ModalProvider.roots.test.tsx); competitor architecture: [1.2.13 source](https://github.com/eBay/nice-modal-react/blob/1.2.13/src/index.tsx) |
54
+ | React compatibility | Declares React 18 and 19 and runs the same root/Strict Mode suite against both | Declares React and React DOM `>16.8.0`; the published package was developed with React 17 | Verified local matrix: [CI](.github/workflows/ci.yml); published competitor metadata: [package manifest](https://unpkg.com/@ebay/nice-modal-react@1.2.13/package.json) |
55
+ | SSR and RSC | Provider server rendering, separate-request isolation, and hydration are tested; RSC usage has a compiled Client Component example | No SSR, hydration, or React Server Components contract is documented in the 1.2.13 README | Verified local behavior: [SSR tests](src/provider/__tests__/ModalProvider.ssr.test.tsx) and [RSC-style fixture](examples/next-app-router-provider.typecheck.tsx); competitor documentation: [1.2.13 README](https://github.com/eBay/nice-modal-react/tree/1.2.13) |
56
+ | Imperative access | A typed registry opens outside React after being bound to a provider; keys, input, and results are inferred | `NiceModal.show(component, props)` or a registered string id can be called directly after a provider establishes the module-level dispatch | Verified local behavior: [registry tests](src/registry/__tests__/createModalRegistry.test.tsx); documented competitor behavior: [component and id APIs](https://github.com/eBay/nice-modal-react/tree/1.2.13#using-your-modal-component) |
57
+ | Rendering independence | Lifecycle renders modal definitions through a replaceable wrapper; no portal, overlay, CSS, or design-system dependency | Supplies no dialog markup and wraps consumer components; includes helpers for Ant Design, MUI, and React Bootstrap lifecycles | Public interfaces: [local renderer types](src/types/modal.ts), [Nice Modal helpers](https://unpkg.com/@ebay/nice-modal-react@1.2.13/lib/esm/index.d.ts) |
58
+ | Accessibility composition | Built-in confirmation provides dialog semantics, initial focus, focus trapping, and safe destructive focus; custom modals/renderers remain consumer-owned | Accessibility belongs entirely to the consumer’s chosen modal component or UI library | Verified local behavior: [confirmation tests](src/confirm/__tests__/ConfirmModal.test.tsx); documented competitor scope: [“not a React modal component”](https://github.com/eBay/nice-modal-react/tree/1.2.13#nice-modal) |
59
+ | First-use ergonomics | `confirm()` is the shortest path; custom flows define a modal and open it directly or through a registry | `show(component, props)` is the shortest path; string access adds `register(id, component)` | Documented public APIs: [this README](#quick-start), [Nice Modal usage](https://github.com/eBay/nice-modal-react/tree/1.2.13#usage) |
60
+ | Package cost | Recorded minimal `createModal` consumer: **2,058 B / 1,044 B gzip**; React is the only peer and `type-utils` the only runtime dependency | Reproduced minimal named-`show` consumer: **758 B / 473 B gzip**; zero runtime dependencies, with React and React DOM as peers | Reproduce with [`package:check`](scripts/check-packed-package.mjs) and [`competitive:check`](scripts/check-competitive-package.mjs). The entry points differ, so these are package-cost observations, not a universal size ranking. |
61
+ | Performance | Optimized-build raw samples and summaries cover mount, unmount, open/render, settlement, delayed removal, stacking, and registry routing | No like-for-like run was made against the competitor | Reproducible local evidence: [`benchmark:lifecycle`](scripts/benchmark-lifecycle.mjs). No performance winner is claimed. |
62
+ | Maintenance status | 0.2.0 is the release line evaluated in this repository | 1.2.13 was published 2023-10-03; it remains the npm `latest` release on the evaluation date | Release evidence: [local manifest](package.json), [npm version](https://www.npmjs.com/package/@ebay/nice-modal-react/v/1.2.13), [GitHub release](https://github.com/eBay/nice-modal-react/releases/tag/1.2.13) |
63
+
64
+ The main trade-off is deliberate: this package does not provide unchecked `show("any-string")` routing. Imperative callers import a typed definition or use a typed registry, and a registry must be bound to a mounted provider. Nice Modal’s global component/id calls require less setup and can be more convenient when that trade-off is acceptable. Conversely, this package’s provider ownership, result inference, SSR behavior, and built-in confirmation are explicit tested contracts rather than conclusions drawn only from implementation structure.
57
65
 
58
66
  ## Installation
59
67
 
60
68
  ```bash
61
- npm install @okyrychenko-dev/react-modal-manager zustand
69
+ npm install @okyrychenko-dev/react-modal-manager
62
70
  # or
63
- yarn add @okyrychenko-dev/react-modal-manager zustand
71
+ yarn add @okyrychenko-dev/react-modal-manager
64
72
  # or
65
- pnpm add @okyrychenko-dev/react-modal-manager zustand
73
+ pnpm add @okyrychenko-dev/react-modal-manager
66
74
  ```
67
75
 
68
76
  Peer dependencies:
69
77
 
70
78
  - [React](https://react.dev/) `^18.0.0 || ^19.0.0`
71
- - [Zustand](https://zustand-demo.pmnd.rs/) `^5.0.0`
72
79
 
73
- There are **no runtime dependencies** beyond these peers the provider-scoped store is built into the package.
80
+ Modal lifecycle state is provider-owned and observed directly by React. There is no global lifecycle singleton.
81
+
82
+ The package also installs its small runtime guard dependency automatically; React is the only peer dependency consumers provide.
74
83
 
75
84
  ## Quick Start
76
85
 
@@ -110,6 +119,20 @@ function ReportsPage() {
110
119
  }
111
120
  ```
112
121
 
122
+ ## Adoption Path
123
+
124
+ Start with the smallest API that solves the current problem, then add the next capability only when the application needs it:
125
+
126
+ 1. Use [`confirm()`](#confirmation-modals) for a typed yes/no decision.
127
+ 2. Define a [typed custom modal](#typed-modal-flow) when the flow needs application-specific input, UI, or results.
128
+ 3. Keep the returned [modal handle](#typed-modal-flow) when the caller must identify or dismiss that exact instance.
129
+ 4. Add a [typed registry](#typed-modal-registry) for commands that originate outside React.
130
+ 5. Supply a [custom renderer](#custom-renderer) for overlays, portals, design-system shells, and exit animations.
131
+ 6. Split independent application areas into [provider scopes](#provider-scope).
132
+ 7. Follow the [Next.js App Router guidance](#nextjs-app-router-ssr) for SSR and React Server Components.
133
+
134
+ The complete path is compile-checked as a package consumer in [`examples/adoption-paths.typecheck.tsx`](examples/adoption-paths.typecheck.tsx). The client boundary used by a server layout is checked separately in [`examples/next-app-router-provider.typecheck.tsx`](examples/next-app-router-provider.typecheck.tsx). Interactive equivalents live in Storybook under `Components/confirmModal`, `Context/ModalProvider`, and `Components/ModalViewport`.
135
+
113
136
  ## Type Safety
114
137
 
115
138
  This is where the library earns its place. Define a modal once and every call site is checked end to end.
@@ -122,9 +145,16 @@ interface RenameInput {
122
145
  currentName: string;
123
146
  }
124
147
 
125
- type RenameResult =
126
- | { status: "renamed"; name: string }
127
- | { status: "cancelled" };
148
+ interface RenameSucceededResult {
149
+ status: "renamed";
150
+ name: string;
151
+ }
152
+
153
+ interface RenameCancelledResult {
154
+ status: "cancelled";
155
+ }
156
+
157
+ type RenameResult = RenameSucceededResult | RenameCancelledResult;
128
158
 
129
159
  function RenameModal({ close, input }: ModalComponentProps<RenameInput, RenameResult>) {
130
160
  // `input` is RenameInput. `close` only accepts a RenameResult.
@@ -221,6 +251,16 @@ const result = await handle;
221
251
 
222
252
  The handle's `dismiss()` stays bound to the provider that opened the modal.
223
253
 
254
+ For a modal that needs no input, declare its input as `void` and omit the second argument:
255
+
256
+ ```tsx
257
+ const infoModal = createModal<void, void>({ component: InfoModal });
258
+
259
+ await modal.open(infoModal);
260
+ ```
261
+
262
+ Modals with any other input type still require an input argument.
263
+
224
264
  ## Typed Modal Registry
225
265
 
226
266
  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.
@@ -262,6 +302,32 @@ export async function renameFromAction(reportId: string, currentName: string) {
262
302
 
263
303
  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
304
 
305
+ Before a provider binds the registry, `modals.isReady()` is `false` and registry operations throw. Binding happens in a client effect, so a registry is intentionally unbound during server rendering.
306
+
307
+ ## Provider Scope
308
+
309
+ Each `ModalProvider` owns an independent lifecycle. `useModalManager()` always targets the nearest provider, so adjacent or nested application areas can keep their modal state, renderers, and teardown behavior isolated. A handle remains bound to the provider that created it, even when another provider opens the same modal definition.
310
+
311
+ Use a separate registry for each strictly isolated scope. Binding the same registry to multiple providers is a deliberate routing mechanism instead: external calls target the most recently mounted binding and fall back to the previous binding when it unmounts.
312
+
313
+ ```tsx
314
+ const accountModals = createModalRegistry({ rename: renameReportModal });
315
+ const workspaceModals = createModalRegistry({ rename: renameReportModal });
316
+
317
+ function App() {
318
+ return (
319
+ <>
320
+ <ModalProvider registry={accountModals} renderer={AccountModalRenderer}>
321
+ <AccountSettings />
322
+ </ModalProvider>
323
+ <ModalProvider registry={workspaceModals} renderer={WorkspaceModalRenderer}>
324
+ <Workspace />
325
+ </ModalProvider>
326
+ </>
327
+ );
328
+ }
329
+ ```
330
+
265
331
  ## Confirmation Modals
266
332
 
267
333
  `modal.confirm()` (and `registry.confirm()`) opens the built-in confirmation modal and resolves to a typed, discriminated-union result.
@@ -283,7 +349,7 @@ if (result.confirmed) {
283
349
  The bundled `confirmModal` is an **accessible, unstyled reference implementation**:
284
350
 
285
351
  - `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
352
+ - 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 previously focused element when the modal is removed
287
353
  - `Tab` / `Shift+Tab` are trapped within the dialog
288
354
  - `Escape` dismisses it (unless `dismissible: false`)
289
355
 
@@ -313,13 +379,13 @@ function App() {
313
379
  }
314
380
  ```
315
381
 
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.
382
+ The core prescribes no DOM structure, focus management, or styling — renderers provide those while reusing the same modal manager interface. When `closeDelayMs` is greater than `0`, resolved, dismissed, or rejected instances move from `modal.status === "open"` to `modal.status === "closing"` before removal, giving exit animations time to run. A value of `0` or less removes the instance immediately. Updating the prop changes the removal delay used by later settlements in that provider.
317
383
 
318
384
  ## Recipes
319
385
 
320
386
  ### Next.js App Router (SSR)
321
387
 
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.
388
+ The lifecycle is created lazily **per provider**, lives in React context, and exposes its state directly to React through external-store observation. It owns opening, settlement, dismissal, delayed removal, and disposal. Server rendering reads a stable empty snapshot, and there is no module-level singleton or shared state across requests. The provider uses hooks, so it must run in a Client Component. Wrap it once and render that wrapper from your server layout.
323
389
 
324
390
  ```tsx
325
391
  // app/providers/modal-provider.tsx
@@ -350,6 +416,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
350
416
 
351
417
  Modal components and any component calling `useModalManager()` must also be Client Components (`"use client"`).
352
418
 
419
+ Opening modal work while React is rendering on the server is unsupported. The server snapshot is intentionally empty: open modals from event handlers, effects, command handlers, or other client-side code after hydration. A registry likewise remains unbound until its provider's client effect runs, so check `registry.isReady()` before dispatching startup commands from outside React.
420
+
353
421
  ### Tailwind CSS
354
422
 
355
423
  Provide the overlay and centering through the `renderer`, and style modal components with Tailwind utilities.
@@ -374,18 +442,31 @@ function TailwindRenderer({ children, modal }: ModalRendererProps) {
374
442
  </ModalProvider>;
375
443
  ```
376
444
 
377
- ### shadcn/ui
445
+ ### shadcn/ui (Radix)
378
446
 
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.
447
+ Use the current [Radix-based shadcn `Dialog`](https://ui.shadcn.com/docs/components/radix/dialog) as a controlled renderer shell, so every opened modal is wrapped in the design system's overlay and animations. Route `onOpenChange(false)` back to the modal manager so Escape, outside interaction, and shadcn's generated close button dismiss the correct instance.
380
448
 
381
449
  ```tsx
450
+ import {
451
+ type ModalRendererProps,
452
+ useModalManager,
453
+ } from "@okyrychenko-dev/react-modal-manager";
382
454
  import { Dialog, DialogContent } from "@/components/ui/dialog";
383
- import type { ModalRendererProps } from "@okyrychenko-dev/react-modal-manager";
384
455
 
385
456
  function ShadcnRenderer({ children, modal }: ModalRendererProps) {
386
- // `open` stays true while mounted; the library removes the instance after closeDelayMs.
457
+ const modalManager = useModalManager();
458
+
459
+ function handleOpenChange(open: boolean) {
460
+ if (!open && modal.status === "open") {
461
+ modalManager.dismiss(modal.instanceId);
462
+ }
463
+ }
464
+
387
465
  return (
388
- <Dialog open={modal.status === "open"}>
466
+ <Dialog
467
+ open={modal.status === "open"}
468
+ onOpenChange={handleOpenChange}
469
+ >
389
470
  <DialogContent>{children}</DialogContent>
390
471
  </Dialog>
391
472
  );
@@ -396,7 +477,11 @@ function ShadcnRenderer({ children, modal }: ModalRendererProps) {
396
477
  </ModalProvider>;
397
478
  ```
398
479
 
399
- You can also build a fully custom confirm modal on shadcn's `AlertDialog` and pass it via the `confirmModal` prop see below.
480
+ This provider-wide renderer is for content-only custom modals. Each modal rendered inside `DialogContent` must compose shadcn's `DialogHeader`, `DialogTitle`, and, when useful, `DialogDescription`; otherwise Radix cannot establish the accessible title/description relationship.
481
+
482
+ Do not use the built-in confirmation modal with this renderer: it already owns its dialog semantics, so wrapping it in `DialogContent` would create nested dialogs and omit Radix's required `DialogTitle`. If this provider calls `confirm()`, pass a content-only custom `confirmModal` that composes `DialogHeader`, `DialogTitle`, and `DialogDescription` inside this existing shell. Do not nest another `Dialog` or `AlertDialog` in it.
483
+
484
+ Match `closeDelayMs` to your generated component's exit-animation duration. The example uses 200 ms, matching the current [Radix-based component's `duration-200`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/dialog.tsx).
400
485
 
401
486
  ### React Hook Form inside a modal
402
487
 
@@ -472,6 +557,7 @@ Type exports:
472
557
  - `ModalId`
473
558
  - `ModalInstanceId`
474
559
  - `ModalInstanceStatus`
560
+ - `ModalOpenArgs`
475
561
  - `ModalManager`
476
562
  - `ModalOptions`
477
563
  - `ModalProviderProps`
@@ -497,7 +583,7 @@ Creates an isolated modal manager for a React subtree and renders active modals.
497
583
  - `renderer?: ModalRenderer` — Optional wrapper for each rendered modal instance
498
584
  - `confirmModal?: ModalDefinition<ConfirmModalParams, ConfirmModalResult>` — Optional custom confirm modal implementation
499
585
  - `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`
586
+ - `closeDelayMs?: number` — Delay before removing a closing lifecycle instance. Values of `0` or less remove immediately. Defaults to `0`; prop updates apply to later settlements
501
587
 
502
588
  ### `useModalManager()`
503
589
 
@@ -505,7 +591,7 @@ Returns the modal manager from the nearest `ModalProvider`.
505
591
 
506
592
  **Returns:**
507
593
 
508
- - `open(modal, input): ModalHandle<TResult>`
594
+ - `open(modal, ...args: ModalOpenArgs<TInput>): ModalHandle<TResult>`
509
595
  - `confirm(params): Promise<ConfirmModalResult>`
510
596
  - `dismiss(instanceId, reason?): void`
511
597
  - `closeAll(reason?): void`
@@ -516,16 +602,18 @@ Creates a typed modal definition.
516
602
 
517
603
  **Options:**
518
604
 
519
- - `id?: string` — Optional stable modal definition id. An internal debug id is generated when omitted
605
+ - `id?: string` — Optional stable modal definition id. A unique definition id is generated when omitted
520
606
  - `component: ModalComponent<TInput, TResult>` — React component that receives typed input and completion callbacks
521
607
 
608
+ For a modal declared with `TInput = void` (or `undefined`), omit the input argument: `modal.open(infoModal)`. Modals with any other input type still require it.
609
+
522
610
  ### `createModalRegistry(definitions)`
523
611
 
524
612
  Creates a typed registry for opening modals by key. Bind it directly with `<ModalProvider registry={registry}>`.
525
613
 
526
614
  **Returns:**
527
615
 
528
- - `open(key, input): ModalHandle<TResult>`
616
+ - `open(key, ...args: ModalOpenArgs<TInput>): ModalHandle<TResult>` — input is optional only when the registered modal uses `void` or `undefined`
529
617
  - `confirm(params): Promise<ConfirmModalResult>`
530
618
  - `dismiss(instanceId, reason?): void`
531
619
  - `closeAll(reason?): void`
@@ -535,17 +623,17 @@ Creates a typed registry for opening modals by key. Bind it directly with `<Moda
535
623
 
536
624
  Props passed to custom modal components.
537
625
 
538
- - `input: TInput` — Input supplied to `modal.open()`
626
+ - `input: ModalOpenArgs<TInput>[0]` — Input supplied to `modal.open()`; `undefined` when a `void` input is omitted
539
627
  - `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
628
+ - `close(result: TResult): void` — Resolve the modal promise and begin closing the instance
629
+ - `dismiss(reason?): void` — Reject with `ModalDismissError` and begin closing the instance
630
+ - `reject(error): void` — Reject with an error and begin closing the instance
543
631
 
544
- When `closeDelayMs` is configured, `close`, `dismiss`, and `reject` settle the promise immediately, mark the modal as `"closing"`, and remove it after the delay.
632
+ When `closeDelayMs` is greater than `0`, `close`, `dismiss`, and `reject` settle the promise immediately, mark the modal as `"closing"`, and remove it after the delay. A value of `0` or less removes the instance immediately after settlement.
545
633
 
546
634
  ### `ModalRendererProps`
547
635
 
548
- Props passed to the `renderer` boundary.
636
+ Props passed to the `renderer` seam.
549
637
 
550
638
  - `children: ReactNode` — Rendered modal component
551
639
  - `modal.definitionId: string` — Stable modal definition id
@@ -567,14 +655,16 @@ Opens the built-in (or provided) confirmation modal.
567
655
 
568
656
  **Returns:**
569
657
 
658
+ `ConfirmModalResult` is a discriminated union. Its structure is shown with named branches below so the successful and cancelled paths remain explicit; only `ConfirmModalResult` is exported from the package root.
659
+
570
660
  ```ts
571
- export type ConfirmationModalRejectReason = "cancel" | "dismiss";
661
+ type ConfirmationModalRejectReason = "cancel" | "dismiss";
572
662
 
573
- export interface ConfirmationModalConfirmedResult {
663
+ interface ConfirmationModalConfirmedResult {
574
664
  confirmed: true;
575
665
  }
576
666
 
577
- export interface ConfirmationModalRejectedResult {
667
+ interface ConfirmationModalRejectedResult {
578
668
  confirmed: false;
579
669
  reason: ConfirmationModalRejectReason;
580
670
  }
@@ -613,11 +703,15 @@ react-action-guard-dialog
613
703
  ## Development
614
704
 
615
705
  ```bash
616
- npm install
617
- npm run typecheck
618
- npm run lint
619
- npm run test:run
620
- npm run build
706
+ pnpm install --frozen-lockfile
707
+ pnpm run typecheck
708
+ pnpm run lint
709
+ pnpm run format:check
710
+ pnpm run test:run
711
+ pnpm run test:coverage
712
+ pnpm run build
713
+ pnpm run build-storybook
714
+ pnpm pack --pack-destination /tmp/react-modal-manager-pack
621
715
  ```
622
716
 
623
717
  ## License