forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type { PackageVersionInfo } from '#shared/types'
3import { onClickOutside } from '@vueuse/core'
4import { compare } from 'semver'
5import {
6 buildVersionToTagsMap,
7 getPrereleaseChannel,
8 getVersionGroupKey,
9 getVersionGroupLabel,
10 isSameVersionGroup,
11} from '~/utils/versions'
12import { fetchAllPackageVersions } from '~/utils/npm/api'
13
14const props = defineProps<{
15 packageName: string
16 currentVersion: string
17 versions: Record<string, unknown>
18 distTags: Record<string, string>
19 /** URL pattern for navigation. Use {version} as placeholder. */
20 urlPattern: string
21}>()
22
23const isOpen = shallowRef(false)
24const dropdownRef = useTemplateRef('dropdownRef')
25const listboxRef = useTemplateRef('listboxRef')
26const focusedIndex = shallowRef(-1)
27
28onClickOutside(dropdownRef, () => {
29 isOpen.value = false
30})
31
32// ============================================================================
33// Version Display Types
34// ============================================================================
35
36interface VersionDisplay {
37 version: string
38 tags?: string[]
39 isCurrent?: boolean
40}
41
42interface VersionGroup {
43 id: string
44 label: string
45 primaryVersion: VersionDisplay
46 versions: VersionDisplay[]
47 isExpanded: boolean
48 isLoading: boolean
49}
50
51// ============================================================================
52// State
53// ============================================================================
54
55/** All version groups (dist-tags + major versions) */
56const versionGroups = ref<VersionGroup[]>([])
57
58/** Whether we've loaded all versions from the API */
59const hasLoadedAll = shallowRef(false)
60
61/** Loading state for initial all-versions fetch */
62const isLoadingAll = shallowRef(false)
63
64/** Cached full version list */
65const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null)
66
67// ============================================================================
68// Computed
69// ============================================================================
70
71const latestVersion = computed(() => props.distTags.latest)
72
73const versionToTags = computed(() => buildVersionToTagsMap(props.distTags))
74
75/** Get URL for a specific version */
76function getVersionUrl(version: string): string {
77 return props.urlPattern.replace('{version}', version)
78}
79
80/** Safe semver comparison with fallback */
81function safeCompareVersions(a: string, b: string): number {
82 try {
83 return compare(a, b)
84 } catch {
85 return a.localeCompare(b)
86 }
87}
88
89// ============================================================================
90// Initial Groups (SSR-safe, from props only)
91// ============================================================================
92
93/** Build initial version groups from dist-tags only */
94function buildInitialGroups(): VersionGroup[] {
95 const groups: VersionGroup[] = []
96 const seenVersions = new Set<string>()
97
98 // Group tags by version (multiple tags can point to same version)
99 const versionMap = new Map<string, { tags: string[] }>()
100 for (const [tag, version] of Object.entries(props.distTags)) {
101 const existing = versionMap.get(version)
102 if (existing) {
103 existing.tags.push(tag)
104 } else {
105 versionMap.set(version, { tags: [tag] })
106 }
107 }
108
109 // Sort tags within each version: 'latest' first, then alphabetically
110 for (const entry of versionMap.values()) {
111 entry.tags.sort((a, b) => {
112 if (a === 'latest') return -1
113 if (b === 'latest') return 1
114 return a.localeCompare(b)
115 })
116 }
117
118 // Build groups from tagged versions, sorted by version descending
119 const sortedEntries = Array.from(versionMap.entries()).sort((a, b) =>
120 safeCompareVersions(b[0], a[0]),
121 )
122
123 for (const [version, { tags }] of sortedEntries) {
124 seenVersions.add(version)
125 const primaryTag = tags[0]!
126
127 groups.push({
128 id: `tag:${primaryTag}`,
129 label: primaryTag,
130 primaryVersion: {
131 version,
132 tags,
133 isCurrent: version === props.currentVersion,
134 },
135 versions: [], // Will be populated when expanded
136 isExpanded: false,
137 isLoading: false,
138 })
139 }
140
141 return groups
142}
143
144// Initialize groups
145versionGroups.value = buildInitialGroups()
146
147// ============================================================================
148// Load All Versions
149// ============================================================================
150
151async function loadAllVersions(): Promise<PackageVersionInfo[]> {
152 if (allVersionsCache.value) return allVersionsCache.value
153
154 isLoadingAll.value = true
155 try {
156 const versions = await fetchAllPackageVersions(props.packageName)
157 allVersionsCache.value = versions
158 hasLoadedAll.value = true
159 return versions
160 } finally {
161 isLoadingAll.value = false
162 }
163}
164
165/** Process loaded versions and populate groups */
166function processLoadedVersions(allVersions: PackageVersionInfo[]) {
167 const groups: VersionGroup[] = []
168 const claimedVersions = new Set<string>()
169
170 // Process each dist-tag and find its channel versions
171 for (const [tag, tagVersion] of Object.entries(props.distTags)) {
172 // Skip if we already have a group for this version
173 const existingGroup = groups.find(g => g.primaryVersion.version === tagVersion)
174 if (existingGroup) {
175 // Add tag to existing group
176 if (!existingGroup.primaryVersion.tags?.includes(tag)) {
177 existingGroup.primaryVersion.tags = [...(existingGroup.primaryVersion.tags ?? []), tag]
178 existingGroup.primaryVersion.tags.sort((a, b) => {
179 if (a === 'latest') return -1
180 if (b === 'latest') return 1
181 return a.localeCompare(b)
182 })
183 // Update label to primary tag
184 existingGroup.label = existingGroup.primaryVersion.tags[0]!
185 existingGroup.id = `tag:${existingGroup.label}`
186 }
187 continue
188 }
189
190 const tagChannel = getPrereleaseChannel(tagVersion)
191
192 // Find all versions in the same version group + prerelease channel
193 // For 0.x versions, this means same major.minor; for 1.x+, same major
194 const channelVersions = allVersions
195 .filter(v => {
196 const vChannel = getPrereleaseChannel(v.version)
197 return isSameVersionGroup(v.version, tagVersion) && vChannel === tagChannel
198 })
199 .sort((a, b) => safeCompareVersions(b.version, a.version))
200 .map(v => ({
201 version: v.version,
202 tags: versionToTags.value.get(v.version),
203 isCurrent: v.version === props.currentVersion,
204 }))
205
206 // Mark these versions as claimed
207 for (const v of channelVersions) {
208 claimedVersions.add(v.version)
209 }
210
211 groups.push({
212 id: `tag:${tag}`,
213 label: tag,
214 primaryVersion: {
215 version: tagVersion,
216 tags: versionToTags.value.get(tagVersion),
217 isCurrent: tagVersion === props.currentVersion,
218 },
219 versions: channelVersions,
220 isExpanded: false,
221 isLoading: false,
222 })
223 }
224
225 // Sort groups by primary version descending
226 groups.sort((a, b) => safeCompareVersions(b.primaryVersion.version, a.primaryVersion.version))
227
228 // Deduplicate groups with same version (merge their tags)
229 const deduped: VersionGroup[] = []
230 for (const group of groups) {
231 const existing = deduped.find(g => g.primaryVersion.version === group.primaryVersion.version)
232 if (existing) {
233 // Merge tags
234 const allTags = [
235 ...(existing.primaryVersion.tags ?? []),
236 ...(group.primaryVersion.tags ?? []),
237 ]
238 const uniqueTags = [...new Set(allTags)].sort((a, b) => {
239 if (a === 'latest') return -1
240 if (b === 'latest') return 1
241 return a.localeCompare(b)
242 })
243 existing.primaryVersion.tags = uniqueTags
244 existing.label = uniqueTags[0]!
245 existing.id = `tag:${existing.label}`
246 } else {
247 deduped.push(group)
248 }
249 }
250
251 // Group unclaimed versions by version group key
252 // For 0.x versions, group by major.minor (e.g., "0.9", "0.10")
253 // For 1.x+, group by major (e.g., "1", "2")
254 const byGroupKey = new Map<string, VersionDisplay[]>()
255 for (const v of allVersions) {
256 if (claimedVersions.has(v.version)) continue
257
258 const groupKey = getVersionGroupKey(v.version)
259 if (!byGroupKey.has(groupKey)) {
260 byGroupKey.set(groupKey, [])
261 }
262 byGroupKey.get(groupKey)!.push({
263 version: v.version,
264 tags: versionToTags.value.get(v.version),
265 isCurrent: v.version === props.currentVersion,
266 })
267 }
268
269 // Sort within each group and create groups
270 // Sort group keys: "2", "1", "0.10", "0.9" (descending)
271 const sortedGroupKeys = Array.from(byGroupKey.keys()).sort((a, b) => {
272 // Parse as numbers for proper sorting
273 const [aMajor, aMinor] = a.split('.').map(Number)
274 const [bMajor, bMinor] = b.split('.').map(Number)
275 if (aMajor !== bMajor) return (bMajor ?? 0) - (aMajor ?? 0)
276 return (bMinor ?? -1) - (aMinor ?? -1)
277 })
278
279 for (const groupKey of sortedGroupKeys) {
280 const versions = byGroupKey.get(groupKey)!
281 versions.sort((a, b) => safeCompareVersions(b.version, a.version))
282
283 const primaryVersion = versions[0]
284 if (primaryVersion) {
285 deduped.push({
286 id: `group:${groupKey}`,
287 label: getVersionGroupLabel(groupKey),
288 primaryVersion,
289 versions,
290 isExpanded: false,
291 isLoading: false,
292 })
293 }
294 }
295
296 versionGroups.value = deduped
297}
298
299// ============================================================================
300// Expand/Collapse
301// ============================================================================
302
303async function toggleGroup(groupId: string) {
304 const group = versionGroups.value.find(g => g.id === groupId)
305 if (!group) return
306
307 if (group.isExpanded) {
308 group.isExpanded = false
309 return
310 }
311
312 // Load all versions if not yet loaded
313 if (!hasLoadedAll.value) {
314 group.isLoading = true
315 try {
316 const allVersions = await loadAllVersions()
317 processLoadedVersions(allVersions)
318 // Find the group again after processing (it may have moved)
319 const updatedGroup = versionGroups.value.find(g => g.id === groupId)
320 if (updatedGroup) {
321 updatedGroup.isExpanded = true
322 }
323 } catch (error) {
324 // eslint-disable-next-line no-console
325 console.error('Failed to load versions:', error)
326 } finally {
327 group.isLoading = false
328 }
329 } else {
330 group.isExpanded = true
331 }
332}
333
334// ============================================================================
335// Keyboard Navigation
336// ============================================================================
337
338/** Flat list of navigable items for keyboard navigation */
339const flatItems = computed(() => {
340 const items: Array<{ type: 'group' | 'version'; groupId: string; version?: VersionDisplay }> = []
341
342 for (const group of versionGroups.value) {
343 items.push({ type: 'group', groupId: group.id, version: group.primaryVersion })
344
345 if (group.isExpanded && group.versions.length > 1) {
346 // Skip first version (it's the primary)
347 for (const v of group.versions.slice(1)) {
348 items.push({ type: 'version', groupId: group.id, version: v })
349 }
350 }
351 }
352
353 return items
354})
355
356function handleButtonKeydown(event: KeyboardEvent) {
357 if (event.key === 'Escape') {
358 isOpen.value = false
359 } else if (event.key === 'ArrowDown' && !isOpen.value) {
360 event.preventDefault()
361 isOpen.value = true
362 focusedIndex.value = 0
363 }
364}
365
366function handleListboxKeydown(event: KeyboardEvent) {
367 const items = flatItems.value
368
369 switch (event.key) {
370 case 'Escape':
371 isOpen.value = false
372 break
373 case 'ArrowDown':
374 event.preventDefault()
375 focusedIndex.value = Math.min(focusedIndex.value + 1, items.length - 1)
376 scrollToFocused()
377 break
378 case 'ArrowUp':
379 event.preventDefault()
380 focusedIndex.value = Math.max(focusedIndex.value - 1, 0)
381 scrollToFocused()
382 break
383 case 'Home':
384 event.preventDefault()
385 focusedIndex.value = 0
386 scrollToFocused()
387 break
388 case 'End':
389 event.preventDefault()
390 focusedIndex.value = items.length - 1
391 scrollToFocused()
392 break
393 case 'ArrowRight': {
394 event.preventDefault()
395 const item = items[focusedIndex.value]
396 if (item?.type === 'group') {
397 const group = versionGroups.value.find(g => g.id === item.groupId)
398 if (group && !group.isExpanded && group.versions.length > 1) {
399 toggleGroup(item.groupId)
400 }
401 }
402 break
403 }
404 case 'ArrowLeft': {
405 event.preventDefault()
406 const item = items[focusedIndex.value]
407 if (item?.type === 'group') {
408 const group = versionGroups.value.find(g => g.id === item.groupId)
409 if (group?.isExpanded) {
410 group.isExpanded = false
411 }
412 } else if (item?.type === 'version') {
413 // Jump to parent group
414 const groupIndex = items.findIndex(i => i.type === 'group' && i.groupId === item.groupId)
415 if (groupIndex >= 0) {
416 focusedIndex.value = groupIndex
417 scrollToFocused()
418 }
419 }
420 break
421 }
422 case 'Enter':
423 case ' ':
424 event.preventDefault()
425 if (focusedIndex.value >= 0 && focusedIndex.value < items.length) {
426 const item = items[focusedIndex.value]
427 if (item?.version) {
428 navigateToVersion(item.version.version)
429 }
430 }
431 break
432 }
433}
434
435function scrollToFocused() {
436 nextTick(() => {
437 const focused = listboxRef.value?.querySelector('[data-focused="true"]')
438 focused?.scrollIntoView({ block: 'nearest' })
439 })
440}
441
442function navigateToVersion(version: string) {
443 isOpen.value = false
444 navigateTo(getVersionUrl(version))
445}
446
447// Reset focused index when dropdown opens
448watch(isOpen, open => {
449 if (open) {
450 // Find current version in flat list
451 const currentIdx = flatItems.value.findIndex(item => item.version?.isCurrent)
452 focusedIndex.value = currentIdx >= 0 ? currentIdx : 0
453 }
454})
455
456// Rebuild groups when props change
457watch(
458 () => [props.distTags, props.versions, props.currentVersion],
459 () => {
460 if (hasLoadedAll.value && allVersionsCache.value) {
461 processLoadedVersions(allVersionsCache.value)
462 } else {
463 versionGroups.value = buildInitialGroups()
464 }
465 },
466)
467</script>
468
469<template>
470 <div ref="dropdownRef" class="relative">
471 <button
472 type="button"
473 aria-haspopup="listbox"
474 :aria-expanded="isOpen"
475 class="flex items-center gap-1.5 text-fg-subtle font-mono text-sm hover:text-fg transition-[color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg rounded"
476 @click="isOpen = !isOpen"
477 @keydown="handleButtonKeydown"
478 >
479 <span dir="ltr">{{ currentVersion }}</span>
480 <span
481 v-if="currentVersion === latestVersion"
482 class="text-xs px-1.5 py-0.5 rounded badge-green font-sans font-medium"
483 >
484 latest
485 </span>
486 <span
487 class="i-lucide:chevron-down w-3.5 h-3.5 transition-[transform] duration-200 motion-reduce:transition-none"
488 :class="{ 'rotate-180': isOpen }"
489 aria-hidden="true"
490 />
491 </button>
492
493 <Transition
494 enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
495 enter-from-class="opacity-0 scale-95"
496 enter-to-class="opacity-100 scale-100"
497 leave-active-class="transition-[opacity,transform] duration-100 ease-in motion-reduce:transition-none"
498 leave-from-class="opacity-100 scale-100"
499 leave-to-class="opacity-0 scale-95"
500 >
501 <div
502 v-if="isOpen"
503 ref="listboxRef"
504 role="listbox"
505 tabindex="0"
506 :aria-activedescendant="
507 focusedIndex >= 0 ? `version-${flatItems[focusedIndex]?.version?.version}` : undefined
508 "
509 class="absolute top-full inset-is-0 mt-2 min-w-[220px] bg-bg-subtle/80 backdrop-blur-sm border border-border-subtle rounded-lg shadow-lg shadow-fg-subtle/10 z-50 py-1 max-h-[400px] overflow-y-auto overscroll-contain focus-visible:outline-none"
510 @keydown="handleListboxKeydown"
511 >
512 <!-- Version groups -->
513 <div v-for="group in versionGroups" :key="group.id">
514 <!-- Group header (primary version) -->
515 <div
516 :id="`version-${group.primaryVersion.version}`"
517 role="option"
518 :aria-selected="group.primaryVersion.isCurrent"
519 :data-focused="
520 flatItems[focusedIndex]?.groupId === group.id &&
521 flatItems[focusedIndex]?.type === 'group'
522 "
523 class="flex items-center gap-2 px-3 py-2 text-sm font-mono hover:bg-bg-muted transition-[color,background-color] focus-visible:outline-none"
524 :class="[
525 group.primaryVersion.isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted',
526 flatItems[focusedIndex]?.groupId === group.id &&
527 flatItems[focusedIndex]?.type === 'group'
528 ? 'bg-bg-muted'
529 : '',
530 ]"
531 >
532 <!-- Expand button -->
533 <button
534 v-if="group.versions.length > 1 || !hasLoadedAll"
535 type="button"
536 class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors shrink-0"
537 :aria-expanded="group.isExpanded"
538 :aria-label="group.isExpanded ? 'Collapse' : 'Expand'"
539 @click.stop="toggleGroup(group.id)"
540 >
541 <span
542 v-if="group.isLoading"
543 class="i-svg-spinners:ring-resize w-3 h-3"
544 aria-hidden="true"
545 />
546 <span
547 v-else
548 class="w-3 h-3 transition-transform duration-200 rtl-flip"
549 :class="group.isExpanded ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'"
550 aria-hidden="true"
551 />
552 </button>
553 <span v-else class="w-4" />
554
555 <!-- Version link -->
556 <NuxtLink
557 :to="getVersionUrl(group.primaryVersion.version)"
558 class="flex-1 truncate hover:text-fg transition-colors"
559 @click="isOpen = false"
560 >
561 <span dir="ltr">
562 {{ group.primaryVersion.version }}
563 </span>
564 </NuxtLink>
565
566 <!-- Tags -->
567 <span v-if="group.primaryVersion.tags?.length" class="flex items-center gap-1 shrink-0">
568 <span
569 v-for="tag in group.primaryVersion.tags"
570 :key="tag"
571 class="text-xs px-1.5 py-0.5 rounded font-sans font-medium"
572 :class="tag === 'latest' ? 'badge-green' : 'badge-subtle'"
573 >
574 {{ tag }}
575 </span>
576 </span>
577 </div>
578
579 <!-- Expanded versions -->
580 <div
581 v-if="group.isExpanded && group.versions.length > 1"
582 class="ms-6 border-is border-border"
583 >
584 <template v-for="v in group.versions.slice(1)" :key="v.version">
585 <NuxtLink
586 :id="`version-${v.version}`"
587 :to="getVersionUrl(v.version)"
588 role="option"
589 :aria-selected="v.isCurrent"
590 :data-focused="
591 flatItems[focusedIndex]?.groupId === group.id &&
592 flatItems[focusedIndex]?.type === 'version' &&
593 flatItems[focusedIndex]?.version?.version === v.version
594 "
595 class="flex items-center justify-between gap-2 ps-4 pe-3 py-1.5 text-xs font-mono hover:bg-bg-muted transition-[color,background-color] focus-visible:outline-none"
596 :class="[
597 v.isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-subtle',
598 flatItems[focusedIndex]?.version?.version === v.version ? 'bg-bg-muted' : '',
599 ]"
600 @click="isOpen = false"
601 >
602 <span class="truncate" dir="ltr">{{ v.version }}</span>
603 <span v-if="v.tags?.length" class="flex items-center gap-1 shrink-0">
604 <span
605 v-for="tag in v.tags"
606 :key="tag"
607 class="text-4xs px-1 py-0.5 rounded font-sans font-medium"
608 :class="
609 tag === 'latest'
610 ? 'bg-emerald-500/10 text-emerald-400'
611 : 'bg-bg-muted text-fg-subtle'
612 "
613 >
614 {{ tag }}
615 </span>
616 </span>
617 </NuxtLink>
618 </template>
619 </div>
620 </div>
621
622 <!-- Link to package page for full version list -->
623 <div class="border-t border-border mt-1 pt-1 px-3 py-2">
624 <NuxtLink
625 :to="packageRoute(packageName)"
626 class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg"
627 @click="isOpen = false"
628 >
629 {{
630 $t(
631 'package.versions.view_all',
632 { count: Object.keys(versions).length },
633 Object.keys(versions).length,
634 )
635 }}
636 </NuxtLink>
637 </div>
638 </div>
639 </Transition>
640 </div>
641</template>