picocrank 1.9.1 → 1.11.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/package.json +8 -8
- package/router.js +47 -0
- package/vue/components/Header.vue +4 -0
- package/vue/components/Login.vue +279 -0
- package/vue/components/Navigation.vue +167 -0
- package/vue/components/NavigationGrid.vue +294 -0
- package/vue/components/Sidebar.vue +6 -136
- package/vue/components/Table.vue +5 -1
- package/vue/components/Tabs.vue +233 -0
- package/vue/views/Admin.vue +14 -14
- package/vue/views/App.vue +36 -21
- package/vue/views/FormExample.vue +268 -0
- package/vue/views/Login.vue +65 -0
- package/vue/views/LoginExample.vue +117 -0
- package/vue/views/TabsExample.vue +337 -0
- package/vue/views/UserControlPanel.vue +79 -0
- package/vue/views/Welcome.vue +1 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="navigation-grid-container">
|
|
3
|
+
<div class="navigation-grid" :class="{ 'compact': compact }">
|
|
4
|
+
<button
|
|
5
|
+
v-for="link in filteredLinks"
|
|
6
|
+
:key="link.name"
|
|
7
|
+
:class="['nav-button', { active: isLinkActive(link) }]"
|
|
8
|
+
:title="link.title"
|
|
9
|
+
@click="handleLinkClick(link)"
|
|
10
|
+
>
|
|
11
|
+
<div class="nav-button-icon">
|
|
12
|
+
<HugeiconsIcon
|
|
13
|
+
:icon="link.icon"
|
|
14
|
+
:width="iconSize"
|
|
15
|
+
:height="iconSize"
|
|
16
|
+
/>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="nav-button-label">{{ link.title }}</div>
|
|
19
|
+
<div v-if="link.description" class="nav-button-description">
|
|
20
|
+
{{ link.description }}
|
|
21
|
+
</div>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div v-if="filteredLinks.length === 0" class="no-links">
|
|
25
|
+
<p>No navigation links available</p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup>
|
|
31
|
+
import { computed, inject, ref } from 'vue';
|
|
32
|
+
import { useRouter, useRoute } from 'vue-router';
|
|
33
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
34
|
+
|
|
35
|
+
const props = defineProps({
|
|
36
|
+
// Filter links by type: 'route', 'callback', or null for all
|
|
37
|
+
filterType: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: null,
|
|
40
|
+
validator: (value) => value === null || ['route', 'callback'].includes(value)
|
|
41
|
+
},
|
|
42
|
+
// Custom filter function
|
|
43
|
+
filter: {
|
|
44
|
+
type: Function,
|
|
45
|
+
default: null
|
|
46
|
+
},
|
|
47
|
+
// Compact mode (smaller buttons)
|
|
48
|
+
compact: {
|
|
49
|
+
type: Boolean,
|
|
50
|
+
default: false
|
|
51
|
+
},
|
|
52
|
+
// Icon size
|
|
53
|
+
iconSize: {
|
|
54
|
+
type: String,
|
|
55
|
+
default: '2em'
|
|
56
|
+
},
|
|
57
|
+
// Exclude separator and html items
|
|
58
|
+
excludeNonButtons: {
|
|
59
|
+
type: Boolean,
|
|
60
|
+
default: true
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
const route = useRoute();
|
|
66
|
+
|
|
67
|
+
// Inject navigation from Navigation component
|
|
68
|
+
const navigation = inject('navigation', null);
|
|
69
|
+
|
|
70
|
+
// Get navigation links (it's a ref from the Navigation component)
|
|
71
|
+
const navigationLinks = navigation ? navigation.navigationLinks : ref([]);
|
|
72
|
+
|
|
73
|
+
// Get isActive function from navigation
|
|
74
|
+
const isActive = navigation ? navigation.isActive : (() => false);
|
|
75
|
+
|
|
76
|
+
// Filter links based on props
|
|
77
|
+
const filteredLinks = computed(() => {
|
|
78
|
+
if (!navigationLinks.value || navigationLinks.value.length === 0) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let links = [...navigationLinks.value];
|
|
83
|
+
|
|
84
|
+
// Exclude separators and HTML items by default
|
|
85
|
+
if (props.excludeNonButtons) {
|
|
86
|
+
links = links.filter(link =>
|
|
87
|
+
link.type !== 'separator' && link.type !== 'html'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Filter by type if specified
|
|
92
|
+
if (props.filterType) {
|
|
93
|
+
links = links.filter(link => link.type === props.filterType);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Apply custom filter if provided
|
|
97
|
+
if (props.filter && typeof props.filter === 'function') {
|
|
98
|
+
links = links.filter(props.filter);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return links;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function isLinkActive(link) {
|
|
105
|
+
return isActive(link);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleLinkClick(link) {
|
|
109
|
+
if (link.type === 'route') {
|
|
110
|
+
const to = link.to || link.path;
|
|
111
|
+
if (to) {
|
|
112
|
+
router.push(to);
|
|
113
|
+
}
|
|
114
|
+
} else if (link.type === 'callback' && link.callback) {
|
|
115
|
+
link.callback();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<style scoped>
|
|
121
|
+
.navigation-grid-container {
|
|
122
|
+
width: 100%;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.navigation-grid {
|
|
126
|
+
display: grid;
|
|
127
|
+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
128
|
+
gap: 1rem;
|
|
129
|
+
padding: 1rem 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.navigation-grid.compact {
|
|
133
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
134
|
+
gap: 0.75rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.nav-button {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-direction: column;
|
|
140
|
+
align-items: top;
|
|
141
|
+
justify-content: flex-start;
|
|
142
|
+
gap: 0.75rem;
|
|
143
|
+
padding: 1.25rem 0.75rem;
|
|
144
|
+
background: transparent;
|
|
145
|
+
border: 1px solid var(--border-color, #e1e5e9);
|
|
146
|
+
border-radius: 8px;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
transition: all 0.2s ease;
|
|
149
|
+
text-align: left;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.navigation-grid.compact .nav-button {
|
|
153
|
+
padding: 0.75rem 0.5rem;
|
|
154
|
+
gap: 0.5rem;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.nav-button:hover {
|
|
158
|
+
border-color: var(--primary-color, #007bff);
|
|
159
|
+
transform: translateY(-2px);
|
|
160
|
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.nav-button:active {
|
|
164
|
+
transform: translateY(0);
|
|
165
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.nav-button.active {
|
|
169
|
+
background: var(--primary-color, #007bff);
|
|
170
|
+
border-color: var(--primary-color, #007bff);
|
|
171
|
+
color: #fff;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.nav-button.active .nav-button-icon {
|
|
175
|
+
color: #fff;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.nav-button-icon {
|
|
179
|
+
display: flex;
|
|
180
|
+
align-items: start;
|
|
181
|
+
justify-content: start;
|
|
182
|
+
color: var(--primary-color, #007bff);
|
|
183
|
+
transition: color 0.2s ease;
|
|
184
|
+
flex-shrink: 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.nav-button.active .nav-button-icon {
|
|
188
|
+
color: #fff;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.nav-button-label {
|
|
192
|
+
font-size: normal;
|
|
193
|
+
font-weight: bold;
|
|
194
|
+
word-break: break-word;
|
|
195
|
+
text-align: left;
|
|
196
|
+
line-height: 1.2;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.navigation-grid.compact .nav-button-label {
|
|
200
|
+
font-size: 0.85em;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.nav-button.active .nav-button-label {
|
|
204
|
+
color: #fff;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.nav-button-description {
|
|
208
|
+
font-size: small;
|
|
209
|
+
font-weight: normal;
|
|
210
|
+
color: var(--text-muted, #666);
|
|
211
|
+
word-break: break-word;
|
|
212
|
+
text-align: left;
|
|
213
|
+
line-height: 1.5;
|
|
214
|
+
opacity: 0.8;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.navigation-grid.compact .nav-button-description {
|
|
218
|
+
font-size: 0.7em;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.nav-button.active .nav-button-description {
|
|
222
|
+
color: rgba(255, 255, 255, 0.9);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.no-links {
|
|
226
|
+
padding: 2rem;
|
|
227
|
+
text-align: center;
|
|
228
|
+
color: var(--text-muted, #666);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Dark theme support */
|
|
232
|
+
@media (prefers-color-scheme: dark) {
|
|
233
|
+
.nav-button {
|
|
234
|
+
background: transparent;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.nav-button:hover {
|
|
238
|
+
background: var(--hover-background-color, rgba(96, 165, 250, 0.1));
|
|
239
|
+
border-color: var(--primary-color, #60a5fa);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.nav-button.active {
|
|
243
|
+
background: var(--primary-color, #60a5fa);
|
|
244
|
+
border-color: var(--primary-color, #60a5fa);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.nav-button-description {
|
|
248
|
+
color: var(--text-muted, #999);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.nav-button-icon {
|
|
252
|
+
color: var(--primary-color, #60a5fa);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Responsive */
|
|
257
|
+
@media (max-width: 768px) {
|
|
258
|
+
.navigation-grid {
|
|
259
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
260
|
+
gap: 0.75rem;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.nav-button {
|
|
264
|
+
padding: 1rem 0.5rem;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.nav-button-label {
|
|
268
|
+
font-size: normal;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.nav-button-description {
|
|
272
|
+
font-size: 0.7em;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@media (max-width: 480px) {
|
|
277
|
+
.navigation-grid {
|
|
278
|
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
279
|
+
gap: 0.5rem;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.nav-button {
|
|
283
|
+
padding: 0.75rem 0.25rem;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.nav-button-label {
|
|
287
|
+
font-size: 0.8em;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.nav-button-description {
|
|
291
|
+
font-size: 0.65em;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
</style>
|
|
@@ -36,111 +36,21 @@
|
|
|
36
36
|
</template>
|
|
37
37
|
|
|
38
38
|
<script setup>
|
|
39
|
-
import { ref,
|
|
40
|
-
import { useRoute, useRouter } from 'vue-router'
|
|
39
|
+
import { ref, inject, onUnmounted, watch, nextTick } from 'vue'
|
|
41
40
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
42
41
|
import { Pin02Icon } from '@hugeicons/core-free-icons'
|
|
43
42
|
import { PinIcon } from '@hugeicons/core-free-icons'
|
|
44
43
|
|
|
45
44
|
const isOpen = ref(false)
|
|
46
45
|
const isStuck = ref(false)
|
|
47
|
-
const navigationLinks = ref([])
|
|
48
46
|
const sidebarRef = ref(null)
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
const
|
|
48
|
+
// Get navigation from Navigation component
|
|
49
|
+
const navigation = inject('navigation', null)
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* @param {string|null} altTitle - Optional alternative title
|
|
57
|
-
* @param {object} options - Additional options
|
|
58
|
-
* @param {object} options.params - Route params to pass (e.g., { id: 1 })
|
|
59
|
-
* @param {object} options.props - Props to pass to router-link (e.g., { replace: true, custom: true })
|
|
60
|
-
* @param {object|string} options.to - Override the 'to' prop (if provided, takes precedence over params)
|
|
61
|
-
*/
|
|
62
|
-
function addRouterLink(link, altTitle = null, options = {}) {
|
|
63
|
-
const foundRoute = router.getRoutes().find(r => r.name === link)
|
|
64
|
-
|
|
65
|
-
if (!foundRoute) {
|
|
66
|
-
console.warn(`Route "${link}" not found`)
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const title = altTitle || foundRoute.meta?.title || foundRoute.name
|
|
71
|
-
|
|
72
|
-
// Determine the 'to' value: use options.to if provided, otherwise construct from params, or fall back to path
|
|
73
|
-
let toValue = foundRoute.path
|
|
74
|
-
if (options.to) {
|
|
75
|
-
toValue = options.to
|
|
76
|
-
} else if (options.params) {
|
|
77
|
-
toValue = { name: link, params: options.params }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const routeLink = {
|
|
81
|
-
name: link,
|
|
82
|
-
title: title,
|
|
83
|
-
path: foundRoute.path,
|
|
84
|
-
to: toValue,
|
|
85
|
-
icon: foundRoute.meta?.icon || PinIcon,
|
|
86
|
-
type: 'route',
|
|
87
|
-
props: options.props || {}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
addNavigationLink(routeLink)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function addNavigationLink(link) {
|
|
94
|
-
const existingIndex = navigationLinks.value.findIndex(l => l.name === link.name)
|
|
95
|
-
if (existingIndex >= 0) {
|
|
96
|
-
navigationLinks.value[existingIndex] = { ...link }
|
|
97
|
-
} else {
|
|
98
|
-
navigationLinks.value.push({ ...link })
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function addCallback(title, callback, options = {}) {
|
|
103
|
-
const callbackLink = {
|
|
104
|
-
name: options.name || title.toLowerCase().replace(/\s+/g, '-'),
|
|
105
|
-
type: 'callback',
|
|
106
|
-
icon: options.icon || PinIcon,
|
|
107
|
-
callback: callback || (() => {}),
|
|
108
|
-
title: title
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
addNavigationLink(callbackLink)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function addSeparator(id) {
|
|
115
|
-
const separator = {
|
|
116
|
-
name: id || `nav-separator-${Date.now()}`,
|
|
117
|
-
type: 'separator',
|
|
118
|
-
title: 'Separator'
|
|
119
|
-
}
|
|
120
|
-
addNavigationLink(separator)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function addHtml(html, options = {}) {
|
|
124
|
-
const htmlLink = {
|
|
125
|
-
name: options.name || `html-item-${Date.now()}`,
|
|
126
|
-
type: 'html',
|
|
127
|
-
html: html,
|
|
128
|
-
title: options.title || 'HTML Item'
|
|
129
|
-
}
|
|
130
|
-
addNavigationLink(htmlLink)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function removeNavigationLink(linkId) {
|
|
134
|
-
navigationLinks.value = navigationLinks.value.filter(link => link.id !== linkId)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function clearNavigationLinks() {
|
|
138
|
-
navigationLinks.value = []
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function getNavigationLinks() {
|
|
142
|
-
return [...navigationLinks.value]
|
|
143
|
-
}
|
|
51
|
+
// Get navigation links and isActive function from navigation
|
|
52
|
+
const navigationLinks = navigation ? navigation.navigationLinks : ref([])
|
|
53
|
+
const isActive = navigation ? navigation.isActive : (() => false)
|
|
144
54
|
|
|
145
55
|
function toggleStick() {
|
|
146
56
|
isStuck.value = !isStuck.value
|
|
@@ -168,36 +78,6 @@ function close() {
|
|
|
168
78
|
isStuck.value = false
|
|
169
79
|
}
|
|
170
80
|
|
|
171
|
-
function isActive(link) {
|
|
172
|
-
// If link has a 'to' object with a name, compare route name and params
|
|
173
|
-
if (link.to && typeof link.to === 'object' && link.to.name) {
|
|
174
|
-
// Compare route names first
|
|
175
|
-
if (route.name !== link.to.name) {
|
|
176
|
-
return false
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// If params are specified in the link, all specified params must match
|
|
180
|
-
if (link.to.params && Object.keys(link.to.params).length > 0) {
|
|
181
|
-
const linkParams = link.to.params
|
|
182
|
-
const routeParams = route.params
|
|
183
|
-
|
|
184
|
-
// Check if all params in link.to.params match route.params
|
|
185
|
-
for (const key in linkParams) {
|
|
186
|
-
if (String(routeParams[key]) !== String(linkParams[key])) {
|
|
187
|
-
return false
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Route name matches, and either no params specified or all params match
|
|
193
|
-
return true
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Fall back to path comparison for string paths
|
|
197
|
-
const linkPath = typeof link.to === 'string' ? link.to : link.path
|
|
198
|
-
return route.path === linkPath
|
|
199
|
-
}
|
|
200
|
-
|
|
201
81
|
function handleLinkClick(callback = null) {
|
|
202
82
|
if (callback) {
|
|
203
83
|
callback()
|
|
@@ -255,22 +135,12 @@ onUnmounted(() => {
|
|
|
255
135
|
|
|
256
136
|
defineExpose({
|
|
257
137
|
isOpen,
|
|
258
|
-
navigationLinks,
|
|
259
138
|
stick,
|
|
260
139
|
unstick,
|
|
261
140
|
toggleStick,
|
|
262
141
|
toggle,
|
|
263
142
|
open,
|
|
264
143
|
close,
|
|
265
|
-
isActive,
|
|
266
|
-
addNavigationLink,
|
|
267
|
-
addRouterLink,
|
|
268
|
-
addCallback,
|
|
269
|
-
addSeparator,
|
|
270
|
-
addHtml,
|
|
271
|
-
removeNavigationLink,
|
|
272
|
-
clearNavigationLinks,
|
|
273
|
-
getNavigationLinks,
|
|
274
144
|
})
|
|
275
145
|
</script>
|
|
276
146
|
|
package/vue/components/Table.vue
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
</tr>
|
|
34
34
|
</tbody>
|
|
35
35
|
</table>
|
|
36
|
-
<div class = "padding">
|
|
36
|
+
<div v-if="showPagination" class = "padding">
|
|
37
37
|
<Pagination :total = "total" v-model:page="page" v-model:page-size="pageSize" />
|
|
38
38
|
</div>
|
|
39
39
|
</template>
|
|
@@ -56,6 +56,10 @@
|
|
|
56
56
|
type: Array,
|
|
57
57
|
default: () => [],
|
|
58
58
|
},
|
|
59
|
+
showPagination: {
|
|
60
|
+
type: Boolean,
|
|
61
|
+
default: true,
|
|
62
|
+
},
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
const slots = useSlots()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="tabs-container">
|
|
3
|
+
<div class="tabs-header" role="tablist">
|
|
4
|
+
<button
|
|
5
|
+
v-for="(tab, index) in tabs"
|
|
6
|
+
:key="tab.id || index"
|
|
7
|
+
:class="['tab-button', { active: activeTabId === (tab.id || index) }]"
|
|
8
|
+
:aria-selected="activeTabId === (tab.id || index)"
|
|
9
|
+
:aria-controls="`tab-panel-${tab.id || index}`"
|
|
10
|
+
:id="`tab-${tab.id || index}`"
|
|
11
|
+
role="tab"
|
|
12
|
+
@click="selectTab(tab.id || index)"
|
|
13
|
+
>
|
|
14
|
+
<HugeiconsIcon
|
|
15
|
+
v-if="tab.icon"
|
|
16
|
+
:icon="tab.icon"
|
|
17
|
+
width="1em"
|
|
18
|
+
height="1em"
|
|
19
|
+
/>
|
|
20
|
+
<span>{{ tab.label }}</span>
|
|
21
|
+
<slot :name="`tab-${tab.id || index}-badge`" />
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="tabs-content">
|
|
25
|
+
<div
|
|
26
|
+
v-for="(tab, index) in tabs"
|
|
27
|
+
:key="tab.id || index"
|
|
28
|
+
:id="`tab-panel-${tab.id || index}`"
|
|
29
|
+
:class="['tab-panel', { active: activeTabId === (tab.id || index) }]"
|
|
30
|
+
role="tabpanel"
|
|
31
|
+
:aria-labelledby="`tab-${tab.id || index}`"
|
|
32
|
+
v-show="activeTabId === (tab.id || index)"
|
|
33
|
+
>
|
|
34
|
+
<slot :name="`tab-${tab.id || index}`" :tab="tab" />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup>
|
|
41
|
+
import { ref, onMounted, watch } from 'vue';
|
|
42
|
+
import { HugeiconsIcon } from '@hugeicons/vue';
|
|
43
|
+
|
|
44
|
+
const props = defineProps({
|
|
45
|
+
tabs: {
|
|
46
|
+
type: Array,
|
|
47
|
+
required: true,
|
|
48
|
+
validator: (tabs) => {
|
|
49
|
+
return tabs.every(tab =>
|
|
50
|
+
typeof tab === 'object' &&
|
|
51
|
+
(tab.id !== undefined || true) &&
|
|
52
|
+
typeof tab.label === 'string'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
defaultTab: {
|
|
57
|
+
type: [String, Number],
|
|
58
|
+
default: null
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const emit = defineEmits(['tab-change']);
|
|
63
|
+
|
|
64
|
+
// Initialize activeTabId with first tab by default
|
|
65
|
+
function getInitialTabId() {
|
|
66
|
+
if (props.defaultTab !== null) {
|
|
67
|
+
return props.defaultTab;
|
|
68
|
+
}
|
|
69
|
+
if (props.tabs.length > 0) {
|
|
70
|
+
return props.tabs[0].id ?? 0;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const activeTabId = ref(getInitialTabId());
|
|
76
|
+
|
|
77
|
+
function selectTab(tabId) {
|
|
78
|
+
if (activeTabId.value !== tabId) {
|
|
79
|
+
activeTabId.value = tabId;
|
|
80
|
+
const activeTab = props.tabs.find(tab => (tab.id ?? props.tabs.indexOf(tab)) === tabId);
|
|
81
|
+
emit('tab-change', activeTab, tabId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setActiveTab(tabId) {
|
|
86
|
+
const tab = props.tabs.find(t => (t.id ?? props.tabs.indexOf(t)) === tabId);
|
|
87
|
+
if (tab) {
|
|
88
|
+
selectTab(tabId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Initialize active tab on mount if not already set
|
|
93
|
+
onMounted(() => {
|
|
94
|
+
if (activeTabId.value === null && props.tabs.length > 0) {
|
|
95
|
+
activeTabId.value = props.tabs[0].id ?? 0;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Watch for tabs changes
|
|
100
|
+
watch(() => props.tabs, (newTabs) => {
|
|
101
|
+
if (newTabs.length > 0 && activeTabId.value === null) {
|
|
102
|
+
activeTabId.value = newTabs[0].id ?? 0;
|
|
103
|
+
}
|
|
104
|
+
}, { immediate: true });
|
|
105
|
+
|
|
106
|
+
defineExpose({
|
|
107
|
+
setActiveTab,
|
|
108
|
+
activeTabId
|
|
109
|
+
});
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<style scoped>
|
|
113
|
+
.tabs-container {
|
|
114
|
+
width: 100%;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.tabs-header {
|
|
118
|
+
display: flex;
|
|
119
|
+
border-bottom: 2px solid var(--border-color, #e1e5e9);
|
|
120
|
+
gap: 0.25rem;
|
|
121
|
+
overflow-x: auto;
|
|
122
|
+
scrollbar-width: thin;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.tabs-header::-webkit-scrollbar {
|
|
126
|
+
height: 4px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.tabs-header::-webkit-scrollbar-track {
|
|
130
|
+
background: transparent;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.tabs-header::-webkit-scrollbar-thumb {
|
|
134
|
+
background: var(--border-color, #ccc);
|
|
135
|
+
border-radius: 2px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.tab-button {
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: 0.5rem;
|
|
142
|
+
padding: 0.75rem 1.25rem;
|
|
143
|
+
background: transparent;
|
|
144
|
+
border: none;
|
|
145
|
+
border-bottom: 2px solid transparent;
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
font-size: 1em;
|
|
148
|
+
color: var(--text-muted, #666);
|
|
149
|
+
transition: all 0.2s ease;
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
position: relative;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.tab-button:hover {
|
|
155
|
+
color: var(--text-color, #000);
|
|
156
|
+
background-color: var(--hover-background-color, #f5f5f5);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.tab-button.active {
|
|
160
|
+
color: var(--primary-color, #007bff);
|
|
161
|
+
border-bottom-color: var(--primary-color, #007bff);
|
|
162
|
+
font-weight: 500;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.tab-button:focus {
|
|
166
|
+
outline: 2px solid var(--primary-color, #007bff);
|
|
167
|
+
outline-offset: -2px;
|
|
168
|
+
border-radius: 4px 4px 0 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.tabs-content {
|
|
172
|
+
width: 100%;
|
|
173
|
+
min-height: 200px;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.tab-panel {
|
|
177
|
+
padding: 1.5rem;
|
|
178
|
+
display: none;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.tab-panel.active {
|
|
182
|
+
display: block;
|
|
183
|
+
animation: fadeIn 0.2s ease-in;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@keyframes fadeIn {
|
|
187
|
+
from {
|
|
188
|
+
opacity: 0;
|
|
189
|
+
transform: translateY(4px);
|
|
190
|
+
}
|
|
191
|
+
to {
|
|
192
|
+
opacity: 1;
|
|
193
|
+
transform: translateY(0);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Dark theme support */
|
|
198
|
+
@media (prefers-color-scheme: dark) {
|
|
199
|
+
.tabs-header {
|
|
200
|
+
border-bottom-color: var(--border-color, #444);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.tab-button {
|
|
204
|
+
color: var(--text-muted, #999);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.tab-button:hover {
|
|
208
|
+
color: var(--text-color, #fff);
|
|
209
|
+
background-color: var(--hover-background-color, #333);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.tab-button.active {
|
|
213
|
+
color: var(--primary-color, #60a5fa);
|
|
214
|
+
border-bottom-color: var(--primary-color, #60a5fa);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* Responsive */
|
|
219
|
+
@media (max-width: 768px) {
|
|
220
|
+
.tabs-header {
|
|
221
|
+
gap: 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.tab-button {
|
|
225
|
+
padding: 0.75rem 1rem;
|
|
226
|
+
font-size: 0.9em;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.tab-panel {
|
|
230
|
+
padding: 1rem;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
</style>
|