forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>