@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,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>