From fc001b90220d0fb5d4655068cb8aea1a8d08b28d Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sun, 13 Sep 2026 22:19:54 +0300 Subject: [PATCH] docs: publish universal adoption guidance --- README.md | 40 ++++++- examples/adoption-paths.typecheck.tsx | 148 ++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 examples/adoption-paths.typecheck.tsx diff --git a/README.md b/README.md index ba51a79..9e5d2c6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Revalidated **2026-09-13** against this package at **0.1.0** (`0004c89`) and the | 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) | | 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) | | 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) | -| Package cost | Recorded minimal `createModal` consumer: **2,052 B / 1,042 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. | +| 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. | | 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. | | Maintenance status | 0.1.0 is the version evaluated on this repository’s current main branch | 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) | @@ -119,6 +119,20 @@ function ReportsPage() { } ``` +## Adoption Path + +Start with the smallest API that solves the current problem, then add the next capability only when the application needs it: + +1. Use [`confirm()`](#confirmation-modals) for a typed yes/no decision. +2. Define a [typed custom modal](#typed-modal-flow) when the flow needs application-specific input, UI, or results. +3. Keep the returned [modal handle](#typed-modal-flow) when the caller must identify or dismiss that exact instance. +4. Add a [typed registry](#typed-modal-registry) for commands that originate outside React. +5. Supply a [custom renderer](#custom-renderer) for overlays, portals, design-system shells, and exit animations. +6. Split independent application areas into [provider scopes](#provider-scope). +7. Follow the [Next.js App Router guidance](#nextjs-app-router-ssr) for SSR and React Server Components. + +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`. + ## Type Safety This is where the library earns its place. Define a modal once and every call site is checked end to end. @@ -280,6 +294,30 @@ The registry key is type-checked, and TypeScript infers the required input and t 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. +## Provider Scope + +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. + +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. + +```tsx +const accountModals = createModalRegistry({ rename: renameReportModal }); +const workspaceModals = createModalRegistry({ rename: renameReportModal }); + +function App() { + return ( + <> + + + + + + + + ); +} +``` + ## Confirmation Modals `modal.confirm()` (and `registry.confirm()`) opens the built-in confirmation modal and resolves to a typed, discriminated-union result. diff --git a/examples/adoption-paths.typecheck.tsx b/examples/adoption-paths.typecheck.tsx new file mode 100644 index 0000000..82af590 --- /dev/null +++ b/examples/adoption-paths.typecheck.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { + ModalProvider, + createModal, + createModalRegistry, + useModalManager, +} from "@okyrychenko-dev/react-modal-manager"; +import type { + ModalComponentProps, + ModalHandle, + ModalRendererProps, +} from "@okyrychenko-dev/react-modal-manager"; +import type { ReactNode } from "react"; + +interface RenameReportInput { + reportId: string; + currentName: string; +} + +interface RenameReportSucceededResult { + status: "renamed"; + name: string; +} + +interface RenameReportCancelledResult { + status: "cancelled"; +} + +type RenameReportResult = + | RenameReportSucceededResult + | RenameReportCancelledResult; + +function RenameReportModal({ + close, + input, +}: ModalComponentProps): ReactNode { + return ( +
+ + +
+ ); +} + +const renameReportModal = createModal({ + component: RenameReportModal, +}); + +export const reportModals = createModalRegistry({ + renameReport: renameReportModal, +}); + +const workspaceModals = createModalRegistry({ + renameReport: renameReportModal, +}); + +const openRenameHandles = new Map>(); + +function trackRenameHandle(handle: ModalHandle): void { + openRenameHandles.set(handle.instanceId, handle); + + function stopTracking(): void { + openRenameHandles.delete(handle.instanceId); + } + + void handle.then(stopTracking, stopTracking); +} + +export async function renameReportFromCommand( + input: RenameReportInput, +): Promise { + return reportModals.open("renameReport", input); +} + +function AppModalRenderer({ children, modal }: ModalRendererProps): ReactNode { + return ( +
+ {children} +
+ ); +} + +function ReportsPage(): ReactNode { + const modal = useModalManager(); + + function handleDelete(): void { + void modal + .confirm({ + title: "Delete report?", + description: "This action cannot be undone.", + variant: "danger", + }) + .then((result) => { + if (result.confirmed) { + window.localStorage.removeItem("report-1"); + } else { + window.localStorage.setItem("last-delete-outcome", result.reason); + } + }); + } + + function handleRename(): void { + const handle = modal.open(renameReportModal, { + reportId: "report-1", + currentName: "Q3 report", + }); + + trackRenameHandle(handle); + + void handle.then((result) => { + if (result.status === "renamed") { + window.localStorage.setItem("report-1-name", result.name); + } + }); + } + + return ( + <> + + + + ); +} + +export function AdoptionPathsExample(): ReactNode { + return ( + <> + + + + + + + + + ); +}