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/core.ts ADDED
@@ -0,0 +1,226 @@
1
+ import type { ComponentType, Dispatch } from "react";
2
+ import type {
3
+ DeferredPromise,
4
+ ModalAction,
5
+ ModalHocProps,
6
+ ModalRegistryEntry,
7
+ ModalStore,
8
+ } from "./types";
9
+
10
+ /** Symbol for storing modal IDs on component functions */
11
+ const symModalId: unique symbol = Symbol("ModalManagerId");
12
+
13
+ /** Type for components with attached modal ID */
14
+ type ComponentWithModalId = ComponentType<Record<string, unknown>> & {
15
+ [symModalId]?: string;
16
+ };
17
+
18
+ // Initial empty state
19
+ export const initialState: ModalStore = {};
20
+
21
+ // Modal registry - maps IDs to component definitions
22
+ export const MODAL_REGISTRY: Record<string, ModalRegistryEntry> = {};
23
+
24
+ // Track already mounted modals for delayed visibility
25
+ export const ALREADY_MOUNTED: Record<string, boolean> = {};
26
+
27
+ /** Promise callbacks for modal open/close resolution */
28
+ export const modalCallbacks: Record<string, DeferredPromise<unknown>> = {};
29
+ export const hideModalCallbacks: Record<string, DeferredPromise<unknown>> = {};
30
+
31
+ /**
32
+ * Cleanup all callbacks for a modal (prevents memory leaks)
33
+ */
34
+ export const cleanupCallbacks = (modalId: string): void => {
35
+ delete modalCallbacks[modalId];
36
+ delete hideModalCallbacks[modalId];
37
+ };
38
+
39
+ /**
40
+ * Cleanup all callbacks (useful for testing or full reset)
41
+ */
42
+ export const cleanupAllCallbacks = (): void => {
43
+ for (const key of Object.keys(modalCallbacks)) {
44
+ delete modalCallbacks[key];
45
+ }
46
+ for (const key of Object.keys(hideModalCallbacks)) {
47
+ delete hideModalCallbacks[key];
48
+ }
49
+ };
50
+
51
+ // UID counter for generating unique IDs
52
+ let uidSeed = 0;
53
+
54
+ /**
55
+ * Reset the UID seed (useful for SSR to prevent hydration mismatches)
56
+ * Call this on the server before rendering to ensure consistent IDs
57
+ * @internal
58
+ */
59
+ export const resetUidSeed = (): void => {
60
+ uidSeed = 0;
61
+ };
62
+
63
+ // Note: If you need external store subscriptions, implement useSyncExternalStore support here
64
+
65
+ // Global dispatch function - set by Provider
66
+ let dispatchFn: Dispatch<ModalAction> = () => {
67
+ throw new Error(
68
+ "No dispatch method detected. Did you wrap your app with ModalManager.Provider?",
69
+ );
70
+ };
71
+
72
+ /**
73
+ * Generate a unique modal ID
74
+ */
75
+ export const getUid = (): string => `_modal_${uidSeed++}`;
76
+
77
+ /**
78
+ * Get the modal ID from a string or component
79
+ */
80
+ export const getModalId = (
81
+ modal: string | ComponentType<Record<string, unknown>>,
82
+ ): string => {
83
+ if (typeof modal === "string") {
84
+ return modal;
85
+ }
86
+ const modalWithId = modal as ComponentWithModalId;
87
+ if (!modalWithId[symModalId]) {
88
+ modalWithId[symModalId] = getUid();
89
+ }
90
+ return modalWithId[symModalId];
91
+ };
92
+
93
+ /**
94
+ * Get a registered modal component by ID
95
+ */
96
+ export const getModal = (
97
+ modalId: string,
98
+ ): ComponentType<ModalHocProps & Record<string, unknown>> | undefined => {
99
+ return MODAL_REGISTRY[modalId]?.comp;
100
+ };
101
+
102
+ /**
103
+ * Set the global dispatch function
104
+ */
105
+ export const setDispatch = (fn: Dispatch<ModalAction>): void => {
106
+ dispatchFn = fn;
107
+ };
108
+
109
+ /**
110
+ * Get the current dispatch function
111
+ */
112
+ export const getDispatch = (): Dispatch<ModalAction> => dispatchFn;
113
+
114
+ /**
115
+ * Action creators
116
+ */
117
+ export const actions = {
118
+ open: (modalId: string, data?: Record<string, unknown>): ModalAction => ({
119
+ type: "modalio/show",
120
+ payload: { modalId, data },
121
+ }),
122
+
123
+ close: (modalId: string): ModalAction => ({
124
+ type: "modalio/hide",
125
+ payload: { modalId },
126
+ }),
127
+
128
+ remove: (modalId: string): ModalAction => ({
129
+ type: "modalio/remove",
130
+ payload: { modalId },
131
+ }),
132
+
133
+ setFlags: (modalId: string, flags: Record<string, unknown>): ModalAction => ({
134
+ type: "modalio/set-flags",
135
+ payload: { modalId, flags },
136
+ }),
137
+ };
138
+
139
+ /**
140
+ * Reducer for modal state management
141
+ */
142
+ export const reducer = (state: ModalStore, action: ModalAction): ModalStore => {
143
+ switch (action.type) {
144
+ case "modalio/show": {
145
+ const { modalId, data } = action.payload;
146
+ return {
147
+ ...state,
148
+ [modalId]: {
149
+ ...state[modalId],
150
+ modalId,
151
+ data,
152
+ // If already mounted, show immediately; otherwise delay
153
+ isOpen: !!ALREADY_MOUNTED[modalId],
154
+ delayOpen: !ALREADY_MOUNTED[modalId],
155
+ },
156
+ };
157
+ }
158
+
159
+ case "modalio/hide": {
160
+ const { modalId } = action.payload;
161
+ const modalState = state[modalId];
162
+ if (!modalState) {
163
+ return state;
164
+ }
165
+ return {
166
+ ...state,
167
+ [modalId]: {
168
+ ...modalState,
169
+ isOpen: false,
170
+ },
171
+ };
172
+ }
173
+
174
+ case "modalio/remove": {
175
+ const { modalId } = action.payload;
176
+ const newState = { ...state };
177
+ delete newState[modalId];
178
+ return newState;
179
+ }
180
+
181
+ case "modalio/set-flags": {
182
+ const { modalId, flags } = action.payload;
183
+ const existingState = state[modalId];
184
+ // Only set flags if the modal exists
185
+ if (!existingState) {
186
+ return state;
187
+ }
188
+ return {
189
+ ...state,
190
+ [modalId]: {
191
+ ...existingState,
192
+ ...flags,
193
+ },
194
+ };
195
+ }
196
+
197
+ default:
198
+ return state;
199
+ }
200
+ };
201
+
202
+ /**
203
+ * Register a modal component
204
+ */
205
+ export const register = (
206
+ id: string,
207
+ // biome-ignore lint/suspicious/noExplicitAny: component props vary
208
+ comp: ComponentType<any>,
209
+ props?: Record<string, unknown>,
210
+ ): void => {
211
+ if (MODAL_REGISTRY[id]) {
212
+ MODAL_REGISTRY[id].props = props;
213
+ } else {
214
+ MODAL_REGISTRY[id] = {
215
+ comp: comp as ComponentType<ModalHocProps & Record<string, unknown>>,
216
+ props,
217
+ };
218
+ }
219
+ };
220
+
221
+ /**
222
+ * Unregister a modal component
223
+ */
224
+ export const unregister = (id: string): void => {
225
+ delete MODAL_REGISTRY[id];
226
+ };
package/src/hoc.tsx ADDED
@@ -0,0 +1,114 @@
1
+ import { type ComponentType, type Ref, useContext, useEffect } from "react";
2
+ import { setFlags } from "./api";
3
+ import { ModalContext, ModalIdContext } from "./context";
4
+ import { ALREADY_MOUNTED } from "./core";
5
+ import { useModal } from "./hooks";
6
+ import type { ModalHocProps } from "./types";
7
+
8
+ /** Props passed directly to the HOC wrapper */
9
+ type HocOwnProps = ModalHocProps;
10
+
11
+ /** Props for components that accept a ref (React 19+ style) */
12
+ export interface RefProp<T> {
13
+ ref?: Ref<T>;
14
+ }
15
+
16
+ /**
17
+ * HOC to create a modal component with automatic registration and lifecycle management.
18
+ * Uses React 19's ref-as-prop pattern (no forwardRef needed).
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * interface MyModalProps {
23
+ * title: string;
24
+ * onSave: () => void;
25
+ * }
26
+ *
27
+ * const MyModal = createModal<MyModalProps>(({ title, onSave }) => {
28
+ * const modal = useModal();
29
+ *
30
+ * return (
31
+ * <Dialog open={modal.open} onOpenChange={(open) => !open && modal.dismiss()}>
32
+ * <DialogContent onAnimationEnd={modal.onAnimationEnd}>
33
+ * <h2>{title}</h2>
34
+ * <Button onClick={() => { onSave(); modal.close(); }}>Save</Button>
35
+ * </DialogContent>
36
+ * </Dialog>
37
+ * );
38
+ * });
39
+ *
40
+ * // Usage
41
+ * open(MyModal, { data: { title: 'Edit Item', onSave: handleSave } });
42
+ * ```
43
+ */
44
+ export const createModal = <
45
+ TProps extends Record<string, unknown> = Record<string, unknown>,
46
+ TRef = unknown,
47
+ >(
48
+ Comp: ComponentType<TProps & RefProp<TRef>>,
49
+ ): ComponentType<TProps & HocOwnProps & RefProp<TRef>> => {
50
+ function WrappedComponent(allProps: TProps & HocOwnProps & RefProp<TRef>) {
51
+ // Extract HOC props
52
+ const { defaultOpen, keepMounted, modalId, ref, ...restProps } =
53
+ allProps as HocOwnProps & RefProp<TRef> & Record<string, unknown>;
54
+ const componentProps = restProps as TProps;
55
+
56
+ const { data, open: openModal } = useModal(modalId);
57
+ const modals = useContext(ModalContext);
58
+
59
+ // Only mount if this modal exists in the store
60
+ const shouldMount = modalId in modals;
61
+
62
+ // Handle default visibility and track mounted state
63
+ useEffect(() => {
64
+ if (defaultOpen) {
65
+ openModal();
66
+ }
67
+
68
+ ALREADY_MOUNTED[modalId] = true;
69
+
70
+ return () => {
71
+ delete ALREADY_MOUNTED[modalId];
72
+ };
73
+ }, [modalId, openModal, defaultOpen]);
74
+
75
+ // Handle keepMounted flag
76
+ useEffect(() => {
77
+ if (keepMounted) {
78
+ setFlags(modalId, { keepMounted: true });
79
+ }
80
+ }, [modalId, keepMounted]);
81
+
82
+ // Handle delayed visibility (for modals shown before mount)
83
+ const delayOpen = modals[modalId]?.delayOpen;
84
+ useEffect(() => {
85
+ if (delayOpen) {
86
+ openModal(data);
87
+ }
88
+ }, [delayOpen, data, openModal]);
89
+
90
+ if (!shouldMount) {
91
+ return null;
92
+ }
93
+
94
+ // Merge static props with dynamic data from open()
95
+ const mergedProps: TProps & RefProp<TRef> = {
96
+ ...componentProps,
97
+ ...(data as Partial<TProps>),
98
+ ref,
99
+ };
100
+
101
+ return (
102
+ <ModalIdContext.Provider value={modalId}>
103
+ <Comp {...mergedProps} />
104
+ </ModalIdContext.Provider>
105
+ );
106
+ }
107
+
108
+ // Copy display name for debugging
109
+ WrappedComponent.displayName = `ModalManager(${
110
+ Comp.displayName || Comp.name || "Component"
111
+ })`;
112
+
113
+ return WrappedComponent;
114
+ };
package/src/hooks.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
2
+ import {
3
+ closeModal,
4
+ markClosed,
5
+ notifyOpened,
6
+ openModal,
7
+ removeModal,
8
+ } from "./api";
9
+ import { ModalContext, ModalIdContext } from "./context";
10
+ import {
11
+ getModalId,
12
+ hideModalCallbacks,
13
+ MODAL_REGISTRY,
14
+ modalCallbacks,
15
+ register,
16
+ } from "./core";
17
+ import type { ModalHandler, ModalHocProps, ModalProps } from "./types";
18
+ import { type InternalModalConfig, MODAL_CONFIG_KEY } from "./types";
19
+
20
+ /**
21
+ * Hook to control a modal from within the modal component or from anywhere.
22
+ * Returns an enhanced handler with state and control methods.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * // Inside a modal component (uses context)
27
+ * const modal = useModal();
28
+ *
29
+ * return (
30
+ * <Dialog open={modal.open} onOpenChange={(open) => !open && modal.dismiss()}>
31
+ * <DialogContent onAnimationEnd={modal.onAnimationEnd}>
32
+ * <Button onClick={() => modal.close(result)}>Save</Button>
33
+ * <Button onClick={modal.dismiss}>Cancel</Button>
34
+ * </DialogContent>
35
+ * </Dialog>
36
+ * );
37
+ *
38
+ * // With a specific modal component
39
+ * const modal = useModal(MyModal);
40
+ * modal.open({ userId: '123' });
41
+ *
42
+ * // With a string ID
43
+ * const modal = useModal('my-modal');
44
+ * ```
45
+ */
46
+
47
+ export function useModal(
48
+ modal?: string,
49
+ data?: Record<string, unknown>,
50
+ ): ModalHandler;
51
+
52
+ export function useModal<
53
+ TProps extends Record<string, unknown>,
54
+ P extends Partial<ModalProps<React.ComponentType<TProps & ModalHocProps>>>,
55
+ >(
56
+ modal: React.ComponentType<TProps & ModalHocProps>,
57
+ data?: P,
58
+ ): ModalHandler<TProps>;
59
+
60
+ export function useModal(
61
+ // biome-ignore lint/suspicious/noExplicitAny: component props vary
62
+ modal?: React.ComponentType<any> | string,
63
+ initialData?: Record<string, unknown>,
64
+ ): ModalHandler {
65
+ const modals = useContext(ModalContext);
66
+ const contextModalId = useContext(ModalIdContext);
67
+
68
+ // Determine modal ID - from argument or context
69
+ const modalId = modal ? getModalId(modal) : contextModalId;
70
+
71
+ if (!modalId) {
72
+ throw new Error(
73
+ "[ModalManager] No modal id found in useModal. " +
74
+ "Either pass a modal component/id or use inside a modal component.",
75
+ );
76
+ }
77
+
78
+ // Check if modal argument is a component reference (not a string ID)
79
+ // Note: ForwardRefExoticComponent is an object, not a function
80
+ const isComponentRef = modal !== undefined && typeof modal !== "string";
81
+
82
+ // Register component if passed and not already registered
83
+ useEffect(() => {
84
+ if (isComponentRef && !MODAL_REGISTRY[modalId]) {
85
+ register(
86
+ modalId,
87
+ modal as React.ComponentType<Record<string, unknown>>,
88
+ initialData,
89
+ );
90
+ }
91
+ }, [isComponentRef, modalId, modal, initialData]);
92
+
93
+ const modalInfo = modals[modalId];
94
+
95
+ // Use refs to avoid stale closures in animation handlers
96
+ const modalInfoRef = useRef(modalInfo);
97
+ useEffect(() => {
98
+ modalInfoRef.current = modalInfo;
99
+ }, [modalInfo]);
100
+
101
+ // Memoized control methods
102
+ const openCallback = useCallback(
103
+ (data?: Record<string, unknown>) => {
104
+ const ref = openModal(modalId, { data });
105
+ return ref.afterClosed();
106
+ },
107
+ [modalId],
108
+ );
109
+
110
+ const closeCallback = useCallback(
111
+ (result?: unknown) => {
112
+ modalCallbacks[modalId]?.resolve(result);
113
+ delete modalCallbacks[modalId];
114
+ closeModal(modalId);
115
+ },
116
+ [modalId],
117
+ );
118
+
119
+ const dismissCallback = useCallback(() => {
120
+ modalCallbacks[modalId]?.resolve(undefined);
121
+ delete modalCallbacks[modalId];
122
+ closeModal(modalId);
123
+ }, [modalId]);
124
+
125
+ const removeCallback = useCallback(() => removeModal(modalId), [modalId]);
126
+
127
+ // Animation completion handler
128
+ const onAnimationEnd = useCallback(() => {
129
+ const current = modalInfoRef.current;
130
+
131
+ if (current?.isOpen) {
132
+ // Modal is opening - notify afterOpened promise
133
+ notifyOpened(modalId);
134
+ } else {
135
+ // Modal is closing - mark as fully closed
136
+ markClosed(modalId);
137
+
138
+ // Resolve close promise when closing animation completes
139
+ hideModalCallbacks[modalId]?.resolve(undefined);
140
+ delete hideModalCallbacks[modalId];
141
+
142
+ // Remove if not keepMounted (use ref for latest value)
143
+ if (!current?.keepMounted) {
144
+ removeModal(modalId);
145
+ }
146
+ }
147
+ }, [modalId]);
148
+
149
+ return useMemo(
150
+ () => ({
151
+ // State
152
+ modalId,
153
+ data: modalInfo?.data,
154
+ isOpen: !!modalInfo?.isOpen,
155
+ keepMounted: !!modalInfo?.keepMounted,
156
+ // Controls
157
+ open: openCallback,
158
+ close: closeCallback,
159
+ dismiss: dismissCallback,
160
+ remove: removeCallback,
161
+ // Animation handler
162
+ onAnimationEnd,
163
+ }),
164
+ [
165
+ modalId,
166
+ modalInfo?.data,
167
+ modalInfo?.isOpen,
168
+ modalInfo?.keepMounted,
169
+ openCallback,
170
+ closeCallback,
171
+ dismissCallback,
172
+ removeCallback,
173
+ onAnimationEnd,
174
+ ],
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Hook to get typed data passed to a modal
180
+ * Must be used inside a modal component (within ModalIdContext)
181
+ *
182
+ * @example
183
+ * ```tsx
184
+ * interface MyModalData {
185
+ * userId: string;
186
+ * userName: string;
187
+ * }
188
+ *
189
+ * const MyModal = createModal(() => {
190
+ * const data = useModalData<MyModalData>();
191
+ * const modal = useModal();
192
+ *
193
+ * return (
194
+ * <Dialog open={modal.open}>
195
+ * <DialogContent>
196
+ * <p>User: {data?.userName}</p>
197
+ * </DialogContent>
198
+ * </Dialog>
199
+ * );
200
+ * });
201
+ *
202
+ * // Usage:
203
+ * open(MyModal, { data: { userId: '123', userName: 'John' } });
204
+ * ```
205
+ */
206
+ export function useModalData<TData = Record<string, unknown>>():
207
+ | TData
208
+ | undefined {
209
+ const modals = useContext(ModalContext);
210
+ const modalId = useContext(ModalIdContext);
211
+
212
+ if (!modalId) {
213
+ throw new Error(
214
+ "[ModalManager] useModalData must be used inside a modal component. " +
215
+ "Make sure you're using createModal() to wrap your modal.",
216
+ );
217
+ }
218
+
219
+ const modalInfo = modals[modalId];
220
+ const data = modalInfo?.data;
221
+
222
+ // Filter out internal modal config from user data
223
+ if (!data) {
224
+ return undefined;
225
+ }
226
+ const { [MODAL_CONFIG_KEY]: _, ...userData } = data;
227
+ return userData as TData;
228
+ }
229
+
230
+ /**
231
+ * Hook to get the modal config options (disableClose, keepMounted, etc.)
232
+ * Must be used inside a modal component
233
+ */
234
+ export function useModalConfig(): InternalModalConfig {
235
+ const modals = useContext(ModalContext);
236
+ const modalId = useContext(ModalIdContext);
237
+
238
+ if (!modalId) {
239
+ throw new Error(
240
+ "[ModalManager] useModalConfig must be used inside a modal component.",
241
+ );
242
+ }
243
+
244
+ const modalInfo = modals[modalId];
245
+ const config = modalInfo?.data?.[MODAL_CONFIG_KEY] as
246
+ | InternalModalConfig
247
+ | undefined;
248
+
249
+ return config ?? {};
250
+ }
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Primary API - ModalManager namespace
2
+
3
+ // Re-export adapter types
4
+ export type {
5
+ AdapterOptions,
6
+ BaseUiDialogPopupProps,
7
+ BaseUiDialogPortalProps,
8
+ BaseUiDialogRootProps,
9
+ BaseUiPopoverPopupProps,
10
+ BaseUiPopoverPortalProps,
11
+ BaseUiPopoverRootProps,
12
+ ShadcnUiDrawerContentProps,
13
+ ShadcnUiDrawerRootProps,
14
+ } from "./adapters";
15
+ // Re-export adapters
16
+ export {
17
+ // Base UI adapters
18
+ baseUiAlertDialog,
19
+ baseUiAlertDialogPopup,
20
+ baseUiAlertDialogPortal,
21
+ baseUiDialog,
22
+ baseUiDialogPopup,
23
+ baseUiDialogPortal,
24
+ // Base UI Popover adapters
25
+ baseUiPopover,
26
+ baseUiPopoverPopup,
27
+ baseUiPopoverPortal,
28
+ // Base UI Sheet adapters
29
+ baseUiSheet,
30
+ baseUiSheetPopup,
31
+ baseUiSheetPortal,
32
+ // Radix UI adapters
33
+ radixUiAlertDialog,
34
+ radixUiAlertDialogContent,
35
+ radixUiDialog,
36
+ radixUiDialogContent,
37
+ radixUiPopover,
38
+ radixUiSheet,
39
+ radixUiSheetContent,
40
+ // Shadcn adapters
41
+ shadcnUiAlertDialog,
42
+ shadcnUiAlertDialogContent,
43
+ shadcnUiDialog,
44
+ shadcnUiDialogContent,
45
+ shadcnUiDrawer,
46
+ shadcnUiDrawerContent,
47
+ shadcnUiPopover,
48
+ shadcnUiSheet,
49
+ shadcnUiSheetContent,
50
+ } from "./adapters";
51
+ // Re-export context (for advanced usage)
52
+ export { ModalContext, ModalIdContext } from "./context";
53
+ // Re-export core utilities (for advanced usage)
54
+ export { getModalId, reducer } from "./core";
55
+ // Re-export hooks
56
+ export { useModal, useModalConfig, useModalData } from "./hooks";
57
+ export { ModalManager } from "./modal-manager";
58
+ // Re-export types
59
+ export type {
60
+ DeferredPromise,
61
+ InternalModalConfig,
62
+ ModalAction,
63
+ ModalActionType,
64
+ ModalAnimationHandlers,
65
+ ModalConfig,
66
+ ModalControls,
67
+ ModalHandler,
68
+ ModalHocProps,
69
+ ModalLifecycleState,
70
+ ModalProps,
71
+ ModalProviderProps,
72
+ ModalReadState,
73
+ ModalRef,
74
+ ModalState,
75
+ ModalStore,
76
+ RadixDialogContentProps,
77
+ RadixDialogProps,
78
+ ShadcnDialogProps,
79
+ } from "./types";
80
+ // Re-export constants
81
+ export { MODAL_CONFIG_KEY } from "./types";