Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ What npmx offers:
- **Dark mode and light mode** – plus customize the color palette to your preferences
- **Translated interface** – localized UI across 39+ locales, including RTL support
- **First-class accessibility** – accessible components, keyboard workflows, and automated axe/Lighthouse checks
- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, and timelines
- **URL-driven feature views** – share exact package versions, search results, compare sets, source files and lines, diffs, docs, changelogs, stats and timelines
- **Fast search** – quick package search with instant results
- **Package details** – READMEs, versions, dependencies, and metadata
- **Code viewer** – browse package source code with syntax highlighting and permalink to specific lines
Expand Down
4 changes: 4 additions & 0 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ const footerSections = computed<Array<{ label: string; links: FooterLink[] }>>((
<kbd class="kbd">t</kbd>
<span>{{ $t('shortcuts.open_timeline') }}</span>
</li>
<li class="flex gap-2 items-center">
<kbd class="kbd">s</kbd>
<span>{{ $t('shortcuts.open_stats') }}</span>
</li>
<li class="flex gap-2 items-center">
<kbd class="kbd">c</kbd>
<span>{{ $t('shortcuts.compare_from_package') }}</span>
Expand Down
25 changes: 0 additions & 25 deletions app/components/Package/ChartModal.vue

This file was deleted.

17 changes: 16 additions & 1 deletion app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const props = defineProps<{
latestVersion?: SlimVersion | null
provenanceData?: ProvenanceDetails | null
provenanceStatus?: string | null
page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline'
page: 'main' | 'docs' | 'code' | 'diff' | 'changelog' | 'timeline' | 'stats'
versionUrlPattern: string
}>()

Expand Down Expand Up @@ -182,6 +182,11 @@ const timelineLink = computed((): RouteLocationRaw | null => {
return packageTimelineRoute(props.pkg.name, props.resolvedVersion)
})

const statsLink = computed((): RouteLocationRaw | null => {
if (props.pkg == null || props.resolvedVersion == null) return null
return packageStatsRoute(props.pkg.name, props.resolvedVersion)
})

useShortcuts({
'.': () => codeLink.value,
'm': () => mainLink.value,
Expand All @@ -190,6 +195,7 @@ useShortcuts({
'f': () => diffLink.value,
'-': () => changelogLink.value,
't': () => timelineLink.value,
's': () => statsLink.value,
})
</script>

Expand Down Expand Up @@ -370,6 +376,15 @@ useShortcuts({
>
{{ $t('package.links.timeline') }}
</LinkBase>
<LinkBase
v-if="statsLink"
:to="statsLink"
aria-keyshortcuts="s"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'stats' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.stats') }}
</LinkBase>
</nav>
</div>
</div>
Expand Down
6 changes: 4 additions & 2 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ const props = withDefaults(
/** When true, shows facet selector (e.g. Downloads / Likes). */
showFacetSelector?: boolean
permalink?: boolean
defaultRange?: 'auto' | '52-weeks'
}>(),
{
defaultRange: 'auto',
permalink: false,
},
)
Expand Down Expand Up @@ -372,7 +374,7 @@ function addUtcDays(date: Date, days: number): Date {
function initDateRangeForMultiPackageWeekly52() {
if (hasUserEditedDates.value) return
if (!import.meta.client) return
if (!isMultiPackageMode.value) return
if (!isMultiPackageMode.value && props.defaultRange === 'auto') return
if (startDate.value && endDate.value) return

const today = new Date()
Expand All @@ -385,7 +387,7 @@ function initDateRangeForMultiPackageWeekly52() {
}

watch(
() => (props.packageNames ?? []).length,
() => (props.packageNames ?? []).length || props.defaultRange === '52-weeks',
() => {
initDateRangeForMultiPackageWeekly52()
},
Expand Down
121 changes: 14 additions & 107 deletions app/components/Package/Versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,73 +12,8 @@ const props = defineProps<{
selectedVersion?: string
}>()

const QUERY_MODAL_VALUE = 'versions'
const chartModal = useModal('chart-modal')
const hasDistributionModalTransitioned = shallowRef(false)
const isDistributionModalOpen = shallowRef(false)
let distributionModalFallbackTimer: ReturnType<typeof setTimeout> | null = null

function clearDistributionModalFallbackTimer() {
if (distributionModalFallbackTimer) {
clearTimeout(distributionModalFallbackTimer)
distributionModalFallbackTimer = null
}
}

const router = useRouter()
const route = useRoute()

async function openDistributionModal() {
isDistributionModalOpen.value = true
hasDistributionModalTransitioned.value = false
// ensure the component renders before opening the dialog
await nextTick()
chartModal.open()

await router.replace({
query: {
...route.query,
modal: QUERY_MODAL_VALUE,
},
})

// Fallback: Force mount if transition event doesn't fire
clearDistributionModalFallbackTimer()
distributionModalFallbackTimer = setTimeout(() => {
if (!hasDistributionModalTransitioned.value) {
hasDistributionModalTransitioned.value = true
}
}, 500)
}

function closeDistributionModal() {
isDistributionModalOpen.value = false

router.replace({
query: {
...route.query,
modal: undefined,
grouping: undefined,
recent: undefined,
lowUsage: undefined,
},
})

hasDistributionModalTransitioned.value = false
clearDistributionModalFallbackTimer()
}

onMounted(() => {
if (route.query.modal === QUERY_MODAL_VALUE) {
openDistributionModal()
}
})

function handleDistributionModalTransitioned() {
hasDistributionModalTransitioned.value = true
clearDistributionModalFallbackTimer()
}

/** Maximum number of dist-tag rows to show before collapsing into "Other versions" */
const MAX_VISIBLE_TAGS = 10

Expand All @@ -96,6 +31,13 @@ function versionRoute(version: string): RouteLocationRaw {
return packageRoute(props.packageName, version)
}

const distributionRoute = computed(() => {
if (route.name === 'stats') return null
const version = effectiveCurrentVersion.value || props.distTags.latest
if (!version) return null
return packageStatsRoute(props.packageName, version, '#distribution')
})

// Route to the full versions history page
const versionsPageRoute = computed(() => packageVersionsRoute(props.packageName))

Expand Down Expand Up @@ -540,15 +482,17 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
>
<span class="sr-only">{{ $t('package.versions.view_all_versions') }}</span>
</LinkBase>
<ButtonBase
variant="secondary"
class="text-fg-subtle hover:text-fg transition-colors min-w-6 min-h-6 -m-1 p-1 rounded"
<LinkBase
v-if="distributionRoute"
:to="distributionRoute"
variant="button-secondary"
class="text-fg-subtle hover:text-fg transition-colors min-w-6 min-h-6 p-1 rounded"
:title="$t('package.downloads.community_distribution')"
classicon="i-lucide:file-stack"
@click="openDistributionModal"
data-testid="view-distribution-link"
>
<span class="sr-only">{{ $t('package.downloads.community_distribution') }}</span>
</ButtonBase>
</LinkBase>
</div>
</template>
<div class="space-y-0.5 min-w-0">
Expand Down Expand Up @@ -1124,41 +1068,4 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
</div>
</div>
</CollapsibleSection>

<!-- Version Distribution Modal -->
<PackageChartModal
v-if="isDistributionModalOpen"
:modal-title="$t('package.versions.distribution_modal_title')"
@close="closeDistributionModal"
@transitioned="handleDistributionModalTransitioned"
>
<!-- The Chart is mounted after the dialog has transitioned -->
<!-- This avoids flaky behavior and ensures proper modal lifecycle -->
<Transition name="opacity" mode="out-in">
<PackageVersionDistribution
v-if="hasDistributionModalTransitioned"
:package-name="packageName"
:in-modal="true"
/>
</Transition>

<!-- This placeholder bears the same dimensions as the VersionDistribution component -->
<!-- Avoids CLS when the dialog has transitioned -->
<div
v-if="!hasDistributionModalTransitioned"
class="w-full aspect-[272/609] sm:aspect-[718/592.67]"
/>
</PackageChartModal>
</template>

<style scoped>
.opacity-enter-active,
.opacity-leave-active {
transition: opacity 200ms ease;
}

.opacity-enter-from,
.opacity-leave-to {
opacity: 0;
}
</style>
Loading
Loading