modalio 0.9.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/src/api.ts ADDED
@@ -0,0 +1,418 @@
1
+ import {
2
+ actions,
3
+ getDispatch,
4
+ getModalId,
5
+ hideModalCallbacks,
6
+ MODAL_REGISTRY,
7
+ modalCallbacks,
8
+ register,
9
+ } from "./core";
10
+ import type {
11
+ DeferredPromise,
12
+ ModalConfig,
13
+ ModalHocProps,
14
+ ModalLifecycleState,
15
+ ModalRef,
16
+ } from "./types";
17
+ import { type InternalModalConfig, MODAL_CONFIG_KEY } from "./types";
18
+
19
+ /** Delay before cleaning up closed modal state (allows getState() calls) */
20
+ const CLEANUP_DELAY_MS = 5000;
21
+
22
+ /** Creates a deferred promise with exposed resolve/reject handlers */
23
+ function createDeferredPromise<T = unknown>(): DeferredPromise<T> {
24
+ let resolve!: (value: T) => void;
25
+ let reject!: (reason: unknown) => void;
26
+ const promise = new Promise<T>((res, rej) => {
27
+ resolve = res;
28
+ reject = rej;
29
+ });
30
+ return { resolve, reject, promise };
31
+ }
32
+
33
+ // Store for opened promises (afterOpened callbacks)
34
+ const openedCallbacks: Record<
35
+ string,
36
+ { resolve: () => void; promise: Promise<void> }
37
+ > = {};
38
+
39
+ // Store for beforeClosed promises
40
+ const beforeClosedCallbacks: Record<
41
+ string,
42
+ { resolve: (result: unknown) => void; promise: Promise<unknown> }
43
+ > = {};
44
+
45
+ // Track modal lifecycle states
46
+ const modalStates: Record<string, ModalLifecycleState> = {};
47
+
48
+ // Store promises separately so they persist after close() deletes callbacks
49
+ // This ensures afterClosed() returns the correct promise even after close() is called
50
+ const closedPromises: Record<string, Promise<unknown>> = {};
51
+
52
+ /**
53
+ * Clean up all internal state for a modal to prevent memory leaks
54
+ */
55
+ export const cleanupModal = (modalId: string): void => {
56
+ delete modalCallbacks[modalId];
57
+ delete hideModalCallbacks[modalId];
58
+ delete openedCallbacks[modalId];
59
+ delete beforeClosedCallbacks[modalId];
60
+ delete modalStates[modalId];
61
+ delete closedPromises[modalId];
62
+ };
63
+
64
+ /**
65
+ * Clean up all modal state (useful for testing)
66
+ */
67
+ export const cleanupAllModals = (): void => {
68
+ const allIds = new Set([
69
+ ...Object.keys(modalCallbacks),
70
+ ...Object.keys(hideModalCallbacks),
71
+ ...Object.keys(openedCallbacks),
72
+ ...Object.keys(beforeClosedCallbacks),
73
+ ...Object.keys(modalStates),
74
+ ...Object.keys(closedPromises),
75
+ ]);
76
+ for (const id of allIds) {
77
+ cleanupModal(id);
78
+ }
79
+ };
80
+
81
+ /**
82
+ * Close a modal and return a promise that resolves when the animation completes
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * await closeModal(MyModal);
87
+ * await closeModal('my-modal');
88
+ * ```
89
+ */
90
+ export function closeModal<TResult = unknown>(
91
+ // biome-ignore lint/suspicious/noExplicitAny: implementation signature needs flexibility
92
+ modal: string | React.ComponentType<any>,
93
+ ): Promise<TResult>;
94
+
95
+ export function closeModal(
96
+ // biome-ignore lint/suspicious/noExplicitAny: implementation signature needs flexibility
97
+ modal: string | React.ComponentType<any>,
98
+ ): Promise<unknown> {
99
+ const modalId = getModalId(modal);
100
+
101
+ // Update state
102
+ modalStates[modalId] = "closing";
103
+
104
+ // Dispatch close action
105
+ getDispatch()(actions.close(modalId));
106
+
107
+ // Resolve open promise with undefined when closing directly
108
+ if (modalCallbacks[modalId]) {
109
+ modalCallbacks[modalId].resolve(undefined);
110
+ delete modalCallbacks[modalId];
111
+ }
112
+
113
+ // Create close promise if it doesn't exist
114
+ if (!hideModalCallbacks[modalId]) {
115
+ hideModalCallbacks[modalId] = createDeferredPromise();
116
+ }
117
+
118
+ return hideModalCallbacks[modalId].promise;
119
+ }
120
+
121
+ /**
122
+ * Remove a modal from the DOM completely
123
+ *
124
+ * @example
125
+ * ```tsx
126
+ * removeModal(MyModal);
127
+ * removeModal('my-modal');
128
+ * ```
129
+ */
130
+ export const removeModal = (
131
+ modal: string | React.ComponentType<Record<string, unknown>>,
132
+ ): void => {
133
+ const modalId = getModalId(modal);
134
+
135
+ // Dispatch remove action
136
+ getDispatch()(actions.remove(modalId));
137
+
138
+ // Resolve any pending promises before cleanup
139
+ modalCallbacks[modalId]?.resolve(undefined);
140
+ hideModalCallbacks[modalId]?.resolve(undefined);
141
+ beforeClosedCallbacks[modalId]?.resolve(undefined);
142
+ openedCallbacks[modalId]?.resolve();
143
+
144
+ // Clean up all callbacks to prevent memory leaks
145
+ cleanupModal(modalId);
146
+ };
147
+
148
+ /**
149
+ * Set flags on a modal (internal use)
150
+ */
151
+ export const setFlags = (
152
+ modalId: string,
153
+ flags: Record<string, unknown>,
154
+ ): void => {
155
+ getDispatch()(actions.setFlags(modalId, flags));
156
+ };
157
+
158
+ /**
159
+ * Open a modal and return a ModalRef for controlling it
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * // Open a modal with typed data
164
+ * const modalRef = openModal<{ confirmed: boolean }, { userId: string }>(
165
+ * ConfirmModal,
166
+ * { data: { userId: '123' } }
167
+ * );
168
+ *
169
+ * // Wait for it to close
170
+ * const result = await modalRef.afterClosed();
171
+ * if (result?.confirmed) {
172
+ * // User confirmed
173
+ * }
174
+ *
175
+ * // Or close it programmatically
176
+ * modalRef.close({ confirmed: false });
177
+ *
178
+ * // Check lifecycle state
179
+ * if (modalRef.getState() === 'open') {
180
+ * modalRef.close();
181
+ * }
182
+ * ```
183
+ */
184
+ export function openModal<
185
+ TResult = unknown,
186
+ TData = Record<string, unknown>,
187
+ TProps extends Record<string, unknown> = Record<string, unknown>,
188
+ >(
189
+ modal: React.ComponentType<TProps & ModalHocProps>,
190
+ config?: ModalConfig<TData>,
191
+ ): ModalRef<TResult, TData>;
192
+
193
+ export function openModal<TResult = unknown, TData = Record<string, unknown>>(
194
+ modal: string,
195
+ config?: ModalConfig<TData>,
196
+ ): ModalRef<TResult, TData>;
197
+
198
+ export function openModal<TResult = unknown, TData = Record<string, unknown>>(
199
+ modal: React.ComponentType<Record<string, unknown>> | string,
200
+ config: ModalConfig<TData> = {},
201
+ ): ModalRef<TResult, TData> {
202
+ const modalId = config.modalId ?? getModalId(modal);
203
+
204
+ // Auto-register if it's a component and not already registered
205
+ if (typeof modal !== "string" && !MODAL_REGISTRY[modalId]) {
206
+ register(modalId, modal);
207
+ }
208
+
209
+ // Track disableClose state for the ref
210
+ let disableClose = config.disableClose ?? false;
211
+
212
+ // Prepare data with internal config
213
+ const modalConfig: InternalModalConfig = {
214
+ disableClose,
215
+ keepMounted: config.keepMounted,
216
+ };
217
+ const data: Record<string, unknown> = {
218
+ ...(config.data as Record<string, unknown>),
219
+ [MODAL_CONFIG_KEY]: modalConfig,
220
+ };
221
+
222
+ // Set initial state
223
+ modalStates[modalId] = "open";
224
+
225
+ // Dispatch open action
226
+ getDispatch()(actions.open(modalId, data));
227
+
228
+ // Set keepMounted flag if specified
229
+ if (config.keepMounted) {
230
+ getDispatch()(actions.setFlags(modalId, { keepMounted: true }));
231
+ }
232
+
233
+ // Create show promise
234
+ const mainCallbacks = createDeferredPromise();
235
+ modalCallbacks[modalId] = mainCallbacks;
236
+
237
+ // Store promise reference that persists after close() for afterClosed()
238
+ closedPromises[modalId] = mainCallbacks.promise;
239
+
240
+ // Create opened promise
241
+ const openedDeferred = createDeferredPromise<void>();
242
+ openedCallbacks[modalId] = {
243
+ resolve: openedDeferred.resolve,
244
+ promise: openedDeferred.promise,
245
+ };
246
+
247
+ // Create beforeClosed promise
248
+ const beforeClosedDeferred = createDeferredPromise();
249
+ beforeClosedCallbacks[modalId] = {
250
+ resolve: beforeClosedDeferred.resolve,
251
+ promise: beforeClosedDeferred.promise,
252
+ };
253
+
254
+ // Return the ModalRef
255
+ const modalRef: ModalRef<TResult, TData> = {
256
+ modalId,
257
+ data: config.data,
258
+
259
+ get disableClose() {
260
+ return disableClose;
261
+ },
262
+ set disableClose(value: boolean) {
263
+ disableClose = value;
264
+ // Update the modal config
265
+ const currentConfig =
266
+ (data[MODAL_CONFIG_KEY] as InternalModalConfig) ?? {};
267
+ const updatedData = {
268
+ ...data,
269
+ [MODAL_CONFIG_KEY]: {
270
+ ...currentConfig,
271
+ disableClose: value,
272
+ },
273
+ };
274
+ getDispatch()(actions.setFlags(modalId, { data: updatedData }));
275
+ },
276
+
277
+ close: (result?: TResult) => {
278
+ if (modalStates[modalId] === "closed") {
279
+ return;
280
+ }
281
+
282
+ // Trigger beforeClosed
283
+ modalStates[modalId] = "closing";
284
+ beforeClosedCallbacks[modalId]?.resolve(result);
285
+ delete beforeClosedCallbacks[modalId];
286
+
287
+ // Resolve the main promise
288
+ modalCallbacks[modalId]?.resolve(result);
289
+ delete modalCallbacks[modalId];
290
+
291
+ closeModal(modalId);
292
+ },
293
+
294
+ afterOpened: () => {
295
+ return openedCallbacks[modalId]?.promise ?? Promise.resolve();
296
+ },
297
+
298
+ afterClosed: () => {
299
+ // Return stored promise that persists even after close() is called
300
+ return (closedPromises[modalId] ?? Promise.resolve(undefined)) as Promise<
301
+ TResult | undefined
302
+ >;
303
+ },
304
+
305
+ beforeClosed: () => {
306
+ return (beforeClosedCallbacks[modalId]?.promise ??
307
+ Promise.resolve(undefined)) as Promise<TResult | undefined>;
308
+ },
309
+
310
+ updateData: (newData: Partial<TData>) => {
311
+ Object.assign(data, newData as Record<string, unknown>);
312
+ getDispatch()(actions.open(modalId, data));
313
+ },
314
+
315
+ getState: () => {
316
+ return modalStates[modalId] ?? "closed";
317
+ },
318
+ };
319
+
320
+ return modalRef;
321
+ }
322
+
323
+ /**
324
+ * Mark a modal as fully closed (called after animation completes)
325
+ * @internal
326
+ */
327
+ export const markClosed = (modalId: string): void => {
328
+ modalStates[modalId] = "closed";
329
+ // Clean up state after a delay to allow getState() calls
330
+ setTimeout(() => {
331
+ if (modalStates[modalId] === "closed") {
332
+ delete modalStates[modalId];
333
+ delete closedPromises[modalId];
334
+ }
335
+ }, CLEANUP_DELAY_MS);
336
+ };
337
+
338
+ /**
339
+ * Get all currently open modal IDs
340
+ *
341
+ * @example
342
+ * ```tsx
343
+ * const openModals = ModalManager.getOpenModals();
344
+ * console.log(`${openModals.length} modals are open`);
345
+ * ```
346
+ */
347
+ export const getOpenModals = (): string[] => {
348
+ return Object.entries(modalStates)
349
+ .filter(([_, state]) => state === "open" || state === "closing")
350
+ .map(([id]) => id);
351
+ };
352
+
353
+ /**
354
+ * Close all open modals
355
+ * Useful for navigation, logout, or error handling scenarios
356
+ * Returns a promise that resolves when all modals are hidden
357
+ *
358
+ * @example
359
+ * ```tsx
360
+ * // Close all modals when navigating away
361
+ * useEffect(() => {
362
+ * return () => {
363
+ * closeAllModals();
364
+ * };
365
+ * }, []);
366
+ *
367
+ * // Close all modals on logout
368
+ * const handleLogout = async () => {
369
+ * await closeAllModals();
370
+ * logout();
371
+ * };
372
+ * ```
373
+ */
374
+ export const closeAllModals = async (): Promise<void> => {
375
+ const openModals = getOpenModals();
376
+
377
+ // Collect all close promises to await them
378
+ const closePromises: Promise<unknown>[] = [];
379
+
380
+ for (const modalId of openModals) {
381
+ // Trigger beforeClosed
382
+ modalStates[modalId] = "closing";
383
+ beforeClosedCallbacks[modalId]?.resolve(undefined);
384
+ delete beforeClosedCallbacks[modalId];
385
+
386
+ // Resolve the main promise with undefined
387
+ modalCallbacks[modalId]?.resolve(undefined);
388
+ delete modalCallbacks[modalId];
389
+
390
+ // Close the modal and collect the promise
391
+ closePromises.push(closeModal(modalId));
392
+ }
393
+
394
+ // Wait for all close animations to complete
395
+ await Promise.all(closePromises);
396
+ };
397
+
398
+ /**
399
+ * Check if any modals are currently open
400
+ *
401
+ * @example
402
+ * ```tsx
403
+ * if (ModalManager.hasOpenModals()) {
404
+ * // Prevent navigation or show warning
405
+ * }
406
+ * ```
407
+ */
408
+ export const hasOpenModals = (): boolean => {
409
+ return getOpenModals().length > 0;
410
+ };
411
+
412
+ /**
413
+ * Notify that a modal has been opened (called internally after mount/animation)
414
+ */
415
+ export const notifyOpened = (modalId: string): void => {
416
+ openedCallbacks[modalId]?.resolve();
417
+ delete openedCallbacks[modalId];
418
+ };
@@ -0,0 +1,206 @@
1
+ import {
2
+ type ComponentType,
3
+ createContext,
4
+ type ReactNode,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useReducer,
9
+ } from "react";
10
+ import { closeModal, openModal } from "./api";
11
+ import {
12
+ ALREADY_MOUNTED,
13
+ getUid,
14
+ initialState,
15
+ MODAL_REGISTRY,
16
+ reducer,
17
+ register,
18
+ setDispatch,
19
+ unregister,
20
+ } from "./core";
21
+ import type { ModalProviderProps, ModalStore } from "./types";
22
+
23
+ /**
24
+ * Context for the modal store
25
+ */
26
+ export const ModalContext = createContext<ModalStore>(initialState);
27
+
28
+ /**
29
+ * Context for the current modal ID (used in HOC-wrapped components)
30
+ */
31
+ export const ModalIdContext = createContext<string | null>(null);
32
+
33
+ /**
34
+ * Component that renders all currently visible modals from the registry
35
+ */
36
+ function ModalPlaceholder(): ReactNode {
37
+ const modals = useContext(ModalContext);
38
+ const visibleModalIds = Object.keys(modals).filter(
39
+ (id) => modals[id] !== undefined,
40
+ );
41
+
42
+ // Build render list and warn about unregistered modals in single pass
43
+ const toRender: Array<{
44
+ id: string;
45
+ comp: (typeof MODAL_REGISTRY)[string]["comp"];
46
+ props: (typeof MODAL_REGISTRY)[string]["props"];
47
+ }> = [];
48
+
49
+ for (const id of visibleModalIds) {
50
+ const entry = MODAL_REGISTRY[id];
51
+ if (entry) {
52
+ toRender.push({ id, comp: entry.comp, props: entry.props });
53
+ } else if (!ALREADY_MOUNTED[id]) {
54
+ console.warn(
55
+ `[ModalManager] No modal found for id: ${id}. ` +
56
+ "Please check if it is registered or declared via JSX.",
57
+ );
58
+ }
59
+ }
60
+
61
+ return (
62
+ <>
63
+ {toRender.map(({ id, comp: Comp, props }) => (
64
+ <Comp key={id} modalId={id} {...props} />
65
+ ))}
66
+ </>
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Internal provider that creates its own reducer
72
+ */
73
+ function InnerContextProvider({
74
+ children,
75
+ }: {
76
+ children: ReactNode;
77
+ }): ReactNode {
78
+ const [modals, dispatch] = useReducer(reducer, initialState);
79
+ setDispatch(dispatch);
80
+
81
+ return (
82
+ <ModalContext.Provider value={modals}>
83
+ {children}
84
+ <ModalPlaceholder />
85
+ </ModalContext.Provider>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Modal Provider - wrap your app with this to enable modal management
91
+ *
92
+ * @example
93
+ * ```tsx
94
+ * // Basic usage
95
+ * <ModalProvider>
96
+ * <App />
97
+ * </ModalProvider>
98
+ *
99
+ * // With external state management (Redux, etc.)
100
+ * <ModalProvider dispatch={dispatch} modals={modals}>
101
+ * <App />
102
+ * </ModalProvider>
103
+ * ```
104
+ */
105
+ export function ModalProvider({
106
+ children,
107
+ dispatch: givenDispatch,
108
+ modals: givenModals,
109
+ }: ModalProviderProps): ReactNode {
110
+ // If external state management is provided, use it
111
+ if (givenDispatch && givenModals) {
112
+ setDispatch(givenDispatch);
113
+ return (
114
+ <ModalContext.Provider value={givenModals}>
115
+ {children}
116
+ <ModalPlaceholder />
117
+ </ModalContext.Provider>
118
+ );
119
+ }
120
+
121
+ // Otherwise, use internal state management
122
+ return <InnerContextProvider>{children}</InnerContextProvider>;
123
+ }
124
+
125
+ /**
126
+ * Declarative modal definition component
127
+ *
128
+ * @example
129
+ * ```tsx
130
+ * <ModalDef id="my-modal" component={MyModal} />
131
+ * ```
132
+ */
133
+ export function ModalDef({
134
+ id,
135
+ component,
136
+ }: {
137
+ id: string;
138
+ // biome-ignore lint/suspicious/noExplicitAny: component props vary
139
+ component: ComponentType<any>;
140
+ }): ReactNode {
141
+ useEffect(() => {
142
+ register(id, component);
143
+ return () => {
144
+ unregister(id);
145
+ };
146
+ }, [id, component]);
147
+
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Imperative modal holder - useful for controlled scenarios
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * const handler = useRef<{ open?: Function; close?: Function }>({});
157
+ *
158
+ * <ModalHolder modal={MyModal} handler={handler.current} />
159
+ *
160
+ * // Later:
161
+ * handler.current.open?.({ title: 'Hello' });
162
+ * ```
163
+ */
164
+ export function ModalHolder({
165
+ modal,
166
+ handler,
167
+ ...restProps
168
+ }: {
169
+ // biome-ignore lint/suspicious/noExplicitAny: component props vary
170
+ modal: string | ComponentType<any>;
171
+ handler: {
172
+ open?: (data?: Record<string, unknown>) => Promise<unknown>;
173
+ close?: () => Promise<unknown>;
174
+ };
175
+ [key: string]: unknown;
176
+ }): ReactNode {
177
+ const mid = useMemo(() => getUid(), []);
178
+
179
+ // biome-ignore lint/suspicious/noExplicitAny: component props vary
180
+ const ModalComp: ComponentType<any> | undefined =
181
+ typeof modal === "string" ? MODAL_REGISTRY[modal]?.comp : modal;
182
+
183
+ if (!handler) {
184
+ throw new Error("[ModalManager] No handler found in ModalHolder.");
185
+ }
186
+
187
+ if (!ModalComp) {
188
+ throw new Error(
189
+ `[ModalManager] No modal found for id: ${String(modal)} in ModalHolder.`,
190
+ );
191
+ }
192
+
193
+ // Attach open/close methods to handler after mount
194
+ useEffect(() => {
195
+ handler.open = (data?: Record<string, unknown>) => {
196
+ const ref = openModal(mid, { data });
197
+ return ref.afterClosed();
198
+ };
199
+
200
+ handler.close = () => {
201
+ return closeModal(mid);
202
+ };
203
+ }, [mid, handler]);
204
+
205
+ return <ModalComp modalId={mid} {...restProps} />;
206
+ }