@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.
@@ -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
+ }
@@ -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
+ }