@inertia-vuetify/modal 0.0.0-stage → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +50 -0
- package/README.md +371 -2
- package/dist/CloseButton.vue.d.ts +3 -0
- package/dist/Deferred.vue.d.ts +16 -0
- package/dist/ModalBtn.vue.d.ts +17 -0
- package/dist/ModalContent.vue.d.ts +33 -0
- package/dist/ModalLink.vue.d.ts +201 -0
- package/dist/ModalRenderer.vue.d.ts +6 -0
- package/dist/ModalRoot.vue.d.ts +13 -0
- package/dist/WhenVisible.vue.d.ts +24 -0
- package/dist/app.d.ts +5 -0
- package/dist/cache.d.ts +12 -0
- package/dist/config.d.ts +16 -0
- package/dist/helpers.d.ts +5 -0
- package/dist/index.d.ts +14 -0
- package/dist/inertia-modal-vuetify.js +1140 -0
- package/dist/keys.d.ts +2 -0
- package/dist/modalStack.d.ts +64 -0
- package/dist/transitions.d.ts +10 -0
- package/dist/types.d.ts +109 -0
- package/dist/useModal.d.ts +3 -0
- package/package.json +71 -4
- package/src/CloseButton.vue +13 -0
- package/src/Deferred.vue +21 -0
- package/src/HeadlessModal.vue +174 -0
- package/src/Modal.vue +120 -0
- package/src/ModalBtn.vue +28 -0
- package/src/ModalContent.vue +238 -0
- package/src/ModalLink.vue +189 -0
- package/src/ModalRenderer.vue +21 -0
- package/src/ModalRoot.vue +152 -0
- package/src/WhenVisible.vue +90 -0
- package/src/app.ts +17 -0
- package/src/cache.ts +67 -0
- package/src/config.ts +116 -0
- package/src/helpers.ts +18 -0
- package/src/index.ts +15 -0
- package/src/keys.ts +3 -0
- package/src/modalStack.ts +525 -0
- package/src/transitions.ts +30 -0
- package/src/types.ts +121 -0
- package/src/useModal.ts +7 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { mergeDataIntoQueryString, type HttpResponse, type RequestPayload } from '@inertiajs/core'
|
|
2
|
+
import { http, progress, router, usePage } from '@inertiajs/vue3'
|
|
3
|
+
import { computed, markRaw, nextTick, reactive, readonly, ref, type Component, type ComputedRef, type Ref } from 'vue'
|
|
4
|
+
import { ResponseCache } from './cache'
|
|
5
|
+
import { getConfig } from './config'
|
|
6
|
+
import { except, generateId, kebabCase, parseResponseData, sameUrlPath } from './helpers'
|
|
7
|
+
import type { HttpMethod, ModalConfig, ModalContext, ModalResponseData, PrefetchOptions, ReloadOptions, VisitOptions } from './types'
|
|
8
|
+
|
|
9
|
+
type EventCallback = (...args: unknown[]) => void
|
|
10
|
+
type ComponentResolver = (name: string) => Promise<Component>
|
|
11
|
+
|
|
12
|
+
const baseUrl = ref<string | null>(null)
|
|
13
|
+
const stack = ref<ModalInstance[]>([])
|
|
14
|
+
const localModals = ref<Record<string, { name: string; callback: (modal: ModalInstance) => void }>>({})
|
|
15
|
+
|
|
16
|
+
// The URL the stack is closing to, so the navigate handler doesn't re-set the base URL for it.
|
|
17
|
+
let closingToBaseUrlTarget: string | null = null
|
|
18
|
+
|
|
19
|
+
const prefetchCache = new ResponseCache<HttpResponse>()
|
|
20
|
+
|
|
21
|
+
function requestHeaders(headers: Record<string, string>, modalId = generateId()): Record<string, string> {
|
|
22
|
+
return {
|
|
23
|
+
...headers,
|
|
24
|
+
Accept: 'text/html, application/xhtml+xml',
|
|
25
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
26
|
+
'X-Inertia': 'true',
|
|
27
|
+
'X-Inertia-Version': usePage().version ?? '',
|
|
28
|
+
'X-InertiaUI-Modal': modalId,
|
|
29
|
+
'X-InertiaUI-Modal-Base-Url': baseUrl.value ?? '',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function prefetch(href: string, options: PrefetchOptions = {}): Promise<void> {
|
|
34
|
+
if (href.startsWith('#')) {
|
|
35
|
+
return Promise.resolve()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const method = options.method ?? 'get'
|
|
39
|
+
const [url, data] = mergeDataIntoQueryString(method, href, options.data ?? {}, options.queryStringArrayFormat ?? 'brackets')
|
|
40
|
+
const cacheKey = ResponseCache.key(method, url, data)
|
|
41
|
+
|
|
42
|
+
if (prefetchCache.get(cacheKey)) {
|
|
43
|
+
return Promise.resolve()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inFlight = prefetchCache.getInFlight(cacheKey)
|
|
47
|
+
|
|
48
|
+
if (inFlight) {
|
|
49
|
+
return inFlight.then(() => {})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
options.onPrefetching?.()
|
|
53
|
+
|
|
54
|
+
const request = http
|
|
55
|
+
.getClient()
|
|
56
|
+
.request({ url, method, data, headers: requestHeaders(options.headers ?? {}) })
|
|
57
|
+
.then((response) => {
|
|
58
|
+
prefetchCache.set(cacheKey, response, options.cacheFor ?? 30000)
|
|
59
|
+
options.onPrefetched?.()
|
|
60
|
+
return response
|
|
61
|
+
})
|
|
62
|
+
.finally(() => prefetchCache.deleteInFlight(cacheKey))
|
|
63
|
+
|
|
64
|
+
prefetchCache.setInFlight(cacheKey, request)
|
|
65
|
+
|
|
66
|
+
return request.then(() => {})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @deprecated A no-op kept for setup code written for Inertia 2. */
|
|
70
|
+
export const initFromPageProps = (_pageProps: Record<string, unknown>): void => {}
|
|
71
|
+
|
|
72
|
+
export class ModalInstance {
|
|
73
|
+
id: string
|
|
74
|
+
isOpen = false
|
|
75
|
+
shouldRender = false
|
|
76
|
+
listeners: Record<string, EventCallback[]> = {}
|
|
77
|
+
component: Component | null
|
|
78
|
+
props: Ref<Record<string, unknown>>
|
|
79
|
+
response: ModalResponseData
|
|
80
|
+
config: ModalConfig
|
|
81
|
+
onCloseCallback: (() => void) | null
|
|
82
|
+
afterLeaveCallback: (() => void) | null
|
|
83
|
+
index: ComputedRef<number>
|
|
84
|
+
onTopOfStack: ComputedRef<boolean>
|
|
85
|
+
name?: string
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
component: Component | null,
|
|
89
|
+
response: ModalResponseData,
|
|
90
|
+
config?: ModalConfig | null,
|
|
91
|
+
onClose?: (() => void) | null,
|
|
92
|
+
afterLeave?: (() => void) | null,
|
|
93
|
+
) {
|
|
94
|
+
this.id = response.id ?? generateId()
|
|
95
|
+
this.component = component
|
|
96
|
+
this.props = ref(response.props ?? {})
|
|
97
|
+
this.response = response
|
|
98
|
+
this.config = config ?? {}
|
|
99
|
+
this.onCloseCallback = onClose ?? null
|
|
100
|
+
this.afterLeaveCallback = afterLeave ?? null
|
|
101
|
+
|
|
102
|
+
this.index = computed(() => stack.value.findIndex((modal) => modal.id === this.id))
|
|
103
|
+
this.onTopOfStack = computed(() => {
|
|
104
|
+
if (stack.value.length < 2) {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return stack.value.findLast((modal) => modal.shouldRender)?.id === this.id
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Methods are arrow functions because templates pass them around detached from the instance.
|
|
113
|
+
|
|
114
|
+
getComponentPropKeys = (): string[] => {
|
|
115
|
+
const componentProps = (this.component as { props?: string[] | Record<string, unknown> } | null)?.props
|
|
116
|
+
|
|
117
|
+
if (Array.isArray(componentProps)) {
|
|
118
|
+
return componentProps
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return componentProps ? Object.keys(componentProps) : []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getParentModal = (): ModalContext | null => {
|
|
125
|
+
const index = this.index.value
|
|
126
|
+
|
|
127
|
+
return index < 1 ? null : (stack.value.slice(0, index).findLast((modal) => modal.isOpen) ?? null)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getChildModal = (): ModalContext | null => {
|
|
131
|
+
const index = this.index.value
|
|
132
|
+
|
|
133
|
+
return index === stack.value.length - 1 ? null : (stack.value.slice(index + 1).find((modal) => modal.isOpen) ?? null)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// State is written through the stack's reactive proxy so components re-render.
|
|
137
|
+
|
|
138
|
+
show = (): void => {
|
|
139
|
+
const modal = stack.value[this.index.value]
|
|
140
|
+
|
|
141
|
+
if (modal && !modal.isOpen) {
|
|
142
|
+
modal.isOpen = true
|
|
143
|
+
modal.shouldRender = true
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
close = (): void => {
|
|
148
|
+
const modal = stack.value[this.index.value]
|
|
149
|
+
|
|
150
|
+
if (!modal?.isOpen) {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Object.keys(this.listeners).forEach((event) => this.off(event))
|
|
155
|
+
|
|
156
|
+
modal.isOpen = false
|
|
157
|
+
this.onCloseCallback?.()
|
|
158
|
+
this.onCloseCallback = null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setOpen = (open: boolean): void => {
|
|
162
|
+
if (open) {
|
|
163
|
+
this.show()
|
|
164
|
+
} else {
|
|
165
|
+
this.close()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
afterLeave = (): void => {
|
|
170
|
+
const index = this.index.value
|
|
171
|
+
const modal = stack.value[index]
|
|
172
|
+
|
|
173
|
+
if (modal) {
|
|
174
|
+
if (modal.isOpen) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
modal.shouldRender = false
|
|
179
|
+
this.afterLeaveCallback?.()
|
|
180
|
+
this.afterLeaveCallback = null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (index !== 0) {
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
stack.value = []
|
|
188
|
+
|
|
189
|
+
// Clear the base URL before router.push() so its navigate event doesn't set it again.
|
|
190
|
+
const savedBaseUrl = baseUrl.value
|
|
191
|
+
baseUrl.value = null
|
|
192
|
+
closingToBaseUrlTarget = savedBaseUrl
|
|
193
|
+
|
|
194
|
+
// Only navigate mode changes the URL; pushing the same URL would re-render the whole page.
|
|
195
|
+
if (savedBaseUrl && typeof window !== 'undefined' && !sameUrlPath(savedBaseUrl, window.location.href)) {
|
|
196
|
+
router.push({
|
|
197
|
+
url: savedBaseUrl,
|
|
198
|
+
preserveScroll: true,
|
|
199
|
+
preserveState: true,
|
|
200
|
+
// Props are merged, so the modal data has to be cleared explicitly or the modal reopens.
|
|
201
|
+
props: (currentProps: Record<string, unknown>) => ({ ...currentProps, _inertiaui_modal: undefined }),
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
on = (event: string, callback: EventCallback): void => {
|
|
207
|
+
event = kebabCase(event)
|
|
208
|
+
this.listeners[event] = this.listeners[event] ?? []
|
|
209
|
+
this.listeners[event].push(callback)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
off = (event: string, callback?: EventCallback): void => {
|
|
213
|
+
event = kebabCase(event)
|
|
214
|
+
|
|
215
|
+
if (callback) {
|
|
216
|
+
this.listeners[event] = this.listeners[event]?.filter((listener) => listener !== callback) ?? []
|
|
217
|
+
} else {
|
|
218
|
+
delete this.listeners[event]
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
emit = (event: string, ...args: unknown[]): void => {
|
|
223
|
+
this.listeners[kebabCase(event)]?.forEach((callback) => callback(...args))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
registerEventListenersFromAttrs = (attrs: Record<string, unknown>): (() => void) => {
|
|
227
|
+
const unsubscribers = Object.keys(attrs)
|
|
228
|
+
.filter((key) => key.startsWith('on'))
|
|
229
|
+
.map((key) => {
|
|
230
|
+
const event = kebabCase(key).replace(/^on-/, '')
|
|
231
|
+
const callback = attrs[key] as EventCallback
|
|
232
|
+
this.on(event, callback)
|
|
233
|
+
return () => this.off(event, callback)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return () => unsubscribers.forEach((unsubscribe) => unsubscribe())
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
reload = async (options: ReloadOptions = {}): Promise<void> => {
|
|
240
|
+
if (!this.response.url) {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const keys = except(options.only ?? Object.keys(this.response.props), options.except ?? [])
|
|
245
|
+
const method = options.method ?? 'get'
|
|
246
|
+
const data = options.data ?? {}
|
|
247
|
+
|
|
248
|
+
options.onStart?.()
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const response = await http.getClient().request({
|
|
252
|
+
url: this.response.url,
|
|
253
|
+
method,
|
|
254
|
+
data: method === 'get' ? undefined : data,
|
|
255
|
+
params: method === 'get' ? data : undefined,
|
|
256
|
+
headers: {
|
|
257
|
+
...options.headers,
|
|
258
|
+
Accept: 'text/html, application/xhtml+xml',
|
|
259
|
+
'X-Inertia': 'true',
|
|
260
|
+
'X-Inertia-Partial-Component': this.response.component,
|
|
261
|
+
'X-Inertia-Version': this.response.version ?? '',
|
|
262
|
+
'X-Inertia-Partial-Data': keys.join(','),
|
|
263
|
+
'X-InertiaUI-Modal': generateId(),
|
|
264
|
+
'X-InertiaUI-Modal-Base-Url': baseUrl.value ?? '',
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
this.updateProps((parseResponseData(response.data) as ModalResponseData).props)
|
|
269
|
+
options.onSuccess?.(response)
|
|
270
|
+
} catch (error) {
|
|
271
|
+
options.onError?.(error)
|
|
272
|
+
} finally {
|
|
273
|
+
options.onFinish?.()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
updateProps = (props: Record<string, unknown>): void => {
|
|
278
|
+
Object.assign(this.props.value, props)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function registerLocalModal(name: string, callback: (modal: ModalInstance) => void): void {
|
|
283
|
+
localModals.value[name] = { name, callback }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function pushLocalModal(
|
|
287
|
+
name: string,
|
|
288
|
+
config?: ModalConfig | null,
|
|
289
|
+
onClose?: (() => void) | null,
|
|
290
|
+
afterLeave?: (() => void) | null,
|
|
291
|
+
props?: Record<string, unknown> | null,
|
|
292
|
+
): ModalInstance {
|
|
293
|
+
const localModal = localModals.value[name]
|
|
294
|
+
|
|
295
|
+
if (!localModal) {
|
|
296
|
+
throw new Error(`The local modal "${name}" has not been registered.`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const modal = push(null, { props: props ?? {} } as ModalResponseData, config, onClose, afterLeave)
|
|
300
|
+
modal.name = name
|
|
301
|
+
localModal.callback(modal)
|
|
302
|
+
|
|
303
|
+
return modal
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Removes a modal without waiting for its leave transition. */
|
|
307
|
+
export function discardModal(modal: ModalContext): void {
|
|
308
|
+
modal.close()
|
|
309
|
+
stack.value = stack.value.filter((entry) => entry.id !== modal.id)
|
|
310
|
+
modal.afterLeaveCallback?.()
|
|
311
|
+
modal.afterLeaveCallback = null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isValidModalResponse(data: unknown): data is ModalResponseData {
|
|
315
|
+
return typeof data === 'object' && data !== null && typeof (data as ModalResponseData).component === 'string'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function updateBrowserUrl(url: string | undefined, useBrowserHistory: boolean, modalData: ModalResponseData): void {
|
|
319
|
+
if (!url || !useBrowserHistory || typeof window === 'undefined') {
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
router.push({
|
|
324
|
+
url,
|
|
325
|
+
preserveScroll: true,
|
|
326
|
+
preserveState: true,
|
|
327
|
+
// Keep the modal in the history entry so back and forward can restore it.
|
|
328
|
+
props: (currentProps: Record<string, unknown>) => ({
|
|
329
|
+
...currentProps,
|
|
330
|
+
_inertiaui_modal: { ...modalData, baseUrl: baseUrl.value },
|
|
331
|
+
}),
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function pushFromResponseData(
|
|
336
|
+
responseData: ModalResponseData,
|
|
337
|
+
config: ModalConfig = {},
|
|
338
|
+
onClose: (() => void) | null = null,
|
|
339
|
+
onAfterLeave: (() => void) | null = null,
|
|
340
|
+
): Promise<ModalInstance> {
|
|
341
|
+
if (!isValidModalResponse(responseData)) {
|
|
342
|
+
return Promise.reject(
|
|
343
|
+
new Error(
|
|
344
|
+
'Invalid modal response. This usually happens when the server returns a redirect (e.g., due to session expiration). ' +
|
|
345
|
+
'Check if the user is still authenticated.',
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return router
|
|
351
|
+
.resolveComponent(responseData.component)
|
|
352
|
+
.then((component) => push(markRaw(component as Component), responseData, config, onClose, onAfterLeave))
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Inertia's progress bar only exists when the app enabled it; otherwise its methods throw.
|
|
356
|
+
function toggleProgress(start: boolean): void {
|
|
357
|
+
try {
|
|
358
|
+
if (start) {
|
|
359
|
+
progress.start()
|
|
360
|
+
} else {
|
|
361
|
+
progress.finish()
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// Progress bar disabled.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Async so that every failure, including a missing local modal, rejects instead of throwing.
|
|
369
|
+
async function visit(
|
|
370
|
+
href: string,
|
|
371
|
+
method: HttpMethod,
|
|
372
|
+
payload: RequestPayload = {},
|
|
373
|
+
headers: Record<string, string> = {},
|
|
374
|
+
config: ModalConfig = {},
|
|
375
|
+
onClose: (() => void) | null = null,
|
|
376
|
+
onAfterLeave: (() => void) | null = null,
|
|
377
|
+
queryStringArrayFormat: 'brackets' | 'indices' = 'brackets',
|
|
378
|
+
useBrowserHistory = false,
|
|
379
|
+
onStart: (() => void) | null = null,
|
|
380
|
+
onSuccess: ((response?: HttpResponse) => void) | null = null,
|
|
381
|
+
onError: ((...args: unknown[]) => void) | null = null,
|
|
382
|
+
props: Record<string, unknown> | null = null,
|
|
383
|
+
): Promise<ModalInstance> {
|
|
384
|
+
if (href.startsWith('#')) {
|
|
385
|
+
return pushLocalModal(href.substring(1), config, onClose, onAfterLeave, props)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const [url, data] = mergeDataIntoQueryString(method, href, payload, queryStringArrayFormat)
|
|
389
|
+
|
|
390
|
+
const open = (response: HttpResponse) => {
|
|
391
|
+
const responseData = parseResponseData(response.data) as ModalResponseData
|
|
392
|
+
onSuccess?.(response)
|
|
393
|
+
|
|
394
|
+
return pushFromResponseData(responseData, config, onClose, onAfterLeave).then((modal) => {
|
|
395
|
+
updateBrowserUrl(responseData.url, useBrowserHistory, responseData)
|
|
396
|
+
return modal
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Set before using a prefetched response too, or closing in navigate mode can't restore the URL.
|
|
401
|
+
if (stack.value.length === 0) {
|
|
402
|
+
baseUrl.value = typeof window !== 'undefined' ? window.location.href : ''
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const cachedResponse = prefetchCache.get(ResponseCache.key(method, url, data))
|
|
406
|
+
|
|
407
|
+
if (cachedResponse) {
|
|
408
|
+
return open(cachedResponse)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
onStart?.()
|
|
412
|
+
toggleProgress(true)
|
|
413
|
+
|
|
414
|
+
return http
|
|
415
|
+
.getClient()
|
|
416
|
+
.request({ url, method, data, headers: requestHeaders(headers) })
|
|
417
|
+
.then(open, (error: unknown) => {
|
|
418
|
+
onError?.(error)
|
|
419
|
+
throw error
|
|
420
|
+
})
|
|
421
|
+
.finally(() => toggleProgress(false))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Opens a modal for a URL, or a local modal for `#name`. */
|
|
425
|
+
export function visitModal(url: string, options: VisitOptions = {}): Promise<ModalInstance> {
|
|
426
|
+
return visit(
|
|
427
|
+
url,
|
|
428
|
+
options.method ?? 'get',
|
|
429
|
+
options.data ?? {},
|
|
430
|
+
options.headers ?? {},
|
|
431
|
+
options.config ?? {},
|
|
432
|
+
options.onClose,
|
|
433
|
+
options.onAfterLeave,
|
|
434
|
+
options.queryStringArrayFormat ?? 'brackets',
|
|
435
|
+
options.navigate ?? (getConfig('navigate') as boolean),
|
|
436
|
+
options.onStart,
|
|
437
|
+
options.onSuccess,
|
|
438
|
+
options.onError,
|
|
439
|
+
options.props ?? null,
|
|
440
|
+
).then((modal) => {
|
|
441
|
+
Object.entries(options.listeners ?? {}).forEach(([event, callback]) => modal.on(kebabCase(event), callback))
|
|
442
|
+
return modal
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function loadDeferredProps(modal: ModalInstance): void {
|
|
447
|
+
Object.values(modal.response.meta?.deferredProps ?? {}).forEach((only) => modal.reload({ only }))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function push(
|
|
451
|
+
component: Component | null,
|
|
452
|
+
response: ModalResponseData,
|
|
453
|
+
config?: ModalConfig | null,
|
|
454
|
+
onClose?: (() => void) | null,
|
|
455
|
+
afterLeave?: (() => void) | null,
|
|
456
|
+
): ModalInstance {
|
|
457
|
+
const modal = new ModalInstance(component, response, config, onClose, afterLeave)
|
|
458
|
+
stack.value.push(reactive(modal))
|
|
459
|
+
loadDeferredProps(modal)
|
|
460
|
+
|
|
461
|
+
nextTick(() => modal.show())
|
|
462
|
+
|
|
463
|
+
return modal
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export const modalPropNames = [
|
|
467
|
+
'closeButton',
|
|
468
|
+
'closeExplicitly',
|
|
469
|
+
'closeOnClickOutside',
|
|
470
|
+
'maxWidth',
|
|
471
|
+
'paddingClasses',
|
|
472
|
+
'panelClasses',
|
|
473
|
+
'position',
|
|
474
|
+
'slideover',
|
|
475
|
+
'fullscreen',
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
export interface ModalStack {
|
|
479
|
+
/** @deprecated A no-op kept for setup code written for Inertia 2. */
|
|
480
|
+
setComponentResolver: (resolver: ComponentResolver) => void
|
|
481
|
+
getBaseUrl: () => string | null
|
|
482
|
+
setBaseUrl: (url: string | null) => void
|
|
483
|
+
isClosingToBaseUrl: (pageUrl: string) => boolean
|
|
484
|
+
clearClosingToBaseUrl: () => void
|
|
485
|
+
stack: Readonly<Ref<readonly ModalContext[]>>
|
|
486
|
+
push: typeof push
|
|
487
|
+
pushFromResponseData: typeof pushFromResponseData
|
|
488
|
+
closeAll: (force?: boolean) => void
|
|
489
|
+
reset: () => void
|
|
490
|
+
visit: typeof visit
|
|
491
|
+
registerLocalModal: typeof registerLocalModal
|
|
492
|
+
removeLocalModal: (name: string) => boolean
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function useModalStack(): ModalStack {
|
|
496
|
+
return {
|
|
497
|
+
setComponentResolver: () => {},
|
|
498
|
+
getBaseUrl: () => baseUrl.value,
|
|
499
|
+
setBaseUrl: (url) => (baseUrl.value = url),
|
|
500
|
+
isClosingToBaseUrl: (pageUrl) => {
|
|
501
|
+
if (!closingToBaseUrlTarget) {
|
|
502
|
+
return false
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Compare paths only, ignoring query strings.
|
|
506
|
+
return new URL(closingToBaseUrlTarget, 'http://x').pathname === new URL(pageUrl, 'http://x').pathname
|
|
507
|
+
},
|
|
508
|
+
clearClosingToBaseUrl: () => (closingToBaseUrlTarget = null),
|
|
509
|
+
stack: readonly(stack) as Readonly<Ref<readonly ModalContext[]>>,
|
|
510
|
+
push,
|
|
511
|
+
pushFromResponseData,
|
|
512
|
+
closeAll: (force = false) => {
|
|
513
|
+
if (force) {
|
|
514
|
+
// Removes every modal at once, without leave transitions.
|
|
515
|
+
stack.value = []
|
|
516
|
+
} else {
|
|
517
|
+
;[...stack.value].toReversed().forEach((modal) => modal.close())
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
reset: () => (stack.value = []),
|
|
521
|
+
visit,
|
|
522
|
+
registerLocalModal,
|
|
523
|
+
removeLocalModal: (name) => delete localModals.value[name],
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { prefersReducedMotion } from '@inertiaui/vanilla'
|
|
2
|
+
import { Transition } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A `v-dialog` transition that moves the panel in from off-screen, e.g. `translateX(100%)`.
|
|
6
|
+
* Vuetify only ships short slide transitions, so this uses the Web Animations API.
|
|
7
|
+
*/
|
|
8
|
+
export function slideTransition(hiddenTransform: string) {
|
|
9
|
+
// Like Vuetify's own transitions, fade instead of moving when motion is reduced.
|
|
10
|
+
const hidden = () => (prefersReducedMotion() ? { opacity: 0 } : { transform: hiddenTransform })
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
component: Transition,
|
|
14
|
+
css: false,
|
|
15
|
+
onEnter: (el: Element, done: () => void) => animate(el, [hidden(), {}], 225, 'cubic-bezier(0, 0, 0.2, 1)', done),
|
|
16
|
+
onLeave: (el: Element, done: () => void) => animate(el, [{}, hidden()], 195, 'cubic-bezier(0.4, 0, 1, 1)', done),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function animate(el: Element, keyframes: Keyframe[], duration: number, easing: string, done: () => void) {
|
|
21
|
+
if (!el.animate) {
|
|
22
|
+
return done()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Vue treats an argument passed to done() as a cancellation.
|
|
26
|
+
el.animate(keyframes, { duration, easing }).finished.then(
|
|
27
|
+
() => done(),
|
|
28
|
+
() => done(),
|
|
29
|
+
)
|
|
30
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { HttpResponse, Method, RequestPayload } from '@inertiajs/core'
|
|
2
|
+
import type { UnwrapNestedRefs } from 'vue'
|
|
3
|
+
import type { ModalInstance } from './modalStack'
|
|
4
|
+
|
|
5
|
+
export type MaxWidth = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
|
|
6
|
+
export type ModalPosition = 'top' | 'center' | 'bottom'
|
|
7
|
+
export type SlideoverPosition = 'left' | 'right' | 'bottom'
|
|
8
|
+
export type Breakpoint = 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
|
|
9
|
+
export type HttpMethod = Method
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `true`/`false`, `'mobile'` for Vuetify's `display.mobile`, or fullscreen
|
|
13
|
+
* below a Vuetify breakpoint or pixel width.
|
|
14
|
+
*/
|
|
15
|
+
export type Fullscreen = boolean | 'mobile' | Breakpoint | number
|
|
16
|
+
|
|
17
|
+
/** Runs before a user-initiated close; return `false` to keep the modal open. */
|
|
18
|
+
export type BeforeClose = () => boolean | void | Promise<boolean | void>
|
|
19
|
+
|
|
20
|
+
export interface ModalTypeConfig {
|
|
21
|
+
closeButton: boolean
|
|
22
|
+
closeExplicitly: boolean
|
|
23
|
+
closeOnClickOutside: boolean
|
|
24
|
+
maxWidth: MaxWidth | (string & {})
|
|
25
|
+
paddingClasses: string | false
|
|
26
|
+
panelClasses: string | false
|
|
27
|
+
position: ModalPosition | SlideoverPosition
|
|
28
|
+
fullscreen: Fullscreen
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The configuration of a single modal, as resolved from its props, the visit, and the defaults. */
|
|
32
|
+
export type ModalConfig = Partial<ModalTypeConfig & { slideover: boolean }>
|
|
33
|
+
|
|
34
|
+
/** What `putConfig()` accepts; omitted keys get their defaults. */
|
|
35
|
+
export interface GlobalModalConfig {
|
|
36
|
+
type?: 'modal' | 'slideover'
|
|
37
|
+
navigate?: boolean
|
|
38
|
+
appElement?: string | null
|
|
39
|
+
modal?: Partial<ModalTypeConfig>
|
|
40
|
+
slideover?: Partial<ModalTypeConfig>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The `_inertiaui_modal` payload the `inertiaui/modal` Laravel package sends. */
|
|
44
|
+
export interface ModalResponseData {
|
|
45
|
+
id?: string
|
|
46
|
+
component: string
|
|
47
|
+
props: Record<string, unknown>
|
|
48
|
+
url?: string
|
|
49
|
+
version?: string
|
|
50
|
+
meta?: {
|
|
51
|
+
deferredProps?: Record<string, string[]>
|
|
52
|
+
}
|
|
53
|
+
baseUrl?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ReloadOptions {
|
|
57
|
+
only?: string[]
|
|
58
|
+
except?: string[]
|
|
59
|
+
method?: HttpMethod
|
|
60
|
+
data?: Record<string, unknown>
|
|
61
|
+
headers?: Record<string, string>
|
|
62
|
+
onStart?: () => void
|
|
63
|
+
onSuccess?: (response: HttpResponse) => void
|
|
64
|
+
onError?: (error: unknown) => void
|
|
65
|
+
onFinish?: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VisitOptions {
|
|
69
|
+
method?: HttpMethod
|
|
70
|
+
data?: RequestPayload
|
|
71
|
+
headers?: Record<string, string>
|
|
72
|
+
config?: ModalConfig
|
|
73
|
+
onClose?: () => void
|
|
74
|
+
onAfterLeave?: () => void
|
|
75
|
+
queryStringArrayFormat?: 'brackets' | 'indices'
|
|
76
|
+
navigate?: boolean
|
|
77
|
+
onStart?: () => void
|
|
78
|
+
onSuccess?: (response?: HttpResponse) => void
|
|
79
|
+
onError?: (...args: unknown[]) => void
|
|
80
|
+
listeners?: Record<string, (...args: unknown[]) => void>
|
|
81
|
+
/** Props for local modals. */
|
|
82
|
+
props?: Record<string, unknown>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type PrefetchOption = boolean | 'hover' | 'click' | 'mount' | Array<'hover' | 'click' | 'mount'>
|
|
86
|
+
|
|
87
|
+
export interface PrefetchOptions {
|
|
88
|
+
method?: HttpMethod
|
|
89
|
+
data?: RequestPayload
|
|
90
|
+
headers?: Record<string, string>
|
|
91
|
+
queryStringArrayFormat?: 'brackets' | 'indices'
|
|
92
|
+
cacheFor?: number
|
|
93
|
+
onPrefetching?: () => void
|
|
94
|
+
onPrefetched?: () => void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A modal instance as components see it, with its computed refs unwrapped. */
|
|
98
|
+
export type ModalContext = UnwrapNestedRefs<ModalInstance>
|
|
99
|
+
|
|
100
|
+
export interface HeadlessModalSlotProps extends Record<string, unknown> {
|
|
101
|
+
id: string
|
|
102
|
+
index: number
|
|
103
|
+
isOpen: boolean
|
|
104
|
+
onTopOfStack: boolean
|
|
105
|
+
shouldRender: boolean
|
|
106
|
+
config: ModalConfig
|
|
107
|
+
modalContext: ModalContext
|
|
108
|
+
afterLeave: () => void
|
|
109
|
+
/** Closes immediately, skipping `before-close`. */
|
|
110
|
+
close: () => void
|
|
111
|
+
emit: (event: string, ...args: unknown[]) => void
|
|
112
|
+
getChildModal: () => ModalContext | null
|
|
113
|
+
getParentModal: () => ModalContext | null
|
|
114
|
+
reload: (options?: ReloadOptions) => Promise<void>
|
|
115
|
+
setOpen: (open: boolean) => void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ModalSlotProps extends HeadlessModalSlotProps {
|
|
119
|
+
/** Closes like the close button does, so `before-close` can veto it. */
|
|
120
|
+
requestClose: () => Promise<void>
|
|
121
|
+
}
|
package/src/useModal.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { inject, toValue, type MaybeRef } from 'vue'
|
|
2
|
+
import type { ModalContext } from './types'
|
|
3
|
+
|
|
4
|
+
/** The modal the calling component is rendered in, or `null` outside a modal. */
|
|
5
|
+
export default function useModal(): ModalContext | null {
|
|
6
|
+
return toValue(inject<MaybeRef<ModalContext | null>>('modalContext', null))
|
|
7
|
+
}
|