@lerx/promise-modal 0.2.8 → 0.3.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.
@@ -1,21 +1,233 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
2
  import type { BackgroundComponent, FooterOptions, ForegroundComponent, ModalBackground, PromptContentProps, PromptFooterRender, PromptInputProps } from '../../types';
3
- interface PromptProps<T, B = any> {
3
+ interface PromptProps<InputValue, BackgroundValue = any> {
4
4
  group?: string;
5
5
  title?: ReactNode;
6
6
  subtitle?: ReactNode;
7
7
  content?: ReactNode | ComponentType<PromptContentProps>;
8
- defaultValue?: T;
9
- Input: (props: PromptInputProps<T>) => ReactNode;
10
- disabled?: (value: T) => boolean;
8
+ defaultValue?: InputValue;
9
+ Input: (props: PromptInputProps<InputValue>) => ReactNode;
10
+ disabled?: (value: InputValue) => boolean;
11
11
  returnOnCancel?: boolean;
12
- background?: ModalBackground<B>;
13
- footer?: PromptFooterRender<T> | FooterOptions | false;
12
+ background?: ModalBackground<BackgroundValue>;
13
+ footer?: PromptFooterRender<InputValue> | FooterOptions | false;
14
14
  dimmed?: boolean;
15
15
  manualDestroy?: boolean;
16
16
  closeOnBackdropClick?: boolean;
17
17
  ForegroundComponent?: ForegroundComponent;
18
18
  BackgroundComponent?: BackgroundComponent;
19
19
  }
20
- export declare const prompt: <T, B = any>({ group, title, subtitle, content, defaultValue, Input, disabled, returnOnCancel, background, footer, dimmed, manualDestroy, closeOnBackdropClick, ForegroundComponent, BackgroundComponent, }: PromptProps<T, B>) => Promise<T>;
20
+ /**
21
+ * Displays a promise-based prompt modal that collects user input.
22
+ *
23
+ * Creates a modal dialog with a custom input component and confirmation/cancel buttons.
24
+ * The promise resolves with the input value when confirmed, or rejects when cancelled
25
+ * (unless `returnOnCancel` is true).
26
+ *
27
+ * @typeParam InputValue - Type of the value collected from user input
28
+ * @typeParam BackgroundValue - Type of background data passed to BackgroundComponent
29
+ * @param props - Prompt dialog configuration options
30
+ * @returns Promise that resolves with the user's input value
31
+ * @throws Rejects when cancelled (unless returnOnCancel is true)
32
+ *
33
+ * @example
34
+ * Basic text input:
35
+ * ```tsx
36
+ * const name = await prompt<string>({
37
+ * title: 'Enter Your Name',
38
+ * content: 'Please enter your full name for the certificate.',
39
+ * Input: ({ value, onChange, onConfirm }) => (
40
+ * <input
41
+ * type="text"
42
+ * value={value || ''}
43
+ * onChange={(e) => onChange(e.target.value)}
44
+ * onKeyPress={(e) => e.key === 'Enter' && onConfirm()}
45
+ * autoFocus
46
+ * />
47
+ * ),
48
+ * defaultValue: 'John Doe',
49
+ * });
50
+ * ```
51
+ *
52
+ * @example
53
+ * Number input with validation:
54
+ * ```tsx
55
+ * const age = await prompt<number>({
56
+ * title: 'Enter Your Age',
57
+ * content: 'Must be between 18 and 100',
58
+ * Input: ({ value, onChange }) => (
59
+ * <input
60
+ * type="number"
61
+ * min="18"
62
+ * max="100"
63
+ * value={value || ''}
64
+ * onChange={(e) => onChange(parseInt(e.target.value))}
65
+ * />
66
+ * ),
67
+ * disabled: (value) => !value || value < 18 || value > 100,
68
+ * defaultValue: 25,
69
+ * });
70
+ * ```
71
+ *
72
+ * @example
73
+ * Select dropdown:
74
+ * ```tsx
75
+ * interface Theme {
76
+ * id: string;
77
+ * name: string;
78
+ * primary: string;
79
+ * }
80
+ *
81
+ * const theme = await prompt<Theme>({
82
+ * title: 'Choose Theme',
83
+ * Input: ({ value, onChange }) => (
84
+ * <select
85
+ * value={value?.id || ''}
86
+ * onChange={(e) => {
87
+ * const selected = themes.find(t => t.id === e.target.value);
88
+ * onChange(selected);
89
+ * }}
90
+ * >
91
+ * <option value="">Select a theme...</option>
92
+ * {themes.map(theme => (
93
+ * <option key={theme.id} value={theme.id}>
94
+ * {theme.name}
95
+ * </option>
96
+ * ))}
97
+ * </select>
98
+ * ),
99
+ * disabled: (value) => !value,
100
+ * });
101
+ * ```
102
+ *
103
+ * @example
104
+ * Multi-field form:
105
+ * ```tsx
106
+ * interface UserData {
107
+ * username: string;
108
+ * email: string;
109
+ * subscribe: boolean;
110
+ * }
111
+ *
112
+ * const userData = await prompt<UserData>({
113
+ * title: 'Create Account',
114
+ * Input: ({ value, onChange }) => {
115
+ * const data = value || { username: '', email: '', subscribe: false };
116
+ *
117
+ * return (
118
+ * <div className="form">
119
+ * <input
120
+ * placeholder="Username"
121
+ * value={data.username}
122
+ * onChange={(e) => onChange({ ...data, username: e.target.value })}
123
+ * />
124
+ * <input
125
+ * type="email"
126
+ * placeholder="Email"
127
+ * value={data.email}
128
+ * onChange={(e) => onChange({ ...data, email: e.target.value })}
129
+ * />
130
+ * <label>
131
+ * <input
132
+ * type="checkbox"
133
+ * checked={data.subscribe}
134
+ * onChange={(e) => onChange({ ...data, subscribe: e.target.checked })}
135
+ * />
136
+ * Subscribe to newsletter
137
+ * </label>
138
+ * </div>
139
+ * );
140
+ * },
141
+ * disabled: (value) => !value?.username || !value?.email,
142
+ * });
143
+ * ```
144
+ *
145
+ * @example
146
+ * File upload:
147
+ * ```tsx
148
+ * const file = await prompt<File>({
149
+ * title: 'Upload Avatar',
150
+ * content: 'Select an image file (max 5MB)',
151
+ * Input: ({ value, onChange }) => (
152
+ * <div>
153
+ * <input
154
+ * type="file"
155
+ * accept="image/*"
156
+ * onChange={(e) => {
157
+ * const file = e.target.files?.[0];
158
+ * if (file && file.size <= 5 * 1024 * 1024) {
159
+ * onChange(file);
160
+ * }
161
+ * }}
162
+ * />
163
+ * {value && (
164
+ * <div>
165
+ * <img src={URL.createObjectURL(value)} alt="Preview" />
166
+ * <p>{value.name} ({(value.size / 1024).toFixed(1)} KB)</p>
167
+ * </div>
168
+ * )}
169
+ * </div>
170
+ * ),
171
+ * disabled: (value) => !value,
172
+ * });
173
+ * ```
174
+ *
175
+ * @example
176
+ * With error handling:
177
+ * ```tsx
178
+ * try {
179
+ * const email = await prompt<string>({
180
+ * title: 'Reset Password',
181
+ * content: 'Enter your email to receive a reset link',
182
+ * Input: ({ value, onChange, context }) => (
183
+ * <EmailInput
184
+ * value={value}
185
+ * onChange={onChange}
186
+ * error={context.error}
187
+ * />
188
+ * ),
189
+ * });
190
+ *
191
+ * await sendResetEmail(email);
192
+ * } catch (error) {
193
+ * // User cancelled the prompt
194
+ * console.log('Password reset cancelled');
195
+ * }
196
+ * ```
197
+ *
198
+ * @example
199
+ * Custom footer with validation:
200
+ * ```tsx
201
+ * const password = await prompt<string>({
202
+ * title: 'Set New Password',
203
+ * Input: ({ value, onChange }) => (
204
+ * <PasswordInput value={value} onChange={onChange} />
205
+ * ),
206
+ * footer: ({ value, onChange, onConfirm, onCancel, disabled }) => (
207
+ * <div>
208
+ * <PasswordStrength password={value} />
209
+ * <div className="buttons">
210
+ * <button onClick={onCancel}>Cancel</button>
211
+ * <button
212
+ * onClick={onConfirm}
213
+ * disabled={disabled}
214
+ * className={disabled ? 'disabled' : 'primary'}
215
+ * >
216
+ * Set Password
217
+ * </button>
218
+ * </div>
219
+ * </div>
220
+ * ),
221
+ * disabled: (value) => !value || value.length < 8,
222
+ * });
223
+ * ```
224
+ *
225
+ * @remarks
226
+ * - The Input component receives value, onChange, onConfirm, onCancel, and context props
227
+ * - Use the `disabled` function to control when the confirm button is enabled
228
+ * - Set `returnOnCancel: true` to return defaultValue instead of rejecting on cancel
229
+ * - The promise rejects when cancelled (unless returnOnCancel is set)
230
+ * - Use onConfirm prop in Input to handle Enter key submission
231
+ */
232
+ export declare const prompt: <InputValue, BackgroundValue = any>({ group, title, subtitle, content, defaultValue, Input, disabled, returnOnCancel, background, footer, dimmed, manualDestroy, closeOnBackdropClick, ForegroundComponent, BackgroundComponent, }: PromptProps<InputValue, BackgroundValue>) => Promise<InputValue>;
21
233
  export {};
@@ -1,3 +1,166 @@
1
1
  import type { Fn } from '../@aileron/declare';
2
2
  import type { ModalNode } from '../core';
3
+ /**
4
+ * Hook that counts the number of active modals based on a validation function.
5
+ *
6
+ * Provides a reactive count of modals that match specific criteria. By default,
7
+ * counts visible modals, but can be customized with any validation logic.
8
+ * Useful for managing overlays, z-index stacking, or conditional UI based on modal count.
9
+ *
10
+ * @param validate - Function to determine if a modal should be counted (default: checks visibility)
11
+ * @param refreshKey - Optional key to force recalculation when changed
12
+ * @returns Number of modals that pass the validation
13
+ *
14
+ * @example
15
+ * Basic usage - count visible modals:
16
+ * ```tsx
17
+ * function ModalOverlay() {
18
+ * const activeCount = useActiveModalCount();
19
+ *
20
+ * if (activeCount === 0) return null;
21
+ *
22
+ * return (
23
+ * <div
24
+ * className="modal-backdrop"
25
+ * style={{
26
+ * opacity: Math.min(activeCount * 0.3, 0.8),
27
+ * zIndex: 1000 + activeCount
28
+ * }}
29
+ * />
30
+ * );
31
+ * }
32
+ * ```
33
+ *
34
+ * @example
35
+ * Count modals by type:
36
+ * ```tsx
37
+ * function ModalStats() {
38
+ * const alertCount = useActiveModalCount(
39
+ * (modal) => modal?.type === 'alert' && modal.visible
40
+ * );
41
+ *
42
+ * const confirmCount = useActiveModalCount(
43
+ * (modal) => modal?.type === 'confirm' && modal.visible
44
+ * );
45
+ *
46
+ * const promptCount = useActiveModalCount(
47
+ * (modal) => modal?.type === 'prompt' && modal.visible
48
+ * );
49
+ *
50
+ * return (
51
+ * <div className="modal-stats">
52
+ * <p>Alerts: {alertCount}</p>
53
+ * <p>Confirms: {confirmCount}</p>
54
+ * <p>Prompts: {promptCount}</p>
55
+ * </div>
56
+ * );
57
+ * }
58
+ * ```
59
+ *
60
+ * @example
61
+ * Prevent interactions when modals are active:
62
+ * ```tsx
63
+ * function App() {
64
+ * const hasActiveModals = useActiveModalCount() > 0;
65
+ *
66
+ * return (
67
+ * <div className={hasActiveModals ? 'app-disabled' : 'app'}>
68
+ * <nav className={hasActiveModals ? 'pointer-events-none' : ''}>
69
+ * <button disabled={hasActiveModals}>Navigate</button>
70
+ * </nav>
71
+ * <main inert={hasActiveModals}>
72
+ * // Main content
73
+ * </main>
74
+ * </div>
75
+ * );
76
+ * }
77
+ * ```
78
+ *
79
+ * @example
80
+ * Count alive modals (including hidden ones):
81
+ * ```tsx
82
+ * function ModalMemoryMonitor() {
83
+ * const aliveCount = useActiveModalCount(
84
+ * (modal) => modal?.alive === true
85
+ * );
86
+ *
87
+ * const hiddenCount = useActiveModalCount(
88
+ * (modal) => modal?.alive && !modal.visible
89
+ * );
90
+ *
91
+ * return (
92
+ * <div className="debug-panel">
93
+ * <p>Total alive modals: {aliveCount}</p>
94
+ * <p>Hidden (animating out): {hiddenCount}</p>
95
+ * <button
96
+ * onClick={cleanupHiddenModals}
97
+ * disabled={hiddenCount === 0}
98
+ * >
99
+ * Cleanup Hidden Modals
100
+ * </button>
101
+ * </div>
102
+ * );
103
+ * }
104
+ * ```
105
+ *
106
+ * @example
107
+ * Dynamic refresh with dependencies:
108
+ * ```tsx
109
+ * function FilteredModalCount({ filter }) {
110
+ * const [refreshKey, setRefreshKey] = useState(0);
111
+ *
112
+ * // Count modals matching dynamic filter
113
+ * const count = useActiveModalCount(
114
+ * (modal) => {
115
+ * if (!modal?.visible) return false;
116
+ * if (filter.type && modal.type !== filter.type) return false;
117
+ * if (filter.group && modal.group !== filter.group) return false;
118
+ * return true;
119
+ * },
120
+ * refreshKey // Force recalculation when key changes
121
+ * );
122
+ *
123
+ * // Refresh count when filter changes
124
+ * useEffect(() => {
125
+ * setRefreshKey(prev => prev + 1);
126
+ * }, [filter]);
127
+ *
128
+ * return <div>Matching modals: {count}</div>;
129
+ * }
130
+ * ```
131
+ *
132
+ * @example
133
+ * Limit modal stacking:
134
+ * ```tsx
135
+ * function LimitedModalProvider({ children, maxModals = 3 }) {
136
+ * const activeCount = useActiveModalCount();
137
+ *
138
+ * const openModal = useCallback((modalConfig) => {
139
+ * if (activeCount >= maxModals) {
140
+ * alert({
141
+ * title: 'Too Many Modals',
142
+ * content: `Maximum of ${maxModals} modals can be open at once.`,
143
+ * subtype: 'warning',
144
+ * });
145
+ * return Promise.reject(new Error('Modal limit exceeded'));
146
+ * }
147
+ *
148
+ * return originalOpenModal(modalConfig);
149
+ * }, [activeCount, maxModals]);
150
+ *
151
+ * return (
152
+ * <ModalContext.Provider value={{ openModal }}>
153
+ * {children}
154
+ * </ModalContext.Provider>
155
+ * );
156
+ * }
157
+ * ```
158
+ *
159
+ * @remarks
160
+ * - Recalculates whenever modal list changes or refreshKey updates
161
+ * - Default validation counts visible modals only
162
+ * - Custom validation can check any modal properties
163
+ * - Efficient memoization prevents unnecessary recalculations
164
+ * - Use refreshKey to force updates based on external dependencies
165
+ */
3
166
  export declare const useActiveModalCount: (validate?: Fn<[node?: ModalNode], boolean | undefined>, refreshKey?: string | number) => number;
@@ -1,3 +1,130 @@
1
1
  import type { Duration } from '../@aileron/declare';
2
2
  import type { ModalNode } from '../core';
3
+ /**
4
+ * Hook that automatically destroys a modal after it becomes hidden.
5
+ *
6
+ * Monitors the modal's visibility state and schedules destruction after a specified
7
+ * duration once the modal is hidden but still alive. Useful for cleanup after
8
+ * closing animations complete.
9
+ *
10
+ * @param modalId - ID of the modal to monitor
11
+ * @param duration - Delay before destruction (ms or duration string like '300ms')
12
+ *
13
+ * @example
14
+ * Basic usage with milliseconds:
15
+ * ```tsx
16
+ * function AnimatedModal({ modalId }) {
17
+ * // Destroy modal 300ms after it becomes hidden
18
+ * useDestroyAfter(modalId, 300);
19
+ *
20
+ * return (
21
+ * <div className="modal-with-exit-animation">
22
+ * // Modal content
23
+ * </div>
24
+ * );
25
+ * }
26
+ * ```
27
+ *
28
+ * @example
29
+ * Using duration string:
30
+ * ```tsx
31
+ * function FadeOutModal({ modalId }) {
32
+ * // Destroy after CSS animation completes
33
+ * useDestroyAfter(modalId, '500ms');
34
+ *
35
+ * const { modal } = useModal(modalId);
36
+ *
37
+ * return (
38
+ * <div
39
+ * className={modal?.visible ? 'fade-in' : 'fade-out'}
40
+ * style={{ animationDuration: '500ms' }}
41
+ * >
42
+ * // Content
43
+ * </div>
44
+ * );
45
+ * }
46
+ * ```
47
+ *
48
+ * @example
49
+ * Conditional destruction based on modal type:
50
+ * ```tsx
51
+ * function SmartModal({ modalId }) {
52
+ * const { modal } = useModal(modalId);
53
+ *
54
+ * // Different timings for different modal types
55
+ * const destroyDelay = useMemo(() => {
56
+ * switch (modal?.type) {
57
+ * case 'alert': return '200ms';
58
+ * case 'confirm': return '300ms';
59
+ * case 'prompt': return '400ms';
60
+ * default: return 300;
61
+ * }
62
+ * }, [modal?.type]);
63
+ *
64
+ * useDestroyAfter(modalId, destroyDelay);
65
+ *
66
+ * return <ModalContent modalId={modalId} />;
67
+ * }
68
+ * ```
69
+ *
70
+ * @example
71
+ * With custom animations and effects:
72
+ * ```tsx
73
+ * function NotificationModal({ modalId }) {
74
+ * const { modal } = useModal(modalId);
75
+ * const [particles, setParticles] = useState([]);
76
+ *
77
+ * // Cleanup after particle animation
78
+ * useDestroyAfter(modalId, '1000ms');
79
+ *
80
+ * useEffect(() => {
81
+ * if (!modal?.visible && modal?.alive) {
82
+ * // Trigger particle effect on close
83
+ * setParticles(generateParticles());
84
+ * }
85
+ * }, [modal?.visible, modal?.alive]);
86
+ *
87
+ * return (
88
+ * <>
89
+ * <div className={`notification ${!modal?.visible ? 'explode' : ''}`}>
90
+ * // Content
91
+ * </div>
92
+ * {particles.map(particle => (
93
+ * <Particle key={particle.id} {...particle} />
94
+ * ))}
95
+ * </>
96
+ * );
97
+ * }
98
+ * ```
99
+ *
100
+ * @example
101
+ * Coordinated with manual destruction:
102
+ * ```tsx
103
+ * function PersistentModal({ modalId }) {
104
+ * const { modal } = useModal(modalId);
105
+ * const [shouldPersist, setShouldPersist] = useState(false);
106
+ *
107
+ * // Only auto-destroy if not persisting
108
+ * if (!shouldPersist) {
109
+ * useDestroyAfter(modalId, '300ms');
110
+ * }
111
+ *
112
+ * return (
113
+ * <div>
114
+ * <button onClick={() => setShouldPersist(true)}>
115
+ * Keep in Background
116
+ * </button>
117
+ * // Modal content
118
+ * </div>
119
+ * );
120
+ * }
121
+ * ```
122
+ *
123
+ * @remarks
124
+ * - Only triggers when modal is hidden (visible: false) but still alive
125
+ * - Automatically cancels if modal becomes visible again
126
+ * - Useful for cleanup after exit animations
127
+ * - Works with both millisecond numbers and duration strings
128
+ * - The timer resets if modal visibility changes
129
+ */
3
130
  export declare const useDestroyAfter: (modalId: ModalNode["id"], duration: Duration | number) => void;