From 2afd555de3b251915019119bc7063d7f91d2d42d Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sun, 13 Sep 2026 18:36:32 +0300 Subject: [PATCH] feat: allow omitted void modal input --- README.md | 9 ++++--- scripts/benchmark-lifecycle.mjs | 4 +-- scripts/package-consumer.typecheck.ts | 27 ++++++++++++++++++- .../useModalManager/useModalManager.types.ts | 3 ++- src/index.ts | 1 + .../__tests__/modalLifecycle.test.ts | 2 +- src/lifecycle/modalLifecycle.ts | 4 ++- src/lifecycle/modalLifecycle.types.ts | 3 ++- .../ModalProvider.stories.fixtures.tsx | 8 +++--- .../__tests__/ModalProvider.fixtures.tsx | 14 +++++----- src/registry/createModal.ts | 2 +- src/registry/createModal.types.ts | 12 +++++++-- src/registry/createModalRegistry.ts | 10 ++++--- src/registry/createModalRegistry.types.ts | 8 ++++-- src/types/index.ts | 1 + src/types/modal.ts | 8 +++++- 16 files changed, 84 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index daea77a..4ef1996 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,7 @@ Type exports: - `ModalId` - `ModalInstanceId` - `ModalInstanceStatus` +- `ModalOpenArgs` - `ModalManager` - `ModalOptions` - `ModalProviderProps` @@ -510,7 +511,7 @@ Returns the modal manager from the nearest `ModalProvider`. **Returns:** -- `open(modal, input): ModalHandle` +- `open(modal, ...args: ModalOpenArgs): ModalHandle` - `confirm(params): Promise` - `dismiss(instanceId, reason?): void` - `closeAll(reason?): void` @@ -524,13 +525,15 @@ Creates a typed modal definition. - `id?: string` — Optional stable modal definition id. A unique definition id is generated when omitted - `component: ModalComponent` — React component that receives typed input and completion callbacks +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. + ### `createModalRegistry(definitions)` Creates a typed registry for opening modals by key. Bind it directly with ``. **Returns:** -- `open(key, input): ModalHandle` +- `open(key, ...args: ModalOpenArgs): ModalHandle` — input is optional only when the registered modal uses `void` or `undefined` - `confirm(params): Promise` - `dismiss(instanceId, reason?): void` - `closeAll(reason?): void` @@ -540,7 +543,7 @@ Creates a typed registry for opening modals by key. Bind it directly with `[0]` — Input supplied to `modal.open()`; `undefined` when a `void` input is omitted - `instanceId: string` — Runtime modal instance id - `close(result: TResult): void` — Resolve the modal promise and begin closing the instance - `dismiss(reason?): void` — Reject with `ModalDismissError` and begin closing the instance diff --git a/scripts/benchmark-lifecycle.mjs b/scripts/benchmark-lifecycle.mjs index 233bb47..3f46f92 100644 --- a/scripts/benchmark-lifecycle.mjs +++ b/scripts/benchmark-lifecycle.mjs @@ -124,7 +124,7 @@ function unmountProvider({ container, root }) { } function openHandled(manager) { - const handle = manager.open(benchmarkModal, undefined); + const handle = manager.open(benchmarkModal); void handle.catch(() => undefined); @@ -276,7 +276,7 @@ scenarios.push( name: "typed registry route, open, and render", run: ({ registry }) => { flushSync(() => { - const handle = registry.open("benchmark", undefined); + const handle = registry.open("benchmark"); void handle.catch(() => undefined); }); }, diff --git a/scripts/package-consumer.typecheck.ts b/scripts/package-consumer.typecheck.ts index 18692aa..0a33051 100644 --- a/scripts/package-consumer.typecheck.ts +++ b/scripts/package-consumer.typecheck.ts @@ -31,6 +31,9 @@ const renameModal = createModal({ component: RenameModal, }); const registry = createModalRegistry({ rename: renameModal }); + +declare const manager: ModalManager; + const handle: ModalHandle = registry.open("rename", { currentName: "Quarterly report", }); @@ -38,7 +41,27 @@ const handle: ModalHandle = registry.open("rename", { // @ts-expect-error The packed declarations must reject invalid modal input. registry.open("rename", { reportName: "Quarterly report" }); -declare const manager: ModalManager; +// @ts-expect-error A modal with required input cannot be opened without it. +manager.open(renameModal); +// @ts-expect-error A registry entry with required input cannot be opened without it. +registry.open("rename"); + +type EmptyModalValue = ReturnType; + +function InfoModal( + _: ModalComponentProps, +): ReactNode { + return null; +} + +const infoModal = createModal({ + component: InfoModal, +}); +const infoRegistry = createModalRegistry({ info: infoModal }); +const directInfoHandle: ModalHandle = manager.open(infoModal); +const registryInfoHandle: ModalHandle = + infoRegistry.open("info"); + const confirmation: Promise = manager.confirm({ title: "Continue?", }); @@ -54,5 +77,7 @@ const providerProps: ComponentProps = { }; void confirmation; +void directInfoHandle; void handle; +void registryInfoHandle; void providerProps; diff --git a/src/hooks/useModalManager/useModalManager.types.ts b/src/hooks/useModalManager/useModalManager.types.ts index 2914da0..15b6f6f 100644 --- a/src/hooks/useModalManager/useModalManager.types.ts +++ b/src/hooks/useModalManager/useModalManager.types.ts @@ -3,6 +3,7 @@ import type { ModalDefinition, ModalDismissReason, ModalInstanceId, + ModalOpenArgs, } from "../../types"; export interface ModalHandle extends Promise { @@ -13,7 +14,7 @@ export interface ModalHandle extends Promise { export interface ModalManager { open: ( modal: ModalDefinition, - input: TInput, + ...args: ModalOpenArgs ) => ModalHandle; confirm: (params: ConfirmModalParams) => Promise; dismiss: (instanceId: ModalInstanceId, reason?: ModalDismissReason) => void; diff --git a/src/index.ts b/src/index.ts index c411242..d1b2413 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,4 +35,5 @@ export type { ModalId, ModalInstanceId, ModalInstanceStatus, + ModalOpenArgs, } from "./types"; diff --git a/src/lifecycle/__tests__/modalLifecycle.test.ts b/src/lifecycle/__tests__/modalLifecycle.test.ts index 5ac606f..474ea22 100644 --- a/src/lifecycle/__tests__/modalLifecycle.test.ts +++ b/src/lifecycle/__tests__/modalLifecycle.test.ts @@ -326,7 +326,7 @@ describe("modal lifecycle", () => { "Reject with Error", ), }; - const preserved = lifecycle.open(errorModal, undefined); + const preserved = lifecycle.open(errorModal); const preservedOutcome = preserved.catch((error: unknown) => error); const [preservedInstance] = lifecycle.getSnapshot().instances; diff --git a/src/lifecycle/modalLifecycle.ts b/src/lifecycle/modalLifecycle.ts index 7694370..8a9d719 100644 --- a/src/lifecycle/modalLifecycle.ts +++ b/src/lifecycle/modalLifecycle.ts @@ -5,6 +5,7 @@ import type { ModalDefinition, ModalDismissReason, ModalInstanceId, + ModalOpenArgs, } from "../types"; import type { CreateModalLifecycleOptions, @@ -139,8 +140,9 @@ export function createModalLifecycle( getServerSnapshot: () => serverSnapshot, open: ( modal: ModalDefinition, - input: TInput, + ...args: ModalOpenArgs ): ModalLifecycleHandle => { + const input = args[0]; const instanceId = `modal-${String(nextInstanceIndex)}`; nextInstanceIndex += 1; diff --git a/src/lifecycle/modalLifecycle.types.ts b/src/lifecycle/modalLifecycle.types.ts index 0f9c22c..1b5d2c4 100644 --- a/src/lifecycle/modalLifecycle.types.ts +++ b/src/lifecycle/modalLifecycle.types.ts @@ -5,6 +5,7 @@ import type { ModalId, ModalInstanceId, ModalInstanceStatus, + ModalOpenArgs, } from "../types"; export interface ModalLifecycleInstance { @@ -35,7 +36,7 @@ export interface ModalLifecycle extends ModalLifecycleObservation { dismiss: (instanceId: ModalInstanceId, reason?: ModalDismissReason) => void; open: ( modal: ModalDefinition, - input: TInput, + ...args: ModalOpenArgs ) => ModalLifecycleHandle; setCloseDelayMs: (closeDelayMs: number) => void; } diff --git a/src/provider/ModalProvider.stories.fixtures.tsx b/src/provider/ModalProvider.stories.fixtures.tsx index a417f68..77d6dcd 100644 --- a/src/provider/ModalProvider.stories.fixtures.tsx +++ b/src/provider/ModalProvider.stories.fixtures.tsx @@ -81,11 +81,9 @@ export function DismissRejectDemo(): ReactElement { }; const handleReject = (): void => { - void modal - .open(rejectWithStringModal, undefined) - .catch((error: unknown) => { - setState(describeRejection(error)); - }); + void modal.open(rejectWithStringModal).catch((error: unknown) => { + setState(describeRejection(error)); + }); }; return ( diff --git a/src/provider/__tests__/ModalProvider.fixtures.tsx b/src/provider/__tests__/ModalProvider.fixtures.tsx index f48e754..85e8949 100644 --- a/src/provider/__tests__/ModalProvider.fixtures.tsx +++ b/src/provider/__tests__/ModalProvider.fixtures.tsx @@ -280,17 +280,15 @@ export function RejectExample(): ReactNode { const modal = useModalManager(); const handleOpenStringReject = (): void => { - void modal - .open(rejectWithStringModal, undefined) - .catch((error: unknown) => { - if (isInstanceOf(error, ModalRejectError)) { - document.body.dataset.rejectValue = String(error.value); - } - }); + void modal.open(rejectWithStringModal).catch((error: unknown) => { + if (isInstanceOf(error, ModalRejectError)) { + document.body.dataset.rejectValue = String(error.value); + } + }); }; const handleOpenErrorReject = (): void => { - void modal.open(rejectWithErrorModal, undefined).catch((error: unknown) => { + void modal.open(rejectWithErrorModal).catch((error: unknown) => { if (isError(error)) { document.body.dataset.rejectMessage = error.message; } diff --git a/src/registry/createModal.ts b/src/registry/createModal.ts index e89582d..be41a9b 100644 --- a/src/registry/createModal.ts +++ b/src/registry/createModal.ts @@ -17,6 +17,6 @@ export function createModal( return { ...definition, - open: (manager, input) => manager.open(definition, input), + open: (manager, ...args) => manager.open(definition, ...args), }; } diff --git a/src/registry/createModal.types.ts b/src/registry/createModal.types.ts index ed65230..09f3018 100644 --- a/src/registry/createModal.types.ts +++ b/src/registry/createModal.types.ts @@ -1,5 +1,10 @@ import type { ModalHandle, ModalManager } from "../hooks"; -import type { ModalComponent, ModalDefinition, ModalId } from "../types"; +import type { + ModalComponent, + ModalDefinition, + ModalId, + ModalOpenArgs, +} from "../types"; export interface ModalOptions { id?: ModalId; @@ -10,5 +15,8 @@ export interface RegisteredModalDefinition< TInput, TResult, > extends ModalDefinition { - open(manager: ModalManager, input: TInput): ModalHandle; + open( + manager: ModalManager, + ...args: ModalOpenArgs + ): ModalHandle; } diff --git a/src/registry/createModalRegistry.ts b/src/registry/createModalRegistry.ts index 21cb9b3..18009b7 100644 --- a/src/registry/createModalRegistry.ts +++ b/src/registry/createModalRegistry.ts @@ -10,7 +10,11 @@ import { MODAL_REGISTRY_UNKNOWN_KEY_ERROR, } from "./createModalRegistry.constants"; import type { ModalHandle, ModalManager } from "../hooks"; -import type { ModalDismissReason, ModalInstanceId } from "../types"; +import type { + ModalDismissReason, + ModalInstanceId, + ModalOpenArgs, +} from "../types"; import type { ModalRegistry, ModalRegistryDefinitions, @@ -58,9 +62,9 @@ export function createModalRegistry< function open( key: TKey, - input: ModalRegistryInput, + ...args: ModalOpenArgs> ): ReturnType; - function open(key: string, input: unknown): ModalHandle { + function open(key: string, input?: unknown): ModalHandle { assertTrue( hasProperty(definitions, key), () => `${MODAL_REGISTRY_UNKNOWN_KEY_ERROR}: ${key}`, diff --git a/src/registry/createModalRegistry.types.ts b/src/registry/createModalRegistry.types.ts index 6896535..50e6c3c 100644 --- a/src/registry/createModalRegistry.types.ts +++ b/src/registry/createModalRegistry.types.ts @@ -1,7 +1,11 @@ import type { ModalHandle, ModalManager } from "../hooks"; +import type { ModalOpenArgs } from "../types"; export interface ModalRegistryEntry { - open(manager: ModalManager, input: TInput): ModalHandle; + open( + manager: ModalManager, + ...args: ModalOpenArgs + ): ModalHandle; } export type ModalRegistryDefinitions = Readonly< @@ -33,6 +37,6 @@ export interface ModalRegistry< dismiss: ModalManager["dismiss"]; open: ( key: TKey, - input: ModalRegistryInput, + ...args: ModalOpenArgs> ) => ReturnType; } diff --git a/src/types/index.ts b/src/types/index.ts index 6dbcdcf..d434b62 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,4 +6,5 @@ export type { ModalId, ModalInstanceId, ModalInstanceStatus, + ModalOpenArgs, } from "./modal"; diff --git a/src/types/modal.ts b/src/types/modal.ts index fda907f..2263022 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -5,8 +5,14 @@ export type ModalInstanceId = string; export type ModalDismissReason = "dismiss" | "close-all" | "provider-unmount"; export type ModalInstanceStatus = "open" | "closing"; +type InputlessModalValue = ReturnType; + +export type ModalOpenArgs = [TInput] extends [InputlessModalValue] + ? [input?: TInput] + : [input: TInput]; + export interface ModalComponentProps { - input: TInput; + input: ModalOpenArgs[0]; instanceId: ModalInstanceId; close: (result: TResult) => void; dismiss: (reason?: ModalDismissReason) => void;