[READ-ONLY] a fast, modern browser for the npm registry
at main 641 lines 21 kB view raw
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>