@innertia-solutions/nuxt-theme-spark 0.1.133 → 0.1.135

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>
@@ -0,0 +1,540 @@
1
+ <script setup>
2
+ import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical, IconDownload, IconBookmark, IconChevronDown, IconX } from '@tabler/icons-vue'
3
+
4
+ // ─── Props ────────────────────────────────────────────────────────────────────
5
+
6
+ const props = defineProps({
7
+ // DataTable wiring
8
+ table: { type: Object, default: null },
9
+ endpoint: { type: String, default: '' },
10
+ endpointParams: { type: Object, default: () => ({}) },
11
+ columns: { type: Array, required: true },
12
+ name: { type: String, default: '' },
13
+ params: { type: Object, default: () => ({}) },
14
+ cached: { type: Boolean, default: true },
15
+ showReloadButton: { type: Boolean, default: true },
16
+ clickRowToOpen: { type: Boolean, default: false },
17
+ // Toolbar
18
+ searchPlaceholder: { type: String, default: 'Buscar...' },
19
+ showSearch: { type: Boolean, default: true },
20
+ showFilters: { type: Boolean, default: true },
21
+ showColumns: { type: Boolean, default: true },
22
+ showExport: { type: Boolean, default: true },
23
+ showSaveView: { type: Boolean, default: false },
24
+ // Checkboxes
25
+ checkable: { type: Boolean, default: true },
26
+ // Preview panel tabs: [{ key: 'resumen', label: 'Resumen' }, ...]
27
+ previewTabs: { type: Array, default: () => [] },
28
+ // Split ratio
29
+ splitRatio: { type: Number, default: 55 },
30
+ // Filters as chips in the filter bar: [{ key, label, options: [{value,label}] }]
31
+ filterChips: { type: Array, default: () => [] },
32
+ })
33
+
34
+ const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
35
+ const resolvedName = computed(() => props.table?.name ?? props.name)
36
+
37
+ // ─── Emits / Slots ────────────────────────────────────────────────────────────
38
+
39
+ const emit = defineEmits(['row-click', 'loaded', 'save-view'])
40
+ const slots = useSlots()
41
+
42
+ const excludedSlots = new Set(['toolbar', 'action', 'filter-bar', 'preview', 'preview-header'])
43
+ const forwardedSlots = computed(() =>
44
+ Object.fromEntries(Object.entries(slots).filter(([k]) => !excludedSlots.has(k)))
45
+ )
46
+
47
+ // ─── Search & filters ─────────────────────────────────────────────────────────
48
+
49
+ const search = ref('')
50
+ const activeFilters = ref({})
51
+
52
+ // Filter chips state: { [key]: selectedValue }
53
+ const chipValues = reactive(
54
+ Object.fromEntries((props.filterChips ?? []).map(f => [f.key, '']))
55
+ )
56
+ const openChip = ref(null)
57
+
58
+ const clearChips = () => {
59
+ for (const key of Object.keys(chipValues)) chipValues[key] = ''
60
+ }
61
+ const hasActiveChips = computed(() =>
62
+ Object.values(chipValues).some(v => v !== '' && v !== null && v !== undefined)
63
+ )
64
+
65
+ // Advanced filter panel
66
+ const showFilterPanel = ref(false)
67
+ const filterPanelRef = ref(null)
68
+
69
+ const filtersConfig = computed(() =>
70
+ props.columns.filter(c => c.filterType)
71
+ )
72
+ const activeFilterCount = computed(() =>
73
+ Object.values(activeFilters.value).filter(v => v !== null && v !== undefined && v !== '').length
74
+ )
75
+
76
+ const mergedParams = computed(() => ({
77
+ ...props.params,
78
+ ...props.endpointParams,
79
+ ...activeFilters.value,
80
+ ...Object.fromEntries(Object.entries(chipValues).filter(([, v]) => v !== '')),
81
+ }))
82
+
83
+ // ─── Preview panel ────────────────────────────────────────────────────────────
84
+
85
+ const previewRow = ref(null)
86
+ const currentRatio = ref(props.splitRatio)
87
+ const containerRef = ref(null)
88
+ const previewEnabled = ref(false)
89
+ const paginationHeight = ref(0)
90
+ const previewFromCache = ref(false)
91
+
92
+ const previewCacheKey = computed(() => `table-enterprise-preview-${resolvedName.value}`)
93
+
94
+ // Active preview tab
95
+ const firstTabKey = computed(() =>
96
+ props.previewTabs?.length ? props.previewTabs[0].key : 'datos'
97
+ )
98
+ const previewTab = ref(firstTabKey.value)
99
+ watch(previewRow, () => { previewTab.value = firstTabKey.value })
100
+
101
+ const closePreview = () => { previewRow.value = null }
102
+
103
+ const tableRef = ref(null)
104
+ const tableMeta = ref(null)
105
+
106
+ const handleRowClick = (row) => {
107
+ if (previewEnabled.value) {
108
+ previewRow.value = previewRow.value?.id === row.id ? null : row
109
+ } else {
110
+ emit('row-click', row)
111
+ }
112
+ }
113
+
114
+ const handleLoaded = (res) => {
115
+ emit('loaded', res)
116
+ if (res?.meta) tableMeta.value = res.meta
117
+ if (previewRow.value && Array.isArray(res?.data)) {
118
+ const fresh = res.data.find(r => r.id === previewRow.value.id)
119
+ if (fresh) previewRow.value = fresh
120
+ else closePreview()
121
+ }
122
+ }
123
+
124
+ // Persist preview in session cache
125
+ watch(previewRow, (row) => {
126
+ if (!props.cached) return
127
+ if (row) sessionStorage.setItem(previewCacheKey.value, JSON.stringify(row))
128
+ else sessionStorage.removeItem(previewCacheKey.value)
129
+ })
130
+
131
+ // Track pagination height
132
+ let paginationObserver = null
133
+ watch(() => tableRef.value?.paginationBarRef, (el) => {
134
+ paginationObserver?.disconnect()
135
+ paginationObserver = null
136
+ if (!el) return
137
+ paginationHeight.value = el.offsetHeight
138
+ paginationObserver = new ResizeObserver(() => { paginationHeight.value = el.offsetHeight })
139
+ paginationObserver.observe(el)
140
+ }, { flush: 'post' })
141
+
142
+ // Resize handle
143
+ const startResize = (e) => {
144
+ e.preventDefault()
145
+ const onMove = (ev) => {
146
+ if (!containerRef.value) return
147
+ const rect = containerRef.value.getBoundingClientRect()
148
+ const ratio = ((ev.clientX - rect.left) / rect.width) * 100
149
+ currentRatio.value = Math.min(80, Math.max(25, ratio))
150
+ }
151
+ const onUp = () => {
152
+ window.removeEventListener('mousemove', onMove)
153
+ window.removeEventListener('mouseup', onUp)
154
+ }
155
+ window.addEventListener('mousemove', onMove)
156
+ window.addEventListener('mouseup', onUp)
157
+ }
158
+
159
+ // ─── Column panel ─────────────────────────────────────────────────────────────
160
+
161
+ const showColumnPanel = ref(false)
162
+ const columnPanelRef = ref(null)
163
+ const columnButtonRef = ref(null)
164
+ const columnPanelStyle = ref({})
165
+
166
+ const orderedColumns = computed(() => {
167
+ if (!tableRef.value) return props.columns.filter(c => c.label)
168
+ const ids = tableRef.value.table.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
169
+ return ids.map(id => props.columns.find(c => c.key === id)).filter(c => c?.label)
170
+ })
171
+
172
+ let draggedKey = null
173
+ const dragOverKey = ref(null)
174
+ const onDragStart = (key) => { draggedKey = key }
175
+ const onDragOver = (e, key) => { e.preventDefault(); dragOverKey.value = key }
176
+ const onDragLeave = () => { dragOverKey.value = null }
177
+ const onDrop = (key) => {
178
+ if (!draggedKey || draggedKey === key) return
179
+ const ids = tableRef.value?.table.getAllLeafColumns().map(c => c.id) ?? []
180
+ const from = ids.indexOf(draggedKey)
181
+ const to = ids.indexOf(key)
182
+ if (from < 0 || to < 0) return
183
+ ids.splice(from, 1); ids.splice(to, 0, draggedKey)
184
+ const selIdx = ids.indexOf('select')
185
+ if (selIdx > 0) { ids.splice(selIdx, 1); ids.unshift('select') }
186
+ tableRef.value?.setColumnOrder(ids)
187
+ draggedKey = null; dragOverKey.value = null
188
+ }
189
+
190
+ // ─── Outside-click helpers ────────────────────────────────────────────────────
191
+
192
+ const onColumnOutsideClick = (e) => {
193
+ if (
194
+ columnPanelRef.value && !columnPanelRef.value.contains(e.target) &&
195
+ columnButtonRef.value && !columnButtonRef.value.contains(e.target)
196
+ ) showColumnPanel.value = false
197
+ }
198
+ const onFilterOutsideClick = (e) => {
199
+ if (filterPanelRef.value && !filterPanelRef.value.contains(e.target)) showFilterPanel.value = false
200
+ }
201
+
202
+ watch(showColumnPanel, async (v) => {
203
+ if (v) {
204
+ await nextTick()
205
+ const rect = columnButtonRef.value?.getBoundingClientRect()
206
+ if (rect) columnPanelStyle.value = { top: rect.bottom + 6 + 'px', right: window.innerWidth - rect.right + 'px' }
207
+ document.addEventListener('mousedown', onColumnOutsideClick)
208
+ } else {
209
+ document.removeEventListener('mousedown', onColumnOutsideClick)
210
+ }
211
+ })
212
+ watch(showFilterPanel, (v) => {
213
+ if (v) document.addEventListener('mousedown', onFilterOutsideClick)
214
+ else document.removeEventListener('mousedown', onFilterOutsideClick)
215
+ })
216
+
217
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
218
+
219
+ const onEsc = (e) => { if (e.key === 'Escape' && previewRow.value) closePreview() }
220
+ onMounted(async () => {
221
+ previewEnabled.value = !!(slots.preview)
222
+ window.addEventListener('keydown', onEsc)
223
+ if (props.cached && previewEnabled.value) {
224
+ try {
225
+ const raw = sessionStorage.getItem(previewCacheKey.value)
226
+ if (raw) {
227
+ previewFromCache.value = true
228
+ previewRow.value = JSON.parse(raw)
229
+ await nextTick()
230
+ previewFromCache.value = false
231
+ }
232
+ } catch {}
233
+ }
234
+ })
235
+ onBeforeUnmount(() => {
236
+ window.removeEventListener('keydown', onEsc)
237
+ paginationObserver?.disconnect()
238
+ })
239
+
240
+ // ─── Expose ───────────────────────────────────────────────────────────────────
241
+
242
+ const getSelectedRows = () => tableRef.value?.getSelectedRows()
243
+ const reload = () => tableRef.value?.reload()
244
+ const clearCache = () => tableRef.value?.clearCache()
245
+ const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
246
+ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
247
+ </script>
248
+
249
+ <template>
250
+ <div class="relative" ref="containerRef">
251
+ <div class="bg-card border border-card-line rounded-2xl shadow-sm overflow-hidden">
252
+
253
+ <!-- ── Toolbar ──────────────────────────────────────────────────────── -->
254
+ <div class="flex flex-wrap items-center gap-2 px-4 py-3 border-b border-card-line">
255
+
256
+ <!-- Search -->
257
+ <div v-if="showSearch" class="flex-1 min-w-48">
258
+ <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
259
+ </div>
260
+
261
+ <!-- Advanced filters button -->
262
+ <button
263
+ v-if="showFilters && filtersConfig.length > 0"
264
+ type="button"
265
+ @click="showFilterPanel = !showFilterPanel"
266
+ :class="[
267
+ 'py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border transition-colors',
268
+ showFilterPanel || activeFilterCount > 0
269
+ ? 'border-primary/50 bg-primary/5 text-primary dark:bg-primary/10'
270
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
271
+ ]"
272
+ >
273
+ <IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
274
+ Filtros
275
+ <span v-if="activeFilterCount > 0" class="text-xs font-bold">({{ activeFilterCount }})</span>
276
+ <IconChevronDown class="size-3.5" stroke="2" />
277
+ </button>
278
+
279
+ <!-- Toolbar slot (custom filter dropdowns, etc.) -->
280
+ <slot name="toolbar" />
281
+
282
+ <!-- Spacer -->
283
+ <div class="flex-1" />
284
+
285
+ <!-- Save view -->
286
+ <button
287
+ v-if="showSaveView"
288
+ type="button"
289
+ class="py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover transition-colors"
290
+ @click="emit('save-view')"
291
+ >
292
+ <IconBookmark class="size-4" stroke="1.5" />
293
+ Guardar vista
294
+ </button>
295
+
296
+ <!-- Columns -->
297
+ <button
298
+ v-if="showColumns"
299
+ ref="columnButtonRef"
300
+ type="button"
301
+ @click="showColumnPanel = !showColumnPanel"
302
+ :class="[
303
+ 'py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border transition-colors',
304
+ showColumnPanel
305
+ ? 'border-primary/50 bg-primary/5 text-primary dark:bg-primary/10'
306
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
307
+ ]"
308
+ >
309
+ <IconLayoutColumns class="size-4" stroke="1.5" />
310
+ Columnas
311
+ </button>
312
+
313
+ <!-- Export -->
314
+ <TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
315
+
316
+ <!-- Primary action slot -->
317
+ <slot name="action" />
318
+ </div>
319
+
320
+ <!-- ── Filter bar (chips) ───────────────────────────────────────────── -->
321
+ <div
322
+ v-if="filterChips.length > 0 || $slots['filter-bar']"
323
+ class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-card-line bg-surface/40"
324
+ >
325
+ <!-- Built-in chips from filterChips prop -->
326
+ <template v-if="filterChips.length > 0">
327
+ <div
328
+ v-for="chip in filterChips"
329
+ :key="chip.key"
330
+ class="relative"
331
+ >
332
+ <button
333
+ type="button"
334
+ @click="openChip = openChip === chip.key ? null : chip.key"
335
+ :class="[
336
+ 'inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-sm border transition-colors',
337
+ chipValues[chip.key]
338
+ ? 'border-primary/50 bg-primary/5 text-primary font-medium dark:bg-primary/10'
339
+ : 'border-card-line bg-card text-muted-foreground hover:bg-muted-hover',
340
+ ]"
341
+ >
342
+ <span class="text-muted-foreground">{{ chip.label }}:</span>
343
+ <span class="font-medium">
344
+ {{ chip.options?.find(o => o.value === chipValues[chip.key])?.label ?? 'Todos' }}
345
+ </span>
346
+ <IconChevronDown class="size-3" stroke="2" />
347
+ </button>
348
+
349
+ <!-- Chip dropdown -->
350
+ <Transition
351
+ enter-active-class="transition ease-out duration-100"
352
+ enter-from-class="opacity-0 scale-95"
353
+ enter-to-class="opacity-100 scale-100"
354
+ leave-active-class="transition ease-in duration-75"
355
+ leave-from-class="opacity-100 scale-100"
356
+ leave-to-class="opacity-0 scale-95"
357
+ >
358
+ <div
359
+ v-if="openChip === chip.key"
360
+ v-click-outside="() => openChip = null"
361
+ class="absolute top-full left-0 z-50 mt-1 bg-dropdown border border-dropdown-line rounded-xl shadow-xl py-1 min-w-40"
362
+ >
363
+ <button
364
+ type="button"
365
+ class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted-hover transition-colors"
366
+ :class="chipValues[chip.key] === '' ? 'font-semibold text-foreground' : 'text-muted-foreground'"
367
+ @click="chipValues[chip.key] = ''; openChip = null"
368
+ >Todos</button>
369
+ <button
370
+ v-for="opt in chip.options"
371
+ :key="opt.value"
372
+ type="button"
373
+ class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted-hover transition-colors"
374
+ :class="chipValues[chip.key] === opt.value ? 'font-semibold text-foreground' : 'text-muted-foreground'"
375
+ @click="chipValues[chip.key] = opt.value; openChip = null"
376
+ >{{ opt.label }}</button>
377
+ </div>
378
+ </Transition>
379
+ </div>
380
+
381
+ <button
382
+ v-if="hasActiveChips"
383
+ type="button"
384
+ class="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
385
+ @click="clearChips"
386
+ >
387
+ <IconX class="size-3" stroke="2" />
388
+ Limpiar filtros
389
+ </button>
390
+ </template>
391
+
392
+ <!-- Custom filter bar slot -->
393
+ <slot name="filter-bar" />
394
+ </div>
395
+
396
+ <!-- Advanced filter panel -->
397
+ <Transition
398
+ enter-active-class="transition ease-out duration-150"
399
+ enter-from-class="opacity-0 translate-y-1 scale-95"
400
+ enter-to-class="opacity-100 translate-y-0 scale-100"
401
+ leave-active-class="transition ease-in duration-100"
402
+ leave-from-class="opacity-100 translate-y-0 scale-100"
403
+ leave-to-class="opacity-0 translate-y-1 scale-95"
404
+ >
405
+ <div
406
+ v-if="showFilterPanel"
407
+ ref="filterPanelRef"
408
+ class="absolute top-14 left-4 z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-64 max-h-96 overflow-y-auto"
409
+ >
410
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Filtros avanzados</p>
411
+ <TableFilter v-model="activeFilters" :columns="filtersConfig" />
412
+ </div>
413
+ </Transition>
414
+
415
+ <!-- ── Table + Preview ──────────────────────────────────────────────── -->
416
+ <div class="relative overflow-hidden">
417
+
418
+ <Table
419
+ ref="tableRef"
420
+ :endpoint="resolvedEndpoint"
421
+ :columns="columns"
422
+ :name="resolvedName"
423
+ :params="mergedParams"
424
+ :search="search"
425
+ :checkable="checkable"
426
+ :cached="cached"
427
+ :show-reload-button="showReloadButton"
428
+ :click-row-to-open="clickRowToOpen"
429
+ :preview-row-id="previewRow?.id ?? null"
430
+ :preview-mode="!!previewEnabled"
431
+ @row-click="handleRowClick"
432
+ @loaded="handleLoaded"
433
+ @page-change="closePreview"
434
+ @per-page-change="closePreview"
435
+ >
436
+ <template v-for="(_, sname) in forwardedSlots" #[sname]="slotProps">
437
+ <slot :name="sname" v-bind="slotProps ?? {}" />
438
+ </template>
439
+ </Table>
440
+
441
+ <!-- Preview overlay -->
442
+ <Transition
443
+ :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
444
+ :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
445
+ :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
446
+ leave-active-class="transition ease-in duration-150"
447
+ leave-from-class="opacity-100 translate-x-0"
448
+ leave-to-class="opacity-0 translate-x-6"
449
+ >
450
+ <div
451
+ v-if="previewRow && previewEnabled"
452
+ class="absolute top-0 right-0 z-30 flex bg-card border-l border-card-line shadow-[-4px_0_16px_rgba(0,0,0,0.06)]"
453
+ :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
454
+ >
455
+ <!-- Resize handle -->
456
+ <div
457
+ class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-primary/40 transition-colors"
458
+ @mousedown="startResize"
459
+ />
460
+
461
+ <div class="flex flex-col flex-1 overflow-hidden">
462
+
463
+ <!-- Fixed header -->
464
+ <div v-if="$slots['preview-header']" class="shrink-0 border-b border-card-line">
465
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
466
+ </div>
467
+
468
+ <!-- Tabs (top) — only if previewTabs provided -->
469
+ <div
470
+ v-if="previewTabs && previewTabs.length > 1"
471
+ class="shrink-0 flex border-b border-card-line overflow-x-auto"
472
+ >
473
+ <button
474
+ v-for="tab in previewTabs"
475
+ :key="tab.key"
476
+ type="button"
477
+ @click="previewTab = tab.key"
478
+ :class="[
479
+ 'px-4 py-2.5 text-xs font-semibold whitespace-nowrap transition-colors border-b-2 -mb-px',
480
+ previewTab === tab.key
481
+ ? 'border-primary text-primary'
482
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover',
483
+ ]"
484
+ >{{ tab.label }}</button>
485
+ </div>
486
+
487
+ <!-- Scrollable preview content -->
488
+ <div class="flex-1 overflow-y-auto min-h-0">
489
+ <slot name="preview" :row="previewRow" :tab="previewTab" :close="closePreview" />
490
+ </div>
491
+
492
+ </div>
493
+ </div>
494
+ </Transition>
495
+ </div>
496
+ </div>
497
+
498
+ <!-- Column panel (teleported) -->
499
+ <Teleport to="body">
500
+ <Transition
501
+ enter-active-class="transition ease-out duration-150"
502
+ enter-from-class="opacity-0 translate-y-1 scale-95"
503
+ enter-to-class="opacity-100 translate-y-0 scale-100"
504
+ leave-active-class="transition ease-in duration-100"
505
+ leave-from-class="opacity-100 translate-y-0 scale-100"
506
+ leave-to-class="opacity-0 translate-y-1 scale-95"
507
+ >
508
+ <div
509
+ v-if="showColumnPanel"
510
+ ref="columnPanelRef"
511
+ class="fixed z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-56 max-h-80 overflow-y-auto"
512
+ :style="columnPanelStyle"
513
+ >
514
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2 px-1">Columnas visibles</p>
515
+ <div
516
+ v-for="col in orderedColumns"
517
+ :key="col.key"
518
+ draggable="true"
519
+ @dragstart="onDragStart(col.key)"
520
+ @dragover="(e) => onDragOver(e, col.key)"
521
+ @dragleave="onDragLeave"
522
+ @drop="onDrop(col.key)"
523
+ class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none transition-colors"
524
+ :class="dragOverKey === col.key ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted-hover cursor-grab'"
525
+ >
526
+ <IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
527
+ <input
528
+ type="checkbox"
529
+ :checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
530
+ @change="tableRef?.table.getColumn(col.key)?.toggleVisibility()"
531
+ @click.stop
532
+ class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
533
+ />
534
+ <span class="text-sm text-foreground truncate">{{ col.label }}</span>
535
+ </div>
536
+ </div>
537
+ </Transition>
538
+ </Teleport>
539
+ </div>
540
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.133",
3
+ "version": "0.1.135",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",