[READ-ONLY] a fast, modern browser for the npm registry
at main 1070 lines 39 kB view raw
1<script setup lang="ts"> 2import type { PackageVersionInfo, SlimVersion } from '#shared/types' 3import { compare, validRange } from 'semver' 4import type { RouteLocationRaw } from 'vue-router' 5import { fetchAllPackageVersions } from '~/utils/npm/api' 6import { NPMX_DOCS_SITE } from '#shared/utils/constants' 7import { 8 buildVersionToTagsMap, 9 filterExcludedTags, 10 filterVersions, 11 getPrereleaseChannel, 12 getVersionGroupKey, 13 getVersionGroupLabel, 14 isSameVersionGroup, 15} from '~/utils/versions' 16 17const props = defineProps<{ 18 packageName: string 19 versions: Record<string, SlimVersion> 20 distTags: Record<string, string> 21 time: Record<string, string> 22 selectedVersion?: string 23}>() 24 25const chartModal = useModal('chart-modal') 26const hasDistributionModalTransitioned = shallowRef(false) 27const isDistributionModalOpen = shallowRef(false) 28let distributionModalFallbackTimer: ReturnType<typeof setTimeout> | null = null 29 30function clearDistributionModalFallbackTimer() { 31 if (distributionModalFallbackTimer) { 32 clearTimeout(distributionModalFallbackTimer) 33 distributionModalFallbackTimer = null 34 } 35} 36 37async function openDistributionModal() { 38 isDistributionModalOpen.value = true 39 hasDistributionModalTransitioned.value = false 40 // ensure the component renders before opening the dialog 41 await nextTick() 42 chartModal.open() 43 44 // Fallback: Force mount if transition event doesn't fire 45 clearDistributionModalFallbackTimer() 46 distributionModalFallbackTimer = setTimeout(() => { 47 if (!hasDistributionModalTransitioned.value) { 48 hasDistributionModalTransitioned.value = true 49 } 50 }, 500) 51} 52 53function closeDistributionModal() { 54 isDistributionModalOpen.value = false 55 hasDistributionModalTransitioned.value = false 56 clearDistributionModalFallbackTimer() 57} 58 59function handleDistributionModalTransitioned() { 60 hasDistributionModalTransitioned.value = true 61 clearDistributionModalFallbackTimer() 62} 63 64/** Maximum number of dist-tag rows to show before collapsing into "Other versions" */ 65const MAX_VISIBLE_TAGS = 10 66 67/** A version with its metadata */ 68interface VersionDisplay { 69 version: string 70 time?: string 71 tags?: string[] 72 hasProvenance: boolean 73 deprecated?: string 74} 75 76// Build route object for package version link 77function versionRoute(version: string): RouteLocationRaw { 78 return packageRoute(props.packageName, version) 79} 80 81// Version to tags lookup (supports multiple tags per version) 82const versionToTags = computed(() => buildVersionToTagsMap(props.distTags)) 83 84const effectiveCurrentVersion = computed( 85 () => props.selectedVersion ?? props.distTags.latest ?? undefined, 86) 87 88// Semver range filter 89const semverFilter = ref('') 90// Collect all known versions: initial props + dynamically loaded ones 91const allKnownVersions = computed(() => { 92 const versions = new Set(Object.keys(props.versions)) 93 for (const versionList of tagVersions.value.values()) { 94 for (const v of versionList) { 95 versions.add(v.version) 96 } 97 } 98 for (const group of otherMajorGroups.value) { 99 for (const v of group.versions) { 100 versions.add(v.version) 101 } 102 } 103 return [...versions] 104}) 105const filteredVersionSet = computed(() => 106 filterVersions(allKnownVersions.value, semverFilter.value), 107) 108const isFilterActive = computed(() => semverFilter.value.trim() !== '') 109const isInvalidRange = computed( 110 () => isFilterActive.value && validRange(semverFilter.value.trim()) === null, 111) 112 113// All tag rows derived from props (SSR-safe) 114// Deduplicates so each version appears only once, with all its tags 115const allTagRows = computed(() => { 116 // Group tags by version with their metadata 117 const versionMap = new Map<string, { tags: string[]; versionData: SlimVersion | undefined }>() 118 for (const [tag, version] of Object.entries(props.distTags)) { 119 const existing = versionMap.get(version) 120 if (existing) { 121 existing.tags.push(tag) 122 } else { 123 versionMap.set(version, { 124 tags: [tag], 125 versionData: props.versions[version], 126 }) 127 } 128 } 129 130 // Sort tags within each version: 'latest' first, then alphabetically 131 for (const entry of versionMap.values()) { 132 entry.tags.sort((a, b) => { 133 if (a === 'latest') return -1 134 if (b === 'latest') return 1 135 return a.localeCompare(b) 136 }) 137 } 138 139 // Convert to rows, using the first (most important) tag as the primary 140 return Array.from(versionMap.entries()) 141 .map(([version, { tags, versionData }]) => ({ 142 id: `version:${version}`, 143 tag: tags[0]!, // Primary tag for expand/collapse logic 144 tags, // All tags for this version 145 primaryVersion: { 146 version, 147 time: props.time[version], 148 tags, 149 hasProvenance: versionData?.hasProvenance, 150 deprecated: versionData?.deprecated, 151 } as VersionDisplay, 152 })) 153 .sort((a, b) => compare(b.primaryVersion.version, a.primaryVersion.version)) 154}) 155 156// Check if the whole package is deprecated (latest version is deprecated) 157const isPackageDeprecated = computed(() => { 158 const latestVersion = props.distTags.latest 159 if (!latestVersion) return false 160 return !!props.versions[latestVersion]?.deprecated 161}) 162 163// Visible tag rows: limited to MAX_VISIBLE_TAGS 164// If package is NOT deprecated, filter out deprecated tags from visible list 165// When semver filter is active, also filter by matching version 166const visibleTagRows = computed(() => { 167 const rowsMaybeFilteredForDeprecation = isPackageDeprecated.value 168 ? allTagRows.value 169 : allTagRows.value.filter(row => !row.primaryVersion.deprecated) 170 const rows = isFilterActive.value 171 ? rowsMaybeFilteredForDeprecation.filter(row => 172 filteredVersionSet.value.has(row.primaryVersion.version), 173 ) 174 : rowsMaybeFilteredForDeprecation 175 const first = rows.slice(0, MAX_VISIBLE_TAGS) 176 const latestTagRow = rows.find(row => row.tag === 'latest') 177 // Ensure 'latest' tag is always included (at the end) if not already present 178 if (latestTagRow && !first.includes(latestTagRow)) { 179 first.pop() 180 first.push(latestTagRow) 181 } 182 return first 183}) 184 185// Hidden tag rows (all other tags) - shown in "Other versions" 186// When semver filter is active, also filter by matching version 187const hiddenTagRows = computed(() => { 188 const hiddenRows = allTagRows.value.filter(row => !visibleTagRows.value.includes(row)) 189 const rows = isFilterActive.value 190 ? hiddenRows.filter(row => filteredVersionSet.value.has(row.primaryVersion.version)) 191 : hiddenRows 192 return rows 193}) 194 195// Client-side state for expansion and loaded versions 196const expandedTags = ref<Set<string>>(new Set()) 197const tagVersions = ref<Map<string, VersionDisplay[]>>(new Map()) 198const loadingTags = ref<Set<string>>(new Set()) 199 200const otherVersionsExpanded = shallowRef(false) 201const expandedMajorGroups = ref<Set<string>>(new Set()) 202const otherMajorGroups = shallowRef< 203 Array<{ groupKey: string; label: string; versions: VersionDisplay[] }> 204>([]) 205const otherVersionsLoading = shallowRef(false) 206 207// Filtered major groups (applies semver filter when active) 208const filteredOtherMajorGroups = computed(() => { 209 if (!isFilterActive.value) return otherMajorGroups.value 210 return otherMajorGroups.value 211 .map(group => ({ 212 ...group, 213 versions: group.versions.filter(v => filteredVersionSet.value.has(v.version)), 214 })) 215 .filter(group => group.versions.length > 0) 216}) 217 218// Whether the filter is active but nothing matches anywhere 219const hasNoFilterMatches = computed(() => { 220 if (!isFilterActive.value) return false 221 return ( 222 visibleTagRows.value.length === 0 && 223 hiddenTagRows.value.length === 0 && 224 filteredOtherMajorGroups.value.length === 0 225 ) 226}) 227 228// Cached full version list (local to component instance) 229const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null) 230const loadingVersions = shallowRef(false) 231const hasLoadedAll = shallowRef(false) 232 233// Load all versions using shared function 234async function loadAllVersions(): Promise<PackageVersionInfo[]> { 235 if (allVersionsCache.value) return allVersionsCache.value 236 237 if (loadingVersions.value) { 238 await new Promise<void>(resolve => { 239 const unwatch = watch(allVersionsCache, val => { 240 if (val) { 241 unwatch() 242 resolve() 243 } 244 }) 245 }) 246 return allVersionsCache.value! 247 } 248 249 loadingVersions.value = true 250 try { 251 const versions = await fetchAllPackageVersions(props.packageName) 252 allVersionsCache.value = versions 253 hasLoadedAll.value = true 254 return versions 255 } finally { 256 loadingVersions.value = false 257 } 258} 259 260// Process loaded versions 261function processLoadedVersions(allVersions: PackageVersionInfo[]) { 262 const distTags = props.distTags 263 264 // For each tag, find versions in its channel (same major + same prerelease channel) 265 const claimedVersions = new Set<string>() 266 267 for (const row of allTagRows.value) { 268 const tagVersion = distTags[row.tag] 269 if (!tagVersion) continue 270 271 const tagChannel = getPrereleaseChannel(tagVersion) 272 273 // Find all versions in the same version group + prerelease channel 274 // For 0.x versions, this means same major.minor; for 1.x+, same major 275 const channelVersions = allVersions 276 .filter(v => { 277 const vChannel = getPrereleaseChannel(v.version) 278 return isSameVersionGroup(v.version, tagVersion) && vChannel === tagChannel 279 }) 280 .sort((a, b) => compare(b.version, a.version)) 281 .map(v => ({ 282 version: v.version, 283 time: v.time, 284 tags: versionToTags.value.get(v.version), 285 hasProvenance: v.hasProvenance, 286 deprecated: v.deprecated, 287 })) 288 289 tagVersions.value.set(row.tag, channelVersions) 290 291 for (const v of channelVersions) { 292 claimedVersions.add(v.version) 293 } 294 } 295 296 // Group unclaimed versions by version group key 297 // For 0.x versions, group by major.minor (e.g., "0.9", "0.10") 298 // For 1.x+, group by major (e.g., "1", "2") 299 const byGroupKey = new Map<string, VersionDisplay[]>() 300 301 for (const v of allVersions) { 302 if (claimedVersions.has(v.version)) continue 303 304 const groupKey = getVersionGroupKey(v.version) 305 if (!byGroupKey.has(groupKey)) { 306 byGroupKey.set(groupKey, []) 307 } 308 byGroupKey.get(groupKey)!.push({ 309 version: v.version, 310 time: v.time, 311 tags: versionToTags.value.get(v.version), 312 hasProvenance: v.hasProvenance, 313 deprecated: v.deprecated, 314 }) 315 } 316 317 // Sort within each group 318 for (const versions of byGroupKey.values()) { 319 versions.sort((a, b) => compare(b.version, a.version)) 320 } 321 322 // Build groups sorted by group key descending 323 // Sort: "2", "1", "0.10", "0.9" (numerically descending) 324 const sortedGroupKeys = Array.from(byGroupKey.keys()).sort((a, b) => { 325 const [aMajor, aMinor] = a.split('.').map(Number) 326 const [bMajor, bMinor] = b.split('.').map(Number) 327 if (aMajor !== bMajor) return (bMajor ?? 0) - (aMajor ?? 0) 328 return (bMinor ?? -1) - (aMinor ?? -1) 329 }) 330 otherMajorGroups.value = sortedGroupKeys.map(groupKey => ({ 331 groupKey, 332 label: getVersionGroupLabel(groupKey), 333 versions: byGroupKey.get(groupKey)!, 334 })) 335 expandedMajorGroups.value.clear() 336} 337 338// Expand a tag row 339async function expandTagRow(tag: string) { 340 if (expandedTags.value.has(tag)) { 341 expandedTags.value.delete(tag) 342 expandedTags.value = new Set(expandedTags.value) 343 return 344 } 345 346 if (!hasLoadedAll.value) { 347 loadingTags.value.add(tag) 348 loadingTags.value = new Set(loadingTags.value) 349 try { 350 const allVersions = await loadAllVersions() 351 processLoadedVersions(allVersions) 352 } catch (error) { 353 // oxlint-disable-next-line no-console -- error logging 354 console.error('Failed to load versions:', error) 355 } finally { 356 loadingTags.value.delete(tag) 357 loadingTags.value = new Set(loadingTags.value) 358 } 359 } 360 361 expandedTags.value.add(tag) 362 expandedTags.value = new Set(expandedTags.value) 363} 364 365// Expand "Other versions" section 366async function expandOtherVersions() { 367 if (otherVersionsExpanded.value) { 368 otherVersionsExpanded.value = false 369 return 370 } 371 372 if (!hasLoadedAll.value) { 373 otherVersionsLoading.value = true 374 try { 375 const allVersions = await loadAllVersions() 376 processLoadedVersions(allVersions) 377 } catch (error) { 378 // oxlint-disable-next-line no-console -- error logging 379 console.error('Failed to load versions:', error) 380 } finally { 381 otherVersionsLoading.value = false 382 } 383 } 384 385 otherVersionsExpanded.value = true 386} 387 388// Toggle a version group 389function toggleMajorGroup(groupKey: string) { 390 if (expandedMajorGroups.value.has(groupKey)) { 391 expandedMajorGroups.value.delete(groupKey) 392 } else { 393 expandedMajorGroups.value.add(groupKey) 394 } 395} 396 397// Get versions for a tag (from loaded data or empty) 398function getTagVersions(tag: string): VersionDisplay[] { 399 return tagVersions.value.get(tag) ?? [] 400} 401 402// Get filtered versions for a tag (applies semver filter when active) 403function getFilteredTagVersions(tag: string): VersionDisplay[] { 404 const versions = getTagVersions(tag) 405 if (!isFilterActive.value) return versions 406 return versions.filter(v => filteredVersionSet.value.has(v.version)) 407} 408 409function findClaimingTag(version: string): string | null { 410 const versionChannel = getPrereleaseChannel(version) 411 412 // First matching tag claims the version 413 for (const row of allTagRows.value) { 414 const tagVersion = props.distTags[row.tag] 415 if (!tagVersion) continue 416 417 const tagChannel = getPrereleaseChannel(tagVersion) 418 419 if (isSameVersionGroup(version, tagVersion) && versionChannel === tagChannel) { 420 return row.tag 421 } 422 } 423 424 return null 425} 426 427// Whether this row should be highlighted for the current version 428function rowContainsCurrentVersion(row: (typeof visibleTagRows.value)[0]): boolean { 429 if (!effectiveCurrentVersion.value) return false 430 431 if (row.primaryVersion.version === effectiveCurrentVersion.value) return true 432 433 if (getTagVersions(row.tag).some(v => v.version === effectiveCurrentVersion.value)) return true 434 435 const claimingTag = findClaimingTag(effectiveCurrentVersion.value) 436 return claimingTag === row.tag 437} 438 439function otherVersionsContainsCurrent(): boolean { 440 if (!effectiveCurrentVersion.value) return false 441 442 const claimingTag = findClaimingTag(effectiveCurrentVersion.value) 443 444 // If a tag claims it, check if that tag is in visibleTagRows 445 if (claimingTag) { 446 const isInVisibleTags = visibleTagRows.value.some(row => row.tag === claimingTag) 447 if (!isInVisibleTags) return true 448 return false 449 } 450 451 // No tag claims it - it would be in otherMajorGroups 452 return true 453} 454 455function hiddenRowContainsCurrent(row: (typeof hiddenTagRows.value)[0]): boolean { 456 if (!effectiveCurrentVersion.value) return false 457 if (row.primaryVersion.version === effectiveCurrentVersion.value) return true 458 459 const claimingTag = findClaimingTag(effectiveCurrentVersion.value) 460 return claimingTag === row.tag 461} 462 463function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): boolean { 464 if (!effectiveCurrentVersion.value) return false 465 return group.versions.some(v => v.version === effectiveCurrentVersion.value) 466} 467</script> 468 469<template> 470 <CollapsibleSection 471 v-if="allTagRows.length > 0" 472 :title="$t('package.versions.title')" 473 id="versions" 474 > 475 <template #actions> 476 <ButtonBase 477 variant="secondary" 478 class="text-fg-subtle hover:text-fg transition-colors min-w-6 min-h-6 -m-1 p-1 rounded" 479 :title="$t('package.downloads.community_distribution')" 480 classicon="i-lucide:file-stack" 481 @click="openDistributionModal" 482 > 483 <span class="sr-only">{{ $t('package.downloads.community_distribution') }}</span> 484 </ButtonBase> 485 </template> 486 <div class="space-y-0.5 min-w-0"> 487 <!-- Semver range filter --> 488 <div class="px-1 pb-1"> 489 <div class="flex items-center gap-1.5"> 490 <InputBase 491 v-model="semverFilter" 492 type="text" 493 :placeholder="$t('package.versions.filter_placeholder')" 494 :aria-label="$t('package.versions.filter_placeholder')" 495 :aria-invalid="isInvalidRange ? 'true' : undefined" 496 :aria-describedby="isInvalidRange ? 'semver-filter-error' : undefined" 497 autocomplete="off" 498 class="flex-1 min-w-0" 499 :class="isInvalidRange ? '!border-red-500' : ''" 500 size="small" 501 /> 502 <TooltipApp interactive position="top"> 503 <span 504 tabindex="0" 505 class="i-lucide:info w-3.5 h-3.5 text-fg-subtle cursor-help shrink-0 rounded-sm" 506 role="img" 507 :aria-label="$t('package.versions.filter_help')" 508 /> 509 <template #content> 510 <p class="text-xs text-fg-muted"> 511 <i18n-t keypath="package.versions.filter_tooltip" tag="span"> 512 <template #link> 513 <LinkBase :to="`${NPMX_DOCS_SITE}/guide/semver-ranges`">{{ 514 $t('package.versions.filter_tooltip_link') 515 }}</LinkBase> 516 </template> 517 </i18n-t> 518 </p> 519 </template> 520 </TooltipApp> 521 </div> 522 <p 523 v-if="isInvalidRange" 524 id="semver-filter-error" 525 class="text-red-500 text-3xs mt-1" 526 role="alert" 527 > 528 {{ $t('package.versions.filter_invalid') }} 529 </p> 530 </div> 531 532 <!-- No matches message --> 533 <div 534 v-if="hasNoFilterMatches" 535 class="px-1 py-2 text-xs text-fg-subtle" 536 role="status" 537 aria-live="polite" 538 > 539 {{ $t('package.versions.no_matches') }} 540 </div> 541 542 <!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) --> 543 <div v-for="row in visibleTagRows" :key="row.id"> 544 <div 545 class="flex items-center gap-2 pe-2 px-1" 546 :class="rowContainsCurrentVersion(row) ? 'bg-bg-subtle rounded-lg' : ''" 547 > 548 <!-- Expand button (only if there are more versions to show) --> 549 <button 550 v-if="getTagVersions(row.tag).length > 1 || !hasLoadedAll" 551 type="button" 552 class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors rounded-sm" 553 :aria-expanded="expandedTags.has(row.tag)" 554 :aria-label=" 555 expandedTags.has(row.tag) 556 ? $t('package.versions.collapse', { tag: row.tag }) 557 : $t('package.versions.expand', { tag: row.tag }) 558 " 559 data-testid="tag-expand-button" 560 @click="expandTagRow(row.tag)" 561 > 562 <span 563 v-if="loadingTags.has(row.tag)" 564 class="i-svg-spinners:ring-resize w-3 h-3" 565 data-testid="loading-spinner" 566 aria-hidden="true" 567 /> 568 <span 569 v-else 570 class="w-3 h-3 transition-transform duration-200 rtl-flip" 571 :class=" 572 expandedTags.has(row.tag) ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right' 573 " 574 aria-hidden="true" 575 /> 576 </button> 577 <span v-else class="w-4" /> 578 579 <!-- Version info --> 580 <div class="flex-1 py-1.5 min-w-0 flex gap-2 justify-between items-center"> 581 <div class="overflow-hidden"> 582 <LinkBase 583 :to="versionRoute(row.primaryVersion.version)" 584 block 585 class="text-sm" 586 :class=" 587 row.primaryVersion.deprecated 588 ? 'text-red-800 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' 589 : undefined 590 " 591 :title=" 592 row.primaryVersion.deprecated 593 ? $t('package.versions.deprecated_title', { 594 version: row.primaryVersion.version, 595 }) 596 : row.primaryVersion.version 597 " 598 :classicon="row.primaryVersion.deprecated ? 'i-lucide:octagon-alert' : undefined" 599 > 600 <span dir="ltr" class="block truncate"> 601 {{ row.primaryVersion.version }} 602 </span> 603 </LinkBase> 604 <div v-if="row.tags.length" class="flex items-center gap-1 mt-0.5 flex-wrap"> 605 <span 606 v-for="tag in row.tags" 607 :key="tag" 608 class="text-4xs font-semibold text-fg-subtle uppercase tracking-wide truncate" 609 :title="tag" 610 > 611 {{ tag }} 612 </span> 613 </div> 614 </div> 615 <div class="flex items-center gap-2 shrink-0"> 616 <DateTime 617 v-if="row.primaryVersion.time" 618 :datetime="row.primaryVersion.time" 619 year="numeric" 620 month="short" 621 day="numeric" 622 class="text-xs text-fg-subtle" 623 /> 624 <ProvenanceBadge 625 v-if="row.primaryVersion.hasProvenance" 626 :package-name="packageName" 627 :version="row.primaryVersion.version" 628 compact 629 /> 630 </div> 631 </div> 632 </div> 633 634 <!-- Expanded versions --> 635 <div 636 v-if="expandedTags.has(row.tag) && getFilteredTagVersions(row.tag).length > 1" 637 class="ms-4 ps-2 border-is border-border space-y-0.5 pe-2" 638 > 639 <div 640 v-for="v in getFilteredTagVersions(row.tag).slice(1)" 641 :key="v.version" 642 class="py-1" 643 :class="v.version === effectiveCurrentVersion ? 'rounded bg-bg-subtle px-2 -mx-2' : ''" 644 > 645 <div class="flex items-center justify-between gap-2"> 646 <LinkBase 647 :to="versionRoute(v.version)" 648 block 649 class="text-xs" 650 :class=" 651 v.deprecated 652 ? 'text-red-800 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' 653 : undefined 654 " 655 :title=" 656 v.deprecated 657 ? $t('package.versions.deprecated_title', { 658 version: v.version, 659 }) 660 : v.version 661 " 662 :classicon="v.deprecated ? 'i-lucide:octagon-alert' : undefined" 663 > 664 <span dir="ltr" class="block truncate"> 665 {{ v.version }} 666 </span> 667 </LinkBase> 668 <div class="flex items-center gap-2 shrink-0"> 669 <DateTime 670 v-if="v.time" 671 :datetime="v.time" 672 class="text-3xs text-fg-subtle" 673 year="numeric" 674 month="short" 675 day="numeric" 676 /> 677 <ProvenanceBadge 678 v-if="v.hasProvenance" 679 :package-name="packageName" 680 :version="v.version" 681 compact 682 /> 683 </div> 684 </div> 685 <div 686 v-if="v.tags?.length && filterExcludedTags(v.tags, row.tags).length" 687 class="flex items-center gap-1 mt-0.5" 688 > 689 <span 690 v-for="tag in filterExcludedTags(v.tags, row.tags)" 691 :key="tag" 692 class="text-5xs font-semibold text-fg-subtle uppercase tracking-wide truncate max-w-[120px]" 693 :title="tag" 694 > 695 {{ tag }} 696 </span> 697 </div> 698 </div> 699 </div> 700 </div> 701 702 <!-- Other versions section --> 703 <div class="p-1"> 704 <button 705 type="button" 706 class="flex items-center gap-2 text-start rounded-sm w-full" 707 :class="otherVersionsContainsCurrent() ? 'bg-bg-subtle' : ''" 708 :aria-expanded="otherVersionsExpanded" 709 :aria-label=" 710 otherVersionsExpanded 711 ? $t('package.versions.collapse_other') 712 : $t('package.versions.expand_other') 713 " 714 @click="expandOtherVersions" 715 > 716 <span 717 class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors" 718 > 719 <span 720 v-if="otherVersionsLoading" 721 class="i-svg-spinners:ring-resize w-3 h-3" 722 data-testid="loading-spinner" 723 aria-hidden="true" 724 /> 725 <span 726 v-else 727 class="w-3 h-3 transition-transform duration-200 rtl-flip" 728 :class="otherVersionsExpanded ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'" 729 aria-hidden="true" 730 /> 731 </span> 732 <span class="text-xs text-fg-muted py-1.5"> 733 {{ $t('package.versions.other_versions') }} 734 <span v-if="hiddenTagRows.length > 0" class="text-fg-subtle"> 735 ({{ 736 $t( 737 'package.versions.more_tagged', 738 { count: hiddenTagRows.length }, 739 hiddenTagRows.length, 740 ) 741 }}) 742 </span> 743 </span> 744 </button> 745 746 <!-- Expanded other versions --> 747 <div v-if="otherVersionsExpanded" class="ms-4 ps-2 border-is border-border space-y-0.5"> 748 <!-- Hidden tag rows (overflow from visible tags) --> 749 <div 750 v-for="row in hiddenTagRows" 751 :key="row.id" 752 class="py-1" 753 :class="hiddenRowContainsCurrent(row) ? 'rounded bg-bg-subtle px-2 -mx-2' : ''" 754 > 755 <div class="flex items-center justify-between gap-2"> 756 <LinkBase 757 :to="versionRoute(row.primaryVersion.version)" 758 block 759 class="text-xs" 760 :class=" 761 row.primaryVersion.deprecated 762 ? 'text-red-800 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' 763 : undefined 764 " 765 :title=" 766 row.primaryVersion.deprecated 767 ? $t('package.versions.deprecated_title', { 768 version: row.primaryVersion.version, 769 }) 770 : row.primaryVersion.version 771 " 772 :classicon="row.primaryVersion.deprecated ? 'i-lucide:octagon-alert' : undefined" 773 > 774 <span dir="ltr" class="block truncate"> 775 {{ row.primaryVersion.version }} 776 </span> 777 </LinkBase> 778 <div class="flex items-center gap-2 shrink-0 pe-2"> 779 <DateTime 780 v-if="row.primaryVersion.time" 781 :datetime="row.primaryVersion.time" 782 class="text-3xs text-fg-subtle" 783 year="numeric" 784 month="short" 785 day="numeric" 786 /> 787 </div> 788 </div> 789 <div v-if="row.tags.length" class="flex items-center gap-1 mt-0.5 flex-wrap"> 790 <span 791 v-for="tag in row.tags" 792 :key="tag" 793 class="text-5xs font-semibold text-fg-subtle uppercase tracking-wide truncate max-w-[120px]" 794 :title="tag" 795 > 796 {{ tag }} 797 </span> 798 </div> 799 </div> 800 801 <!-- Version groups (untagged versions) --> 802 <template v-if="filteredOtherMajorGroups.length > 0"> 803 <div v-for="group in filteredOtherMajorGroups" :key="group.groupKey"> 804 <!-- Version group header --> 805 <div 806 v-if="group.versions.length > 1" 807 class="py-1" 808 :class="majorGroupContainsCurrent(group) ? 'rounded bg-bg-subtle px-2 -mx-2' : ''" 809 > 810 <div class="flex items-center justify-between gap-2"> 811 <div class="flex items-center gap-2 min-w-0"> 812 <button 813 type="button" 814 class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors shrink-0 rounded-sm" 815 :aria-expanded="expandedMajorGroups.has(group.groupKey)" 816 :aria-label=" 817 expandedMajorGroups.has(group.groupKey) 818 ? $t('package.versions.collapse_major', { 819 major: group.label, 820 }) 821 : $t('package.versions.expand_major', { 822 major: group.label, 823 }) 824 " 825 data-testid="major-group-expand-button" 826 @click="toggleMajorGroup(group.groupKey)" 827 > 828 <span 829 class="w-3 h-3 transition-transform duration-200 rtl-flip" 830 :class=" 831 expandedMajorGroups.has(group.groupKey) 832 ? 'i-lucide:chevron-down' 833 : 'i-lucide:chevron-right' 834 " 835 aria-hidden="true" 836 /> 837 </button> 838 <LinkBase 839 v-if="group.versions[0]?.version" 840 :to="versionRoute(group.versions[0]?.version)" 841 block 842 class="text-xs" 843 :class=" 844 group.versions[0]?.deprecated 845 ? 'text-red-800 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' 846 : undefined 847 " 848 :title=" 849 group.versions[0]?.deprecated 850 ? $t('package.versions.deprecated_title', { 851 version: group.versions[0]?.version, 852 }) 853 : group.versions[0]?.version 854 " 855 :classicon=" 856 group.versions[0]?.deprecated ? 'i-lucide:octagon-alert' : undefined 857 " 858 > 859 <span dir="ltr" class="block truncate"> 860 {{ group.versions[0]?.version }} 861 </span> 862 </LinkBase> 863 </div> 864 <div class="flex items-center gap-2 shrink-0 pe-2"> 865 <DateTime 866 v-if="group.versions[0]?.time" 867 :datetime="group.versions[0]?.time" 868 class="text-3xs text-fg-subtle" 869 year="numeric" 870 month="short" 871 day="numeric" 872 /> 873 <ProvenanceBadge 874 v-if="group.versions[0]?.hasProvenance" 875 :package-name="packageName" 876 :version="group.versions[0]?.version" 877 compact 878 /> 879 </div> 880 </div> 881 <div 882 v-if="group.versions[0]?.tags?.length" 883 class="flex items-center gap-1 ms-5 flex-wrap" 884 > 885 <span 886 v-for="tag in group.versions[0].tags" 887 :key="tag" 888 class="text-5xs font-semibold text-fg-subtle uppercase tracking-wide truncate max-w-[120px]" 889 :title="tag" 890 > 891 {{ tag }} 892 </span> 893 </div> 894 </div> 895 <!-- Single version (no expand needed) --> 896 <div 897 v-else 898 class="py-1" 899 :class="majorGroupContainsCurrent(group) ? 'rounded bg-bg-subtle px-2 -mx-2' : ''" 900 > 901 <div class="flex items-center justify-between gap-2"> 902 <div class="flex items-center gap-2 min-w-0"> 903 <LinkBase 904 v-if="group.versions[0]?.version" 905 :to="versionRoute(group.versions[0]?.version)" 906 block 907 class="text-xs ms-6" 908 :class=" 909 group.versions[0]?.deprecated 910 ? 'text-red-800 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' 911 : undefined 912 " 913 :title=" 914 group.versions[0]?.deprecated 915 ? $t('package.versions.deprecated_title', { 916 version: group.versions[0]?.version, 917 }) 918 : group.versions[0]?.version 919 " 920 :classicon=" 921 group.versions[0]?.deprecated ? 'i-lucide:octagon-alert' : undefined 922 " 923 > 924 <span dir="ltr" class="block truncate"> 925 {{ group.versions[0]?.version }} 926 </span> 927 </LinkBase> 928 </div> 929 <div class="flex items-center gap-2 shrink-0 pe-2"> 930 <DateTime 931 v-if="group.versions[0]?.time" 932 :datetime="group.versions[0]?.time" 933 class="text-3xs text-fg-subtle" 934 year="numeric" 935 month="short" 936 day="numeric" 937 /> 938 <ProvenanceBadge 939 v-if="group.versions[0]?.hasProvenance" 940 :package-name="packageName" 941 :version="group.versions[0]?.version" 942 compact 943 /> 944 </div> 945 </div> 946 <div v-if="group.versions[0]?.tags?.length" class="flex items-center gap-1 ms-5"> 947 <span 948 v-for="tag in group.versions[0].tags" 949 :key="tag" 950 class="text-5xs font-semibold text-fg-subtle uppercase tracking-wide" 951 > 952 {{ tag }} 953 </span> 954 </div> 955 </div> 956 957 <!-- Version group versions --> 958 <div 959 v-if="expandedMajorGroups.has(group.groupKey) && group.versions.length > 1" 960 class="ms-6 space-y-0.5" 961 > 962 <div 963 v-for="v in group.versions.slice(1)" 964 :key="v.version" 965 class="py-1" 966 :class=" 967 v.version === effectiveCurrentVersion ? 'rounded bg-bg-subtle px-2 -mx-2' : '' 968 " 969 > 970 <div class="flex items-center justify-between gap-2"> 971 <LinkBase 972 :to="versionRoute(v.version)" 973 block 974 class="text-xs" 975 :class=" 976 v.deprecated 977 ? 'text-red-800 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' 978 : undefined 979 " 980 :title=" 981 v.deprecated 982 ? $t('package.versions.deprecated_title', { 983 version: v.version, 984 }) 985 : v.version 986 " 987 :classicon="v.deprecated ? 'i-lucide:octagon-alert' : undefined" 988 > 989 <span dir="ltr" class="block truncate"> 990 {{ v.version }} 991 </span> 992 </LinkBase> 993 <div class="flex items-center gap-2 shrink-0 pe-2"> 994 <DateTime 995 v-if="v.time" 996 :datetime="v.time" 997 class="text-3xs text-fg-subtle" 998 year="numeric" 999 month="short" 1000 day="numeric" 1001 /> 1002 <ProvenanceBadge 1003 v-if="v.hasProvenance" 1004 :package-name="packageName" 1005 :version="v.version" 1006 compact 1007 /> 1008 </div> 1009 </div> 1010 <div v-if="v.tags?.length" class="flex items-center gap-1 mt-0.5"> 1011 <span 1012 v-for="tag in v.tags" 1013 :key="tag" 1014 class="text-5xs font-semibold text-fg-subtle uppercase tracking-wide" 1015 > 1016 {{ tag }} 1017 </span> 1018 </div> 1019 </div> 1020 </div> 1021 </div> 1022 </template> 1023 <div 1024 v-else-if="hasLoadedAll && hiddenTagRows.length === 0" 1025 class="py-1 text-xs text-fg-subtle" 1026 > 1027 {{ $t('package.versions.all_covered') }} 1028 </div> 1029 </div> 1030 </div> 1031 </div> 1032 </CollapsibleSection> 1033 1034 <!-- Version Distribution Modal --> 1035 <PackageChartModal 1036 v-if="isDistributionModalOpen" 1037 :modal-title="$t('package.versions.distribution_modal_title')" 1038 @close="closeDistributionModal" 1039 @transitioned="handleDistributionModalTransitioned" 1040 > 1041 <!-- The Chart is mounted after the dialog has transitioned --> 1042 <!-- This avoids flaky behavior and ensures proper modal lifecycle --> 1043 <Transition name="opacity" mode="out-in"> 1044 <PackageVersionDistribution 1045 v-if="hasDistributionModalTransitioned" 1046 :package-name="packageName" 1047 :in-modal="true" 1048 /> 1049 </Transition> 1050 1051 <!-- This placeholder bears the same dimensions as the VersionDistribution component --> 1052 <!-- Avoids CLS when the dialog has transitioned --> 1053 <div 1054 v-if="!hasDistributionModalTransitioned" 1055 class="w-full aspect-[272/609] sm:aspect-[718/571]" 1056 /> 1057 </PackageChartModal> 1058</template> 1059 1060<style scoped> 1061.opacity-enter-active, 1062.opacity-leave-active { 1063 transition: opacity 200ms ease; 1064} 1065 1066.opacity-enter-from, 1067.opacity-leave-to { 1068 opacity: 0; 1069} 1070</style>