diff --git a/app/app.vue b/app/app.vue index 34fc1d5c01..02842b3300 100644 --- a/app/app.vue +++ b/app/app.vue @@ -5,6 +5,7 @@ import { isEditableElement } from '~/utils/input' const route = useRoute() const router = useRouter() +const { mobileLinks } = useGlobalNavLinks() const { locale, locales } = useI18n() // Initialize user preferences (accent color, package manager) before hydration to prevent flash/CLS @@ -155,6 +156,9 @@ defineOgImage('Page.takumi', {}, { alt: 'npmx — a fast, modern browser for the + + + diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue index e90464ca5b..33a1b3e66e 100644 --- a/app/components/AppHeader.vue +++ b/app/components/AppHeader.vue @@ -1,9 +1,6 @@ diff --git a/app/components/Header/MobileBottomBar.client.vue b/app/components/Header/MobileBottomBar.client.vue new file mode 100644 index 0000000000..ab5e343768 --- /dev/null +++ b/app/components/Header/MobileBottomBar.client.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/components/Header/MobileMenu.client.vue b/app/components/Header/MobileMenu.client.vue deleted file mode 100644 index 0ab5cff0b7..0000000000 --- a/app/components/Header/MobileMenu.client.vue +++ /dev/null @@ -1,254 +0,0 @@ - - - diff --git a/app/components/Header/MobileMenuDocsView.vue b/app/components/Header/MobileMenuDocsView.vue new file mode 100644 index 0000000000..52a362c808 --- /dev/null +++ b/app/components/Header/MobileMenuDocsView.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/components/Header/MobileMenuRootView.vue b/app/components/Header/MobileMenuRootView.vue new file mode 100644 index 0000000000..a3960a199f --- /dev/null +++ b/app/components/Header/MobileMenuRootView.vue @@ -0,0 +1,156 @@ + + + diff --git a/app/components/Header/MobileMenuSheet.client.vue b/app/components/Header/MobileMenuSheet.client.vue new file mode 100644 index 0000000000..b7f5e694b6 --- /dev/null +++ b/app/components/Header/MobileMenuSheet.client.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 571dff4264..37d2fc2ae8 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -235,7 +235,7 @@ useShortcuts({
diff --git a/app/components/Package/Skeleton.vue b/app/components/Package/Skeleton.vue index 0c2409fd64..c9fe09257c 100644 --- a/app/components/Package/Skeleton.vue +++ b/app/components/Package/Skeleton.vue @@ -19,7 +19,7 @@
-
+
diff --git a/app/composables/useGlobalNavLinks.ts b/app/composables/useGlobalNavLinks.ts new file mode 100644 index 0000000000..1f7a02ab8d --- /dev/null +++ b/app/composables/useGlobalNavLinks.ts @@ -0,0 +1,131 @@ +import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' + +export function useGlobalNavLinks() { + const discord = useDiscordLink() + const { t: $t } = useI18n() + + const desktopLinks = computed(() => [ + { + name: 'Compare', + label: $t('nav.compare'), + to: { name: 'compare' }, + keyshortcut: 'c', + type: 'link', + external: false, + iconClass: 'i-lucide:git-compare', + }, + { + name: 'Settings', + label: $t('nav.settings'), + to: { name: 'settings' }, + keyshortcut: ',', + type: 'link', + external: false, + iconClass: 'i-lucide:settings', + }, + ]) + + const mobileLinks = computed(() => [ + { + name: 'Desktop Links', + type: 'group', + items: [...desktopLinks.value], + }, + { + type: 'separator', + }, + { + name: 'About & Policies', + type: 'group', + items: [ + { + name: 'About', + label: $t('footer.about'), + to: { name: 'about' }, + type: 'link', + external: false, + iconClass: 'i-lucide:info', + }, + { + name: 'Blog', + label: $t('footer.blog'), + to: { name: 'blog' }, + type: 'link', + external: false, + iconClass: 'i-lucide:notebook-pen', + }, + { + name: 'Privacy Policy', + label: $t('privacy_policy.title'), + to: { name: 'privacy' }, + type: 'link', + external: false, + iconClass: 'i-lucide:shield-check', + }, + { + name: 'Accessibility', + label: $t('a11y.title'), + to: { name: 'accessibility' }, + type: 'link', + external: false, + iconClass: 'i-custom:a11y', + }, + { + name: 'Translation Status', + label: $t('translation_status.title'), + to: { name: 'translation-status' }, + type: 'link', + external: false, + iconClass: 'i-lucide:languages', + }, + { + name: 'Brand', + label: $t('footer.brand'), + to: { name: 'brand' }, + type: 'link', + external: false, + iconClass: 'i-lucide:palette', + }, + ], + }, + { + type: 'separator', + }, + { + name: 'External Links', + type: 'group', + label: $t('nav.links'), + items: [ + { + name: 'Source', + label: $t('footer.source'), + href: 'https://repo.npmx.dev', + target: '_blank', + type: 'link', + external: true, + iconClass: 'i-simple-icons:github', + }, + { + name: 'Social', + label: $t('footer.social'), + href: 'https://social.npmx.dev', + target: '_blank', + type: 'link', + external: true, + iconClass: 'i-simple-icons:bluesky', + }, + { + name: 'Chat', + label: discord.value.label, + href: discord.value.url, + target: '_blank', + type: 'link', + external: true, + iconClass: 'i-lucide:message-circle', + }, + ], + }, + ]) + + return { desktopLinks, mobileLinks } +} diff --git a/app/composables/useMobileNav.ts b/app/composables/useMobileNav.ts new file mode 100644 index 0000000000..0d4548e1a1 --- /dev/null +++ b/app/composables/useMobileNav.ts @@ -0,0 +1,57 @@ +import { ref, readonly } from 'vue' +import { useRoute } from '#imports' + +export type MobileNavView = 'root' | 'docs' + +const isOpen = ref(false) +const activeView = ref('root') + +function deriveDefaultView(path: string): MobileNavView { + if (path === '/docs' || path.startsWith('/docs/')) return 'docs' + return 'root' +} + +export function useMobileNav() { + const route = useRoute() + + function open(view?: MobileNavView) { + activeView.value = view ?? deriveDefaultView(route.path) + isOpen.value = true + } + + function close() { + isOpen.value = false + activeView.value = 'root' + } + + function toggle() { + if (isOpen.value) close() + else open() + } + + function enterView(view: MobileNavView) { + activeView.value = view + } + + function back() { + activeView.value = 'root' + } + + return { + isOpen: readonly(isOpen), + activeView: readonly(activeView), + open, + close, + toggle, + enterView, + back, + } +} + +// Test helper: resets module-level state between tests. +// Needed because isOpen/activeView are module-level singletons; without resetting +// them, test state bleeds across cases when the mock replaces ref() with plain objects. +export function __resetMobileNav() { + isOpen.value = false + activeView.value = 'root' +} diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 1610b8aa16..f210ddc40b 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -5,7 +5,10 @@ import { getDependencyCount } from '~/utils/npm/dependency-count' const readmeHeader = useTemplateRef('readmeHeader') const isReadmeHeaderPinned = shallowRef(false) const packageHeaderHeight = usePackageHeaderHeight() -const readmeStickyTop = computed(() => `${56 + (packageHeaderHeight.value || 44)}px`) +const isSmUp = useMediaQuery('(min-width: 640px)') +const readmeStickyTop = computed( + () => `${(isSmUp.value ? 56 : 0) + (packageHeaderHeight.value || 44)}px`, +) function isStickyPinned(el: HTMLElement | null): boolean { if (!el) return false diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index 2f91330670..538f81e644 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -271,7 +271,7 @@ const flatItems = computed(() => {