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/README.md +157 -0
- package/dist/index.cjs +912 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +618 -0
- package/dist/index.d.ts +618 -0
- package/dist/index.js +874 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
- package/src/adapters.ts +567 -0
- package/src/api.ts +418 -0
- package/src/context.tsx +206 -0
- package/src/core.ts +226 -0
- package/src/hoc.tsx +114 -0
- package/src/hooks.ts +250 -0
- package/src/index.ts +81 -0
- package/src/modal-manager.ts +136 -0
- package/src/types.ts +243 -0
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
|
+
};
|
package/src/context.tsx
ADDED
|
@@ -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
|
+
}
|