@innertia-solutions/nuxt-theme-spark 0.1.134 → 0.1.136

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.
@@ -11,7 +11,7 @@ const isOpen = ref(false)
11
11
  const open = () => { isOpen.value = true }
12
12
  const close = () => { isOpen.value = false }
13
13
 
14
- provide('vantage:sidebar', { isOpen, open, close })
14
+ provide('spark:sidebar', { isOpen, open, close })
15
15
 
16
16
  const userInitial = computed(() =>
17
17
  props.user?.name?.charAt(0).toUpperCase() ?? props.user?.email?.charAt(0).toUpperCase() ?? 'U'
@@ -3,7 +3,7 @@ defineProps<{
3
3
  title?: string
4
4
  }>()
5
5
 
6
- const sidebar = inject('vantage:sidebar', null) as any
6
+ const sidebar = inject('spark:sidebar', null) as any
7
7
  </script>
8
8
 
9
9
  <template>
@@ -0,0 +1,388 @@
1
+ <script setup lang="ts">
2
+ import * as TablerIcons from '@tabler/icons-vue'
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────────────
5
+
6
+ interface NavItem {
7
+ label: string
8
+ icon: string
9
+ route: string
10
+ pattern?: string
11
+ }
12
+
13
+ interface AppItem {
14
+ label: string
15
+ icon: string
16
+ route: string
17
+ bg?: string
18
+ text?: string
19
+ nav?: NavItem[]
20
+ }
21
+
22
+ interface MenuItem {
23
+ label: string
24
+ icon: string
25
+ route?: string
26
+ pattern?: string
27
+ children?: NavItem[]
28
+ }
29
+
30
+ // ─── Props / Emits ────────────────────────────────────────────────────────────
31
+
32
+ const props = withDefaults(defineProps<{
33
+ appItems?: AppItem[]
34
+ menuItems?: MenuItem[]
35
+ homeRoute?: string
36
+ user?: { name?: string; email?: string } | null
37
+ floating?: boolean
38
+ }>(), {
39
+ appItems: () => [],
40
+ menuItems: () => [],
41
+ homeRoute: '/',
42
+ })
43
+
44
+ const emit = defineEmits<{ logout: [] }>()
45
+
46
+ // ─── Mobile sidebar ───────────────────────────────────────────────────────────
47
+
48
+ const isOpen = ref(false)
49
+ const open = () => { isOpen.value = true }
50
+ const close = () => { isOpen.value = false }
51
+ provide('spark:sidebar', { isOpen, open, close })
52
+
53
+ // ─── Route matching ───────────────────────────────────────────────────────────
54
+
55
+ const route = useRoute()
56
+
57
+ const matchPattern = (pattern?: string) => {
58
+ if (!pattern) return false
59
+ return new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(route.path)
60
+ }
61
+
62
+ const isChildActive = (children?: NavItem[]) =>
63
+ children?.some(c => matchPattern(c.pattern ?? c.route + '/*')) ?? false
64
+
65
+ // ─── Active app detection ─────────────────────────────────────────────────────
66
+
67
+ const currentApp = computed(() =>
68
+ props.appItems.find(a => route.path.startsWith(a.route)) ?? null
69
+ )
70
+ const isInApp = computed(() => !!currentApp.value)
71
+
72
+ // ─── Hover preview ────────────────────────────────────────────────────────────
73
+
74
+ const hoveredApp = ref<AppItem | 'home' | null>(null)
75
+
76
+ const previewApp = computed(() =>
77
+ hoveredApp.value && hoveredApp.value !== 'home'
78
+ ? hoveredApp.value as AppItem
79
+ : currentApp.value
80
+ )
81
+
82
+ const showMainMenu = computed(() =>
83
+ hoveredApp.value === 'home' || (!hoveredApp.value && !isInApp.value)
84
+ )
85
+
86
+ watch(isInApp, (val) => { if (!val) hoveredApp.value = null })
87
+
88
+ // ─── Main menu accordion ──────────────────────────────────────────────────────
89
+
90
+ const openAccordions = ref<Record<number, boolean>>({})
91
+ const toggleAccordion = (i: number) => { openAccordions.value[i] = !openAccordions.value[i] }
92
+
93
+ watch(() => route.path, () => {
94
+ let active = -1
95
+ props.menuItems.forEach((item, i) => {
96
+ if (item.children && (matchPattern(item.pattern) || isChildActive(item.children))) active = i
97
+ })
98
+ openAccordions.value = active !== -1 ? { [active]: true } : {}
99
+ }, { immediate: true })
100
+
101
+ // ─── User / env ───────────────────────────────────────────────────────────────
102
+
103
+ const userInitial = computed(() =>
104
+ props.user?.name?.charAt(0).toUpperCase() ??
105
+ props.user?.email?.charAt(0).toUpperCase() ?? 'U'
106
+ )
107
+
108
+ const config = useRuntimeConfig()
109
+ const appEnv = config.public.appEnv as string | undefined
110
+ const envLabel = computed(() => (!appEnv || appEnv === 'production') ? null : appEnv)
111
+
112
+ // ─── Icon resolver ────────────────────────────────────────────────────────────
113
+
114
+ const icon = (name: string) => (TablerIcons as Record<string, unknown>)[name]
115
+ </script>
116
+
117
+ <template>
118
+ <div class="bg-background-1 min-h-screen">
119
+
120
+ <!-- Mobile backdrop -->
121
+ <Transition
122
+ enter-from-class="opacity-0" enter-active-class="transition-opacity duration-300"
123
+ leave-to-class="opacity-0" leave-active-class="transition-opacity duration-300"
124
+ >
125
+ <div
126
+ v-if="isOpen"
127
+ class="lg:hidden fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
128
+ @click="close"
129
+ />
130
+ </Transition>
131
+
132
+ <!-- ═══ SIDEBAR ════════════════════════════════════════════════════════════ -->
133
+ <aside
134
+ :class="[
135
+ 'fixed inset-y-0 start-0 z-60 w-65',
136
+ 'transition-transform duration-300 lg:translate-x-0',
137
+ isOpen ? 'translate-x-0' : 'max-lg:-translate-x-full',
138
+ floating ? 'p-3' : '',
139
+ ]"
140
+ >
141
+ <div
142
+ :class="[
143
+ 'flex flex-col h-full bg-sidebar',
144
+ floating
145
+ ? 'rounded-2xl border border-sidebar-line shadow-sm overflow-hidden'
146
+ : 'border-e border-sidebar-line',
147
+ ]"
148
+ >
149
+
150
+ <!-- Logo ─────────────────────────────────────────────────────────────── -->
151
+ <header class="px-4 pt-4 pb-3 border-b border-sidebar-line shrink-0 flex items-center gap-x-2">
152
+ <div class="flex-1 min-w-0">
153
+ <slot name="logo" />
154
+ </div>
155
+ <!-- Mobile close -->
156
+ <button
157
+ type="button"
158
+ class="lg:hidden size-7 inline-flex justify-center items-center rounded-lg text-muted-foreground hover:bg-muted-hover transition-colors"
159
+ @click="close"
160
+ >
161
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
162
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
163
+ </svg>
164
+ </button>
165
+ </header>
166
+
167
+ <!-- Search (optional) ───────────────────────────────────────────────── -->
168
+ <div v-if="$slots.search" class="px-3 pb-2 border-b border-sidebar-line shrink-0">
169
+ <slot name="search" />
170
+ </div>
171
+
172
+ <!-- Body: icon strip + right panel ──────────────────────────────────── -->
173
+ <div class="flex flex-1 min-h-0" @mouseleave="hoveredApp = null">
174
+
175
+ <!-- ── Icon strip (50 px) ──────────────────────────────────────────── -->
176
+ <div class="w-[50px] shrink-0 flex flex-col relative">
177
+
178
+ <!-- Separator line (behind strip items via z-0) -->
179
+ <span class="pointer-events-none absolute right-0 inset-y-0 w-px bg-sidebar-line z-0" />
180
+
181
+ <div class="flex-1 flex flex-col py-2 gap-1.5 overflow-y-auto relative z-10">
182
+
183
+ <!-- Home button -->
184
+ <NuxtLink
185
+ :to="homeRoute"
186
+ class="group relative flex items-center justify-center h-12 border border-r-0"
187
+ :class="!isInApp
188
+ ? 'ml-[2px] bg-card border-sidebar-line rounded-l-lg'
189
+ : 'ml-[1px] w-12 border-transparent rounded-lg'"
190
+ @mouseenter="hoveredApp = 'home'"
191
+ >
192
+ <span
193
+ class="size-10 flex items-center justify-center rounded-lg shrink-0 transition-opacity duration-150 bg-surface text-muted-foreground"
194
+ :class="!isInApp ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'"
195
+ >
196
+ <component :is="icon('IconHome')" class="size-[22px]" />
197
+ </span>
198
+ </NuxtLink>
199
+
200
+ <!-- App items -->
201
+ <NuxtLink
202
+ v-for="app in appItems"
203
+ :key="app.route"
204
+ :to="app.route"
205
+ class="group relative flex items-center justify-center h-12 border border-r-0"
206
+ :class="route.path.startsWith(app.route)
207
+ ? 'ml-[2px] bg-card border-sidebar-line rounded-l-lg'
208
+ : 'ml-[1px] w-12 border-transparent rounded-lg'"
209
+ @mouseenter="hoveredApp = app"
210
+ >
211
+ <span
212
+ class="size-10 flex items-center justify-center rounded-lg shrink-0 transition-opacity duration-150"
213
+ :class="[
214
+ app.bg ?? 'bg-surface',
215
+ app.text ?? 'text-muted-foreground',
216
+ route.path.startsWith(app.route) ? 'opacity-100' : 'opacity-50 group-hover:opacity-100',
217
+ ]"
218
+ >
219
+ <component :is="icon(app.icon)" class="size-[22px]" />
220
+ </span>
221
+ </NuxtLink>
222
+
223
+ </div>
224
+ </div>
225
+ <!-- /icon strip -->
226
+
227
+ <!-- ── Right panel ─────────────────────────────────────────────────── -->
228
+ <div class="flex-1 relative overflow-hidden bg-card">
229
+
230
+ <!-- MAIN MENU -->
231
+ <Transition name="panel-swap">
232
+ <div v-if="showMainMenu" key="main" class="absolute inset-0 overflow-y-auto">
233
+ <ul class="flex flex-col gap-y-1 px-3 pt-2 pb-3">
234
+ <li v-for="(item, index) in menuItems" :key="'menu-' + index">
235
+
236
+ <!-- Simple link -->
237
+ <NuxtLink
238
+ v-if="!item.children && item.route"
239
+ :to="item.route"
240
+ class="flex items-center gap-x-3 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-all border border-transparent"
241
+ :class="{ 'bg-surface text-foreground font-semibold border-card-line': matchPattern(item.pattern ?? item.route) }"
242
+ >
243
+ <component :is="icon(item.icon)" class="shrink-0 size-4" />
244
+ {{ item.label }}
245
+ </NuxtLink>
246
+
247
+ <!-- Accordion group -->
248
+ <div v-else-if="item.children" class="flex flex-col">
249
+ <button
250
+ type="button"
251
+ class="w-full text-start flex items-center gap-x-3 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-all"
252
+ :class="{ 'bg-muted text-foreground font-semibold': openAccordions[index] }"
253
+ @click="toggleAccordion(index)"
254
+ >
255
+ <component :is="icon(item.icon)" class="shrink-0 size-4" />
256
+ <span class="flex-1">{{ item.label }}</span>
257
+ <component
258
+ :is="icon('IconChevronDown')"
259
+ class="shrink-0 size-4 transition-transform duration-300"
260
+ :class="{ '-rotate-180': openAccordions[index] }"
261
+ />
262
+ </button>
263
+ <div
264
+ class="overflow-hidden transition-all duration-300 ease-in-out"
265
+ :style="{
266
+ maxHeight: openAccordions[index] ? '300px' : '0px',
267
+ opacity: openAccordions[index] ? '1' : '0',
268
+ marginTop: openAccordions[index] ? '4px' : '0px',
269
+ }"
270
+ >
271
+ <ul class="ps-8 flex flex-col gap-y-1 relative before:absolute before:start-4.5 before:w-px before:h-full before:bg-sidebar-line">
272
+ <li v-for="child in item.children" :key="child.route">
273
+ <NuxtLink
274
+ :to="child.route"
275
+ class="flex items-center gap-x-4 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-colors border border-transparent"
276
+ :class="{ 'bg-surface text-foreground font-semibold': matchPattern(child.pattern ?? child.route) }"
277
+ >
278
+ <component :is="icon(child.icon)" class="shrink-0 size-4" />
279
+ {{ child.label }}
280
+ </NuxtLink>
281
+ </li>
282
+ </ul>
283
+ </div>
284
+ </div>
285
+
286
+ </li>
287
+ </ul>
288
+ </div>
289
+ </Transition>
290
+
291
+ <!-- APP SUBNAV -->
292
+ <Transition name="panel-swap">
293
+ <div v-if="!showMainMenu" :key="previewApp?.route ?? 'app'" class="absolute inset-0 flex flex-col">
294
+ <div class="px-3 pt-3 pb-3 border-b border-sidebar-line shrink-0">
295
+ <span class="text-[13px] font-bold text-muted-foreground uppercase tracking-wider truncate">
296
+ {{ previewApp?.label }}
297
+ </span>
298
+ </div>
299
+ <nav class="flex-1 overflow-y-auto py-2 px-2">
300
+ <ul class="flex flex-col gap-0.5">
301
+ <li v-for="item in (previewApp?.nav ?? [])" :key="item.route">
302
+ <NuxtLink
303
+ :to="item.route"
304
+ class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted-hover transition-colors border border-transparent"
305
+ :class="{ 'bg-surface text-foreground font-semibold border-card-line': matchPattern(item.pattern ?? item.route + '*') }"
306
+ >
307
+ <component :is="icon(item.icon)" class="size-4 shrink-0" />
308
+ {{ item.label }}
309
+ </NuxtLink>
310
+ </li>
311
+ </ul>
312
+ </nav>
313
+ </div>
314
+ </Transition>
315
+
316
+ </div>
317
+ <!-- /right panel -->
318
+
319
+ </div>
320
+ <!-- /body -->
321
+
322
+ <!-- Footer ───────────────────────────────────────────────────────────── -->
323
+ <div class="shrink-0 border-t border-sidebar-line">
324
+
325
+ <!-- Controls slot (dark mode, notifications, etc.) -->
326
+ <div v-if="$slots['user-controls']" class="flex items-center gap-x-1.5 px-3 pt-3">
327
+ <slot name="user-controls" />
328
+ </div>
329
+
330
+ <!-- User info + logout -->
331
+ <div v-if="user" class="flex items-center gap-x-3 px-3 py-3">
332
+ <div class="size-9 rounded-lg bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold shrink-0 select-none">
333
+ {{ userInitial }}
334
+ </div>
335
+ <div class="flex-1 min-w-0">
336
+ <p class="text-sm font-semibold text-foreground truncate">{{ user.name ?? user.email }}</p>
337
+ <p v-if="user.name && user.email" class="text-xs text-muted-foreground truncate">{{ user.email }}</p>
338
+ </div>
339
+ <button
340
+ type="button"
341
+ class="size-7 inline-flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
342
+ title="Cerrar sesión"
343
+ @click="emit('logout')"
344
+ >
345
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
346
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M18 9l3 3m0 0l-3 3m3-3H9" />
347
+ </svg>
348
+ </button>
349
+ </div>
350
+
351
+ <!-- Extra footer slot -->
352
+ <slot name="user-footer" />
353
+
354
+ <!-- Env banner -->
355
+ <div
356
+ v-if="envLabel"
357
+ class="flex items-center justify-center gap-x-2 py-2 bg-amber-400/15 border-t border-amber-400/30"
358
+ >
359
+ <span class="size-1.5 rounded-full bg-amber-400 shrink-0" />
360
+ <span class="text-[11px] font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">
361
+ {{ envLabel }}
362
+ </span>
363
+ </div>
364
+
365
+ </div>
366
+ <!-- /footer -->
367
+
368
+ </div>
369
+ </aside>
370
+
371
+ <!-- ═══ MAIN CONTENT ══════════════════════════════════════════════════════ -->
372
+ <div class="lg:ps-65 flex flex-col min-h-screen">
373
+ <slot name="topbar" />
374
+ <div class="flex-1 min-h-0">
375
+ <slot />
376
+ </div>
377
+ </div>
378
+
379
+ </div>
380
+ </template>
381
+
382
+ <style scoped>
383
+ /* Panel swap animation */
384
+ .panel-swap-enter-active,
385
+ .panel-swap-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
386
+ .panel-swap-enter-from { opacity: 0; transform: translateX(10px); }
387
+ .panel-swap-leave-to { opacity: 0; transform: translateX(-10px); }
388
+ </style>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ // ─── Props / Emits ────────────────────────────────────────────────────────────
3
+
4
+ const props = withDefaults(defineProps<{
5
+ user?: { name?: string; email?: string; role?: string } | null
6
+ notificationsCount?: number
7
+ searchPlaceholder?: string
8
+ showSearch?: boolean
9
+ showNotifications?: boolean
10
+ showUser?: boolean
11
+ }>(), {
12
+ user: null,
13
+ notificationsCount: 0,
14
+ searchPlaceholder: 'Buscar…',
15
+ showSearch: true,
16
+ showNotifications: true,
17
+ showUser: true,
18
+ })
19
+
20
+ const emit = defineEmits<{
21
+ search: [query: string]
22
+ 'notification-click': []
23
+ logout: []
24
+ }>()
25
+
26
+ // ─── Search ───────────────────────────────────────────────────────────────────
27
+
28
+ const searchQuery = ref('')
29
+
30
+ const handleSearch = () => emit('search', searchQuery.value)
31
+
32
+ // ⌘K / Ctrl+K → focus search
33
+ const searchRef = ref<HTMLInputElement | null>(null)
34
+ onMounted(() => {
35
+ window.addEventListener('keydown', (e) => {
36
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
37
+ e.preventDefault()
38
+ searchRef.value?.focus()
39
+ }
40
+ })
41
+ })
42
+
43
+ // ─── User initials ────────────────────────────────────────────────────────────
44
+
45
+ const userInitials = computed(() => {
46
+ const name = props.user?.name ?? props.user?.email ?? ''
47
+ return name.split(' ').slice(0, 2).map(p => p[0]).join('').toUpperCase() || 'U'
48
+ })
49
+ </script>
50
+
51
+ <template>
52
+ <header class="sticky top-0 z-40 h-14 bg-card/95 backdrop-blur-sm border-b border-card-line flex items-center gap-4 px-6">
53
+
54
+ <!-- Left slot (breadcrumb, page title, etc.) -->
55
+ <div v-if="$slots.left" class="flex items-center gap-3 min-w-0 mr-auto">
56
+ <slot name="left" />
57
+ </div>
58
+ <div v-else class="mr-auto" />
59
+
60
+ <!-- Global search -->
61
+ <div v-if="showSearch" class="relative hidden sm:block w-72">
62
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
63
+ <svg class="size-4 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
64
+ <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
65
+ </svg>
66
+ </div>
67
+ <input
68
+ ref="searchRef"
69
+ v-model="searchQuery"
70
+ type="search"
71
+ :placeholder="searchPlaceholder"
72
+ class="w-full h-9 pl-9 pr-12 text-sm bg-surface border border-card-line rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition"
73
+ @keydown.enter="handleSearch"
74
+ />
75
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
76
+ <kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-muted-foreground bg-muted border border-card-line rounded">
77
+ <span class="text-[9px]">⌘</span>K
78
+ </kbd>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Right slot -->
83
+ <slot name="right" />
84
+
85
+ <!-- Notifications -->
86
+ <button
87
+ v-if="showNotifications"
88
+ type="button"
89
+ class="relative size-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
90
+ @click="emit('notification-click')"
91
+ >
92
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
93
+ <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
94
+ </svg>
95
+ <span
96
+ v-if="notificationsCount > 0"
97
+ class="absolute top-1.5 right-1.5 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none"
98
+ >{{ notificationsCount > 99 ? '99+' : notificationsCount }}</span>
99
+ </button>
100
+
101
+ <!-- User -->
102
+ <div v-if="showUser && user" class="flex items-center gap-2.5 pl-2 border-l border-card-line">
103
+ <div class="size-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs font-bold shrink-0 select-none">
104
+ {{ userInitials }}
105
+ </div>
106
+ <div class="hidden md:block min-w-0">
107
+ <p class="text-sm font-semibold text-foreground truncate leading-tight">{{ user.name ?? user.email }}</p>
108
+ <p v-if="user.role" class="text-xs text-muted-foreground truncate leading-tight">{{ user.role }}</p>
109
+ </div>
110
+ </div>
111
+
112
+ </header>
113
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ modelValue: { type: Boolean, default: false },
4
+ title: { type: String, default: '' },
5
+ size: { type: String, default: 'md' },
6
+ loading: { type: Boolean, default: false },
7
+ closable: { type: Boolean, default: true },
8
+ })
9
+
10
+ const emit = defineEmits(['update:modelValue', 'close'])
11
+ </script>
12
+
13
+ <template>
14
+ <Modal
15
+ :model-value="modelValue"
16
+ :title="title"
17
+ :size="size"
18
+ :closable="closable && !loading"
19
+ :backdrop-dismiss="closable && !loading"
20
+ :show-footer="!!$slots.footer"
21
+ @update:model-value="emit('update:modelValue', $event)"
22
+ @close="emit('close')"
23
+ >
24
+ <slot />
25
+ <template v-if="$slots.footer" #footer>
26
+ <slot name="footer" />
27
+ </template>
28
+ </Modal>
29
+ </template>