From 8b6f4d85e1958f7e502940d5fcad13ba4cdd5181 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 15:20:27 +0100 Subject: [PATCH 01/24] feat: add useMobileNav composable for mobile nav state Co-Authored-By: Claude Sonnet 4.6 --- app/composables/useMobileNav.ts | 57 +++++++++++++++ .../unit/app/composables/useMobileNav.spec.ts | 72 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 app/composables/useMobileNav.ts create mode 100644 test/unit/app/composables/useMobileNav.spec.ts 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/test/unit/app/composables/useMobileNav.spec.ts b/test/unit/app/composables/useMobileNav.spec.ts new file mode 100644 index 0000000000..3aff00c575 --- /dev/null +++ b/test/unit/app/composables/useMobileNav.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Mock Nuxt's useRoute +const mockRoute = { value: { path: '/' } } +vi.mock('#imports', () => ({ + useRoute: () => mockRoute.value, + ref: (v: unknown) => ({ value: v }), + readonly: (v: T) => v, +})) + +import { useMobileNav, __resetMobileNav } from '~/composables/useMobileNav' + +describe('useMobileNav', () => { + beforeEach(() => { + mockRoute.value = { path: '/' } + __resetMobileNav() + }) + + it('starts closed with root view', () => { + const nav = useMobileNav() + expect(nav.isOpen.value).toBe(false) + expect(nav.activeView.value).toBe('root') + }) + + it('open() on a non-docs route starts in root view', () => { + mockRoute.value = { path: '/package/nuxt' } + const nav = useMobileNav() + nav.open() + expect(nav.isOpen.value).toBe(true) + expect(nav.activeView.value).toBe('root') + }) + + it('open() on a /docs route starts in docs view', () => { + mockRoute.value = { path: '/docs/getting-started' } + const nav = useMobileNav() + nav.open() + expect(nav.isOpen.value).toBe(true) + expect(nav.activeView.value).toBe('docs') + }) + + it('enterView() switches view while open', () => { + const nav = useMobileNav() + nav.open() + nav.enterView('docs') + expect(nav.activeView.value).toBe('docs') + }) + + it('back() returns to root from docs', () => { + const nav = useMobileNav() + nav.open() + nav.enterView('docs') + nav.back() + expect(nav.activeView.value).toBe('root') + }) + + it('close() resets isOpen and activeView', () => { + const nav = useMobileNav() + nav.open() + nav.enterView('docs') + nav.close() + expect(nav.isOpen.value).toBe(false) + expect(nav.activeView.value).toBe('root') + }) + + it('toggle() opens when closed and closes when open', () => { + const nav = useMobileNav() + nav.toggle() + expect(nav.isOpen.value).toBe(true) + nav.toggle() + expect(nav.isOpen.value).toBe(false) + }) +}) From 4deb10db2760f09385633df92c8eee1ae480f43c Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 15:33:27 +0100 Subject: [PATCH 02/24] chore(i18n): add keys for mobile bottom-bar menu --- i18n/locales/en.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c2031b8ca9..397b24baa8 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -196,6 +196,9 @@ "menu": "Menu", "mobile_menu": "Navigation menu", "open_menu": "Open menu", + "back_to_main_menu": "Back to main menu", + "docs_label": "Docs", + "close_menu": "Close menu", "links": "Links", "tap_to_search": "Tap to search" }, From 84ce80fdda36b3a112e1b85a4dcfeb53372d34c2 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 15:44:43 +0100 Subject: [PATCH 03/24] feat: add MobileMenuRootView component --- app/components/Header/MobileMenuRootView.vue | 155 +++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 app/components/Header/MobileMenuRootView.vue diff --git a/app/components/Header/MobileMenuRootView.vue b/app/components/Header/MobileMenuRootView.vue new file mode 100644 index 0000000000..ebbe0e2654 --- /dev/null +++ b/app/components/Header/MobileMenuRootView.vue @@ -0,0 +1,155 @@ + + + From 11eb95780863fa1466a60e32d39181755bf5f822 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 16:07:12 +0100 Subject: [PATCH 04/24] feat: add MobileMenuDocsView with minimal docs drill-down --- app/components/Header/MobileMenuDocsView.vue | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 app/components/Header/MobileMenuDocsView.vue diff --git a/app/components/Header/MobileMenuDocsView.vue b/app/components/Header/MobileMenuDocsView.vue new file mode 100644 index 0000000000..175295a8dc --- /dev/null +++ b/app/components/Header/MobileMenuDocsView.vue @@ -0,0 +1,51 @@ + + + From 43c1e30ad24b447d2493ba9978843fc8bda00e41 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 16:27:01 +0100 Subject: [PATCH 05/24] feat: add MobileMenuSheet overlay with drill-down slide --- .../Header/MobileMenuSheet.client.vue | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 app/components/Header/MobileMenuSheet.client.vue diff --git a/app/components/Header/MobileMenuSheet.client.vue b/app/components/Header/MobileMenuSheet.client.vue new file mode 100644 index 0000000000..a5ae8d44b7 --- /dev/null +++ b/app/components/Header/MobileMenuSheet.client.vue @@ -0,0 +1,91 @@ + + + From 4e97065477774f2d7a619098ebb9b6c834fdc71f Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 16:28:56 +0100 Subject: [PATCH 06/24] feat: add MobileBottomBar fixed to viewport bottom --- .../Header/MobileBottomBar.client.vue | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 app/components/Header/MobileBottomBar.client.vue diff --git a/app/components/Header/MobileBottomBar.client.vue b/app/components/Header/MobileBottomBar.client.vue new file mode 100644 index 0000000000..475f85e693 --- /dev/null +++ b/app/components/Header/MobileBottomBar.client.vue @@ -0,0 +1,66 @@ + + + From 669b90c41b0bd70ea270993e38f1b41e4ed92a94 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 16:36:00 +0100 Subject: [PATCH 07/24] feat: mount mobile bottom bar globally; hide AppHeader on mobile Co-Authored-By: Claude Sonnet 4.6 --- app/app.vue | 4 + app/components/AppHeader.vue | 204 +-------------------------- app/composables/useGlobalNavLinks.ts | 141 ++++++++++++++++++ 3 files changed, 152 insertions(+), 197 deletions(-) create mode 100644 app/composables/useGlobalNavLinks.ts 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/composables/useGlobalNavLinks.ts b/app/composables/useGlobalNavLinks.ts new file mode 100644 index 0000000000..9f23318878 --- /dev/null +++ b/app/composables/useGlobalNavLinks.ts @@ -0,0 +1,141 @@ +import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' +import { NPMX_DOCS_SITE } from '#shared/utils/constants' + +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: 'Docs', + label: $t('footer.docs'), + href: NPMX_DOCS_SITE, + target: '_blank', + type: 'link', + external: true, + iconClass: 'i-lucide:file-text', + }, + { + 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 } +} From 255030a3b5b05d4d7f38decc845e8a11eaa638d8 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 16:38:40 +0100 Subject: [PATCH 08/24] fix(mobile-nav): make context label a visible back button in the bar --- .../Header/MobileBottomBar.client.vue | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/components/Header/MobileBottomBar.client.vue b/app/components/Header/MobileBottomBar.client.vue index 475f85e693..cf816b1a4e 100644 --- a/app/components/Header/MobileBottomBar.client.vue +++ b/app/components/Header/MobileBottomBar.client.vue @@ -1,5 +1,5 @@ - - diff --git a/test/nuxt/components/Header/MobileMenu.spec.ts b/test/nuxt/components/Header/MobileMenu.spec.ts deleted file mode 100644 index 7298381541..0000000000 --- a/test/nuxt/components/Header/MobileMenu.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' -import { computed, nextTick } from 'vue' -import { HeaderMobileMenu } from '#components' - -// Mock useConnector -mockNuxtImport('useConnector', () => () => ({ - isConnected: computed(() => false), - npmUser: computed(() => null), - avatar: computed(() => null), -})) - -// Mock useAtproto -mockNuxtImport('useAtproto', () => () => ({ - user: computed(() => null), -})) - -// Mock useFocusTrap (from @vueuse/integrations) -vi.mock('@vueuse/integrations/useFocusTrap', () => ({ - useFocusTrap: () => ({ - activate: vi.fn(), - deactivate: vi.fn(), - }), -})) - -describe('MobileMenu', () => { - async function mountMenu(open = false) { - return mountSuspended(HeaderMobileMenu, { - props: { - open, - links: [ - { - type: 'group' as const, - name: 'main', - label: 'Navigation', - items: [ - { - type: 'link' as const, - name: 'home', - label: 'Home', - to: '/', - iconClass: 'i-lucide:home', - }, - ], - }, - ], - }, - attachTo: document.body, - }) - } - - it('is closed by default', async () => { - const wrapper = await mountMenu(false) - try { - // Menu content is behind v-if="isOpen" inside a Teleport - expect(document.querySelector('[role="dialog"]')).toBeNull() - } finally { - wrapper.unmount() - } - }) - - it('opens when the open prop is set to true', async () => { - const wrapper = await mountMenu(true) - try { - await nextTick() - const dialog = document.querySelector('[role="dialog"]') - expect(dialog).not.toBeNull() - expect(dialog?.getAttribute('aria-modal')).toBe('true') - } finally { - wrapper.unmount() - } - }) - - it('closes when open prop changes from true to false', async () => { - const wrapper = await mountMenu(true) - try { - await nextTick() - expect(document.querySelector('[role="dialog"]')).not.toBeNull() - - await wrapper.setProps({ open: false }) - await nextTick() - expect(document.querySelector('[role="dialog"]')).toBeNull() - } finally { - wrapper.unmount() - } - }) - - it('emits update:open false when backdrop is clicked', async () => { - const wrapper = await mountMenu(true) - try { - await nextTick() - const backdrop = document.querySelector('[role="dialog"] > button') - expect(backdrop).not.toBeNull() - backdrop?.dispatchEvent(new Event('click', { bubbles: true })) - await nextTick() - expect(wrapper.emitted('update:open')).toBeTruthy() - expect(wrapper.emitted('update:open')![0]).toEqual([false]) - } finally { - wrapper.unmount() - } - }) - - it('emits update:open false when close button is clicked', async () => { - const wrapper = await mountMenu(true) - try { - await nextTick() - // Close button has aria-label matching $t('common.close') — find it inside nav - const closeBtn = document.querySelector('nav button[aria-label]') - expect(closeBtn).not.toBeNull() - closeBtn?.dispatchEvent(new Event('click', { bubbles: true })) - await nextTick() - expect(wrapper.emitted('update:open')).toBeTruthy() - expect(wrapper.emitted('update:open')![0]).toEqual([false]) - } finally { - wrapper.unmount() - } - }) -}) From e1ce416a2c678f2bbfd25e70efccb9bd2c255407 Mon Sep 17 00:00:00 2001 From: Adebesin Tolulope Date: Mon, 20 Apr 2026 16:47:47 +0100 Subject: [PATCH 10/24] fix(mobile-nav): partial bottom sheet with backdrop, fix scroll overflow --- app/components/Header/MobileMenuDocsView.vue | 4 +-- app/components/Header/MobileMenuRootView.vue | 2 +- .../Header/MobileMenuSheet.client.vue | 25 ++++++++++++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/components/Header/MobileMenuDocsView.vue b/app/components/Header/MobileMenuDocsView.vue index 175295a8dc..8f46fb8e68 100644 --- a/app/components/Header/MobileMenuDocsView.vue +++ b/app/components/Header/MobileMenuDocsView.vue @@ -10,8 +10,8 @@ const docsLinks = [{ label: 'Docs home', href: NPMX_DOCS_SITE, external: true }]