@inertiaui/modal-react 1.0.0-beta-5 → 2.0.2
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/dist/CloseButton.d.ts +5 -0
- package/dist/Deferred.d.ts +11 -0
- package/dist/HeadlessModal.d.ts +64 -0
- package/dist/Modal.d.ts +48 -0
- package/dist/ModalContent.d.ts +24 -0
- package/dist/ModalLink.d.ts +29 -0
- package/dist/ModalRenderer.d.ts +4 -0
- package/dist/ModalRoot.d.ts +22 -0
- package/dist/SlideoverContent.d.ts +24 -0
- package/dist/WhenVisible.d.ts +16 -0
- package/dist/config.d.ts +21 -0
- package/dist/constants.d.ts +7 -0
- package/dist/helpers.d.ts +3 -0
- package/dist/inertiaui-modal.d.ts +2 -0
- package/dist/inertiaui-modal.js +1680 -2167
- package/dist/inertiaui-modal.js.map +1 -0
- package/dist/inertiaui-modal.umd.cjs +1854 -21
- package/dist/inertiaui-modal.umd.cjs.map +1 -0
- package/dist/inertiauiModal.d.ts +21 -0
- package/dist/types.d.ts +111 -0
- package/dist/useModal.d.ts +2 -0
- package/package.json +37 -22
- package/src/{CloseButton.jsx → CloseButton.tsx} +5 -1
- package/src/{Deferred.jsx → Deferred.tsx} +10 -3
- package/src/HeadlessModal.tsx +238 -0
- package/src/Modal.tsx +292 -0
- package/src/ModalContent.tsx +311 -0
- package/src/ModalLink.tsx +224 -0
- package/src/ModalRenderer.tsx +33 -0
- package/src/ModalRoot.tsx +880 -0
- package/src/SlideoverContent.tsx +319 -0
- package/src/{WhenVisible.jsx → WhenVisible.tsx} +20 -9
- package/src/config.ts +99 -0
- package/src/constants.ts +22 -0
- package/src/helpers.ts +17 -0
- package/src/inertiauiModal.ts +65 -0
- package/src/types.ts +150 -0
- package/src/useModal.ts +7 -0
- package/src/HeadlessModal.jsx +0 -143
- package/src/Modal.jsx +0 -136
- package/src/ModalContent.jsx +0 -56
- package/src/ModalLink.jsx +0 -123
- package/src/ModalRenderer.jsx +0 -34
- package/src/ModalRoot.jsx +0 -623
- package/src/SlideoverContent.jsx +0 -55
- package/src/config.js +0 -3
- package/src/helpers.js +0 -2
- package/src/inertiauiModal.js +0 -34
- package/src/useModal.js +0 -6
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo, ReactNode, SyntheticEvent, MouseEvent } from 'react'
|
|
2
|
+
import CloseButton from './CloseButton'
|
|
3
|
+
import clsx from 'clsx'
|
|
4
|
+
import { createFocusTrap, onEscapeKey, animate, cancelAnimations } from '@inertiaui/vanilla'
|
|
5
|
+
import { getMaxWidthClass } from './constants'
|
|
6
|
+
import type { Modal } from './types'
|
|
7
|
+
|
|
8
|
+
interface ModalContentConfig {
|
|
9
|
+
maxWidth: string
|
|
10
|
+
paddingClasses: string
|
|
11
|
+
panelClasses: string
|
|
12
|
+
position: string
|
|
13
|
+
closeButton: boolean
|
|
14
|
+
closeExplicitly?: boolean
|
|
15
|
+
closeOnClickOutside?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ModalContentProps {
|
|
19
|
+
modalContext: Modal
|
|
20
|
+
config: ModalContentConfig
|
|
21
|
+
useNativeDialog: boolean
|
|
22
|
+
isFirstModal: boolean
|
|
23
|
+
onAfterLeave?: () => void
|
|
24
|
+
children: ReactNode | ((props: { modalContext: Modal; config: ModalContentConfig }) => ReactNode)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ModalContent = ({ modalContext, config, useNativeDialog, isFirstModal, onAfterLeave, children }: ModalContentProps) => {
|
|
28
|
+
const [isRendered, setIsRendered] = useState(false)
|
|
29
|
+
const [isVisible, setIsVisible] = useState(false) // For backdrop sync
|
|
30
|
+
const [entered, setEntered] = useState(false) // After animation completes
|
|
31
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
const dialogRef = useRef<HTMLDialogElement>(null)
|
|
33
|
+
const nativeWrapperRef = useRef<HTMLDivElement>(null)
|
|
34
|
+
const cleanupFocusTrapRef = useRef<(() => void) | null>(null)
|
|
35
|
+
const cleanupEscapeKeyRef = useRef<(() => void) | null>(null)
|
|
36
|
+
|
|
37
|
+
const maxWidthClass = useMemo(() => getMaxWidthClass(config.maxWidth), [config.maxWidth])
|
|
38
|
+
|
|
39
|
+
// ============ Animation handlers using Web Animations API ============
|
|
40
|
+
|
|
41
|
+
const animateIn = useCallback(async (element: HTMLElement | null) => {
|
|
42
|
+
if (!element) return
|
|
43
|
+
|
|
44
|
+
setIsVisible(true) // Trigger backdrop immediately
|
|
45
|
+
|
|
46
|
+
await animate(element, [
|
|
47
|
+
{ transform: 'translate3d(0, 1rem, 0) scale(0.95)', opacity: 0 },
|
|
48
|
+
{ transform: 'translate3d(0, 0, 0) scale(1)', opacity: 1 },
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
setEntered(true)
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
const animateOut = useCallback(
|
|
55
|
+
async (element: HTMLElement | null) => {
|
|
56
|
+
if (!element) return
|
|
57
|
+
|
|
58
|
+
setIsVisible(false) // Trigger backdrop fade out immediately
|
|
59
|
+
|
|
60
|
+
await animate(element, [
|
|
61
|
+
{ transform: 'translate3d(0, 0, 0) scale(1)', opacity: 1 },
|
|
62
|
+
{ transform: 'translate3d(0, 1rem, 0) scale(0.95)', opacity: 0 },
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
setIsRendered(false)
|
|
66
|
+
if (useNativeDialog && dialogRef.current) {
|
|
67
|
+
dialogRef.current.close()
|
|
68
|
+
}
|
|
69
|
+
onAfterLeave?.()
|
|
70
|
+
modalContext.afterLeave()
|
|
71
|
+
},
|
|
72
|
+
[useNativeDialog, onAfterLeave, modalContext],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ============ Non-native dialog handlers ============
|
|
76
|
+
|
|
77
|
+
const setupFocusTrap = useCallback(() => {
|
|
78
|
+
if (useNativeDialog) return
|
|
79
|
+
if (!wrapperRef.current || !modalContext.onTopOfStack) return
|
|
80
|
+
if (cleanupFocusTrapRef.current) return
|
|
81
|
+
|
|
82
|
+
cleanupFocusTrapRef.current = createFocusTrap(wrapperRef.current, {
|
|
83
|
+
initialFocus: true,
|
|
84
|
+
returnFocus: false,
|
|
85
|
+
})
|
|
86
|
+
}, [modalContext.onTopOfStack, useNativeDialog])
|
|
87
|
+
|
|
88
|
+
const cleanupFocusTrap = useCallback(() => {
|
|
89
|
+
if (cleanupFocusTrapRef.current) {
|
|
90
|
+
cleanupFocusTrapRef.current()
|
|
91
|
+
cleanupFocusTrapRef.current = null
|
|
92
|
+
}
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const setupEscapeKey = useCallback(() => {
|
|
96
|
+
if (useNativeDialog) return
|
|
97
|
+
if (cleanupEscapeKeyRef.current) return
|
|
98
|
+
if (config?.closeExplicitly) return
|
|
99
|
+
|
|
100
|
+
cleanupEscapeKeyRef.current = onEscapeKey(() => {
|
|
101
|
+
if (modalContext.onTopOfStack) {
|
|
102
|
+
modalContext.close()
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}, [config?.closeExplicitly, modalContext, useNativeDialog])
|
|
106
|
+
|
|
107
|
+
const cleanupEscapeKey = useCallback(() => {
|
|
108
|
+
if (cleanupEscapeKeyRef.current) {
|
|
109
|
+
cleanupEscapeKeyRef.current()
|
|
110
|
+
cleanupEscapeKeyRef.current = null
|
|
111
|
+
}
|
|
112
|
+
}, [])
|
|
113
|
+
|
|
114
|
+
const handleClickOutside = useCallback(
|
|
115
|
+
(event: MouseEvent) => {
|
|
116
|
+
if (useNativeDialog) return
|
|
117
|
+
if (!modalContext.onTopOfStack) return
|
|
118
|
+
if (config?.closeExplicitly) return
|
|
119
|
+
if (config?.closeOnClickOutside === false) return
|
|
120
|
+
if (!wrapperRef.current) return
|
|
121
|
+
|
|
122
|
+
if (!wrapperRef.current.contains(event.target as Node)) {
|
|
123
|
+
modalContext.close()
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[modalContext, config?.closeExplicitly, config?.closeOnClickOutside, useNativeDialog],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// ============ Native dialog handlers ============
|
|
130
|
+
|
|
131
|
+
const handleCancel = useCallback(
|
|
132
|
+
(event: SyntheticEvent) => {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
if (modalContext.onTopOfStack && !config?.closeExplicitly) {
|
|
135
|
+
modalContext.close()
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
[modalContext, config?.closeExplicitly],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const handleDialogClick = useCallback(
|
|
142
|
+
(event: MouseEvent) => {
|
|
143
|
+
if (event.target === dialogRef.current) {
|
|
144
|
+
if (modalContext.onTopOfStack && !config?.closeExplicitly && config?.closeOnClickOutside !== false) {
|
|
145
|
+
modalContext.close()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[modalContext, config?.closeExplicitly, config?.closeOnClickOutside],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// ============ Lifecycle ============
|
|
153
|
+
|
|
154
|
+
// Track previous isOpen state for detecting close
|
|
155
|
+
const prevIsOpenRef = useRef(modalContext.isOpen)
|
|
156
|
+
|
|
157
|
+
// Initial mount and open state changes
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (useNativeDialog) {
|
|
160
|
+
if (modalContext.isOpen && !dialogRef.current?.open) {
|
|
161
|
+
dialogRef.current?.showModal()
|
|
162
|
+
animateIn(nativeWrapperRef.current)
|
|
163
|
+
} else if (!modalContext.isOpen && prevIsOpenRef.current) {
|
|
164
|
+
setEntered(false)
|
|
165
|
+
animateOut(nativeWrapperRef.current)
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
if (modalContext.isOpen && !isRendered) {
|
|
169
|
+
setIsRendered(true)
|
|
170
|
+
} else if (!modalContext.isOpen && prevIsOpenRef.current) {
|
|
171
|
+
setEntered(false)
|
|
172
|
+
animateOut(wrapperRef.current)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
prevIsOpenRef.current = modalContext.isOpen
|
|
176
|
+
}, [modalContext.isOpen, useNativeDialog, animateIn, animateOut, isRendered])
|
|
177
|
+
|
|
178
|
+
// Trigger animation after render (non-native)
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!useNativeDialog && isRendered && !entered && modalContext.isOpen) {
|
|
181
|
+
animateIn(wrapperRef.current).then(() => {
|
|
182
|
+
setupFocusTrap()
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}, [isRendered, useNativeDialog, entered, modalContext.isOpen, animateIn, setupFocusTrap])
|
|
186
|
+
|
|
187
|
+
// Setup escape key (non-native)
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!useNativeDialog) {
|
|
190
|
+
setupEscapeKey()
|
|
191
|
+
}
|
|
192
|
+
return () => {
|
|
193
|
+
cleanupEscapeKey()
|
|
194
|
+
}
|
|
195
|
+
}, [useNativeDialog, setupEscapeKey, cleanupEscapeKey])
|
|
196
|
+
|
|
197
|
+
// Handle becoming top of stack / losing top of stack (non-native only)
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (useNativeDialog) return
|
|
200
|
+
|
|
201
|
+
if (modalContext.onTopOfStack) {
|
|
202
|
+
setupEscapeKey()
|
|
203
|
+
if (entered) {
|
|
204
|
+
setupFocusTrap()
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
cleanupFocusTrap()
|
|
208
|
+
cleanupEscapeKey()
|
|
209
|
+
}
|
|
210
|
+
}, [modalContext.onTopOfStack, entered, setupEscapeKey, setupFocusTrap, cleanupFocusTrap, cleanupEscapeKey, useNativeDialog])
|
|
211
|
+
|
|
212
|
+
// Cleanup on unmount
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
return () => {
|
|
215
|
+
const wrapper = useNativeDialog ? nativeWrapperRef.current : wrapperRef.current
|
|
216
|
+
if (wrapper) {
|
|
217
|
+
cancelAnimations(wrapper)
|
|
218
|
+
}
|
|
219
|
+
if (useNativeDialog) {
|
|
220
|
+
if (dialogRef.current?.open) {
|
|
221
|
+
dialogRef.current.close()
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
cleanupFocusTrap()
|
|
225
|
+
cleanupEscapeKey()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}, [useNativeDialog, cleanupFocusTrap, cleanupEscapeKey])
|
|
229
|
+
|
|
230
|
+
// ============ Render ============
|
|
231
|
+
|
|
232
|
+
const renderContent = () => (
|
|
233
|
+
<div
|
|
234
|
+
className={`im-modal-content relative ${config.paddingClasses} ${config.panelClasses}`}
|
|
235
|
+
data-inertiaui-modal-entered={entered}
|
|
236
|
+
>
|
|
237
|
+
{config.closeButton && (
|
|
238
|
+
<div className="absolute right-0 top-0 pr-3 pt-3">
|
|
239
|
+
<CloseButton onClick={modalContext.close} />
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
{typeof children === 'function' ? children({ modalContext, config }) : children}
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
// Native dialog mode
|
|
247
|
+
if (useNativeDialog) {
|
|
248
|
+
return (
|
|
249
|
+
<dialog
|
|
250
|
+
ref={dialogRef}
|
|
251
|
+
className={clsx(
|
|
252
|
+
'im-modal-dialog m-0 overflow-visible bg-transparent p-0',
|
|
253
|
+
'size-full max-h-none max-w-none',
|
|
254
|
+
'backdrop:bg-black/75 backdrop:transition-opacity backdrop:duration-300',
|
|
255
|
+
isVisible ? 'backdrop:opacity-100' : 'backdrop:opacity-0',
|
|
256
|
+
!isFirstModal && 'backdrop:bg-transparent',
|
|
257
|
+
)}
|
|
258
|
+
onCancel={handleCancel}
|
|
259
|
+
onClick={handleDialogClick}
|
|
260
|
+
>
|
|
261
|
+
<div className="im-modal-container fixed inset-0 overflow-y-auto p-4">
|
|
262
|
+
<div
|
|
263
|
+
className={clsx('im-modal-positioner flex min-h-full justify-center', {
|
|
264
|
+
'items-start': config.position === 'top',
|
|
265
|
+
'items-center': config.position === 'center',
|
|
266
|
+
'items-end': config.position === 'bottom',
|
|
267
|
+
})}
|
|
268
|
+
>
|
|
269
|
+
<div
|
|
270
|
+
ref={nativeWrapperRef}
|
|
271
|
+
className={clsx('im-modal-wrapper w-full transition-[filter] duration-300', modalContext.onTopOfStack ? '' : 'blur-xs', maxWidthClass)}
|
|
272
|
+
>
|
|
273
|
+
{renderContent()}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</dialog>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Non-native dialog mode
|
|
282
|
+
if (!isRendered) return null
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div
|
|
286
|
+
className="im-modal-container fixed inset-0 z-40 overflow-y-auto p-4"
|
|
287
|
+
onMouseDown={handleClickOutside}
|
|
288
|
+
>
|
|
289
|
+
<div
|
|
290
|
+
className={clsx('im-modal-positioner flex min-h-full justify-center', {
|
|
291
|
+
'items-start': config.position === 'top',
|
|
292
|
+
'items-center': config.position === 'center',
|
|
293
|
+
'items-end': config.position === 'bottom',
|
|
294
|
+
})}
|
|
295
|
+
onMouseDown={handleClickOutside}
|
|
296
|
+
>
|
|
297
|
+
<div
|
|
298
|
+
ref={wrapperRef}
|
|
299
|
+
role="dialog"
|
|
300
|
+
aria-modal="true"
|
|
301
|
+
className={clsx('im-modal-wrapper w-full transition-[filter] duration-300', modalContext.onTopOfStack ? '' : 'blur-xs', maxWidthClass)}
|
|
302
|
+
>
|
|
303
|
+
<span className="sr-only">Dialog</span>
|
|
304
|
+
{renderContent()}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export default ModalContent
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useCallback, useState, useEffect, useMemo, useRef, ReactNode, ElementType, MouseEvent } from 'react'
|
|
2
|
+
import { useModalStack, modalPropNames, prefetch as prefetchModal } from './ModalRoot'
|
|
3
|
+
import { only, rejectNullValues, isStandardDomEvent } from './helpers'
|
|
4
|
+
import { getConfig } from './config'
|
|
5
|
+
import type { Modal, PrefetchOption } from './types'
|
|
6
|
+
import type { RequestPayload } from '@inertiajs/core'
|
|
7
|
+
|
|
8
|
+
interface ModalLinkProps {
|
|
9
|
+
href: string
|
|
10
|
+
method?: string
|
|
11
|
+
data?: RequestPayload
|
|
12
|
+
as?: ElementType
|
|
13
|
+
headers?: Record<string, string>
|
|
14
|
+
queryStringArrayFormat?: 'brackets' | 'indices'
|
|
15
|
+
onAfterLeave?: () => void
|
|
16
|
+
onBlur?: () => void
|
|
17
|
+
onClose?: () => void
|
|
18
|
+
onError?: (error: unknown) => void
|
|
19
|
+
onFocus?: () => void
|
|
20
|
+
onStart?: () => void
|
|
21
|
+
onSuccess?: () => void
|
|
22
|
+
onPrefetching?: () => void
|
|
23
|
+
onPrefetched?: () => void
|
|
24
|
+
navigate?: boolean
|
|
25
|
+
// Prefetch options (#146)
|
|
26
|
+
prefetch?: PrefetchOption
|
|
27
|
+
cacheFor?: number
|
|
28
|
+
children: ReactNode | ((props: { loading: boolean }) => ReactNode)
|
|
29
|
+
[key: string]: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ModalLink = ({
|
|
33
|
+
href,
|
|
34
|
+
method = 'get',
|
|
35
|
+
data = {} as RequestPayload,
|
|
36
|
+
as: Component = 'a',
|
|
37
|
+
headers = {},
|
|
38
|
+
queryStringArrayFormat = 'brackets' as const,
|
|
39
|
+
onAfterLeave,
|
|
40
|
+
onBlur,
|
|
41
|
+
onClose,
|
|
42
|
+
onError,
|
|
43
|
+
onFocus,
|
|
44
|
+
onStart,
|
|
45
|
+
onSuccess,
|
|
46
|
+
onPrefetching,
|
|
47
|
+
onPrefetched,
|
|
48
|
+
navigate,
|
|
49
|
+
prefetch = false,
|
|
50
|
+
cacheFor = 30000,
|
|
51
|
+
children,
|
|
52
|
+
...props
|
|
53
|
+
}: ModalLinkProps) => {
|
|
54
|
+
const [loading, setLoading] = useState(false)
|
|
55
|
+
const [modalContext, setModalContext] = useState<Modal | null>(null)
|
|
56
|
+
const { stack, visit } = useModalStack()
|
|
57
|
+
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
58
|
+
|
|
59
|
+
const shouldNavigate = useMemo(() => {
|
|
60
|
+
return navigate ?? (getConfig('navigate') as boolean)
|
|
61
|
+
}, [navigate])
|
|
62
|
+
|
|
63
|
+
// Prefetch logic (#146)
|
|
64
|
+
const prefetchModes = useMemo(() => {
|
|
65
|
+
if (prefetch === true) {
|
|
66
|
+
return ['hover']
|
|
67
|
+
}
|
|
68
|
+
if (prefetch === false) {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(prefetch)) {
|
|
72
|
+
return prefetch
|
|
73
|
+
}
|
|
74
|
+
return [prefetch]
|
|
75
|
+
}, [prefetch])
|
|
76
|
+
|
|
77
|
+
const doPrefetch = useCallback(() => {
|
|
78
|
+
prefetchModal(href, {
|
|
79
|
+
method,
|
|
80
|
+
data,
|
|
81
|
+
headers,
|
|
82
|
+
queryStringArrayFormat,
|
|
83
|
+
cacheFor,
|
|
84
|
+
onPrefetching: onPrefetching ?? undefined,
|
|
85
|
+
onPrefetched: onPrefetched ?? undefined,
|
|
86
|
+
})
|
|
87
|
+
}, [href, method, data, headers, queryStringArrayFormat, cacheFor, onPrefetching, onPrefetched])
|
|
88
|
+
|
|
89
|
+
const handleMouseEnter = useCallback(() => {
|
|
90
|
+
if (!prefetchModes.includes('hover')) return
|
|
91
|
+
|
|
92
|
+
hoverTimeout.current = setTimeout(() => {
|
|
93
|
+
doPrefetch()
|
|
94
|
+
}, 75) // Small delay to avoid prefetching on accidental hovers
|
|
95
|
+
}, [prefetchModes, doPrefetch])
|
|
96
|
+
|
|
97
|
+
const handleMouseLeave = useCallback(() => {
|
|
98
|
+
if (hoverTimeout.current) {
|
|
99
|
+
clearTimeout(hoverTimeout.current)
|
|
100
|
+
hoverTimeout.current = null
|
|
101
|
+
}
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const handleMouseDown = useCallback(
|
|
105
|
+
(event: MouseEvent) => {
|
|
106
|
+
if (!prefetchModes.includes('click')) return
|
|
107
|
+
if (event.button !== 0) return // Only left click
|
|
108
|
+
|
|
109
|
+
doPrefetch()
|
|
110
|
+
},
|
|
111
|
+
[prefetchModes, doPrefetch],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// Prefetch on mount
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (prefetchModes.includes('mount')) {
|
|
117
|
+
doPrefetch()
|
|
118
|
+
}
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
// Cleanup hover timeout on unmount
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
return () => {
|
|
124
|
+
if (hoverTimeout.current) {
|
|
125
|
+
clearTimeout(hoverTimeout.current)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
// Separate standard props from custom event handlers
|
|
131
|
+
const standardProps: Record<string, unknown> = {}
|
|
132
|
+
const customEvents: Record<string, (...args: unknown[]) => void> = {}
|
|
133
|
+
|
|
134
|
+
Object.keys(props).forEach((key) => {
|
|
135
|
+
if (modalPropNames.includes(key)) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (key.startsWith('on') && typeof props[key] === 'function') {
|
|
140
|
+
if (isStandardDomEvent(key)) {
|
|
141
|
+
standardProps[key] = props[key]
|
|
142
|
+
} else {
|
|
143
|
+
customEvents[key] = props[key] as (...args: unknown[]) => void
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
standardProps[key] = props[key]
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const [isBlurred, setIsBlurred] = useState(false)
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!modalContext) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (modalContext.onTopOfStack && isBlurred) {
|
|
158
|
+
onFocus?.()
|
|
159
|
+
} else if (!modalContext.onTopOfStack && !isBlurred) {
|
|
160
|
+
onBlur?.()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
setIsBlurred(!modalContext.onTopOfStack)
|
|
164
|
+
}, [stack])
|
|
165
|
+
|
|
166
|
+
const onCloseCallback = useCallback(() => {
|
|
167
|
+
onClose?.()
|
|
168
|
+
}, [onClose])
|
|
169
|
+
|
|
170
|
+
const onAfterLeaveCallback = useCallback(() => {
|
|
171
|
+
setModalContext(null)
|
|
172
|
+
onAfterLeave?.()
|
|
173
|
+
}, [onAfterLeave])
|
|
174
|
+
|
|
175
|
+
const handle = useCallback(
|
|
176
|
+
(e?: MouseEvent) => {
|
|
177
|
+
e?.preventDefault()
|
|
178
|
+
if (loading) return
|
|
179
|
+
|
|
180
|
+
if (!href.startsWith('#')) {
|
|
181
|
+
setLoading(true)
|
|
182
|
+
onStart?.()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
visit(
|
|
186
|
+
href,
|
|
187
|
+
method,
|
|
188
|
+
data,
|
|
189
|
+
headers,
|
|
190
|
+
rejectNullValues(only(props, modalPropNames)) as Record<string, unknown>,
|
|
191
|
+
() => onCloseCallback(),
|
|
192
|
+
onAfterLeaveCallback,
|
|
193
|
+
queryStringArrayFormat,
|
|
194
|
+
shouldNavigate,
|
|
195
|
+
)
|
|
196
|
+
.then((newModalContext) => {
|
|
197
|
+
setModalContext(newModalContext)
|
|
198
|
+
newModalContext.registerEventListenersFromProps(customEvents)
|
|
199
|
+
onSuccess?.()
|
|
200
|
+
})
|
|
201
|
+
.catch((error) => {
|
|
202
|
+
console.error(error)
|
|
203
|
+
onError?.(error)
|
|
204
|
+
})
|
|
205
|
+
.finally(() => setLoading(false))
|
|
206
|
+
},
|
|
207
|
+
[href, method, data, headers, queryStringArrayFormat, props, onCloseCallback, onAfterLeaveCallback],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Component
|
|
212
|
+
{...standardProps}
|
|
213
|
+
href={href}
|
|
214
|
+
onClick={handle}
|
|
215
|
+
onMouseEnter={handleMouseEnter}
|
|
216
|
+
onMouseLeave={handleMouseLeave}
|
|
217
|
+
onMouseDown={handleMouseDown}
|
|
218
|
+
>
|
|
219
|
+
{typeof children === 'function' ? children({ loading }) : children}
|
|
220
|
+
</Component>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export default ModalLink
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React, { useMemo, createElement } from 'react'
|
|
2
|
+
import { useModalStack } from './ModalRoot'
|
|
3
|
+
import type { ModalRendererProps } from './types'
|
|
4
|
+
|
|
5
|
+
const ModalIndexContext = React.createContext<number | null>(null)
|
|
6
|
+
ModalIndexContext.displayName = 'ModalIndexContext'
|
|
7
|
+
|
|
8
|
+
export const useModalIndex = (): number | null => {
|
|
9
|
+
return React.useContext(ModalIndexContext)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ModalRenderer = ({ index }: ModalRendererProps) => {
|
|
13
|
+
const { stack } = useModalStack()
|
|
14
|
+
|
|
15
|
+
const modalContext = useMemo(() => {
|
|
16
|
+
return stack[index]
|
|
17
|
+
}, [stack, index])
|
|
18
|
+
|
|
19
|
+
if (!modalContext?.component) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ModalIndexContext.Provider value={index}>
|
|
25
|
+
{createElement(modalContext.component as React.ComponentType<Record<string, unknown>>, {
|
|
26
|
+
...modalContext.props,
|
|
27
|
+
onModalEvent: (...args: unknown[]) => modalContext.emit('modal-event', ...args),
|
|
28
|
+
})}
|
|
29
|
+
</ModalIndexContext.Provider>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default ModalRenderer
|