Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ Type exports:
- `ModalId`
- `ModalInstanceId`
- `ModalInstanceStatus`
- `ModalOpenArgs`
- `ModalManager`
- `ModalOptions`
- `ModalProviderProps`
Expand Down Expand Up @@ -510,7 +511,7 @@ Returns the modal manager from the nearest `ModalProvider`.

**Returns:**

- `open(modal, input): ModalHandle<TResult>`
- `open(modal, ...args: ModalOpenArgs<TInput>): ModalHandle<TResult>`
- `confirm(params): Promise<ConfirmModalResult>`
- `dismiss(instanceId, reason?): void`
- `closeAll(reason?): void`
Expand All @@ -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<TInput, TResult>` — 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 `<ModalProvider registry={registry}>`.

**Returns:**

- `open(key, input): ModalHandle<TResult>`
- `open(key, ...args: ModalOpenArgs<TInput>): ModalHandle<TResult>` — input is optional only when the registered modal uses `void` or `undefined`
- `confirm(params): Promise<ConfirmModalResult>`
- `dismiss(instanceId, reason?): void`
- `closeAll(reason?): void`
Expand All @@ -540,7 +543,7 @@ Creates a typed registry for opening modals by key. Bind it directly with `<Moda

Props passed to custom modal components.

- `input: TInput` — Input supplied to `modal.open()`
- `input: ModalOpenArgs<TInput>[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
Expand Down
4 changes: 2 additions & 2 deletions scripts/benchmark-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
});
},
Expand Down
27 changes: 26 additions & 1 deletion scripts/package-consumer.typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,37 @@ const renameModal = createModal<RenameInput, RenameResult>({
component: RenameModal,
});
const registry = createModalRegistry({ rename: renameModal });

declare const manager: ModalManager;

const handle: ModalHandle<RenameResult> = registry.open("rename", {
currentName: "Quarterly report",
});

// @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<VoidFunction>;

function InfoModal(
_: ModalComponentProps<EmptyModalValue, EmptyModalValue>,
): ReactNode {
return null;
}

const infoModal = createModal<EmptyModalValue, EmptyModalValue>({
component: InfoModal,
});
const infoRegistry = createModalRegistry({ info: infoModal });
const directInfoHandle: ModalHandle<EmptyModalValue> = manager.open(infoModal);
const registryInfoHandle: ModalHandle<EmptyModalValue> =
infoRegistry.open("info");

const confirmation: Promise<ConfirmModalResult> = manager.confirm({
title: "Continue?",
});
Expand All @@ -54,5 +77,7 @@ const providerProps: ComponentProps<typeof ModalProvider> = {
};

void confirmation;
void directInfoHandle;
void handle;
void registryInfoHandle;
void providerProps;
3 changes: 2 additions & 1 deletion src/hooks/useModalManager/useModalManager.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ModalDefinition,
ModalDismissReason,
ModalInstanceId,
ModalOpenArgs,
} from "../../types";

export interface ModalHandle<TResult> extends Promise<TResult> {
Expand All @@ -13,7 +14,7 @@ export interface ModalHandle<TResult> extends Promise<TResult> {
export interface ModalManager {
open: <TInput, TResult>(
modal: ModalDefinition<TInput, TResult>,
input: TInput,
...args: ModalOpenArgs<TInput>
) => ModalHandle<TResult>;
confirm: (params: ConfirmModalParams) => Promise<ConfirmModalResult>;
dismiss: (instanceId: ModalInstanceId, reason?: ModalDismissReason) => void;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export type {
ModalId,
ModalInstanceId,
ModalInstanceStatus,
ModalOpenArgs,
} from "./types";
2 changes: 1 addition & 1 deletion src/lifecycle/__tests__/modalLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion src/lifecycle/modalLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ModalDefinition,
ModalDismissReason,
ModalInstanceId,
ModalOpenArgs,
} from "../types";
import type {
CreateModalLifecycleOptions,
Expand Down Expand Up @@ -139,8 +140,9 @@ export function createModalLifecycle(
getServerSnapshot: () => serverSnapshot,
open: <TInput, TResult>(
modal: ModalDefinition<TInput, TResult>,
input: TInput,
...args: ModalOpenArgs<TInput>
): ModalLifecycleHandle<TResult> => {
const input = args[0];
const instanceId = `modal-${String(nextInstanceIndex)}`;
nextInstanceIndex += 1;

Expand Down
3 changes: 2 additions & 1 deletion src/lifecycle/modalLifecycle.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ModalId,
ModalInstanceId,
ModalInstanceStatus,
ModalOpenArgs,
} from "../types";

export interface ModalLifecycleInstance {
Expand Down Expand Up @@ -35,7 +36,7 @@ export interface ModalLifecycle extends ModalLifecycleObservation {
dismiss: (instanceId: ModalInstanceId, reason?: ModalDismissReason) => void;
open: <TInput, TResult>(
modal: ModalDefinition<TInput, TResult>,
input: TInput,
...args: ModalOpenArgs<TInput>
) => ModalLifecycleHandle<TResult>;
setCloseDelayMs: (closeDelayMs: number) => void;
}
Expand Down
8 changes: 3 additions & 5 deletions src/provider/ModalProvider.stories.fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
14 changes: 6 additions & 8 deletions src/provider/__tests__/ModalProvider.fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/registry/createModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ export function createModal<TInput, TResult>(

return {
...definition,
open: (manager, input) => manager.open(definition, input),
open: (manager, ...args) => manager.open(definition, ...args),
};
}
12 changes: 10 additions & 2 deletions src/registry/createModal.types.ts
Original file line number Diff line number Diff line change
@@ -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<TInput, TResult> {
id?: ModalId;
Expand All @@ -10,5 +15,8 @@ export interface RegisteredModalDefinition<
TInput,
TResult,
> extends ModalDefinition<TInput, TResult> {
open(manager: ModalManager, input: TInput): ModalHandle<TResult>;
open(
manager: ModalManager,
...args: ModalOpenArgs<TInput>
): ModalHandle<TResult>;
}
10 changes: 7 additions & 3 deletions src/registry/createModalRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,9 +62,9 @@ export function createModalRegistry<

function open<TKey extends keyof TDefinitions & string>(
key: TKey,
input: ModalRegistryInput<TDefinitions[TKey]>,
...args: ModalOpenArgs<ModalRegistryInput<TDefinitions[TKey]>>
): ReturnType<TDefinitions[TKey]["open"]>;
function open(key: string, input: unknown): ModalHandle<unknown> {
function open(key: string, input?: unknown): ModalHandle<unknown> {
assertTrue(
hasProperty(definitions, key),
() => `${MODAL_REGISTRY_UNKNOWN_KEY_ERROR}: ${key}`,
Expand Down
8 changes: 6 additions & 2 deletions src/registry/createModalRegistry.types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { ModalHandle, ModalManager } from "../hooks";
import type { ModalOpenArgs } from "../types";

export interface ModalRegistryEntry<TInput, TResult> {
open(manager: ModalManager, input: TInput): ModalHandle<TResult>;
open(
manager: ModalManager,
...args: ModalOpenArgs<TInput>
): ModalHandle<TResult>;
}

export type ModalRegistryDefinitions = Readonly<
Expand Down Expand Up @@ -33,6 +37,6 @@ export interface ModalRegistry<
dismiss: ModalManager["dismiss"];
open: <TKey extends keyof TDefinitions & string>(
key: TKey,
input: ModalRegistryInput<TDefinitions[TKey]>,
...args: ModalOpenArgs<ModalRegistryInput<TDefinitions[TKey]>>
) => ReturnType<TDefinitions[TKey]["open"]>;
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export type {
ModalId,
ModalInstanceId,
ModalInstanceStatus,
ModalOpenArgs,
} from "./modal";
8 changes: 7 additions & 1 deletion src/types/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ export type ModalInstanceId = string;
export type ModalDismissReason = "dismiss" | "close-all" | "provider-unmount";
export type ModalInstanceStatus = "open" | "closing";

type InputlessModalValue = ReturnType<VoidFunction>;

export type ModalOpenArgs<TInput> = [TInput] extends [InputlessModalValue]
? [input?: TInput]
: [input: TInput];

export interface ModalComponentProps<TInput, TResult> {
input: TInput;
input: ModalOpenArgs<TInput>[0];
instanceId: ModalInstanceId;
close: (result: TResult) => void;
dismiss: (reason?: ModalDismissReason) => void;
Expand Down
Loading