11<script setup lang="ts">
22import type { PackageVersionInfo , SlimVersion } from ' #shared/types'
3- import { compare } from ' semver'
3+ import { compare , validRange } from ' semver'
44import type { RouteLocationRaw } from ' vue-router'
55import { fetchAllPackageVersions } from ' ~/utils/npm/api'
6+ import { NPMX_DOCS_SITE } from ' #shared/utils/constants'
67import {
78 buildVersionToTagsMap ,
89 filterExcludedTags ,
10+ filterVersions ,
911 getPrereleaseChannel ,
1012 getVersionGroupKey ,
1113 getVersionGroupLabel ,
@@ -83,6 +85,31 @@ const effectiveCurrentVersion = computed(
8385 () => props .selectedVersion ?? props .distTags .latest ?? undefined ,
8486)
8587
88+ // Semver range filter
89+ const semverFilter = ref (' ' )
90+ // Collect all known versions: initial props + dynamically loaded ones
91+ const 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+ })
105+ const filteredVersionSet = computed (() =>
106+ filterVersions (allKnownVersions .value , semverFilter .value ),
107+ )
108+ const isFilterActive = computed (() => semverFilter .value .trim () !== ' ' )
109+ const isInvalidRange = computed (
110+ () => isFilterActive .value && validRange (semverFilter .value .trim ()) === null ,
111+ )
112+
86113// All tag rows derived from props (SSR-safe)
87114// Deduplicates so each version appears only once, with all its tags
88115const allTagRows = computed (() => {
@@ -135,10 +162,16 @@ const isPackageDeprecated = computed(() => {
135162
136163// Visible tag rows: limited to MAX_VISIBLE_TAGS
137164// If package is NOT deprecated, filter out deprecated tags from visible list
165+ // When semver filter is active, also filter by matching version
138166const visibleTagRows = computed (() => {
139- const rows = isPackageDeprecated .value
167+ const rowsMaybeFilteredForDeprecation = isPackageDeprecated .value
140168 ? allTagRows .value
141169 : 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
142175 const first = rows .slice (0 , MAX_VISIBLE_TAGS )
143176 const latestTagRow = rows .find (row => row .tag === ' latest' )
144177 // Ensure 'latest' tag is always included (at the end) if not already present
@@ -150,9 +183,14 @@ const visibleTagRows = computed(() => {
150183})
151184
152185// Hidden tag rows (all other tags) - shown in "Other versions"
153- const hiddenTagRows = computed (() =>
154- allTagRows .value .filter (row => ! visibleTagRows .value .includes (row )),
155- )
186+ // When semver filter is active, also filter by matching version
187+ const 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+ })
156194
157195// Client-side state for expansion and loaded versions
158196const expandedTags = ref <Set <string >>(new Set ())
@@ -166,6 +204,27 @@ const otherMajorGroups = shallowRef<
166204> ([])
167205const otherVersionsLoading = shallowRef (false )
168206
207+ // Filtered major groups (applies semver filter when active)
208+ const 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
219+ const 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+
169228// Cached full version list (local to component instance)
170229const allVersionsCache = shallowRef <PackageVersionInfo [] | null >(null )
171230const loadingVersions = shallowRef (false )
@@ -340,6 +399,13 @@ function getTagVersions(tag: string): VersionDisplay[] {
340399 return tagVersions .value .get (tag ) ?? []
341400}
342401
402+ // Get filtered versions for a tag (applies semver filter when active)
403+ function 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+
343409function findClaimingTag(version : string ): string | null {
344410 const versionChannel = getPrereleaseChannel (version )
345411
@@ -418,6 +484,61 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
418484 </ButtonBase >
419485 </template >
420486 <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-carbon:information 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+
421542 <!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) -->
422543 <div v-for =" row in visibleTagRows" :key =" row.id" >
423544 <div
@@ -512,11 +633,11 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
512633
513634 <!-- Expanded versions -->
514635 <div
515- v-if =" expandedTags.has(row.tag) && getTagVersions (row.tag).length > 1"
636+ v-if =" expandedTags.has(row.tag) && getFilteredTagVersions (row.tag).length > 1"
516637 class =" ms-4 ps-2 border-is border-border space-y-0.5 pe-2"
517638 >
518639 <div
519- v-for =" v in getTagVersions (row.tag).slice(1)"
640+ v-for =" v in getFilteredTagVersions (row.tag).slice(1)"
520641 :key =" v.version"
521642 class =" py-1"
522643 :class =" v.version === effectiveCurrentVersion ? 'rounded bg-bg-subtle px-2 -mx-2' : ''"
@@ -533,7 +654,9 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
533654 "
534655 :title ="
535656 v.deprecated
536- ? $t('package.versions.deprecated_title', { version: v.version })
657+ ? $t('package.versions.deprecated_title', {
658+ version: v.version,
659+ })
537660 : v.version
538661 "
539662 :classicon =" v.deprecated ? 'i-carbon-warning-hex' : undefined"
@@ -676,8 +799,8 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
676799 </div >
677800
678801 <!-- Version groups (untagged versions) -->
679- <template v-if =" otherMajorGroups .length > 0 " >
680- <div v-for =" group in otherMajorGroups " :key =" group.groupKey" >
802+ <template v-if =" filteredOtherMajorGroups .length > 0 " >
803+ <div v-for =" group in filteredOtherMajorGroups " :key =" group.groupKey" >
681804 <!-- Version group header -->
682805 <div
683806 v-if =" group.versions.length > 1"
@@ -692,8 +815,12 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
692815 :aria-expanded =" expandedMajorGroups.has(group.groupKey)"
693816 :aria-label ="
694817 expandedMajorGroups.has(group.groupKey)
695- ? $t('package.versions.collapse_major', { major: group.label })
696- : $t('package.versions.expand_major', { major: group.label })
818+ ? $t('package.versions.collapse_major', {
819+ major: group.label,
820+ })
821+ : $t('package.versions.expand_major', {
822+ major: group.label,
823+ })
697824 "
698825 data-testid =" major-group-expand-button"
699826 @click =" toggleMajorGroup(group.groupKey)"
@@ -852,7 +979,9 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
852979 "
853980 :title ="
854981 v.deprecated
855- ? $t('package.versions.deprecated_title', { version: v.version })
982+ ? $t('package.versions.deprecated_title', {
983+ version: v.version,
984+ })
856985 : v.version
857986 "
858987 :classicon =" v.deprecated ? 'i-carbon-warning-hex' : undefined"
0 commit comments