diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue
new file mode 100644
index 0000000000..b0fb55425d
--- /dev/null
+++ b/app/components/Package/Header.vue
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+
+ @{{ orgName }}
+
+ /
+
+ {{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }}
+
+
+
+
+
+
+
+ {{ requestedVersion }}
+
+
+
+ {{ resolvedVersion }}
+ v{{ resolvedVersion }}
+
+
+
+
+
+
+ {{ $t('package.not_latest') }}
+
+
+
+
+
+ {{ $t('package.links.docs') }}
+
+
+ {{ $t('package.links.code') }}
+
+
+ {{ $t('package.links.compare') }}
+
+
+ {{ $t('compare.compare_versions') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
+
+
+
+
+
+
+
+
+
diff --git a/app/composables/usePackageHeaderHeight.ts b/app/composables/usePackageHeaderHeight.ts
new file mode 100644
index 0000000000..d33733b84c
--- /dev/null
+++ b/app/composables/usePackageHeaderHeight.ts
@@ -0,0 +1,3 @@
+export function usePackageHeaderHeight() {
+ return useState('package-header-height', () => 0)
+}
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index a57774f110..92ee712790 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -17,9 +17,6 @@ import { areUrlsEquivalent } from '#shared/utils/url'
import { isEditableElement } from '~/utils/input'
import { getDependencyCount } from '~/utils/npm/dependency-count'
import { detectPublishSecurityDowngradeForVersion } from '~/utils/publish-security'
-import { useModal } from '~/composables/useModal'
-import { useAtproto } from '~/composables/atproto/useAtproto'
-import { togglePackageLike } from '~/utils/atproto/likes'
import { useInstallSizeDiff } from '~/composables/useInstallSizeDiff'
import { useViewOnGitProvider } from '~/composables/useViewOnGitProvider'
import type { RouteLocationRaw } from 'vue-router'
@@ -32,15 +29,10 @@ defineOgImageComponent('Package', {
const router = useRouter()
-const header = useTemplateRef('header')
-const isHeaderPinned = shallowRef(false)
const readmeHeader = useTemplateRef('readmeHeader')
const isReadmeHeaderPinned = shallowRef(false)
-const navExtraOffset = shallowRef(0)
-const isMobile = useMediaQuery('(max-width: 639.9px)')
-
-const headerBounds = useElementBounding(header)
-const readmeStickyTop = computed(() => `${56 + headerBounds.height.value}px`)
+const packageHeaderHeight = usePackageHeaderHeight()
+const readmeStickyTop = computed(() => `${56 + packageHeaderHeight.value}px`)
function isStickyPinned(el: HTMLElement | null): boolean {
if (!el) return false
@@ -53,59 +45,17 @@ function isStickyPinned(el: HTMLElement | null): boolean {
}
function checkHeaderPosition() {
- isHeaderPinned.value = isStickyPinned(header.value)
isReadmeHeaderPinned.value = isStickyPinned(readmeHeader.value)
}
useEventListener('scroll', checkHeaderPosition, { passive: true })
useEventListener('resize', checkHeaderPosition)
-const footerTarget = ref(null)
-const footerThresholds = Array.from({ length: 11 }, (_, i) => i / 10)
-
-const { pause: pauseFooterObserver, resume: resumeFooterObserver } = useIntersectionObserver(
- footerTarget,
- ([entry]) => {
- if (!entry) return
-
- navExtraOffset.value = entry.isIntersecting ? entry.intersectionRect.height : 0
- },
- {
- threshold: footerThresholds,
- immediate: false,
- },
-)
-
-function initFooterObserver() {
- footerTarget.value = document.querySelector('footer')
- if (!footerTarget.value) return
-
- pauseFooterObserver()
-
- watch(
- isMobile,
- value => {
- if (value) {
- resumeFooterObserver()
- } else {
- pauseFooterObserver()
- navExtraOffset.value = 0
- }
- },
- { immediate: true },
- )
-}
-
onMounted(() => {
checkHeaderPosition()
- initFooterObserver()
})
-const navExtraOffsetStyle = computed(() => ({
- '--package-nav-extra': `${navExtraOffset.value}px`,
-}))
-
-const { packageName, requestedVersion, orgName } = usePackageRoute()
+const { packageName, requestedVersion } = usePackageRoute()
const { data: resolvedVersion, status: resolvedStatus } = await useResolvedVersion(
packageName,
@@ -318,25 +268,6 @@ const pkgDescription = useMarkdown(() => ({
packageName: pkg.value?.name,
}))
-//copy package name
-const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
- source: packageName,
- copiedDuring: 2000,
-})
-
-//copy version name
-const { copied: copiedVersion, copy: copyVersion } = useClipboard({
- source: () => resolvedVersion.value ?? '',
- copiedDuring: 2000,
-})
-
-const { scrollToTop, isTouchDeviceClient } = useScrollToTop()
-
-const { y: scrollY } = useScroll(window)
-const showScrollToTop = computed(
- () => isTouchDeviceClient.value && scrollY.value > SCROLL_TO_TOP_THRESHOLD,
-)
-
// Fetch dependency analysis (lazy, client-side)
// This is the same composable used by PackageVulnerabilityTree and PackageDeprecatedTree
const { data: vulnTree, status: vulnTreeStatus } = useDependencyAnalysis(
@@ -602,69 +533,6 @@ const canonicalUrl = computed(() => {
return requestedVersion.value ? `${base}/v/${requestedVersion.value}` : base
})
-//atproto
-// TODO: Maybe set this where it's not loaded here every load?
-const { user } = useAtproto()
-
-const authModal = useModal('auth-modal')
-
-const { data: likesData, status: likeStatus } = useFetch(
- () => `/api/social/likes/${packageName.value}`,
- {
- default: () => ({ totalLikes: 0, userHasLiked: false }),
- server: false,
- },
-)
-const isLoadingLikeData = computed(
- () => likeStatus.value === 'pending' || likeStatus.value === 'idle',
-)
-
-const isLikeActionPending = shallowRef(false)
-
-const likeAction = async () => {
- if (user.value?.handle == null) {
- authModal.open()
- return
- }
-
- if (isLikeActionPending.value) return
-
- const currentlyLiked = likesData.value?.userHasLiked ?? false
- const currentLikes = likesData.value?.totalLikes ?? 0
-
- // Optimistic update
- likesData.value = {
- totalLikes: currentlyLiked ? currentLikes - 1 : currentLikes + 1,
- userHasLiked: !currentlyLiked,
- }
-
- isLikeActionPending.value = true
-
- try {
- const result = await togglePackageLike(packageName.value, currentlyLiked, user.value?.handle)
-
- isLikeActionPending.value = false
-
- if (result.success) {
- // Update with server response
- likesData.value = result.data
- } else {
- // Revert on error
- likesData.value = {
- totalLikes: currentLikes,
- userHasLiked: currentlyLiked,
- }
- }
- } catch {
- // Revert on error
- likesData.value = {
- totalLikes: currentLikes,
- userHasLiked: currentlyLiked,
- }
- isLikeActionPending.value = false
- }
-}
-
const dependencyCount = computed(() => getDependencyCount(displayVersion.value))
const numberFormatter = useNumberFormatter()
@@ -772,190 +640,18 @@ const showSkeleton = shallowRef(false)
/>
-
-
-
-
-
-
-
- @{{ orgName }}
-
- /
-
- {{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
-
-
-
-
-
-
-
- {{ requestedVersion }}
-
-
-
- {{ resolvedVersion }}
- v{{ resolvedVersion }}
-
-
-
-
-
-
- {{ $t('package.not_latest') }}
-
-
-
-
-
- {{ $t('package.links.docs') }}
-
-
- {{ $t('package.links.code') }}
-
-
- {{ $t('package.links.compare') }}
-
-
- {{ $t('compare.compare_versions') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}
-
-
-
-
-
-
+
@@ -1632,18 +1328,6 @@ const showSkeleton = shallowRef(false)
grid-area: header;
}
-/* Improve package name wrapping for narrow screens */
-.areaHeader h1 {
- overflow-wrap: anywhere;
-}
-
-/* Ensure description text wraps properly */
-.areaHeader p {
- word-wrap: break-word;
- overflow-wrap: break-word;
- word-break: break-word;
-}
-
.areaDetails {
grid-area: details;
}
@@ -1676,16 +1360,7 @@ const showSkeleton = shallowRef(false)
grid-area: sidebar;
}
-/* Mobile floating nav: safe-area positioning + kbd hiding */
@media (max-width: 639.9px) {
- .packageNav {
- bottom: calc(1.25rem + var(--package-nav-extra, 0px) + env(safe-area-inset-bottom, 0px));
- }
-
- .packageNav > :global(a kbd) {
- display: none;
- }
-
.packagePage {
padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
}
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index 9a079f91c8..ba8345807a 100644
--- a/test/nuxt/a11y.spec.ts
+++ b/test/nuxt/a11y.spec.ts
@@ -171,6 +171,7 @@ import {
PackageCompatibility,
PackageDependencies,
PackageDeprecatedTree,
+ PackageHeader,
PackageInstallScripts,
PackageKeywords,
PackageList,
@@ -725,6 +726,36 @@ describe('component accessibility audits', () => {
})
})
+ describe('PackageHeader', () => {
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(PackageHeader, {
+ props: {
+ pkg: { name: 'vue' },
+ resolvedVersion: '3.5.0',
+ displayVersion: {
+ _id: '1234567890',
+ _npmVersion: '3.5.0',
+ name: 'vue',
+ version: '3.5.0',
+ dist: {
+ shasum: '1234567890',
+ signatures: [],
+ tarball: 'https://npmx.dev/package/vue/tarball',
+ },
+ },
+ latestVersion: { version: '3.5.0', tags: [] },
+ provenanceData: null,
+ provenanceStatus: 'idle',
+ docsLink: null,
+ codeLink: null,
+ isBinaryOnly: false,
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
// Note: PackageWeeklyDownloadStats tests are skipped because vue-data-ui VueUiSparkline
// component has issues in the test environment (requires DOM measurements that aren't
// available during SSR-like test mounting).