@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.
- 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 -2147
- 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 +33 -23
- 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 -625
- 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,880 @@
|
|
|
1
|
+
import { createElement, useEffect, useState, useRef, useReducer, ReactNode, ComponentType } from 'react'
|
|
2
|
+
import { default as Axios, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
|
3
|
+
import { except, kebabCase, generateId, sameUrlPath } from './helpers'
|
|
4
|
+
import { router, usePage, progress } from '@inertiajs/react'
|
|
5
|
+
import { mergeDataIntoQueryString, type RequestPayload } from '@inertiajs/core'
|
|
6
|
+
import { createContext, useContext } from 'react'
|
|
7
|
+
import ModalRenderer from './ModalRenderer'
|
|
8
|
+
import { getConfig } from './config'
|
|
9
|
+
import type {
|
|
10
|
+
Modal,
|
|
11
|
+
ModalConfig,
|
|
12
|
+
ModalResponseData,
|
|
13
|
+
ModalStackContextValue,
|
|
14
|
+
VisitOptions,
|
|
15
|
+
ReloadOptions,
|
|
16
|
+
EventCallback,
|
|
17
|
+
ComponentResolver,
|
|
18
|
+
PageProps,
|
|
19
|
+
ModalRootProps,
|
|
20
|
+
LocalModal,
|
|
21
|
+
PrefetchOptions,
|
|
22
|
+
} from './types'
|
|
23
|
+
|
|
24
|
+
const ModalStackContext = createContext<ModalStackContextValue | null>(null)
|
|
25
|
+
ModalStackContext.displayName = 'ModalStackContext'
|
|
26
|
+
|
|
27
|
+
let pageVersion: string | null = null
|
|
28
|
+
let resolveComponent: ComponentResolver | null = null
|
|
29
|
+
let baseUrl: string | null = null
|
|
30
|
+
|
|
31
|
+
// Track the URL we're closing to (prevents navigate handler from re-setting baseUrl)
|
|
32
|
+
// Only suppresses if navigate event URL matches this URL
|
|
33
|
+
let closingToBaseUrlTarget: string | null = null
|
|
34
|
+
|
|
35
|
+
// Prefetch cache (#146)
|
|
36
|
+
interface PrefetchCacheEntry {
|
|
37
|
+
response: AxiosResponse
|
|
38
|
+
timestamp: number
|
|
39
|
+
expiresAt: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const prefetchCache = new Map<string, PrefetchCacheEntry>()
|
|
43
|
+
const prefetchInFlight = new Map<string, Promise<AxiosResponse>>()
|
|
44
|
+
|
|
45
|
+
function getPrefetchCacheKey(url: string, method: string, data: RequestPayload): string {
|
|
46
|
+
return `${method}:${url}:${JSON.stringify(data)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getCachedResponse(url: string, method: string, data: RequestPayload): AxiosResponse | null {
|
|
50
|
+
const key = getPrefetchCacheKey(url, method, data)
|
|
51
|
+
const cached = prefetchCache.get(key)
|
|
52
|
+
|
|
53
|
+
if (!cached) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Date.now() > cached.expiresAt) {
|
|
58
|
+
prefetchCache.delete(key)
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return cached.response
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setCachedResponse(url: string, method: string, data: RequestPayload, response: AxiosResponse, cacheFor: number): void {
|
|
66
|
+
const key = getPrefetchCacheKey(url, method, data)
|
|
67
|
+
prefetchCache.set(key, {
|
|
68
|
+
response,
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
expiresAt: Date.now() + cacheFor,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function prefetch(href: string, options: PrefetchOptions = {}): Promise<void> {
|
|
75
|
+
if (href.startsWith('#')) {
|
|
76
|
+
return Promise.resolve()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const method = (options.method ?? 'get').toLowerCase()
|
|
80
|
+
const data = options.data ?? ({} as RequestPayload)
|
|
81
|
+
const headers = options.headers ?? {}
|
|
82
|
+
const queryStringArrayFormat = options.queryStringArrayFormat ?? 'brackets'
|
|
83
|
+
const cacheFor = options.cacheFor ?? 30000
|
|
84
|
+
|
|
85
|
+
const [url, mergedData] = mergeDataIntoQueryString(method as 'get' | 'post' | 'put' | 'patch' | 'delete', href || '', data, queryStringArrayFormat)
|
|
86
|
+
|
|
87
|
+
// Check if already cached
|
|
88
|
+
const cached = getCachedResponse(url, method, mergedData)
|
|
89
|
+
if (cached) {
|
|
90
|
+
return Promise.resolve()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check if already in flight
|
|
94
|
+
const cacheKey = getPrefetchCacheKey(url, method, mergedData)
|
|
95
|
+
const inFlight = prefetchInFlight.get(cacheKey)
|
|
96
|
+
if (inFlight) {
|
|
97
|
+
return inFlight.then(() => {})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
options.onPrefetching?.()
|
|
101
|
+
|
|
102
|
+
const requestHeaders: Record<string, string> = {
|
|
103
|
+
...headers,
|
|
104
|
+
Accept: 'text/html, application/xhtml+xml',
|
|
105
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
106
|
+
'X-Inertia': 'true',
|
|
107
|
+
'X-Inertia-Version': pageVersion ?? '',
|
|
108
|
+
'X-InertiaUI-Modal': generateId(),
|
|
109
|
+
'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const request = Axios({
|
|
113
|
+
url,
|
|
114
|
+
method,
|
|
115
|
+
data: mergedData,
|
|
116
|
+
headers: requestHeaders,
|
|
117
|
+
})
|
|
118
|
+
.then((response) => {
|
|
119
|
+
setCachedResponse(url, method, mergedData, response, cacheFor)
|
|
120
|
+
options.onPrefetched?.()
|
|
121
|
+
return response
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
prefetchInFlight.delete(cacheKey)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
prefetchInFlight.set(cacheKey, request)
|
|
128
|
+
|
|
129
|
+
return request.then(() => {})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ModalStackProviderProps {
|
|
133
|
+
children: ReactNode
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
|
|
137
|
+
// Use ref for synchronous access to stack, state only for triggering re-renders
|
|
138
|
+
const stackRef = useRef<Modal[]>([])
|
|
139
|
+
const [, forceUpdate] = useReducer((x) => x + 1, 0)
|
|
140
|
+
const [localModals, setLocalModals] = useState<Record<string, LocalModal>>({})
|
|
141
|
+
|
|
142
|
+
const updateStack = (withStack: (prevStack: Modal[]) => Modal[]) => {
|
|
143
|
+
const newStack = withStack([...stackRef.current])
|
|
144
|
+
|
|
145
|
+
const isOnTopOfStack = (modalId: string) => {
|
|
146
|
+
if (newStack.length < 2) {
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
newStack
|
|
152
|
+
.map((modal) => ({ id: modal.id, shouldRender: modal.shouldRender }))
|
|
153
|
+
.reverse()
|
|
154
|
+
.find((modal) => modal.shouldRender)?.id === modalId
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
newStack.forEach((modal, index) => {
|
|
159
|
+
newStack[index].onTopOfStack = isOnTopOfStack(modal.id)
|
|
160
|
+
newStack[index].getParentModal = () => {
|
|
161
|
+
if (index < 1) {
|
|
162
|
+
// This is the first modal in the stack
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Find the first open modal before this one
|
|
167
|
+
return (
|
|
168
|
+
stackRef.current
|
|
169
|
+
.slice(0, index)
|
|
170
|
+
.reverse()
|
|
171
|
+
.find((m) => m.isOpen) ?? null
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
newStack[index].getChildModal = () => {
|
|
175
|
+
if (index === stackRef.current.length - 1) {
|
|
176
|
+
// This is the last modal in the stack
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Find the first open modal after this one
|
|
181
|
+
return stackRef.current.slice(index + 1).find((m) => m.isOpen) ?? null
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
stackRef.current = newStack
|
|
186
|
+
forceUpdate()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
class ModalClass implements Modal {
|
|
190
|
+
id: string
|
|
191
|
+
isOpen: boolean
|
|
192
|
+
shouldRender: boolean
|
|
193
|
+
listeners: Record<string, EventCallback[]>
|
|
194
|
+
component: ComponentType | null
|
|
195
|
+
props: Record<string, unknown>
|
|
196
|
+
response: ModalResponseData
|
|
197
|
+
config: ModalConfig
|
|
198
|
+
onCloseCallback: (() => void) | null
|
|
199
|
+
afterLeaveCallback: (() => void) | null
|
|
200
|
+
index: number
|
|
201
|
+
onTopOfStack: boolean
|
|
202
|
+
name?: string
|
|
203
|
+
getParentModal: () => Modal | null
|
|
204
|
+
getChildModal: () => Modal | null
|
|
205
|
+
|
|
206
|
+
constructor(
|
|
207
|
+
component: ComponentType | null,
|
|
208
|
+
response: ModalResponseData,
|
|
209
|
+
config?: ModalConfig | null,
|
|
210
|
+
onClose?: (() => void) | null,
|
|
211
|
+
afterLeave?: (() => void) | null,
|
|
212
|
+
) {
|
|
213
|
+
this.id = response.id ?? generateId()
|
|
214
|
+
this.isOpen = false
|
|
215
|
+
this.shouldRender = false
|
|
216
|
+
this.listeners = {}
|
|
217
|
+
|
|
218
|
+
this.component = component
|
|
219
|
+
this.props = response.props ?? {}
|
|
220
|
+
this.response = response
|
|
221
|
+
this.config = config ?? {}
|
|
222
|
+
this.onCloseCallback = onClose ?? null
|
|
223
|
+
this.afterLeaveCallback = afterLeave ?? null
|
|
224
|
+
|
|
225
|
+
this.index = -1 // Will be set when added to the stack
|
|
226
|
+
this.getParentModal = () => null // Will be set in push()
|
|
227
|
+
this.getChildModal = () => null // Will be set in push()
|
|
228
|
+
this.onTopOfStack = true // Will be updated in push()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
show = () => {
|
|
232
|
+
updateStack((prevStack) =>
|
|
233
|
+
prevStack.map((modal) => {
|
|
234
|
+
if (modal.id === this.id && !modal.isOpen) {
|
|
235
|
+
modal.isOpen = true
|
|
236
|
+
modal.shouldRender = true
|
|
237
|
+
}
|
|
238
|
+
return modal
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
setOpen = (open: boolean) => {
|
|
244
|
+
if (open) {
|
|
245
|
+
this.show()
|
|
246
|
+
} else {
|
|
247
|
+
this.close()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
close = () => {
|
|
252
|
+
updateStack((currentStack) => {
|
|
253
|
+
let modalClosed = false
|
|
254
|
+
|
|
255
|
+
const newStack = currentStack.map((modal) => {
|
|
256
|
+
if (modal.id === this.id && modal.isOpen) {
|
|
257
|
+
Object.keys(modal.listeners).forEach((event) => {
|
|
258
|
+
modal.off(event)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
modal.isOpen = false
|
|
262
|
+
modal.onCloseCallback?.()
|
|
263
|
+
modalClosed = true
|
|
264
|
+
}
|
|
265
|
+
return modal
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return modalClosed ? newStack : currentStack
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
afterLeave = () => {
|
|
273
|
+
if (this.isOpen) {
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateStack((prevStack) => {
|
|
278
|
+
const updatedStack = prevStack.map((modal) => {
|
|
279
|
+
if (modal.id === this.id && !modal.isOpen) {
|
|
280
|
+
modal.shouldRender = false
|
|
281
|
+
modal.afterLeaveCallback?.()
|
|
282
|
+
modal.afterLeaveCallback = null
|
|
283
|
+
}
|
|
284
|
+
return modal
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
if (this.index === 0) {
|
|
288
|
+
// Update browser URL back to base when all modals are closed
|
|
289
|
+
// Clear baseUrl BEFORE router.push to prevent the navigate event
|
|
290
|
+
// from setting it back (race condition with props callback)
|
|
291
|
+
const savedBaseUrl = baseUrl
|
|
292
|
+
baseUrl = null
|
|
293
|
+
|
|
294
|
+
// Set target URL to prevent navigate handler from re-setting baseUrl
|
|
295
|
+
// Only suppresses navigate events to this specific URL
|
|
296
|
+
closingToBaseUrlTarget = savedBaseUrl
|
|
297
|
+
|
|
298
|
+
if (savedBaseUrl && typeof window !== 'undefined') {
|
|
299
|
+
router.push({
|
|
300
|
+
url: savedBaseUrl,
|
|
301
|
+
preserveScroll: true,
|
|
302
|
+
preserveState: true,
|
|
303
|
+
// Clear _inertiaui_modal prop to prevent modal from reopening
|
|
304
|
+
props: (currentProps: Record<string, unknown>) => {
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
306
|
+
const { _inertiaui_modal, ...rest } = currentProps
|
|
307
|
+
return { ...rest, _inertiaui_modal: undefined }
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
return []
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return updatedStack
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
on = (event: string, callback: EventCallback) => {
|
|
319
|
+
event = kebabCase(event)
|
|
320
|
+
this.listeners[event] = this.listeners[event] ?? []
|
|
321
|
+
this.listeners[event].push(callback)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
off = (event: string, callback?: EventCallback) => {
|
|
325
|
+
event = kebabCase(event)
|
|
326
|
+
if (callback) {
|
|
327
|
+
this.listeners[event] = this.listeners[event]?.filter((cb) => cb !== callback) ?? []
|
|
328
|
+
} else {
|
|
329
|
+
delete this.listeners[event]
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
emit = (event: string, ...args: unknown[]) => {
|
|
334
|
+
this.listeners[kebabCase(event)]?.forEach((callback) => callback(...args))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
registerEventListenersFromProps = (props: Record<string, unknown>) => {
|
|
338
|
+
const unsubscribers: (() => void)[] = []
|
|
339
|
+
|
|
340
|
+
Object.keys(props)
|
|
341
|
+
.filter((key) => key.startsWith('on'))
|
|
342
|
+
.forEach((key) => {
|
|
343
|
+
// e.g. onRefreshKey -> refresh-key
|
|
344
|
+
const eventName = kebabCase(key).replace(/^on-/, '')
|
|
345
|
+
const callback = props[key] as EventCallback
|
|
346
|
+
this.on(eventName, callback)
|
|
347
|
+
unsubscribers.push(() => this.off(eventName, callback))
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
return () => unsubscribers.forEach((unsub) => unsub())
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
reload = (options: ReloadOptions = {}) => {
|
|
354
|
+
let keys = Object.keys(this.response.props)
|
|
355
|
+
|
|
356
|
+
if (options.only) {
|
|
357
|
+
keys = options.only
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (options.except) {
|
|
361
|
+
keys = except(keys, options.except) as string[]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!this.response?.url) {
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const method = (options.method ?? 'get').toLowerCase()
|
|
369
|
+
const data = options.data ?? {}
|
|
370
|
+
|
|
371
|
+
options.onStart?.()
|
|
372
|
+
|
|
373
|
+
Axios({
|
|
374
|
+
url: this.response.url,
|
|
375
|
+
method,
|
|
376
|
+
data: method === 'get' ? {} : data,
|
|
377
|
+
params: method === 'get' ? data : {},
|
|
378
|
+
headers: {
|
|
379
|
+
...(options.headers ?? {}),
|
|
380
|
+
Accept: 'text/html, application/xhtml+xml',
|
|
381
|
+
'X-Inertia': 'true',
|
|
382
|
+
'X-Inertia-Partial-Component': this.response.component,
|
|
383
|
+
'X-Inertia-Version': this.response.version ?? '',
|
|
384
|
+
'X-Inertia-Partial-Data': keys.join(','),
|
|
385
|
+
'X-InertiaUI-Modal': generateId(),
|
|
386
|
+
'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
|
|
387
|
+
},
|
|
388
|
+
})
|
|
389
|
+
.then((response) => {
|
|
390
|
+
this.updateProps(response.data.props)
|
|
391
|
+
|
|
392
|
+
options.onSuccess?.(response)
|
|
393
|
+
})
|
|
394
|
+
.catch((error) => {
|
|
395
|
+
options.onError?.(error)
|
|
396
|
+
})
|
|
397
|
+
.finally(() => {
|
|
398
|
+
options.onFinish?.()
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
updateProps = (props: Record<string, unknown>) => {
|
|
403
|
+
Object.assign(this.props, props)
|
|
404
|
+
updateStack((prevStack) => prevStack) // Trigger re-render
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const isValidModalResponse = (data: unknown): data is ModalResponseData => {
|
|
409
|
+
return (
|
|
410
|
+
typeof data === 'object' &&
|
|
411
|
+
data !== null &&
|
|
412
|
+
'component' in data &&
|
|
413
|
+
typeof (data as ModalResponseData).component === 'string'
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const pushFromResponseData = (
|
|
418
|
+
responseData: ModalResponseData,
|
|
419
|
+
config: ModalConfig = {},
|
|
420
|
+
onClose: (() => void) | null = null,
|
|
421
|
+
onAfterLeave: (() => void) | null = null,
|
|
422
|
+
): Promise<Modal> => {
|
|
423
|
+
if (!resolveComponent) {
|
|
424
|
+
return Promise.reject(new Error('resolveComponent not set'))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!isValidModalResponse(responseData)) {
|
|
428
|
+
return Promise.reject(
|
|
429
|
+
new Error(
|
|
430
|
+
'Invalid modal response. This usually happens when the server returns a redirect (e.g., due to session expiration). ' +
|
|
431
|
+
'Check if the user is still authenticated.',
|
|
432
|
+
),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return resolveComponent(responseData.component).then((component) =>
|
|
437
|
+
push(component, responseData, config, onClose, onAfterLeave),
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const loadDeferredProps = (modal: Modal) => {
|
|
442
|
+
const deferred = modal.response?.meta?.deferredProps
|
|
443
|
+
|
|
444
|
+
if (!deferred) {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
Object.keys(deferred).forEach((key) => {
|
|
449
|
+
modal.reload({ only: deferred[key] })
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const push = (
|
|
454
|
+
component: ComponentType | null,
|
|
455
|
+
response: ModalResponseData,
|
|
456
|
+
config?: ModalConfig | null,
|
|
457
|
+
onClose?: (() => void) | null,
|
|
458
|
+
afterLeave?: (() => void) | null,
|
|
459
|
+
): Modal => {
|
|
460
|
+
const newModal = new ModalClass(component, response, config, onClose, afterLeave)
|
|
461
|
+
newModal.index = stackRef.current.length
|
|
462
|
+
|
|
463
|
+
updateStack((prevStack) => [...prevStack, newModal])
|
|
464
|
+
loadDeferredProps(newModal)
|
|
465
|
+
|
|
466
|
+
newModal.show()
|
|
467
|
+
|
|
468
|
+
return newModal
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function pushLocalModal(
|
|
472
|
+
name: string,
|
|
473
|
+
config?: ModalConfig | null,
|
|
474
|
+
onClose?: (() => void) | null,
|
|
475
|
+
afterLeave?: (() => void) | null,
|
|
476
|
+
props?: Record<string, unknown> | null,
|
|
477
|
+
): Modal {
|
|
478
|
+
if (!localModals[name]) {
|
|
479
|
+
throw new Error(`The local modal "${name}" has not been registered.`)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const responseData = { props: props ?? {} } as ModalResponseData
|
|
483
|
+
const modal = push(null, responseData, config, onClose, afterLeave)
|
|
484
|
+
modal.name = name
|
|
485
|
+
localModals[name].callback(modal)
|
|
486
|
+
return modal
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const visitModal = (url: string, options: VisitOptions = {}): Promise<Modal> =>
|
|
490
|
+
visit(
|
|
491
|
+
url,
|
|
492
|
+
options.method ?? 'get',
|
|
493
|
+
options.data ?? ({} as RequestPayload),
|
|
494
|
+
options.headers ?? {},
|
|
495
|
+
options.config ?? {},
|
|
496
|
+
options.onClose ?? null,
|
|
497
|
+
options.onAfterLeave ?? null,
|
|
498
|
+
options.queryStringArrayFormat ?? 'brackets',
|
|
499
|
+
options.navigate ?? (getConfig('navigate') as boolean),
|
|
500
|
+
options.onStart ?? null,
|
|
501
|
+
options.onSuccess ?? null,
|
|
502
|
+
options.onError ?? null,
|
|
503
|
+
options.props ?? null,
|
|
504
|
+
).then((modal) => {
|
|
505
|
+
const listeners = options.listeners ?? {}
|
|
506
|
+
|
|
507
|
+
Object.keys(listeners).forEach((event) => {
|
|
508
|
+
// e.g. refreshKey -> refresh-key
|
|
509
|
+
const eventName = kebabCase(event)
|
|
510
|
+
modal.on(eventName, listeners[event])
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
return modal
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
const updateBrowserUrl = (url: string | undefined, useBrowserHistory: boolean, modalData?: ModalResponseData): void => {
|
|
517
|
+
if (!url || !useBrowserHistory || typeof window === 'undefined') {
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
router.push({
|
|
522
|
+
url,
|
|
523
|
+
preserveScroll: true,
|
|
524
|
+
preserveState: true,
|
|
525
|
+
// Store modal data in page props for history navigation
|
|
526
|
+
props: modalData
|
|
527
|
+
? (currentProps: Record<string, unknown>) => ({
|
|
528
|
+
...currentProps,
|
|
529
|
+
_inertiaui_modal: {
|
|
530
|
+
...modalData,
|
|
531
|
+
baseUrl,
|
|
532
|
+
},
|
|
533
|
+
})
|
|
534
|
+
: undefined,
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const visit = (
|
|
539
|
+
href: string,
|
|
540
|
+
method: string,
|
|
541
|
+
payload: RequestPayload = {},
|
|
542
|
+
headers: Record<string, string> = {},
|
|
543
|
+
config: ModalConfig = {},
|
|
544
|
+
onClose: (() => void) | null = null,
|
|
545
|
+
onAfterLeave: (() => void) | null = null,
|
|
546
|
+
queryStringArrayFormat: 'brackets' | 'indices' = 'brackets',
|
|
547
|
+
useBrowserHistory = false,
|
|
548
|
+
onStart: (() => void) | null = null,
|
|
549
|
+
onSuccess: ((response?: AxiosResponse) => void) | null = null,
|
|
550
|
+
onError: ((...args: unknown[]) => void) | null = null,
|
|
551
|
+
props: Record<string, unknown> | null = null,
|
|
552
|
+
): Promise<Modal> => {
|
|
553
|
+
const modalId = generateId()
|
|
554
|
+
|
|
555
|
+
return new Promise((resolve, reject) => {
|
|
556
|
+
if (href.startsWith('#')) {
|
|
557
|
+
resolve(pushLocalModal(href.substring(1), config, onClose, onAfterLeave, props))
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const [url, data] = mergeDataIntoQueryString(method as 'get' | 'post' | 'put' | 'patch' | 'delete', href || '', payload, queryStringArrayFormat)
|
|
562
|
+
|
|
563
|
+
// Check for cached prefetch response (#146)
|
|
564
|
+
const cachedResponse = getCachedResponse(url, method, data)
|
|
565
|
+
if (cachedResponse) {
|
|
566
|
+
onSuccess?.(cachedResponse)
|
|
567
|
+
pushFromResponseData(cachedResponse.data, config, onClose, onAfterLeave)
|
|
568
|
+
.then((modal) => {
|
|
569
|
+
updateBrowserUrl(cachedResponse.data.url, useBrowserHistory, cachedResponse.data)
|
|
570
|
+
resolve(modal)
|
|
571
|
+
})
|
|
572
|
+
.catch(reject)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (stackRef.current.length === 0) {
|
|
577
|
+
baseUrl = typeof window !== 'undefined' ? window.location.href : ''
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const requestHeaders: Record<string, string> = {
|
|
581
|
+
...headers,
|
|
582
|
+
Accept: 'text/html, application/xhtml+xml',
|
|
583
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
584
|
+
'X-Inertia': 'true',
|
|
585
|
+
'X-Inertia-Version': pageVersion ?? '',
|
|
586
|
+
'X-InertiaUI-Modal': modalId,
|
|
587
|
+
'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
onStart?.()
|
|
591
|
+
|
|
592
|
+
progress?.start()
|
|
593
|
+
|
|
594
|
+
Axios({
|
|
595
|
+
url,
|
|
596
|
+
method,
|
|
597
|
+
data,
|
|
598
|
+
headers: requestHeaders,
|
|
599
|
+
})
|
|
600
|
+
.then((response) => {
|
|
601
|
+
onSuccess?.(response)
|
|
602
|
+
pushFromResponseData(response.data, config, onClose, onAfterLeave)
|
|
603
|
+
.then((modal) => {
|
|
604
|
+
updateBrowserUrl(response.data.url, useBrowserHistory, response.data)
|
|
605
|
+
resolve(modal)
|
|
606
|
+
})
|
|
607
|
+
.catch(reject)
|
|
608
|
+
})
|
|
609
|
+
.catch((...args: unknown[]) => {
|
|
610
|
+
onError?.(...args)
|
|
611
|
+
reject(args[0])
|
|
612
|
+
})
|
|
613
|
+
.finally(() => {
|
|
614
|
+
progress?.finish()
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const registerLocalModal = (name: string, callback: (modal: Modal) => void) => {
|
|
620
|
+
setLocalModals((prevLocalModals) => ({
|
|
621
|
+
...prevLocalModals,
|
|
622
|
+
[name]: { name, callback },
|
|
623
|
+
}))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const removeLocalModal = (name: string) => {
|
|
627
|
+
setLocalModals((prevLocalModals) => {
|
|
628
|
+
const newLocalModals = { ...prevLocalModals }
|
|
629
|
+
delete newLocalModals[name]
|
|
630
|
+
return newLocalModals
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Create value object with getter for stack to ensure we always get current ref value
|
|
635
|
+
const value: ModalStackContextValue = {
|
|
636
|
+
get stack() {
|
|
637
|
+
return stackRef.current
|
|
638
|
+
},
|
|
639
|
+
localModals,
|
|
640
|
+
push,
|
|
641
|
+
pushFromResponseData,
|
|
642
|
+
length: () => stackRef.current.length,
|
|
643
|
+
closeAll: (force = false) => {
|
|
644
|
+
if (force) {
|
|
645
|
+
// Force close: immediately remove all modals without transition
|
|
646
|
+
updateStack(() => [])
|
|
647
|
+
} else {
|
|
648
|
+
// Normal close: trigger leave transition for each modal
|
|
649
|
+
;[...stackRef.current].reverse().forEach((modal) => modal.close())
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
reset: () => updateStack(() => []),
|
|
653
|
+
visit,
|
|
654
|
+
visitModal,
|
|
655
|
+
registerLocalModal,
|
|
656
|
+
removeLocalModal,
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return <ModalStackContext.Provider value={value}>{children}</ModalStackContext.Provider>
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export const useModalStack = (): ModalStackContextValue => {
|
|
663
|
+
const context = useContext(ModalStackContext)
|
|
664
|
+
if (context === null) {
|
|
665
|
+
throw new Error('useModalStack must be used within a ModalStackProvider')
|
|
666
|
+
}
|
|
667
|
+
return context
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export const modalPropNames = ['closeButton', 'closeExplicitly', 'closeOnClickOutside', 'maxWidth', 'paddingClasses', 'panelClasses', 'position', 'slideover']
|
|
671
|
+
|
|
672
|
+
export const initFromPageProps = (pageProps: PageProps) => {
|
|
673
|
+
if (pageProps.initialPage) {
|
|
674
|
+
pageVersion = pageProps.initialPage.version ?? null
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (pageProps.resolveComponent) {
|
|
678
|
+
resolveComponent = pageProps.resolveComponent
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
interface RenderInertiaAppProps {
|
|
683
|
+
Component: ComponentType & { layout?: ((page: ReactNode) => ReactNode) | ComponentType[] }
|
|
684
|
+
props: Record<string, unknown>
|
|
685
|
+
key: string
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export const renderApp = (App: ComponentType<{ children: (props: RenderInertiaAppProps) => ReactNode }>, pageProps: PageProps) => {
|
|
689
|
+
initFromPageProps(pageProps)
|
|
690
|
+
|
|
691
|
+
const renderInertiaApp = ({ Component, props, key }: RenderInertiaAppProps) => {
|
|
692
|
+
const renderComponent = () => {
|
|
693
|
+
const child = createElement(Component, { key, ...props })
|
|
694
|
+
|
|
695
|
+
if (typeof Component.layout === 'function') {
|
|
696
|
+
return Component.layout(child)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (Array.isArray(Component.layout)) {
|
|
700
|
+
return Component.layout
|
|
701
|
+
.slice()
|
|
702
|
+
.reverse()
|
|
703
|
+
.reduce(
|
|
704
|
+
(acc, Layout) => createElement(Layout as ComponentType<Record<string, unknown>>, props, acc),
|
|
705
|
+
child as ReactNode,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return child
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
<>
|
|
714
|
+
{renderComponent()}
|
|
715
|
+
<ModalRoot />
|
|
716
|
+
</>
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return (
|
|
721
|
+
<ModalStackProvider>
|
|
722
|
+
<App {...(pageProps as Record<string, unknown>)}>{renderInertiaApp}</App>
|
|
723
|
+
</ModalStackProvider>
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
interface InertiaUIModalPageProps {
|
|
728
|
+
_inertiaui_modal?: ModalResponseData & { baseUrl: string }
|
|
729
|
+
[key: string]: unknown
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export const ModalRoot = ({ children }: ModalRootProps) => {
|
|
733
|
+
const context = useContext(ModalStackContext)
|
|
734
|
+
const $page = usePage<InertiaUIModalPageProps>()
|
|
735
|
+
const pendingModalKeysRef = useRef(new Set<string>())
|
|
736
|
+
|
|
737
|
+
// Generate a unique key for deduplication (handles case when modal has no id)
|
|
738
|
+
const getModalKey = (modalData: ModalResponseData) => modalData.id || `${modalData.component}:${modalData.url}`
|
|
739
|
+
|
|
740
|
+
const isNavigatingRef = useRef(false)
|
|
741
|
+
const initialModalStillOpenedRef = useRef(!!$page.props?._inertiaui_modal)
|
|
742
|
+
|
|
743
|
+
useEffect(() => router.on('start', () => (isNavigatingRef.current = true)), [])
|
|
744
|
+
useEffect(() => router.on('finish', () => (isNavigatingRef.current = false)), [])
|
|
745
|
+
useEffect(
|
|
746
|
+
() =>
|
|
747
|
+
router.on('navigate', function ($event) {
|
|
748
|
+
const modalOnBase = ($event as { detail: { page: { props: InertiaUIModalPageProps; url: string } } }).detail.page.props._inertiaui_modal
|
|
749
|
+
const pageUrl = ($event as { detail: { page: { url: string } } }).detail.page.url
|
|
750
|
+
|
|
751
|
+
// If we're closing to this specific URL, don't re-open the modal
|
|
752
|
+
// This handles the race condition where router.push in afterLeave
|
|
753
|
+
// fires a navigate event before the props callback clears _inertiaui_modal
|
|
754
|
+
// Only suppresses when navigating to our closing target URL (not browser back to modal)
|
|
755
|
+
if (closingToBaseUrlTarget) {
|
|
756
|
+
const targetPath = new URL(closingToBaseUrlTarget, 'http://x').pathname
|
|
757
|
+
const pagePath = new URL(pageUrl, 'http://x').pathname
|
|
758
|
+
if (targetPath === pagePath) {
|
|
759
|
+
closingToBaseUrlTarget = null
|
|
760
|
+
context?.closeAll(true)
|
|
761
|
+
baseUrl = null
|
|
762
|
+
initialModalStillOpenedRef.current = false
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
closingToBaseUrlTarget = null
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!modalOnBase) {
|
|
769
|
+
// No modal data - close any open modals (force close without transition)
|
|
770
|
+
context?.closeAll(true)
|
|
771
|
+
baseUrl = null
|
|
772
|
+
initialModalStillOpenedRef.current = false
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// If the page URL doesn't match the modal URL, close all modals
|
|
777
|
+
if (!sameUrlPath(pageUrl, modalOnBase.url)) {
|
|
778
|
+
context?.closeAll(true)
|
|
779
|
+
baseUrl = null
|
|
780
|
+
initialModalStillOpenedRef.current = false
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Skip if this modal is already being pushed (handles duplicate navigate events)
|
|
785
|
+
const modalKey = getModalKey(modalOnBase)
|
|
786
|
+
if (pendingModalKeysRef.current.has(modalKey)) {
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Also skip if a modal with this id is already in the stack
|
|
791
|
+
if (modalOnBase.id && context?.stack.some((m) => m.id === modalOnBase.id)) {
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Skip if a modal with the same component and URL is already open
|
|
796
|
+
if (context?.stack.some((m) => m.response?.component === modalOnBase.component && sameUrlPath(m.response?.url, modalOnBase.url))) {
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Only set baseUrl when we're actually opening a new modal
|
|
801
|
+
// (after deduplication checks pass)
|
|
802
|
+
baseUrl = modalOnBase.baseUrl
|
|
803
|
+
|
|
804
|
+
pendingModalKeysRef.current.add(modalKey)
|
|
805
|
+
|
|
806
|
+
context
|
|
807
|
+
?.pushFromResponseData(modalOnBase, {}, () => {
|
|
808
|
+
if (!modalOnBase.baseUrl) {
|
|
809
|
+
console.error('No base url in modal response data so cannot navigate back')
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
if (!isNavigatingRef.current && typeof window !== 'undefined' && window.location.href !== modalOnBase.baseUrl) {
|
|
813
|
+
router.visit(modalOnBase.baseUrl, {
|
|
814
|
+
preserveScroll: true,
|
|
815
|
+
preserveState: true,
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
.finally(() => {
|
|
820
|
+
pendingModalKeysRef.current.delete(modalKey)
|
|
821
|
+
})
|
|
822
|
+
}),
|
|
823
|
+
[],
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
const axiosRequestInterceptor = (config: InternalAxiosRequestConfig) => {
|
|
827
|
+
// A Modal is opened on top of a base route, so we need to pass this base route
|
|
828
|
+
// so it can redirect back with the back() helper method...
|
|
829
|
+
// Only send the header when we have an actual base URL value
|
|
830
|
+
const baseUrlValue = baseUrl ?? (initialModalStillOpenedRef.current ? $page.props._inertiaui_modal?.baseUrl : null)
|
|
831
|
+
if (baseUrlValue) {
|
|
832
|
+
config.headers['X-InertiaUI-Modal-Base-Url'] = baseUrlValue
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return config
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
useEffect(() => {
|
|
839
|
+
const interceptorId = Axios.interceptors.request.use(axiosRequestInterceptor)
|
|
840
|
+
return () => Axios.interceptors.request.eject(interceptorId)
|
|
841
|
+
}, [])
|
|
842
|
+
|
|
843
|
+
const previousModalRef = useRef<(ModalResponseData & { baseUrl: string }) | undefined>(undefined)
|
|
844
|
+
|
|
845
|
+
useEffect(() => {
|
|
846
|
+
const newModal = $page.props?._inertiaui_modal
|
|
847
|
+
const previousModal = previousModalRef.current
|
|
848
|
+
|
|
849
|
+
// Store the current value for the next render
|
|
850
|
+
previousModalRef.current = newModal
|
|
851
|
+
|
|
852
|
+
if (!newModal) {
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// If there's a previous modal with same component/URL, update its props
|
|
857
|
+
if (previousModal && newModal.component === previousModal.component && sameUrlPath(newModal.url, previousModal.url)) {
|
|
858
|
+
context?.stack[0]?.updateProps(newModal.props ?? {})
|
|
859
|
+
return
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// If there's no previous modal but we have modals in the stack (opened via XHR),
|
|
863
|
+
// check if the new modal matches any open modal and update its props
|
|
864
|
+
if (!previousModal && context && context.stack.length > 0) {
|
|
865
|
+
const existingModal = context.stack.find(
|
|
866
|
+
(m) => m.response?.component === newModal.component && sameUrlPath(m.response?.url, newModal.url),
|
|
867
|
+
)
|
|
868
|
+
if (existingModal) {
|
|
869
|
+
existingModal.updateProps(newModal.props ?? {})
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}, [$page.props?._inertiaui_modal])
|
|
873
|
+
|
|
874
|
+
return (
|
|
875
|
+
<>
|
|
876
|
+
{children}
|
|
877
|
+
{context && context.stack.length > 0 && <ModalRenderer index={0} />}
|
|
878
|
+
</>
|
|
879
|
+
)
|
|
880
|
+
}
|