@inertia-vuetify/modal 0.0.0-stage → 3.0.0-rc.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,238 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { markAriaHidden } from '@inertiaui/vanilla'
|
|
3
|
+
import { computed, onBeforeUnmount, onMounted, provide, ref, toRef, useId, useTemplateRef, watch } from 'vue'
|
|
4
|
+
import { useDefaults, useDisplay, useRtl } from 'vuetify'
|
|
5
|
+
import { VCard, VCardActions, VCardItem } from 'vuetify/components/VCard'
|
|
6
|
+
import { VDialog } from 'vuetify/components/VDialog'
|
|
7
|
+
import CloseButton from './CloseButton.vue'
|
|
8
|
+
import { getConfig, getDialogLocation, getMaxWidth } from './config'
|
|
9
|
+
import { requestCloseKey } from './keys'
|
|
10
|
+
import { slideTransition } from './transitions'
|
|
11
|
+
import type { BeforeClose, ModalConfig, ModalContext } from './types'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
modalContext: ModalContext
|
|
15
|
+
config: ModalConfig
|
|
16
|
+
title?: string
|
|
17
|
+
beforeClose?: BeforeClose
|
|
18
|
+
dialogProps?: Record<string, unknown>
|
|
19
|
+
cardProps?: Record<string, unknown>
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{ 'after-leave': [] }>()
|
|
23
|
+
|
|
24
|
+
const slots = defineSlots<{
|
|
25
|
+
default(props: { requestClose: () => Promise<void> }): unknown
|
|
26
|
+
title?(props: { requestClose: () => Promise<void> }): unknown
|
|
27
|
+
actions?(props: { requestClose: () => Promise<void> }): unknown
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
// Local modals have no ModalRenderer, so this is what lets their content call useModal().
|
|
31
|
+
provide('modalContext', toRef(props, 'modalContext'))
|
|
32
|
+
provide(requestCloseKey, requestClose)
|
|
33
|
+
|
|
34
|
+
// Scopes `createVuetify({ defaults: { InertiaModal: { VDialog: { ... } } } })` to these modals.
|
|
35
|
+
useDefaults({}, 'InertiaModal')
|
|
36
|
+
|
|
37
|
+
const { isRtl } = useRtl()
|
|
38
|
+
const display = useDisplay()
|
|
39
|
+
const titleId = useId()
|
|
40
|
+
const entered = ref(false)
|
|
41
|
+
const dialog = useTemplateRef<InstanceType<typeof VDialog>>('dialog')
|
|
42
|
+
|
|
43
|
+
// Read before the dialog takes focus: the transition grows from it and focus returns to it.
|
|
44
|
+
const trigger =
|
|
45
|
+
typeof document !== 'undefined' && document.activeElement !== document.body ? (document.activeElement as HTMLElement | null) : null
|
|
46
|
+
|
|
47
|
+
const type = computed(() => (props.config.slideover ? 'slideover' : 'modal'))
|
|
48
|
+
const hasTitle = computed(() => !!props.title || !!slots.title)
|
|
49
|
+
const showCloseButton = computed(() => props.config.closeButton !== false)
|
|
50
|
+
|
|
51
|
+
const fullscreen = computed(() => {
|
|
52
|
+
const value = props.config.fullscreen
|
|
53
|
+
|
|
54
|
+
if (value === 'mobile') {
|
|
55
|
+
return display.mobile.value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof value === 'string') {
|
|
59
|
+
return display.width.value < display.thresholds.value[value]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return typeof value === 'number' ? display.width.value < value : !!value
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const layout = computed(() => {
|
|
66
|
+
const { maxWidth, position, slideover } = props.config
|
|
67
|
+
const contentClass = [`im-${type.value}-dialog`, { [`im-slideover-${position}`]: slideover && position !== 'right' }]
|
|
68
|
+
|
|
69
|
+
if (!slideover) {
|
|
70
|
+
return fullscreen.value
|
|
71
|
+
? { fullscreen: true, transition: 'dialog-bottom-transition', contentClass }
|
|
72
|
+
: { location: getDialogLocation(position), maxWidth: getMaxWidth(maxWidth), target: trigger ?? undefined, contentClass }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (position === 'bottom') {
|
|
76
|
+
return {
|
|
77
|
+
location: 'bottom' as const,
|
|
78
|
+
width: '100%',
|
|
79
|
+
maxWidth: getMaxWidth(maxWidth),
|
|
80
|
+
fullscreen: fullscreen.value,
|
|
81
|
+
contentClass,
|
|
82
|
+
transition: slideTransition('translateY(100%)'),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Slideover sides are physical like upstream, while Vuetify locations follow the text direction.
|
|
87
|
+
const left = position === 'left'
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
location: left !== isRtl.value ? ('left' as const) : ('right' as const),
|
|
91
|
+
width: '100%',
|
|
92
|
+
maxWidth: getMaxWidth(maxWidth),
|
|
93
|
+
height: '100%',
|
|
94
|
+
maxHeight: '100%',
|
|
95
|
+
fullscreen: fullscreen.value,
|
|
96
|
+
contentClass,
|
|
97
|
+
transition: slideTransition(`translateX(${left ? '-100%' : '100%'})`),
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Only the top modal dims the page; leaving it unset keeps Vuetify defaults and dialogProps in charge.
|
|
102
|
+
const scrim = computed(() => (props.modalContext.onTopOfStack ? (props.dialogProps?.scrim as string | boolean | undefined) : false))
|
|
103
|
+
|
|
104
|
+
const contentStyle = computed(() => ({
|
|
105
|
+
margin: props.config.slideover && !fullscreen.value ? 0 : undefined,
|
|
106
|
+
filter: props.modalContext.onTopOfStack ? undefined : 'blur(4px)',
|
|
107
|
+
transition: 'filter 0.3s',
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
let closing = false
|
|
111
|
+
|
|
112
|
+
async function requestClose() {
|
|
113
|
+
if (closing) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
closing = true
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if ((await props.beforeClose?.()) === false) {
|
|
121
|
+
dialog.value?.animateClick()
|
|
122
|
+
} else {
|
|
123
|
+
props.modalContext.close()
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
closing = false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// How to handle the close v-dialog emits right after click:outside.
|
|
131
|
+
let outsideClick: 'close' | 'refuse' | 'ignore' = 'close'
|
|
132
|
+
|
|
133
|
+
function onClickOutside() {
|
|
134
|
+
if (!props.modalContext.onTopOfStack) {
|
|
135
|
+
// Stacked modals render beside this one, so Vuetify reports clicks in them as outside clicks.
|
|
136
|
+
outsideClick = 'ignore'
|
|
137
|
+
} else if (!props.config.closeExplicitly && props.config.closeOnClickOutside === false) {
|
|
138
|
+
// v-dialog has no separate outside-click switch.
|
|
139
|
+
outsideClick = 'refuse'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onUpdateModelValue(open: boolean) {
|
|
144
|
+
const action = outsideClick
|
|
145
|
+
outsideClick = 'close'
|
|
146
|
+
|
|
147
|
+
if (open || action === 'ignore') {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (action === 'refuse') {
|
|
152
|
+
dialog.value?.animateClick()
|
|
153
|
+
} else {
|
|
154
|
+
requestClose()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function onAfterLeave() {
|
|
159
|
+
emit('after-leave')
|
|
160
|
+
props.modalContext.afterLeave()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let restoreAppElement: (() => void) | null = null
|
|
164
|
+
|
|
165
|
+
function onOpenChange(open: boolean) {
|
|
166
|
+
const appElement = getConfig('appElement')
|
|
167
|
+
|
|
168
|
+
if (open) {
|
|
169
|
+
if (typeof appElement === 'string') {
|
|
170
|
+
restoreAppElement ??= markAriaHidden(appElement)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
entered.value = false
|
|
177
|
+
restoreAppElement?.()
|
|
178
|
+
restoreAppElement = null
|
|
179
|
+
trigger?.focus({ preventScroll: true })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
onMounted(() => watch(() => props.modalContext.isOpen, onOpenChange, { immediate: true, flush: 'post' }))
|
|
183
|
+
onBeforeUnmount(() => onOpenChange(false))
|
|
184
|
+
</script>
|
|
185
|
+
|
|
186
|
+
<template>
|
|
187
|
+
<VDialog
|
|
188
|
+
ref="dialog"
|
|
189
|
+
v-bind="{ ...layout, ...dialogProps }"
|
|
190
|
+
:model-value="modalContext.isOpen"
|
|
191
|
+
:persistent="(config.closeExplicitly && modalContext.onTopOfStack) || undefined"
|
|
192
|
+
:scrim
|
|
193
|
+
:close-on-back="false"
|
|
194
|
+
:content-props="{ style: contentStyle }"
|
|
195
|
+
:aria-labelledby="hasTitle ? titleId : undefined"
|
|
196
|
+
:aria-hidden="!modalContext.onTopOfStack || undefined"
|
|
197
|
+
:data-inertiaui-modal-id="modalContext.id"
|
|
198
|
+
:data-inertiaui-modal-index="modalContext.index"
|
|
199
|
+
@click:outside="onClickOutside"
|
|
200
|
+
@update:model-value="onUpdateModelValue"
|
|
201
|
+
@after-enter="entered = true"
|
|
202
|
+
@after-leave="onAfterLeave"
|
|
203
|
+
>
|
|
204
|
+
<VCard
|
|
205
|
+
:class="`im-${type}-wrapper`"
|
|
206
|
+
:rounded="config.slideover ? 0 : undefined"
|
|
207
|
+
:data-inertiaui-modal-entered="entered"
|
|
208
|
+
v-bind="cardProps"
|
|
209
|
+
>
|
|
210
|
+
<VCardItem v-if="hasTitle" :class="`im-${type}-title`">
|
|
211
|
+
<template #title>
|
|
212
|
+
<span :id="titleId">
|
|
213
|
+
<slot name="title" :request-close="requestClose">{{ title }}</slot>
|
|
214
|
+
</span>
|
|
215
|
+
</template>
|
|
216
|
+
|
|
217
|
+
<template v-if="showCloseButton" #append>
|
|
218
|
+
<CloseButton />
|
|
219
|
+
</template>
|
|
220
|
+
</VCardItem>
|
|
221
|
+
|
|
222
|
+
<div v-else-if="showCloseButton" class="position-absolute top-0 pa-2" style="inset-inline-end: 0; z-index: 1">
|
|
223
|
+
<CloseButton />
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div
|
|
227
|
+
:class="[`im-${type}-content`, config.paddingClasses, config.panelClasses]"
|
|
228
|
+
:style="{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', paddingTop: hasTitle ? 0 : undefined }"
|
|
229
|
+
>
|
|
230
|
+
<slot :request-close="requestClose" />
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<VCardActions v-if="slots.actions" :class="`im-${type}-actions`">
|
|
234
|
+
<slot name="actions" :request-close="requestClose" />
|
|
235
|
+
</VCardActions>
|
|
236
|
+
</VCard>
|
|
237
|
+
</VDialog>
|
|
238
|
+
</template>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { RequestPayload } from '@inertiajs/core'
|
|
3
|
+
import { computed, onBeforeUnmount, onMounted, provide, reactive, ref, useAttrs, watch, type Component, type PropType } from 'vue'
|
|
4
|
+
import { getConfig } from './config'
|
|
5
|
+
import { only, rejectNullValues } from './helpers'
|
|
6
|
+
import { modalPropNames, prefetch as prefetchModal, useModalStack } from './modalStack'
|
|
7
|
+
import type { Fullscreen, HttpMethod, MaxWidth, ModalContext, ModalPosition, PrefetchOption, SlideoverPosition } from './types'
|
|
8
|
+
|
|
9
|
+
defineOptions({ inheritAttrs: false })
|
|
10
|
+
|
|
11
|
+
// Modal config props default to null so unset ones fall back to the Modal and the global config.
|
|
12
|
+
const props = defineProps({
|
|
13
|
+
href: { type: String, required: true },
|
|
14
|
+
method: { type: String as PropType<HttpMethod>, default: 'get' },
|
|
15
|
+
data: { type: Object as PropType<RequestPayload>, default: () => ({}) },
|
|
16
|
+
as: { type: [String, Object] as PropType<string | Component>, default: 'a' },
|
|
17
|
+
headers: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
|
|
18
|
+
queryStringArrayFormat: { type: String as PropType<'brackets' | 'indices'>, default: 'brackets' },
|
|
19
|
+
navigate: { type: Boolean, default: null },
|
|
20
|
+
prefetch: { type: [Boolean, String, Array] as PropType<PrefetchOption>, default: false },
|
|
21
|
+
cacheFor: { type: Number, default: 30000 },
|
|
22
|
+
closeButton: { type: Boolean, default: null },
|
|
23
|
+
closeExplicitly: { type: Boolean, default: null },
|
|
24
|
+
closeOnClickOutside: { type: Boolean, default: null },
|
|
25
|
+
maxWidth: { type: String as PropType<MaxWidth>, default: null },
|
|
26
|
+
paddingClasses: { type: [Boolean, String] as PropType<string | false>, default: null },
|
|
27
|
+
panelClasses: { type: [Boolean, String] as PropType<string | false>, default: null },
|
|
28
|
+
position: { type: String as PropType<ModalPosition | SlideoverPosition>, default: null },
|
|
29
|
+
slideover: { type: Boolean, default: null },
|
|
30
|
+
fullscreen: { type: [Boolean, String, Number] as PropType<Fullscreen>, default: null },
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
'after-leave': []
|
|
35
|
+
blur: []
|
|
36
|
+
close: []
|
|
37
|
+
error: [error: unknown]
|
|
38
|
+
focus: []
|
|
39
|
+
start: []
|
|
40
|
+
success: []
|
|
41
|
+
prefetching: []
|
|
42
|
+
prefetched: []
|
|
43
|
+
}>()
|
|
44
|
+
|
|
45
|
+
defineSlots<{ default?(props: { loading: boolean }): unknown }>()
|
|
46
|
+
|
|
47
|
+
const loading = ref(false)
|
|
48
|
+
const modalStack = useModalStack()
|
|
49
|
+
const modalContext = ref<ModalContext | null>(null)
|
|
50
|
+
|
|
51
|
+
provide('modalContext', modalContext)
|
|
52
|
+
|
|
53
|
+
const shouldNavigate = computed(() => props.navigate ?? (getConfig('navigate') as boolean))
|
|
54
|
+
|
|
55
|
+
const prefetchModes = computed(() => {
|
|
56
|
+
if (props.prefetch === true) {
|
|
57
|
+
return ['hover']
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return props.prefetch === false ? [] : [props.prefetch].flat()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function doPrefetch() {
|
|
64
|
+
prefetchModal(props.href, {
|
|
65
|
+
method: props.method,
|
|
66
|
+
data: props.data,
|
|
67
|
+
headers: props.headers,
|
|
68
|
+
queryStringArrayFormat: props.queryStringArrayFormat,
|
|
69
|
+
cacheFor: props.cacheFor,
|
|
70
|
+
onPrefetching: () => emit('prefetching'),
|
|
71
|
+
onPrefetched: () => emit('prefetched'),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let hoverTimeout: ReturnType<typeof setTimeout> | undefined
|
|
76
|
+
|
|
77
|
+
function onMouseenter() {
|
|
78
|
+
if (prefetchModes.value.includes('hover')) {
|
|
79
|
+
// A short delay skips links the pointer only passes over.
|
|
80
|
+
hoverTimeout = setTimeout(doPrefetch, 75)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onMouseleave() {
|
|
85
|
+
clearTimeout(hoverTimeout)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onMousedown(event: MouseEvent) {
|
|
89
|
+
if (prefetchModes.value.includes('click') && event.button === 0) {
|
|
90
|
+
doPrefetch()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onMounted(() => {
|
|
95
|
+
if (prefetchModes.value.includes('mount')) {
|
|
96
|
+
doPrefetch()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
let isBlurred = false
|
|
101
|
+
|
|
102
|
+
watch(
|
|
103
|
+
() => modalContext.value?.onTopOfStack,
|
|
104
|
+
(onTopOfStack) => {
|
|
105
|
+
if (!modalContext.value) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (onTopOfStack && isBlurred) {
|
|
110
|
+
emit('focus')
|
|
111
|
+
} else if (!onTopOfStack) {
|
|
112
|
+
emit('blur')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
isBlurred = !onTopOfStack
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const attrs = useAttrs()
|
|
120
|
+
let unsubscribeEventListeners: (() => void) | undefined
|
|
121
|
+
|
|
122
|
+
onBeforeUnmount(() => {
|
|
123
|
+
unsubscribeEventListeners?.()
|
|
124
|
+
clearTimeout(hoverTimeout)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
watch(modalContext, (value, oldValue) => {
|
|
128
|
+
if (value && !oldValue) {
|
|
129
|
+
unsubscribeEventListeners = value.registerEventListenersFromAttrs(attrs)
|
|
130
|
+
emit('success')
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
function onAfterLeave() {
|
|
135
|
+
modalContext.value = null
|
|
136
|
+
emit('after-leave')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function handle(event: MouseEvent) {
|
|
140
|
+
// Leave modified clicks on links to the browser, e.g. to open a new tab.
|
|
141
|
+
if ((event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) && event.currentTarget instanceof HTMLAnchorElement) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
|
|
147
|
+
if (loading.value) {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!props.href.startsWith('#')) {
|
|
152
|
+
loading.value = true
|
|
153
|
+
emit('start')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
modalStack
|
|
157
|
+
.visit(
|
|
158
|
+
props.href,
|
|
159
|
+
props.method,
|
|
160
|
+
props.data,
|
|
161
|
+
props.headers,
|
|
162
|
+
rejectNullValues(only(props, modalPropNames)),
|
|
163
|
+
() => emit('close'),
|
|
164
|
+
onAfterLeave,
|
|
165
|
+
props.queryStringArrayFormat,
|
|
166
|
+
shouldNavigate.value,
|
|
167
|
+
)
|
|
168
|
+
.then((context) => (modalContext.value = reactive(context)))
|
|
169
|
+
.catch((error: unknown) => {
|
|
170
|
+
console.error(error)
|
|
171
|
+
emit('error', error)
|
|
172
|
+
})
|
|
173
|
+
.finally(() => (loading.value = false))
|
|
174
|
+
}
|
|
175
|
+
</script>
|
|
176
|
+
|
|
177
|
+
<template>
|
|
178
|
+
<component
|
|
179
|
+
:is="as"
|
|
180
|
+
v-bind="$attrs"
|
|
181
|
+
:href
|
|
182
|
+
@click="handle"
|
|
183
|
+
@mouseenter="onMouseenter"
|
|
184
|
+
@mouseleave="onMouseleave"
|
|
185
|
+
@mousedown="onMousedown"
|
|
186
|
+
>
|
|
187
|
+
<slot :loading />
|
|
188
|
+
</component>
|
|
189
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, provide } from 'vue'
|
|
3
|
+
import { only } from './helpers'
|
|
4
|
+
import { useModalStack } from './modalStack'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ index: number }>()
|
|
7
|
+
|
|
8
|
+
const modalStack = useModalStack()
|
|
9
|
+
const modalContext = computed(() => modalStack.stack.value[props.index])
|
|
10
|
+
|
|
11
|
+
provide('modalContext', modalContext)
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<component
|
|
16
|
+
:is="modalContext.component"
|
|
17
|
+
v-if="modalContext?.component"
|
|
18
|
+
v-bind="only(modalContext.props, modalContext.getComponentPropKeys(), true)"
|
|
19
|
+
@modal-event="(event: string, ...args: unknown[]) => modalContext.emit(event, ...args)"
|
|
20
|
+
/>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { http, router, usePage } from '@inertiajs/vue3'
|
|
3
|
+
import { onUnmounted, watch } from 'vue'
|
|
4
|
+
import { sameUrlPath } from './helpers'
|
|
5
|
+
import ModalRenderer from './ModalRenderer.vue'
|
|
6
|
+
import { useModalStack } from './modalStack'
|
|
7
|
+
import type { ModalResponseData } from './types'
|
|
8
|
+
|
|
9
|
+
const modalStack = useModalStack()
|
|
10
|
+
const $page = usePage()
|
|
11
|
+
|
|
12
|
+
let isNavigating = false
|
|
13
|
+
const pendingModalKeys = new Set<string>()
|
|
14
|
+
// Non-GET visits in flight, by id, and whether they should close the modals when they finish.
|
|
15
|
+
const submissions = new Map<string, boolean>()
|
|
16
|
+
|
|
17
|
+
// Deduplicates modal data that has no id.
|
|
18
|
+
const getModalKey = (modalData: ModalResponseData) => modalData.id || `${modalData.component}:${modalData.url}`
|
|
19
|
+
const modalData = (props?: Record<string, unknown>) => props?._inertiaui_modal as ModalResponseData | undefined
|
|
20
|
+
|
|
21
|
+
onUnmounted(
|
|
22
|
+
router.on('start', ({ detail: { visit } }) => {
|
|
23
|
+
isNavigating = true
|
|
24
|
+
|
|
25
|
+
if (visit.method !== 'get') {
|
|
26
|
+
submissions.set(visit.id, false)
|
|
27
|
+
}
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Inertia 3 swaps in a same-URL response, like a redirect back after submitting a form, without
|
|
32
|
+
// a navigate event. Close the modals once such a submission lands on a page without one; waiting
|
|
33
|
+
// for finish lets the submission's onSuccess still emit events to the modals.
|
|
34
|
+
onUnmounted(
|
|
35
|
+
router.on('success', ({ detail: { page, visitId } }) => {
|
|
36
|
+
if (visitId && submissions.has(visitId) && !modalData(page.props)) {
|
|
37
|
+
submissions.set(visitId, true)
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
onUnmounted(
|
|
43
|
+
router.on('finish', ({ detail: { visit } }) => {
|
|
44
|
+
isNavigating = false
|
|
45
|
+
|
|
46
|
+
if (submissions.get(visit.id)) {
|
|
47
|
+
modalStack.closeAll()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
submissions.delete(visit.id)
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
onUnmounted(
|
|
55
|
+
router.on('navigate', ({ detail: { page } }) => {
|
|
56
|
+
const modalOnBase = modalData(page.props)
|
|
57
|
+
|
|
58
|
+
// router.push() in afterLeave() can fire navigate before the modal data is cleared; don't reopen the modal then.
|
|
59
|
+
if (modalStack.isClosingToBaseUrl(page.url)) {
|
|
60
|
+
modalStack.clearClosingToBaseUrl()
|
|
61
|
+
modalStack.closeAll(true)
|
|
62
|
+
modalStack.setBaseUrl(null)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// No modal on this page, or history moved to a URL that isn't the modal's.
|
|
67
|
+
if (!modalOnBase || !sameUrlPath(page.url, modalOnBase.url)) {
|
|
68
|
+
modalStack.closeAll(true)
|
|
69
|
+
modalStack.setBaseUrl(null)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const modalKey = getModalKey(modalOnBase)
|
|
74
|
+
const stack = modalStack.stack.value
|
|
75
|
+
|
|
76
|
+
// Skip modals that are being pushed, already open, or reopened with new props (handled by the watcher below).
|
|
77
|
+
if (
|
|
78
|
+
pendingModalKeys.has(modalKey) ||
|
|
79
|
+
(modalOnBase.id && stack.some((modal) => modal.id === modalOnBase.id)) ||
|
|
80
|
+
stack.some((modal) => modal.response.component === modalOnBase.component && sameUrlPath(modal.response.url, modalOnBase.url))
|
|
81
|
+
) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
modalStack.setBaseUrl(modalOnBase.baseUrl ?? null)
|
|
86
|
+
pendingModalKeys.add(modalKey)
|
|
87
|
+
|
|
88
|
+
modalStack
|
|
89
|
+
.pushFromResponseData(modalOnBase, {}, () => {
|
|
90
|
+
if (!modalOnBase.baseUrl) {
|
|
91
|
+
console.error('No base url in modal response data so cannot navigate back')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clear it first so the base page request (and its deferred props) isn't sent as a modal request.
|
|
96
|
+
modalStack.setBaseUrl(null)
|
|
97
|
+
|
|
98
|
+
if (!isNavigating && typeof window !== 'undefined' && window.location.href !== modalOnBase.baseUrl) {
|
|
99
|
+
router.visit(modalOnBase.baseUrl, { preserveScroll: true, preserveState: true })
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
.finally(() => pendingModalKeys.delete(modalKey))
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Tells the server which page a modal opens on top of, so back() can redirect there. Inertia 3 loads
|
|
107
|
+
// deferred props before the navigate event, so the page props are the fallback.
|
|
108
|
+
// It is registered in setup to be in place for that, and only in the browser: during SSR,
|
|
109
|
+
// onUnmounted never runs and every render would leak a handler.
|
|
110
|
+
const removeRequestInterceptor =
|
|
111
|
+
typeof window === 'undefined'
|
|
112
|
+
? undefined
|
|
113
|
+
: http.onRequest((config) => {
|
|
114
|
+
const baseUrl = modalStack.getBaseUrl() ?? modalData($page.props)?.baseUrl
|
|
115
|
+
|
|
116
|
+
if (baseUrl) {
|
|
117
|
+
config.headers = { ...config.headers, 'X-InertiaUI-Modal-Base-Url': baseUrl }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return config
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
onUnmounted(() => removeRequestInterceptor?.())
|
|
124
|
+
|
|
125
|
+
watch(
|
|
126
|
+
() => modalData($page.props),
|
|
127
|
+
(newModal, previousModal) => {
|
|
128
|
+
if (!newModal) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isSameModal = (modal: { component?: string; url?: string }) =>
|
|
133
|
+
modal.component === newModal.component && sameUrlPath(modal.url, newModal.url)
|
|
134
|
+
|
|
135
|
+
// The server re-rendered an open modal, e.g. after a redirect back to it: refresh its props.
|
|
136
|
+
if (previousModal && isSameModal(previousModal)) {
|
|
137
|
+
modalStack.stack.value[0]?.updateProps(newModal.props ?? {})
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!previousModal) {
|
|
142
|
+
modalStack.stack.value.find((modal) => isSameModal(modal.response))?.updateProps(newModal.props ?? {})
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<template>
|
|
149
|
+
<slot />
|
|
150
|
+
|
|
151
|
+
<ModalRenderer v-if="modalStack.stack.value.length" :index="0" />
|
|
152
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { inject, onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue'
|
|
3
|
+
import type { ModalContext, ReloadOptions } from './types'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
data?: string | string[]
|
|
8
|
+
params?: ReloadOptions
|
|
9
|
+
buffer?: number
|
|
10
|
+
as?: string
|
|
11
|
+
always?: boolean
|
|
12
|
+
}>(),
|
|
13
|
+
{ buffer: 0, as: 'div' },
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
defineSlots<{ default?(): unknown; fallback?(): unknown }>()
|
|
17
|
+
|
|
18
|
+
const modalContext = inject<Ref<ModalContext | null>>('modalContext')
|
|
19
|
+
|
|
20
|
+
if (!modalContext) {
|
|
21
|
+
throw new Error('WhenVisible component must be used inside a Modal component')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const loaded = ref(false)
|
|
25
|
+
const root = useTemplateRef<HTMLElement>('root')
|
|
26
|
+
let fetching = false
|
|
27
|
+
let observer: IntersectionObserver | undefined
|
|
28
|
+
|
|
29
|
+
function reloadParams(): ReloadOptions {
|
|
30
|
+
if (props.data) {
|
|
31
|
+
return { only: [props.data].flat() }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!props.params) {
|
|
35
|
+
throw new Error('You must provide either a `data` or `params` prop.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return props.params
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
if (!root.value) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
observer = new IntersectionObserver(
|
|
47
|
+
([entry]) => {
|
|
48
|
+
if (!entry.isIntersecting) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!props.always) {
|
|
53
|
+
observer?.disconnect()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (fetching) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fetching = true
|
|
61
|
+
const params = reloadParams()
|
|
62
|
+
|
|
63
|
+
modalContext.value?.reload({
|
|
64
|
+
...params,
|
|
65
|
+
onStart: () => {
|
|
66
|
+
fetching = true
|
|
67
|
+
params.onStart?.()
|
|
68
|
+
},
|
|
69
|
+
onFinish: () => {
|
|
70
|
+
loaded.value = true
|
|
71
|
+
fetching = false
|
|
72
|
+
params.onFinish?.()
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
{ rootMargin: `${props.buffer}px` },
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
observer.observe(root.value)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
onUnmounted(() => observer?.disconnect())
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<component :is="as" ref="root">
|
|
87
|
+
<slot v-if="loaded" />
|
|
88
|
+
<slot v-else name="fallback" />
|
|
89
|
+
</component>
|
|
90
|
+
</template>
|