forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { onKeyDown } from '@vueuse/core'
3import { UseFocusTrap } from '@vueuse/integrations/useFocusTrap/component'
4
5defineProps<{
6 /** Dependency path from root to vulnerable package (readonly from VulnerabilityTreeResult) */
7 path: readonly string[]
8}>()
9
10const isOpen = shallowRef(false)
11const popupEl = useTemplateRef('popupEl')
12const popupPosition = shallowRef<{ top: number; left: number } | null>(null)
13
14function closePopup() {
15 isOpen.value = false
16}
17
18// Close popup on click outside
19onClickOutside(popupEl, () => {
20 if (isOpen.value) closePopup()
21})
22
23onKeyDown(
24 'Escape',
25 e => {
26 e.preventDefault()
27 closePopup()
28 },
29 { dedupe: true, target: popupEl },
30)
31
32useEventListener('scroll', closePopup, { passive: true })
33
34function togglePopup(event: MouseEvent) {
35 if (isOpen.value) {
36 closePopup()
37 } else {
38 const button = event.currentTarget as HTMLElement
39 const rect = button.getBoundingClientRect()
40 popupPosition.value = {
41 top: rect.bottom + 4,
42 left: rect.left,
43 }
44 isOpen.value = true
45 }
46}
47
48function getPopupStyle(): Record<string, string> {
49 if (!popupPosition.value) return {}
50 return {
51 top: `${popupPosition.value.top}px`,
52 left: `${popupPosition.value.left}px`,
53 }
54}
55
56// Parse package string "name@version" into { name, version }
57function parsePackageString(pkg: string): { name: string; version: string } {
58 const atIndex = pkg.lastIndexOf('@')
59 if (atIndex > 0) {
60 return { name: pkg.slice(0, atIndex), version: pkg.slice(atIndex + 1) }
61 }
62 return { name: pkg, version: '' }
63}
64</script>
65
66<template>
67 <div class="relative">
68 <!-- Path badge button -->
69 <button
70 type="button"
71 class="path-badge font-mono text-3xs px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/30 text-amber-800 dark:text-amber-400 transition-all duration-200 ease-out whitespace-nowrap flex items-center gap-1 hover:bg-amber-500/20 hover:border-amber-500/50"
72 :aria-expanded="isOpen"
73 @click.stop="togglePopup"
74 >
75 <span class="i-lucide:network w-3 h-3" aria-hidden="true" />
76 <span>{{ $t('package.vulnerabilities.path') }}</span>
77 </button>
78
79 <!-- Tree popup -->
80 <div
81 v-if="isOpen"
82 ref="popupEl"
83 class="fixed z-[100] bg-bg-elevated border border-border rounded-lg shadow-xl p-3 min-w-64 max-w-sm"
84 :style="getPopupStyle()"
85 >
86 <UseFocusTrap :options="{ immediate: true }">
87 <ul class="list-none m-0 p-0 space-y-0.5">
88 <li
89 v-for="(pathItem, idx) in path"
90 :key="idx"
91 class="font-mono text-xs"
92 :style="{ paddingLeft: `${idx * 12}px` }"
93 >
94 <span v-if="idx > 0" class="text-fg-subtle me-1">└─</span>
95 <NuxtLink
96 :to="
97 packageRoute(
98 parsePackageString(pathItem).name,
99 parsePackageString(pathItem).version,
100 )
101 "
102 class="hover:underline"
103 :class="idx === path.length - 1 ? 'text-fg font-medium' : 'text-fg-muted'"
104 @click="closePopup"
105 >
106 {{ pathItem }}
107 </NuxtLink>
108 <span v-if="idx === path.length - 1" class="ms-1 text-amber-500">⚠</span>
109 </li>
110 </ul>
111 </UseFocusTrap>
112 </div>
113 </div>
114</template>