@inertiaui/modal-react 1.0.0-beta-4 → 2.0.0-beta.1

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.
Files changed (49) hide show
  1. package/dist/CloseButton.d.ts +5 -0
  2. package/dist/Deferred.d.ts +11 -0
  3. package/dist/HeadlessModal.d.ts +64 -0
  4. package/dist/Modal.d.ts +48 -0
  5. package/dist/ModalContent.d.ts +24 -0
  6. package/dist/ModalLink.d.ts +29 -0
  7. package/dist/ModalRenderer.d.ts +4 -0
  8. package/dist/ModalRoot.d.ts +22 -0
  9. package/dist/SlideoverContent.d.ts +24 -0
  10. package/dist/WhenVisible.d.ts +16 -0
  11. package/dist/config.d.ts +21 -0
  12. package/dist/constants.d.ts +7 -0
  13. package/dist/helpers.d.ts +3 -0
  14. package/dist/inertiaui-modal.d.ts +2 -0
  15. package/dist/inertiaui-modal.js +1680 -2147
  16. package/dist/inertiaui-modal.js.map +1 -0
  17. package/dist/inertiaui-modal.umd.cjs +1854 -21
  18. package/dist/inertiaui-modal.umd.cjs.map +1 -0
  19. package/dist/inertiauiModal.d.ts +21 -0
  20. package/dist/types.d.ts +111 -0
  21. package/dist/useModal.d.ts +2 -0
  22. package/package.json +33 -23
  23. package/src/{CloseButton.jsx → CloseButton.tsx} +5 -1
  24. package/src/{Deferred.jsx → Deferred.tsx} +10 -3
  25. package/src/HeadlessModal.tsx +238 -0
  26. package/src/Modal.tsx +292 -0
  27. package/src/ModalContent.tsx +311 -0
  28. package/src/ModalLink.tsx +224 -0
  29. package/src/ModalRenderer.tsx +33 -0
  30. package/src/ModalRoot.tsx +880 -0
  31. package/src/SlideoverContent.tsx +319 -0
  32. package/src/{WhenVisible.jsx → WhenVisible.tsx} +20 -9
  33. package/src/config.ts +99 -0
  34. package/src/constants.ts +22 -0
  35. package/src/helpers.ts +17 -0
  36. package/src/inertiauiModal.ts +65 -0
  37. package/src/types.ts +150 -0
  38. package/src/useModal.ts +7 -0
  39. package/src/HeadlessModal.jsx +0 -143
  40. package/src/Modal.jsx +0 -136
  41. package/src/ModalContent.jsx +0 -56
  42. package/src/ModalLink.jsx +0 -123
  43. package/src/ModalRenderer.jsx +0 -34
  44. package/src/ModalRoot.jsx +0 -625
  45. package/src/SlideoverContent.jsx +0 -55
  46. package/src/config.js +0 -3
  47. package/src/helpers.js +0 -2
  48. package/src/inertiauiModal.js +0 -34
  49. 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