@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,238 @@
1
+ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef, ReactNode } from 'react'
2
+ import { getConfig, getConfigByType } from './config'
3
+ import { useModalIndex } from './ModalRenderer'
4
+ import { useModalStack } from './ModalRoot'
5
+ import ModalRenderer from './ModalRenderer'
6
+ import type { Modal, ModalConfig, ReloadOptions } from './types'
7
+
8
+ interface HeadlessModalConfig {
9
+ slideover: boolean
10
+ closeButton: boolean
11
+ closeExplicitly: boolean
12
+ closeOnClickOutside: boolean
13
+ maxWidth: string
14
+ paddingClasses: string
15
+ panelClasses: string
16
+ position: string
17
+ }
18
+
19
+ interface HeadlessModalRenderProps {
20
+ afterLeave: () => void
21
+ close: () => void
22
+ config: HeadlessModalConfig
23
+ emit: (event: string, ...args: unknown[]) => void
24
+ getChildModal: () => Modal | null
25
+ getParentModal: () => Modal | null
26
+ id: string
27
+ index: number
28
+ isOpen: boolean
29
+ modalContext: Modal
30
+ onTopOfStack: boolean
31
+ reload: (options?: ReloadOptions) => void
32
+ setOpen: (open: boolean) => void
33
+ shouldRender: boolean
34
+ // Allow additional props from visitModal
35
+ [key: string]: unknown
36
+ }
37
+
38
+ interface HeadlessModalBaseProps {
39
+ name?: string
40
+ children: ReactNode | ((props: HeadlessModalRenderProps) => ReactNode)
41
+ onFocus?: () => void
42
+ onBlur?: () => void
43
+ onClose?: () => void
44
+ onSuccess?: () => void
45
+ slideover?: boolean
46
+ closeButton?: boolean
47
+ closeExplicitly?: boolean
48
+ closeOnClickOutside?: boolean
49
+ maxWidth?: string
50
+ paddingClasses?: string
51
+ panelClasses?: string
52
+ position?: string
53
+ }
54
+
55
+ type HeadlessModalProps = HeadlessModalBaseProps & Record<string, unknown>
56
+
57
+ export interface HeadlessModalRef {
58
+ afterLeave: () => void
59
+ close: () => void
60
+ emit: (event: string, ...args: unknown[]) => void
61
+ getChildModal: () => Modal | null | undefined
62
+ getParentModal: () => Modal | null | undefined
63
+ reload: (options?: ReloadOptions) => void
64
+ setOpen: (open: boolean) => void
65
+ readonly id: string | undefined
66
+ readonly index: number | undefined
67
+ readonly isOpen: boolean | undefined
68
+ readonly config: ModalConfig | undefined
69
+ readonly modalContext: Modal | null
70
+ readonly onTopOfStack: boolean | undefined
71
+ readonly shouldRender: boolean | undefined
72
+ }
73
+
74
+ const HeadlessModal = forwardRef<HeadlessModalRef, HeadlessModalProps>(
75
+ (allProps, ref) => {
76
+ const { name, children, onFocus, onBlur, onClose, onSuccess, ...props } = allProps as HeadlessModalBaseProps & Record<string, unknown>
77
+ const modalIndex = useModalIndex()
78
+ const { stack, registerLocalModal, removeLocalModal } = useModalStack()
79
+
80
+ const [localModalContext, setLocalModalContext] = useState<Modal | null>(null)
81
+ const modalContext = useMemo(
82
+ () => (name ? localModalContext : stack[modalIndex]),
83
+ [name, localModalContext, modalIndex, stack],
84
+ )
85
+
86
+ const nextIndex = useMemo(() => {
87
+ return stack.find((m) => m.shouldRender && m.index > (modalContext?.index ?? -1))?.index
88
+ }, [modalIndex, stack])
89
+
90
+ const configSlideover = useMemo(
91
+ () => modalContext?.config.slideover ?? props.slideover ?? getConfig('type') === 'slideover',
92
+ [props.slideover, modalContext?.config.slideover],
93
+ )
94
+
95
+ const config: HeadlessModalConfig = useMemo(
96
+ () => ({
97
+ slideover: configSlideover as boolean,
98
+ closeButton: (props.closeButton ?? getConfigByType(configSlideover as boolean, 'closeButton')) as boolean,
99
+ closeExplicitly: (props.closeExplicitly ?? getConfigByType(configSlideover as boolean, 'closeExplicitly')) as boolean,
100
+ closeOnClickOutside: (props.closeOnClickOutside ?? getConfigByType(configSlideover as boolean, 'closeOnClickOutside')) as boolean,
101
+ maxWidth: (props.maxWidth ?? getConfigByType(configSlideover as boolean, 'maxWidth')) as string,
102
+ paddingClasses: (props.paddingClasses ?? getConfigByType(configSlideover as boolean, 'paddingClasses')) as string,
103
+ panelClasses: (props.panelClasses ?? getConfigByType(configSlideover as boolean, 'panelClasses')) as string,
104
+ position: (props.position ?? getConfigByType(configSlideover as boolean, 'position')) as string,
105
+ ...modalContext?.config,
106
+ }),
107
+ [props, modalContext?.config, configSlideover],
108
+ )
109
+
110
+ useEffect(() => {
111
+ if (name) {
112
+ let removeListeners: (() => void) | null = null
113
+
114
+ registerLocalModal(name as string, (localContext) => {
115
+ removeListeners = localContext.registerEventListenersFromProps(props as Record<string, unknown>)
116
+ setLocalModalContext(localContext)
117
+ })
118
+
119
+ return () => {
120
+ removeListeners?.()
121
+ removeListeners = null
122
+ removeLocalModal(name as string)
123
+ }
124
+ }
125
+
126
+ return modalContext?.registerEventListenersFromProps(props as Record<string, unknown>)
127
+ }, [name])
128
+
129
+ // Store the latest modalContext in a ref to maintain reference
130
+ const modalContextRef = useRef(modalContext)
131
+
132
+ // Update the ref whenever modalContext changes
133
+ useEffect(() => {
134
+ modalContextRef.current = modalContext
135
+ }, [modalContext])
136
+
137
+ // Track previous isOpen value to only emit close when transitioning from true to false
138
+ const previousIsOpenRef = useRef<boolean | undefined>(undefined)
139
+
140
+ useEffect(() => {
141
+ if (modalContext !== null) {
142
+ if (modalContext.isOpen) {
143
+ onSuccess?.()
144
+ } else if (previousIsOpenRef.current === true) {
145
+ // Only call onClose when transitioning from open to closed,
146
+ // not when the component first mounts with isOpen undefined/false
147
+ onClose?.()
148
+ }
149
+ previousIsOpenRef.current = modalContext.isOpen
150
+ }
151
+ }, [modalContext?.isOpen])
152
+
153
+ const [rendered, setRendered] = useState(false)
154
+
155
+ useEffect(() => {
156
+ if (rendered && modalContext !== null && modalContext.isOpen) {
157
+ if (modalContext.onTopOfStack) {
158
+ onFocus?.()
159
+ } else {
160
+ onBlur?.()
161
+ }
162
+ }
163
+
164
+ setRendered(true)
165
+ }, [modalContext?.onTopOfStack])
166
+
167
+ useImperativeHandle(
168
+ ref,
169
+ () => ({
170
+ afterLeave: () => modalContextRef.current?.afterLeave(),
171
+ close: () => modalContextRef.current?.close(),
172
+ emit: (...args: [string, ...unknown[]]) => modalContextRef.current?.emit(...args),
173
+ getChildModal: () => modalContextRef.current?.getChildModal(),
174
+ getParentModal: () => modalContextRef.current?.getParentModal(),
175
+ reload: (options?: ReloadOptions) => modalContextRef.current?.reload(options),
176
+ setOpen: (open: boolean) => modalContextRef.current?.setOpen(open),
177
+
178
+ get id() {
179
+ return modalContextRef.current?.id
180
+ },
181
+ get index() {
182
+ return modalContextRef.current?.index
183
+ },
184
+ get isOpen() {
185
+ return modalContextRef.current?.isOpen
186
+ },
187
+ get config() {
188
+ return modalContextRef.current?.config
189
+ },
190
+ get modalContext() {
191
+ return modalContextRef.current
192
+ },
193
+ get onTopOfStack() {
194
+ return modalContextRef.current?.onTopOfStack
195
+ },
196
+ get shouldRender() {
197
+ return modalContextRef.current?.shouldRender
198
+ },
199
+ }),
200
+ [modalContext],
201
+ )
202
+
203
+ if (!modalContext?.shouldRender) {
204
+ return null
205
+ }
206
+
207
+ return (
208
+ <>
209
+ {typeof children === 'function'
210
+ ? children({
211
+ // Spread props first so they can be overridden by built-in props
212
+ ...modalContext.props,
213
+ afterLeave: modalContext.afterLeave,
214
+ close: modalContext.close,
215
+ config,
216
+ emit: modalContext.emit,
217
+ getChildModal: modalContext.getChildModal,
218
+ getParentModal: modalContext.getParentModal,
219
+ id: modalContext.id,
220
+ index: modalContext.index,
221
+ isOpen: modalContext.isOpen,
222
+ modalContext,
223
+ onTopOfStack: modalContext.onTopOfStack,
224
+ reload: modalContext.reload,
225
+ setOpen: modalContext.setOpen,
226
+ shouldRender: modalContext.shouldRender,
227
+ })
228
+ : children}
229
+
230
+ {/* Next modal in the stack */}
231
+ {nextIndex !== undefined && <ModalRenderer index={nextIndex} />}
232
+ </>
233
+ )
234
+ },
235
+ )
236
+
237
+ HeadlessModal.displayName = 'HeadlessModal'
238
+ export default HeadlessModal
package/src/Modal.tsx ADDED
@@ -0,0 +1,292 @@
1
+ import { forwardRef, useRef, useImperativeHandle, useState, useEffect, useCallback, useMemo, ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import HeadlessModal, { HeadlessModalRef } from './HeadlessModal'
4
+ import ModalContent from './ModalContent'
5
+ import SlideoverContent from './SlideoverContent'
6
+ import { lockScroll, markAriaHidden } from '@inertiaui/vanilla'
7
+ import { getConfig } from './config'
8
+ import type { Modal as ModalType, ReloadOptions } from './types'
9
+
10
+ interface ModalConfig {
11
+ slideover: boolean
12
+ closeButton: boolean
13
+ closeExplicitly: boolean
14
+ maxWidth: string
15
+ paddingClasses: string
16
+ panelClasses: string
17
+ position: string
18
+ }
19
+
20
+ interface ModalRenderProps {
21
+ afterLeave: () => void
22
+ close: () => void
23
+ config: ModalConfig
24
+ emit: (event: string, ...args: unknown[]) => void
25
+ getChildModal: () => ModalType | null
26
+ getParentModal: () => ModalType | null
27
+ id: string
28
+ index: number
29
+ isOpen: boolean
30
+ modalContext: ModalType
31
+ onTopOfStack: boolean
32
+ reload: (options?: ReloadOptions) => void
33
+ setOpen: (open: boolean) => void
34
+ shouldRender: boolean
35
+ // Allow additional props from visitModal
36
+ [key: string]: unknown
37
+ }
38
+
39
+ interface ModalBaseProps {
40
+ name?: string
41
+ children: ReactNode | ((props: ModalRenderProps) => ReactNode)
42
+ onFocus?: () => void
43
+ onBlur?: () => void
44
+ onClose?: () => void
45
+ onSuccess?: () => void
46
+ onAfterLeave?: () => void
47
+ slideover?: boolean
48
+ closeButton?: boolean
49
+ closeExplicitly?: boolean
50
+ maxWidth?: string
51
+ paddingClasses?: string
52
+ panelClasses?: string
53
+ position?: string
54
+ }
55
+
56
+ type ModalProps = ModalBaseProps & Record<string, unknown>
57
+
58
+ interface BackdropTransitionProps {
59
+ show: boolean
60
+ appear: boolean
61
+ onAfterAppear?: () => void
62
+ }
63
+
64
+ const Modal = forwardRef<HeadlessModalRef, ModalProps>(
65
+ (allProps, ref) => {
66
+ const { name, children, onFocus, onBlur, onClose, onSuccess, onAfterLeave, ...props } = allProps as ModalBaseProps & Record<string, unknown>
67
+ const renderChildren = (contentProps: ModalRenderProps) => {
68
+ if (typeof children === 'function') {
69
+ return children(contentProps)
70
+ }
71
+
72
+ return children
73
+ }
74
+
75
+ const headlessModalRef = useRef<HeadlessModalRef>(null)
76
+ const cleanupScrollLockRef = useRef<(() => void) | null>(null)
77
+ const cleanupAriaHiddenRef = useRef<(() => void) | null>(null)
78
+ const [rendered, setRendered] = useState(false)
79
+ const useNativeDialog = useMemo(() => getConfig('useNativeDialog') as boolean, [])
80
+
81
+ useImperativeHandle(ref, () => headlessModalRef.current!, [headlessModalRef])
82
+
83
+ // Cleanup on unmount
84
+ useEffect(() => {
85
+ return () => {
86
+ cleanupScrollLockRef.current?.()
87
+ cleanupAriaHiddenRef.current?.()
88
+ }
89
+ }, [])
90
+
91
+ const handleSuccess = useCallback(() => {
92
+ onSuccess?.()
93
+ if (!cleanupScrollLockRef.current) {
94
+ cleanupScrollLockRef.current = lockScroll()
95
+ cleanupAriaHiddenRef.current = markAriaHidden(getConfig('appElement') as string)
96
+ }
97
+ }, [onSuccess])
98
+
99
+ const handleClose = useCallback(() => {
100
+ onClose?.()
101
+ cleanupScrollLockRef.current?.()
102
+ cleanupAriaHiddenRef.current?.()
103
+ cleanupScrollLockRef.current = null
104
+ cleanupAriaHiddenRef.current = null
105
+ }, [onClose])
106
+
107
+ const handleAfterLeave = useCallback(() => {
108
+ onAfterLeave?.()
109
+ }, [onAfterLeave])
110
+
111
+ return (
112
+ <HeadlessModal
113
+ ref={headlessModalRef}
114
+ name={name}
115
+ onFocus={onFocus ?? undefined}
116
+ onBlur={onBlur ?? undefined}
117
+ onClose={handleClose}
118
+ onSuccess={handleSuccess}
119
+ {...props}
120
+ >
121
+ {({
122
+ afterLeave,
123
+ close,
124
+ config,
125
+ emit,
126
+ getChildModal,
127
+ getParentModal,
128
+ id,
129
+ index,
130
+ isOpen,
131
+ modalContext,
132
+ onTopOfStack,
133
+ reload,
134
+ setOpen,
135
+ shouldRender,
136
+ ...extraProps
137
+ }) => (
138
+ <ModalPortal>
139
+ <div
140
+ className="im-dialog relative z-20"
141
+ data-inertiaui-modal-id={id}
142
+ data-inertiaui-modal-index={index}
143
+ aria-hidden={!onTopOfStack}
144
+ >
145
+ {/* Only render backdrop for the first modal (non-native dialog mode) */}
146
+ {/* Native dialog uses ::backdrop pseudo-element instead */}
147
+ {index === 0 && !useNativeDialog && (
148
+ <BackdropTransition
149
+ show={isOpen}
150
+ appear={!rendered}
151
+ onAfterAppear={() => setRendered(true)}
152
+ />
153
+ )}
154
+
155
+ {/* The modal/slideover content itself */}
156
+ {config.slideover ? (
157
+ <SlideoverContent
158
+ modalContext={modalContext}
159
+ config={config}
160
+ useNativeDialog={useNativeDialog}
161
+ isFirstModal={index === 0}
162
+ onAfterLeave={handleAfterLeave}
163
+ >
164
+ {renderChildren({
165
+ ...extraProps,
166
+ afterLeave,
167
+ close,
168
+ config,
169
+ emit,
170
+ getChildModal,
171
+ getParentModal,
172
+ id,
173
+ index,
174
+ isOpen,
175
+ modalContext,
176
+ onTopOfStack,
177
+ reload,
178
+ setOpen,
179
+ shouldRender,
180
+ })}
181
+ </SlideoverContent>
182
+ ) : (
183
+ <ModalContent
184
+ modalContext={modalContext}
185
+ config={config}
186
+ useNativeDialog={useNativeDialog}
187
+ isFirstModal={index === 0}
188
+ onAfterLeave={handleAfterLeave}
189
+ >
190
+ {renderChildren({
191
+ ...extraProps,
192
+ afterLeave,
193
+ close,
194
+ config,
195
+ emit,
196
+ getChildModal,
197
+ getParentModal,
198
+ id,
199
+ index,
200
+ isOpen,
201
+ modalContext,
202
+ onTopOfStack,
203
+ reload,
204
+ setOpen,
205
+ shouldRender,
206
+ })}
207
+ </ModalContent>
208
+ )}
209
+ </div>
210
+ </ModalPortal>
211
+ )}
212
+ </HeadlessModal>
213
+ )
214
+ },
215
+ )
216
+
217
+ // Simple portal component
218
+ function ModalPortal({ children }: { children: ReactNode }) {
219
+ const [mounted, setMounted] = useState(false)
220
+
221
+ useEffect(() => {
222
+ setMounted(true)
223
+ }, [])
224
+
225
+ if (!mounted) return null
226
+
227
+ return createPortal(children, document.body)
228
+ }
229
+
230
+ // Backdrop with CSS transition
231
+ function BackdropTransition({ show, appear, onAfterAppear }: BackdropTransitionProps) {
232
+ const [state, setState] = useState<'entering' | 'entered' | 'leaving' | 'exited'>(() => {
233
+ if (appear && show) return 'entering'
234
+ return show ? 'entered' : 'exited'
235
+ })
236
+ const initialRender = useRef(true)
237
+ const backdropRef = useRef<HTMLDivElement>(null)
238
+
239
+ useEffect(() => {
240
+ if (initialRender.current) {
241
+ initialRender.current = false
242
+ if (appear && show) {
243
+ requestAnimationFrame(() => {
244
+ setState('entered')
245
+ const backdrop = backdropRef.current
246
+ if (backdrop) {
247
+ const onTransitionEnd = (e: TransitionEvent) => {
248
+ if (e.target !== backdrop) return
249
+ backdrop.removeEventListener('transitionend', onTransitionEnd)
250
+ onAfterAppear?.()
251
+ }
252
+ backdrop.addEventListener('transitionend', onTransitionEnd)
253
+ }
254
+ })
255
+ }
256
+ return
257
+ }
258
+
259
+ if (show) {
260
+ setState('entering')
261
+ requestAnimationFrame(() => {
262
+ setState('entered')
263
+ })
264
+ } else {
265
+ setState('leaving')
266
+ const backdrop = backdropRef.current
267
+ if (backdrop) {
268
+ const onTransitionEnd = (e: TransitionEvent) => {
269
+ if (e.target !== backdrop) return
270
+ backdrop.removeEventListener('transitionend', onTransitionEnd)
271
+ setState('exited')
272
+ }
273
+ backdrop.addEventListener('transitionend', onTransitionEnd)
274
+ }
275
+ }
276
+ }, [show, appear, onAfterAppear])
277
+
278
+ if (state === 'exited') return null
279
+
280
+ const isVisible = state === 'entered'
281
+
282
+ return (
283
+ <div
284
+ ref={backdropRef}
285
+ className={`im-backdrop fixed inset-0 z-30 bg-black/75 transition-opacity duration-300 ease-in-out ${isVisible ? 'opacity-100' : 'opacity-0'}`}
286
+ aria-hidden="true"
287
+ />
288
+ )
289
+ }
290
+
291
+ Modal.displayName = 'Modal'
292
+ export default Modal