ketekny-ui-kit 1.0.101 → 1.0.102

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.101",
4
+ "version": "1.0.102",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -1,7 +1,6 @@
1
1
  <script>
2
2
  import {
3
3
  Search,
4
- Bell,
5
4
  Building2,
6
5
  CircleUser,
7
6
  CircleHelp,
@@ -10,14 +9,19 @@ import {
10
9
  PanelLeftClose,
11
10
  PanelLeftOpen,
12
11
  UserRound,
12
+ Ellipsis,
13
13
  } from '@lucide/vue'
14
14
  import SidebarMenuItem from './SidebarMenuItem.vue'
15
+ import kMenu from '../../ui/kMenu.vue'
16
+ import demoAvatarUrl from '../../../dark-sidebar-menu-design/public/placeholder-user.jpg'
17
+
18
+ const FAVORITES_STORAGE_KEY = 'ketekny-ui-sidebar-favorites'
19
+ const MENU_TAB_STORAGE_KEY = 'ketekny-ui-sidebar-menu-tab'
15
20
 
16
21
  export default {
17
22
  name: 'MultilevelSidebar',
18
23
  components: {
19
24
  Search,
20
- Bell,
21
25
  Building2,
22
26
  CircleUser,
23
27
  CircleHelp,
@@ -26,7 +30,9 @@ export default {
26
30
  PanelLeftClose,
27
31
  PanelLeftOpen,
28
32
  UserRound,
33
+ Ellipsis,
29
34
  SidebarMenuItem,
35
+ kMenu,
30
36
  },
31
37
  props: {
32
38
  title: {
@@ -69,10 +75,13 @@ export default {
69
75
  emits: ['select', 'logo-click', 'signin', 'signup', 'signout', 'edit-profile', 'item-click'],
70
76
  data() {
71
77
  return {
78
+ demoAvatarUrl,
72
79
  activeId: '',
80
+ activeMenuTab: 'all',
73
81
  query: '',
74
82
  collapsed: false,
75
83
  openIds: new Set(['projects']),
84
+ favoriteIds: new Set(),
76
85
  footerLinks: [
77
86
  { id: 'my-profile', label: 'Το προφίλ μου', icon: 'UserRound' },
78
87
  { id: 'help', label: 'Βοήθεια', icon: 'CircleHelp' },
@@ -103,6 +112,44 @@ export default {
103
112
  if (!q) return this.mainMenu
104
113
  return this.filterTree(this.mainMenu, q)
105
114
  },
115
+ favoriteItems() {
116
+ const favorites = []
117
+ const collect = (items) => {
118
+ for (const item of items) {
119
+ if (item?.children?.length) {
120
+ collect(item.children)
121
+ continue
122
+ }
123
+ if (this.favoriteIds.has(item.id)) favorites.push(item)
124
+ }
125
+ }
126
+ collect(this.mainMenu)
127
+ return favorites
128
+ },
129
+ hasFavorites() {
130
+ return this.favoriteItems.length > 0
131
+ },
132
+ showMenuTabs() {
133
+ return !this.collapsed && this.hasFavorites
134
+ },
135
+ displayedMenuItems() {
136
+ if (this.activeMenuTab === 'favorites') return this.favoriteItems
137
+ return this.filteredMenu
138
+ },
139
+ collapsedMenuItems() {
140
+ if (!this.collapsed) return this.displayedMenuItems
141
+ if (!this.hasFavorites) return this.mainMenu
142
+ return [
143
+ {
144
+ id: 'favorites-shortcut',
145
+ label: 'Αγαπημένα',
146
+ icon: 'Star',
147
+ syntheticFavoritesShortcut: true,
148
+ children: this.favoriteItems,
149
+ },
150
+ ...this.mainMenu,
151
+ ]
152
+ },
106
153
  effectiveOpenIds() {
107
154
  const q = this.query.trim().toLowerCase()
108
155
  if (!q) return this.openIds
@@ -121,10 +168,31 @@ export default {
121
168
  expandedFooterLinks() {
122
169
  return this.footerLinks.filter(link => link.id !== 'my-profile')
123
170
  },
171
+ accountMenuLinks() {
172
+ return [
173
+ {
174
+ label: 'Το προφίλ μου',
175
+ icon: 'UserRound',
176
+ action: () => this.handleFooterSelect('my-profile'),
177
+ },
178
+ {
179
+ label: 'Βοήθεια',
180
+ icon: 'CircleHelp',
181
+ action: () => this.handleFooterSelect('help'),
182
+ },
183
+ {
184
+ label: 'Αποσύνδεση',
185
+ icon: 'LogOut',
186
+ tone: 'danger',
187
+ action: () => this.handleFooterSelect('sign-out'),
188
+ },
189
+ ]
190
+ },
124
191
  },
125
192
  watch: {
126
193
  mainMenu: {
127
194
  handler() {
195
+ this.syncFavoritesFromMenu()
128
196
  this.syncActiveFromMenu()
129
197
  },
130
198
  deep: true,
@@ -133,8 +201,36 @@ export default {
133
201
  $route() {
134
202
  this.syncActiveFromMenu()
135
203
  },
204
+ favoriteIds: {
205
+ handler(value) {
206
+ this.persistFavorites(value)
207
+ if (!value.size && this.activeMenuTab === 'favorites') this.activeMenuTab = 'all'
208
+ },
209
+ deep: false,
210
+ },
211
+ activeMenuTab(value) {
212
+ this.persistActiveMenuTab(value)
213
+ },
136
214
  },
137
215
  methods: {
216
+ syncFavoritesFromMenu() {
217
+ const storedFavorites = this.readStoredFavorites()
218
+ const storedTab = this.readStoredActiveMenuTab()
219
+ const discovered = []
220
+ const collect = (items) => {
221
+ for (const item of items) {
222
+ if (item?.favorite && !item?.children?.length) discovered.push(item.id)
223
+ if (item?.children?.length) collect(item.children)
224
+ }
225
+ }
226
+ collect(this.mainMenu)
227
+ this.favoriteIds = new Set([...discovered, ...storedFavorites])
228
+ if (storedTab === 'favorites' && (this.favoriteIds.size > 0 || discovered.length > 0)) {
229
+ this.activeMenuTab = 'favorites'
230
+ } else {
231
+ this.activeMenuTab = 'all'
232
+ }
233
+ },
138
234
  syncActiveFromMenu() {
139
235
  const routePath = this.$route?.path
140
236
  const routeFullPath = this.$route?.fullPath
@@ -175,8 +271,11 @@ export default {
175
271
  this.collapsed = !this.collapsed
176
272
  },
177
273
  toggle(id) {
178
- if (this.openIds.has(id)) this.openIds.delete(id)
179
- else this.openIds.add(id)
274
+ if (this.openIds.has(id)) {
275
+ this.openIds = new Set()
276
+ return
277
+ }
278
+ this.openIds = new Set([id])
180
279
  },
181
280
  select(itemOrId) {
182
281
  const id = typeof itemOrId === 'string' ? itemOrId : itemOrId?.id
@@ -239,6 +338,57 @@ export default {
239
338
  clearQuery() {
240
339
  this.query = ''
241
340
  },
341
+ toggleFavorite(item) {
342
+ const next = new Set(this.favoriteIds)
343
+ if (next.has(item.id)) next.delete(item.id)
344
+ else next.add(item.id)
345
+ this.favoriteIds = next
346
+ if (next.size && this.activeMenuTab !== 'favorites' && this.activeMenuTab !== 'all') {
347
+ this.activeMenuTab = 'all'
348
+ }
349
+ },
350
+ readStoredFavorites() {
351
+ if (typeof window === 'undefined') return []
352
+ try {
353
+ const raw = window.localStorage.getItem(FAVORITES_STORAGE_KEY)
354
+ if (!raw) return []
355
+ const parsed = JSON.parse(raw)
356
+ return Array.isArray(parsed) ? parsed.filter(value => typeof value === 'string') : []
357
+ } catch {
358
+ return []
359
+ }
360
+ },
361
+ persistFavorites(favorites) {
362
+ if (typeof window === 'undefined') return
363
+ try {
364
+ window.localStorage.setItem(
365
+ FAVORITES_STORAGE_KEY,
366
+ JSON.stringify(Array.from(favorites))
367
+ )
368
+ } catch {
369
+ // Ignore storage failures silently.
370
+ }
371
+ },
372
+ readStoredActiveMenuTab() {
373
+ if (typeof window === 'undefined') return 'all'
374
+ try {
375
+ const raw = window.localStorage.getItem(MENU_TAB_STORAGE_KEY)
376
+ return raw === 'favorites' ? 'favorites' : 'all'
377
+ } catch {
378
+ return 'all'
379
+ }
380
+ },
381
+ persistActiveMenuTab(value) {
382
+ if (typeof window === 'undefined') return
383
+ try {
384
+ window.localStorage.setItem(
385
+ MENU_TAB_STORAGE_KEY,
386
+ value === 'favorites' ? 'favorites' : 'all'
387
+ )
388
+ } catch {
389
+ // Ignore storage failures silently.
390
+ }
391
+ },
242
392
  },
243
393
  }
244
394
  </script>
@@ -302,79 +452,90 @@ export default {
302
452
  </div>
303
453
  </div>
304
454
 
305
- <div v-if="!collapsed" class="px-3 pb-2">
306
- <div class="relative">
307
- <Search
308
- class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-sidebar-muted"
309
- :stroke-width="2"
310
- />
311
- <input
312
- v-model="query"
313
- type="text"
314
- placeholder="Αναζήτηση μενού..."
315
- aria-label="Αναζήτηση μενού"
316
- class="w-full border rounded-md border-sidebar-border/35 bg-sidebar-accent/40 py-2 pl-8 pr-9 text-sidebar-foreground placeholder:text-sidebar-muted focus:border-white/15 focus:outline-none focus:ring-1 focus:ring-white/10"
317
- />
318
- <button
319
- v-if="query"
320
- type="button"
321
- class="absolute right-2 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
322
- aria-label="Καθαρισμός αναζήτησης"
323
- @click="clearQuery"
324
- >
325
- <X class="size-3.5" :stroke-width="2" />
326
- </button>
327
- </div>
328
- </div>
329
-
330
455
  <nav
331
456
  class="flex-1 px-3 py-2"
332
457
  :class="collapsed ? 'overflow-visible' : 'overflow-y-auto'"
333
458
  aria-label="Main navigation"
334
459
  >
460
+ <div v-if="showMenuTabs" class="mb-3 px-1">
461
+ <div class="inline-flex w-full rounded-lg bg-sidebar-accent/35 p-1">
462
+ <button
463
+ type="button"
464
+ class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
465
+ :class="activeMenuTab === 'all'
466
+ ? 'bg-sidebar text-sidebar-foreground shadow-sm'
467
+ : 'text-sidebar-muted hover:text-sidebar-foreground'"
468
+ @click="activeMenuTab = 'all'"
469
+ >
470
+ Όλα
471
+ </button>
472
+ <button
473
+ type="button"
474
+ class="flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
475
+ :class="activeMenuTab === 'favorites'
476
+ ? 'bg-sidebar text-sidebar-foreground shadow-sm'
477
+ : 'text-sidebar-muted hover:text-sidebar-foreground'"
478
+ @click="activeMenuTab = 'favorites'"
479
+ >
480
+ Αγαπημένα
481
+ </button>
482
+ </div>
483
+ </div>
484
+
485
+ <div v-if="!collapsed && (!showMenuTabs || activeMenuTab === 'all')" class="px-1 pb-3">
486
+ <div class="relative">
487
+ <Search
488
+ class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-sidebar-muted"
489
+ :stroke-width="2"
490
+ />
491
+ <input
492
+ v-model="query"
493
+ type="text"
494
+ placeholder="Αναζήτηση μενού..."
495
+ aria-label="Αναζήτηση μενού"
496
+ class="w-full border rounded-md border-sidebar-border/35 bg-sidebar-accent/40 py-2 pl-8 pr-9 text-sidebar-foreground placeholder:text-sidebar-muted focus:border-white/15 focus:outline-none focus:ring-1 focus:ring-white/10"
497
+ />
498
+ <button
499
+ v-if="query"
500
+ type="button"
501
+ class="absolute right-2 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
502
+ aria-label="Καθαρισμός αναζήτησης"
503
+ @click="clearQuery"
504
+ >
505
+ <X class="size-3.5" :stroke-width="2" />
506
+ </button>
507
+ </div>
508
+ </div>
509
+
335
510
  <ul class="flex flex-col gap-0.5">
336
511
  <SidebarMenuItem
337
- v-for="item in filteredMenu"
512
+ v-for="item in collapsed ? collapsedMenuItems : displayedMenuItems"
338
513
  :key="item.id"
339
514
  :item="item"
340
515
  :level="0"
341
516
  :active-id="activeId"
342
517
  :open-ids="effectiveOpenIds"
343
518
  :collapsed="collapsed"
519
+ :favorites="favoriteIds"
344
520
  @select="handleMenuSelect"
345
521
  @toggle="toggle"
522
+ @toggle-favorite="toggleFavorite"
346
523
  />
347
524
  </ul>
348
525
  <p
349
- v-if="!collapsed && filteredMenu.length === 0"
526
+ v-if="!collapsed && displayedMenuItems.length === 0"
350
527
  class="px-3 py-6 text-center text-sidebar-muted"
351
528
  >
352
529
  Δεν βρέθηκαν αποτελέσματα.
353
530
  </p>
354
531
  </nav>
355
532
 
356
- <div class="p-3 mt-auto border-t border-sidebar-border">
357
- <div :class="collapsed ? 'flex flex-col items-center gap-1' : 'space-y-1'">
358
- <div v-if="!collapsed" class="flex items-center gap-2">
359
- <button
360
- type="button"
361
- class="sidebar-footer-item flex min-h-[38px] flex-1 items-center gap-2.5 rounded-md bg-transparent px-2 py-2 text-left font-medium text-sidebar-foreground/80 outline-none transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-foreground"
362
- @click="handleFooterSelect('my-profile')"
363
- >
364
- <CircleUser class="sidebar-footer-item-icon size-4 shrink-0" :stroke-width="1.9" />
365
- <span class="min-w-0 flex-1">
366
- <span class="block truncate">Το προφίλ μου</span>
367
- <span class="block truncate text-xs font-normal text-sidebar-muted">{{ accountEmail }}</span>
368
- </span>
369
- </button>
370
- <button
371
- type="button"
372
- class="rounded-md p-1.5 text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-foreground"
373
- aria-label="Notifications"
374
- >
375
- <Bell class="size-4" :stroke-width="2" />
376
- </button>
377
- </div>
533
+ <div class="mt-auto border-t border-sidebar-border px-3 pb-3 pt-4">
534
+ <div
535
+ :class="collapsed
536
+ ? 'flex flex-col items-center gap-1'
537
+ : ''"
538
+ >
378
539
  <template v-if="collapsed">
379
540
  <button
380
541
  v-for="link in footerLinks"
@@ -408,35 +569,34 @@ export default {
408
569
  </button>
409
570
  </template>
410
571
  <template v-else>
411
- <button
412
- v-for="link in expandedFooterLinks"
413
- :key="`${link.id}-expanded`"
414
- type="button"
415
- class="flex items-center w-full rounded-md transition-colors duration-150"
416
- :class="[
417
- 'gap-2.5 px-2 py-2 text-left',
418
- activeId === link.id
419
- ? 'bg-brand text-brand-foreground font-medium'
420
- : link.id === 'sign-out'
421
- ? 'text-orange-300 hover:bg-orange-500/10 hover:text-orange-200'
422
- : 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground',
423
- ]"
424
- @click="handleFooterSelect(link.id)"
572
+ <kMenu
573
+ class="block w-full"
574
+ :links="accountMenuLinks"
575
+ aria-label="Account options"
425
576
  >
426
- <component
427
- :is="link.icon"
428
- class="size-4 shrink-0"
429
- :class="
430
- activeId === link.id
431
- ? 'text-brand-foreground'
432
- : link.id === 'sign-out'
433
- ? 'text-orange-400'
434
- : 'text-sidebar-muted'
435
- "
436
- :stroke-width="2"
437
- />
438
- <span class="truncate">{{ link.label }}</span>
439
- </button>
577
+ <template #trigger>
578
+ <div class="flex w-full items-center gap-2 rounded-lg px-1 py-1 text-sidebar-foreground/90 transition-colors duration-150">
579
+ <div class="sidebar-footer-item flex min-h-[44px] flex-1 items-center gap-2.5 rounded-lg bg-transparent px-1.5 py-1.5 text-left font-medium">
580
+ <img
581
+ :src="demoAvatarUrl"
582
+ alt="Account avatar"
583
+ class="size-8 shrink-0 rounded-md object-cover"
584
+ />
585
+ <span class="min-w-0 flex-1">
586
+ <span class="block truncate text-sm font-semibold">{{ accountDisplayName || 'Το προφίλ μου' }}</span>
587
+ <span class="block truncate text-xs font-normal text-sidebar-muted">{{ accountEmail }}</span>
588
+ </span>
589
+ </div>
590
+
591
+ <span
592
+ class="inline-flex size-9 items-center justify-center rounded-lg text-sidebar-muted"
593
+ aria-hidden="true"
594
+ >
595
+ <Ellipsis class="size-4" :stroke-width="2" />
596
+ </span>
597
+ </div>
598
+ </template>
599
+ </kMenu>
440
600
  </template>
441
601
  </div>
442
602
 
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { ChevronRight, ExternalLink } from '@lucide/vue'
2
+ import { ChevronRight, ExternalLink, Star } from '@lucide/vue'
3
3
  import * as icons from '@lucide/vue'
4
4
 
5
5
  export default {
@@ -7,6 +7,7 @@ export default {
7
7
  components: {
8
8
  ChevronRight,
9
9
  ExternalLink,
10
+ Star,
10
11
  },
11
12
  props: {
12
13
  item: { type: Object, required: true },
@@ -14,8 +15,9 @@ export default {
14
15
  activeId: { type: String, default: '' },
15
16
  openIds: { type: Object, required: true },
16
17
  collapsed: { type: Boolean, default: false },
18
+ favorites: { type: Object, default: null },
17
19
  },
18
- emits: ['select', 'toggle'],
20
+ emits: ['select', 'toggle', 'toggle-favorite'],
19
21
  computed: {
20
22
  hasChildren() {
21
23
  return Array.isArray(this.item.children) && this.item.children.length > 0
@@ -27,6 +29,7 @@ export default {
27
29
  return this.item.icon ? icons[this.item.icon] : null
28
30
  },
29
31
  isActiveTrail() {
32
+ if (this.item?.syntheticFavoritesShortcut) return false
30
33
  return this.hasChildren && this.containsActive(this.item)
31
34
  },
32
35
  isActiveLeaf() {
@@ -41,6 +44,9 @@ export default {
41
44
  isExternalLink() {
42
45
  return !this.hasChildren && Boolean(this.item?.link?.href ?? this.item?.href)
43
46
  },
47
+ isFavorite() {
48
+ return !this.hasChildren && this.favorites?.has?.(this.item.id)
49
+ },
44
50
  indentStyle() {
45
51
  return {
46
52
  paddingLeft: this.level === 0 ? '0.75rem' : `${this.level * 0.875 + 0.75}rem`,
@@ -60,6 +66,12 @@ export default {
60
66
  this.$emit('select', this.item)
61
67
  }
62
68
  },
69
+ toggleFavorite(event) {
70
+ event.preventDefault()
71
+ event.stopPropagation()
72
+ if (this.hasChildren) return
73
+ this.$emit('toggle-favorite', this.item)
74
+ },
63
75
  onEnter(el) {
64
76
  el.style.height = '0'
65
77
  el.offsetHeight
@@ -131,6 +143,21 @@ export default {
131
143
  :stroke-width="2"
132
144
  />
133
145
 
146
+ <button
147
+ v-if="!hasChildren"
148
+ type="button"
149
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sidebar-muted opacity-0 transition-all duration-150 group-hover:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:opacity-100 focus-visible:outline-none"
150
+ :class="isFavorite ? 'opacity-100 text-amber-400 hover:text-amber-300' : ''"
151
+ :aria-label="isFavorite ? 'Remove from favorites' : 'Add to favorites'"
152
+ @click="toggleFavorite"
153
+ >
154
+ <Star
155
+ class="size-3.5"
156
+ :class="isFavorite ? 'fill-current' : ''"
157
+ :stroke-width="2"
158
+ />
159
+ </button>
160
+
134
161
  <ChevronRight
135
162
  v-if="hasChildren"
136
163
  class="size-4 shrink-0 text-sidebar-muted transition-transform duration-200"
@@ -165,8 +192,10 @@ export default {
165
192
  :active-id="activeId"
166
193
  :open-ids="openIds"
167
194
  :collapsed="collapsed"
195
+ :favorites="favorites"
168
196
  @select="$emit('select', $event)"
169
197
  @toggle="$emit('toggle', $event)"
198
+ @toggle-favorite="$emit('toggle-favorite', $event)"
170
199
  />
171
200
  </ul>
172
201
  </Transition>
@@ -209,8 +238,10 @@ export default {
209
238
  :active-id="activeId"
210
239
  :open-ids="openIds"
211
240
  :collapsed="false"
241
+ :favorites="favorites"
212
242
  @select="$emit('select', $event)"
213
243
  @toggle="$emit('toggle', $event)"
244
+ @toggle-favorite="$emit('toggle-favorite', $event)"
214
245
  />
215
246
  </ul>
216
247
  </div>
package/src/ui/kMenu.vue CHANGED
@@ -24,7 +24,7 @@
24
24
  <div
25
25
  v-if="isOpen"
26
26
  ref="menuPanelRef"
27
- class="z-[9999] overflow-auto border rounded-lg shadow-lg bg-white/95 backdrop-blur-sm border-primary/20 ring-1 ring-primary/10 max-h-[70vh] dark:bg-slate-800/95 dark:border-slate-700 dark:ring-slate-700"
27
+ class="z-[9999] overflow-visible border rounded-lg shadow-lg bg-white/95 backdrop-blur-sm border-primary/20 ring-1 ring-primary/10 dark:bg-slate-800/95 dark:border-slate-700 dark:ring-slate-700"
28
28
  :style="panelStyle"
29
29
  role="menu">
30
30
  <span
@@ -36,30 +36,28 @@
36
36
  : 'top-[-7px] border-l border-t border-primary/20',
37
37
  ]"
38
38
  :style="{ left: `${chevronOffset}px` }"></span>
39
- <slot name="items" :close-menu="closeMenu" :close="closeMenu" :select="handleLinkClick">
40
- <ul class="p-2">
41
- <li v-for="(link, index) in normalizedLinks" :key="link.key || `${link.label}-${index}`">
42
- <component
43
- :is="resolveLinkTag(link)"
44
- class="flex items-center w-full gap-2 px-3 py-2 min-h-11 text-base rounded-md text-slate-700 dark:text-slate-200"
45
- :class="[
46
- link.disabled
47
- ? 'cursor-not-allowed opacity-50'
48
- : 'cursor-pointer hover:bg-primary/10 hover:text-primary active:bg-primary/15',
49
- ]"
50
- :to="isRouterLink(link) ? link.to : undefined"
51
- :href="!isRouterLink(link) && link.href ? link.href : undefined"
52
- :target="!isRouterLink(link) && link.target ? link.target : undefined"
53
- :rel="!isRouterLink(link) ? resolvedRel(link) : undefined"
54
- :disabled="resolveLinkTag(link) === 'button' ? !!link.disabled : undefined"
55
- role="menuitem"
56
- @click="handleLinkClick(link, $event)">
57
- <kIcon v-if="link.icon" :name="link.icon" :size="16" class="shrink-0 self-center text-primary/80" />
58
- <span class="leading-none">{{ link.label }}</span>
59
- </component>
60
- </li>
61
- </ul>
62
- </slot>
39
+ <div class="max-h-[70vh] overflow-y-auto overflow-x-hidden rounded-lg">
40
+ <slot name="items" :close-menu="closeMenu" :close="closeMenu" :select="handleLinkClick">
41
+ <ul class="p-2">
42
+ <li v-for="(link, index) in normalizedLinks" :key="link.key || `${link.label}-${index}`">
43
+ <component
44
+ :is="resolveLinkTag(link)"
45
+ class="flex items-center w-full gap-2 px-3 py-2 min-h-11 text-base rounded-md"
46
+ :class="itemClass(link)"
47
+ :to="isRouterLink(link) ? link.to : undefined"
48
+ :href="!isRouterLink(link) && link.href ? link.href : undefined"
49
+ :target="!isRouterLink(link) && link.target ? link.target : undefined"
50
+ :rel="!isRouterLink(link) ? resolvedRel(link) : undefined"
51
+ :disabled="resolveLinkTag(link) === 'button' ? !!link.disabled : undefined"
52
+ role="menuitem"
53
+ @click="handleLinkClick(link, $event)">
54
+ <kIcon v-if="link.icon" :name="link.icon" :size="16" class="shrink-0 self-center" :class="iconClass(link)" />
55
+ <span class="leading-none">{{ link.label }}</span>
56
+ </component>
57
+ </li>
58
+ </ul>
59
+ </slot>
60
+ </div>
63
61
  </div>
64
62
  </Teleport>
65
63
  </div>
@@ -126,6 +124,17 @@ export default {
126
124
  if (!link?.target || link.target !== "_blank") return link?.rel || undefined;
127
125
  return link?.rel || "noopener noreferrer";
128
126
  },
127
+ itemClass(link) {
128
+ if (link?.disabled) return "cursor-not-allowed opacity-50 text-slate-700 dark:text-slate-200";
129
+ if (link?.tone === "danger") {
130
+ return "cursor-pointer text-orange-600 hover:bg-orange-500/10 hover:text-orange-700 active:bg-orange-500/15 dark:text-orange-300 dark:hover:bg-orange-500/10 dark:hover:text-orange-200";
131
+ }
132
+ return "cursor-pointer text-slate-700 hover:bg-primary/10 hover:text-primary active:bg-primary/15 dark:text-slate-200";
133
+ },
134
+ iconClass(link) {
135
+ if (link?.tone === "danger") return "text-orange-500 dark:text-orange-300";
136
+ return "text-primary/80";
137
+ },
129
138
  handleLinkClick(link, event) {
130
139
  if (link?.disabled) {
131
140
  event.preventDefault();