Something went wrong. Try again.
[READ-ONLY]
a fast, modern browser for the npm registry forked from npmx.dev/npmx.dev
Something went wrong. Try again.
21 kB · 641 lines
Vue
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642<script setup lang="ts">import type { PackageVersionInfo } from '#shared/types'import { onClickOutside } from '@vueuse/core'import { compare } from 'semver'import { buildVersionToTagsMap, getPrereleaseChannel, getVersionGroupKey, getVersionGroupLabel, isSameVersionGroup,} from '~/utils/versions'import { fetchAllPackageVersions } from '~/utils/npm/api'
const props = defineProps<{ packageName: string currentVersion: string versions: Record<string, unknown> distTags: Record<string, string> /** URL pattern for navigation. Use {version} as placeholder. */ urlPattern: string}>()
const isOpen = shallowRef(false)const dropdownRef = useTemplateRef('dropdownRef')const listboxRef = useTemplateRef('listboxRef')const focusedIndex = shallowRef(-1)
onClickOutside(dropdownRef, () => { isOpen.value = false})
// ============================================================================// Version Display Types// ============================================================================
interface VersionDisplay { version: string tags?: string[] isCurrent?: boolean}
interface VersionGroup { id: string label: string primaryVersion: VersionDisplay versions: VersionDisplay[] isExpanded: boolean isLoading: boolean}
// ============================================================================// State// ============================================================================
/** All version groups (dist-tags + major versions) */const versionGroups = ref<VersionGroup[]>([])
/** Whether we've loaded all versions from the API */const hasLoadedAll = shallowRef(false)
/** Loading state for initial all-versions fetch */const isLoadingAll = shallowRef(false)
/** Cached full version list */const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null)
// ============================================================================// Computed// ============================================================================
const latestVersion = computed(() => props.distTags.latest)
const versionToTags = computed(() => buildVersionToTagsMap(props.distTags))
/** Get URL for a specific version */function getVersionUrl(version: string): string { return props.urlPattern.replace('{version}', version)}
/** Safe semver comparison with fallback */function safeCompareVersions(a: string, b: string): number { try { return compare(a, b) } catch { return a.localeCompare(b) }}
// ============================================================================// Initial Groups (SSR-safe, from props only)// ============================================================================
/** Build initial version groups from dist-tags only */function buildInitialGroups(): VersionGroup[] { const groups: VersionGroup[] = [] const seenVersions = new Set<string>()
// Group tags by version (multiple tags can point to same version) const versionMap = new Map<string, { tags: string[] }>() for (const [tag, version] of Object.entries(props.distTags)) { const existing = versionMap.get(version) if (existing) { existing.tags.push(tag) } else { versionMap.set(version, { tags: [tag] }) } }
// Sort tags within each version: 'latest' first, then alphabetically for (const entry of versionMap.values()) { entry.tags.sort((a, b) => { if (a === 'latest') return -1 if (b === 'latest') return 1 return a.localeCompare(b) }) }
// Build groups from tagged versions, sorted by version descending const sortedEntries = Array.from(versionMap.entries()).sort((a, b) => safeCompareVersions(b[0], a[0]), )
for (const [version, { tags }] of sortedEntries) { seenVersions.add(version) const primaryTag = tags[0]!
groups.push({ id: `tag:${primaryTag}`, label: primaryTag, primaryVersion: { version, tags, isCurrent: version === props.currentVersion, }, versions: [], // Will be populated when expanded isExpanded: false, isLoading: false, }) }
return groups}
// Initialize groupsversionGroups.value = buildInitialGroups()
// ============================================================================// Load All Versions// ============================================================================
async function loadAllVersions(): Promise<PackageVersionInfo[]> { if (allVersionsCache.value) return allVersionsCache.value
isLoadingAll.value = true try { const versions = await fetchAllPackageVersions(props.packageName) allVersionsCache.value = versions hasLoadedAll.value = true return versions } finally { isLoadingAll.value = false }}
/** Process loaded versions and populate groups */function processLoadedVersions(allVersions: PackageVersionInfo[]) { const groups: VersionGroup[] = [] const claimedVersions = new Set<string>()
// Process each dist-tag and find its channel versions for (const [tag, tagVersion] of Object.entries(props.distTags)) { // Skip if we already have a group for this version const existingGroup = groups.find(g => g.primaryVersion.version === tagVersion) if (existingGroup) { // Add tag to existing group if (!existingGroup.primaryVersion.tags?.includes(tag)) { existingGroup.primaryVersion.tags = [...(existingGroup.primaryVersion.tags ?? []), tag] existingGroup.primaryVersion.tags.sort((a, b) => { if (a === 'latest') return -1 if (b === 'latest') return 1 return a.localeCompare(b) }) // Update label to primary tag existingGroup.label = existingGroup.primaryVersion.tags[0]! existingGroup.id = `tag:${existingGroup.label}` } continue }
const tagChannel = getPrereleaseChannel(tagVersion)
// Find all versions in the same version group + prerelease channel // For 0.x versions, this means same major.minor; for 1.x+, same major const channelVersions = allVersions .filter(v => { const vChannel = getPrereleaseChannel(v.version) return isSameVersionGroup(v.version, tagVersion) && vChannel === tagChannel }) .sort((a, b) => safeCompareVersions(b.version, a.version)) .map(v => ({ version: v.version, tags: versionToTags.value.get(v.version), isCurrent: v.version === props.currentVersion, }))
// Mark these versions as claimed for (const v of channelVersions) { claimedVersions.add(v.version) }
groups.push({ id: `tag:${tag}`, label: tag, primaryVersion: { version: tagVersion, tags: versionToTags.value.get(tagVersion), isCurrent: tagVersion === props.currentVersion, }, versions: channelVersions, isExpanded: false, isLoading: false, }) }
// Sort groups by primary version descending groups.sort((a, b) => safeCompareVersions(b.primaryVersion.version, a.primaryVersion.version))
// Deduplicate groups with same version (merge their tags) const deduped: VersionGroup[] = [] for (const group of groups) { const existing = deduped.find(g => g.primaryVersion.version === group.primaryVersion.version) if (existing) { // Merge tags const allTags = [ ...(existing.primaryVersion.tags ?? []), ...(group.primaryVersion.tags ?? []), ] const uniqueTags = [...new Set(allTags)].sort((a, b) => { if (a === 'latest') return -1 if (b === 'latest') return 1 return a.localeCompare(b) }) existing.primaryVersion.tags = uniqueTags existing.label = uniqueTags[0]! existing.id = `tag:${existing.label}` } else { deduped.push(group) } }
// Group unclaimed versions by version group key // For 0.x versions, group by major.minor (e.g., "0.9", "0.10") // For 1.x+, group by major (e.g., "1", "2") const byGroupKey = new Map<string, VersionDisplay[]>() for (const v of allVersions) { if (claimedVersions.has(v.version)) continue
const groupKey = getVersionGroupKey(v.version) if (!byGroupKey.has(groupKey)) { byGroupKey.set(groupKey, []) } byGroupKey.get(groupKey)!.push({ version: v.version, tags: versionToTags.value.get(v.version), isCurrent: v.version === props.currentVersion, }) }
// Sort within each group and create groups // Sort group keys: "2", "1", "0.10", "0.9" (descending) const sortedGroupKeys = Array.from(byGroupKey.keys()).sort((a, b) => { // Parse as numbers for proper sorting const [aMajor, aMinor] = a.split('.').map(Number) const [bMajor, bMinor] = b.split('.').map(Number) if (aMajor !== bMajor) return (bMajor ?? 0) - (aMajor ?? 0) return (bMinor ?? -1) - (aMinor ?? -1) })
for (const groupKey of sortedGroupKeys) { const versions = byGroupKey.get(groupKey)! versions.sort((a, b) => safeCompareVersions(b.version, a.version))
const primaryVersion = versions[0] if (primaryVersion) { deduped.push({ id: `group:${groupKey}`, label: getVersionGroupLabel(groupKey), primaryVersion, versions, isExpanded: false, isLoading: false, }) } }
versionGroups.value = deduped}
// ============================================================================// Expand/Collapse// ============================================================================
async function toggleGroup(groupId: string) { const group = versionGroups.value.find(g => g.id === groupId) if (!group) return
if (group.isExpanded) { group.isExpanded = false return }
// Load all versions if not yet loaded if (!hasLoadedAll.value) { group.isLoading = true try { const allVersions = await loadAllVersions() processLoadedVersions(allVersions) // Find the group again after processing (it may have moved) const updatedGroup = versionGroups.value.find(g => g.id === groupId) if (updatedGroup) { updatedGroup.isExpanded = true } } catch (error) { // eslint-disable-next-line no-console console.error('Failed to load versions:', error) } finally { group.isLoading = false } } else { group.isExpanded = true }}
// ============================================================================// Keyboard Navigation// ============================================================================
/** Flat list of navigable items for keyboard navigation */const flatItems = computed(() => { const items: Array<{ type: 'group' | 'version'; groupId: string; version?: VersionDisplay }> = []
for (const group of versionGroups.value) { items.push({ type: 'group', groupId: group.id, version: group.primaryVersion })
if (group.isExpanded && group.versions.length > 1) { // Skip first version (it's the primary) for (const v of group.versions.slice(1)) { items.push({ type: 'version', groupId: group.id, version: v }) } } }
return items})
function handleButtonKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { isOpen.value = false } else if (event.key === 'ArrowDown' && !isOpen.value) { event.preventDefault() isOpen.value = true focusedIndex.value = 0 }}
function handleListboxKeydown(event: KeyboardEvent) { const items = flatItems.value
switch (event.key) { case 'Escape': isOpen.value = false break case 'ArrowDown': event.preventDefault() focusedIndex.value = Math.min(focusedIndex.value + 1, items.length - 1) scrollToFocused() break case 'ArrowUp': event.preventDefault() focusedIndex.value = Math.max(focusedIndex.value - 1, 0) scrollToFocused() break case 'Home': event.preventDefault() focusedIndex.value = 0 scrollToFocused() break case 'End': event.preventDefault() focusedIndex.value = items.length - 1 scrollToFocused() break case 'ArrowRight': { event.preventDefault() const item = items[focusedIndex.value] if (item?.type === 'group') { const group = versionGroups.value.find(g => g.id === item.groupId) if (group && !group.isExpanded && group.versions.length > 1) { toggleGroup(item.groupId) } } break } case 'ArrowLeft': { event.preventDefault() const item = items[focusedIndex.value] if (item?.type === 'group') { const group = versionGroups.value.find(g => g.id === item.groupId) if (group?.isExpanded) { group.isExpanded = false } } else if (item?.type === 'version') { // Jump to parent group const groupIndex = items.findIndex(i => i.type === 'group' && i.groupId === item.groupId) if (groupIndex >= 0) { focusedIndex.value = groupIndex scrollToFocused() } } break } case 'Enter': case ' ': event.preventDefault() if (focusedIndex.value >= 0 && focusedIndex.value < items.length) { const item = items[focusedIndex.value] if (item?.version) { navigateToVersion(item.version.version) } } break }}
function scrollToFocused() { nextTick(() => { const focused = listboxRef.value?.querySelector('[data-focused="true"]') focused?.scrollIntoView({ block: 'nearest' }) })}
function navigateToVersion(version: string) { isOpen.value = false navigateTo(getVersionUrl(version))}
// Reset focused index when dropdown openswatch(isOpen, open => { if (open) { // Find current version in flat list const currentIdx = flatItems.value.findIndex(item => item.version?.isCurrent) focusedIndex.value = currentIdx >= 0 ? currentIdx : 0 }})
// Rebuild groups when props changewatch( () => [props.distTags, props.versions, props.currentVersion], () => { if (hasLoadedAll.value && allVersionsCache.value) { processLoadedVersions(allVersionsCache.value) } else { versionGroups.value = buildInitialGroups() } },)</script>
<template> <div ref="dropdownRef" class="relative"> <button type="button" aria-haspopup="listbox" :aria-expanded="isOpen" 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" @click="isOpen = !isOpen" @keydown="handleButtonKeydown" > <span dir="ltr">{{ currentVersion }}</span> <span v-if="currentVersion === latestVersion" class="text-xs px-1.5 py-0.5 rounded badge-green font-sans font-medium" > latest </span> <span class="i-lucide:chevron-down w-3.5 h-3.5 transition-[transform] duration-200 motion-reduce:transition-none" :class="{ 'rotate-180': isOpen }" aria-hidden="true" /> </button>
<Transition enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none" enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100" leave-active-class="transition-[opacity,transform] duration-100 ease-in motion-reduce:transition-none" leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95" > <div v-if="isOpen" ref="listboxRef" role="listbox" tabindex="0" :aria-activedescendant=" focusedIndex >= 0 ? `version-${flatItems[focusedIndex]?.version?.version}` : undefined " 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" @keydown="handleListboxKeydown" > <!-- Version groups --> <div v-for="group in versionGroups" :key="group.id"> <!-- Group header (primary version) --> <div :id="`version-${group.primaryVersion.version}`" role="option" :aria-selected="group.primaryVersion.isCurrent" :data-focused=" flatItems[focusedIndex]?.groupId === group.id && flatItems[focusedIndex]?.type === 'group' " 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" :class="[ group.primaryVersion.isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted', flatItems[focusedIndex]?.groupId === group.id && flatItems[focusedIndex]?.type === 'group' ? 'bg-bg-muted' : '', ]" > <!-- Expand button --> <button v-if="group.versions.length > 1 || !hasLoadedAll" type="button" class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg transition-colors shrink-0" :aria-expanded="group.isExpanded" :aria-label="group.isExpanded ? 'Collapse' : 'Expand'" @click.stop="toggleGroup(group.id)" > <span v-if="group.isLoading" class="i-svg-spinners:ring-resize w-3 h-3" aria-hidden="true" /> <span v-else class="w-3 h-3 transition-transform duration-200 rtl-flip" :class="group.isExpanded ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'" aria-hidden="true" /> </button> <span v-else class="w-4" />
<!-- Version link --> <NuxtLink :to="getVersionUrl(group.primaryVersion.version)" class="flex-1 truncate hover:text-fg transition-colors" @click="isOpen = false" > <span dir="ltr"> {{ group.primaryVersion.version }} </span> </NuxtLink>
<!-- Tags --> <span v-if="group.primaryVersion.tags?.length" class="flex items-center gap-1 shrink-0"> <span v-for="tag in group.primaryVersion.tags" :key="tag" class="text-xs px-1.5 py-0.5 rounded font-sans font-medium" :class="tag === 'latest' ? 'badge-green' : 'badge-subtle'" > {{ tag }} </span> </span> </div>
<!-- Expanded versions --> <div v-if="group.isExpanded && group.versions.length > 1" class="ms-6 border-is border-border" > <template v-for="v in group.versions.slice(1)" :key="v.version"> <NuxtLink :id="`version-${v.version}`" :to="getVersionUrl(v.version)" role="option" :aria-selected="v.isCurrent" :data-focused=" flatItems[focusedIndex]?.groupId === group.id && flatItems[focusedIndex]?.type === 'version' && flatItems[focusedIndex]?.version?.version === v.version " 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" :class="[ v.isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-subtle', flatItems[focusedIndex]?.version?.version === v.version ? 'bg-bg-muted' : '', ]" @click="isOpen = false" > <span class="truncate" dir="ltr">{{ v.version }}</span> <span v-if="v.tags?.length" class="flex items-center gap-1 shrink-0"> <span v-for="tag in v.tags" :key="tag" class="text-4xs px-1 py-0.5 rounded font-sans font-medium" :class=" tag === 'latest' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-bg-muted text-fg-subtle' " > {{ tag }} </span> </span> </NuxtLink> </template> </div> </div>
<!-- Link to package page for full version list --> <div class="border-t border-border mt-1 pt-1 px-3 py-2"> <NuxtLink :to="packageRoute(packageName)" class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg" @click="isOpen = false" > {{ $t( 'package.versions.view_all', { count: Object.keys(versions).length }, Object.keys(versions).length, ) }} </NuxtLink> </div> </div> </Transition> </div></template>