() => ({
- 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()
- }
- })
-})
diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts
index 007dd6bfd5..84895aefa1 100644
--- a/test/unit/a11y-component-coverage.spec.ts
+++ b/test/unit/a11y-component-coverage.spec.ts
@@ -41,7 +41,14 @@ const SKIPPED_COMPONENTS: Record = {
// Complex components requiring full app context or specific runtime conditions
'Header/OrgsDropdown.vue': 'Requires connector context and API calls',
'Header/PackagesDropdown.vue': 'Requires connector context and API calls',
- 'Header/MobileMenu.client.vue': 'Requires Teleport and full navigation context',
+ 'Header/MobileBottomBar.client.vue':
+ 'Fixed bar using Teleport + scroll listeners — requires full app context',
+ 'Header/MobileMenuSheet.client.vue':
+ 'Full-screen sheet with Teleport, focus trap, and scroll lock — requires full app context',
+ 'Header/MobileMenuRootView.vue':
+ 'Rendered inside MobileMenuSheet; depends on connector/atproto composables',
+ 'Header/MobileMenuDocsView.vue':
+ 'Rendered inside MobileMenuSheet; covered indirectly via the sheet',
'Modal.client.vue':
'Base modal component - tested via specific modals like ChartModal, ConnectorModal',
'Package/SkillsModal.vue': 'Complex modal with tabs - requires modal context and state',
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)
+ })
+})