/** * @vitest-environment jsdom */ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT_PATH = join(__dirname, '../../static/assets/js/mobile-menu-helper.js'); function setViewportWidth(width: number) { Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true, }); } function createNavDOM() { const nav = document.createElement('nav'); nav.id = 'nav'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = 'nav-toggle'; checkbox.className = 'nav-toggle-input'; nav.appendChild(checkbox); const header = document.createElement('div'); header.className = 'mobile-nav-header'; const toggleButton = document.createElement('button'); toggleButton.type = 'button'; toggleButton.id = 'nav-toggle-button'; toggleButton.setAttribute('aria-controls', 'nav-menu'); toggleButton.setAttribute('aria-expanded', 'false'); const label = document.createElement('label'); label.id = 'nav-toggle-label'; label.htmlFor = 'nav-toggle'; label.className = 'nav-toggle'; toggleButton.appendChild(label); header.appendChild(toggleButton); nav.appendChild(header); const menu = document.createElement('div'); menu.id = 'nav-menu'; menu.className = 'nav-menu container'; const list = document.createElement('ul'); list.className = 'nav-list'; const item = document.createElement('li'); item.className = 'nav-item'; const link = document.createElement('a'); link.href = '#test'; link.className = 'nav-link'; link.textContent = 'Test'; item.appendChild(link); list.appendChild(item); menu.appendChild(list); nav.appendChild(menu); document.body.appendChild(nav); return { nav, menu, toggleButton, label, checkbox, item, link }; } describe('mobile-menu-helper.js', () => { let dom: ReturnType; beforeEach(() => { setViewportWidth(400); // mobile dom = createNavDOM(); const code = readFileSync(SCRIPT_PATH, 'utf8'); eval(code); document.dispatchEvent(new Event('DOMContentLoaded')); }); afterEach(() => { dom.nav.remove(); vi.restoreAllMocks(); }); it('sets aria-hidden on menu when viewport is mobile and menu is closed', () => { expect(dom.menu.getAttribute('aria-hidden')).toBe('true'); expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('false'); }); it('sets inert and tabindex="-1" on links when menu is closed on mobile', () => { expect(dom.menu.hasAttribute('inert')).toBe(true); expect(dom.link.getAttribute('tabindex')).toBe('-1'); }); it('sets aria-hidden to false when menu is open on mobile', () => { dom.checkbox.checked = true; dom.checkbox.dispatchEvent(new Event('change', { bubbles: true })); expect(dom.menu.getAttribute('aria-hidden')).toBe('false'); expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('true'); }); it('removes inert and link tabindex when menu is open on mobile', () => { dom.checkbox.checked = true; dom.checkbox.dispatchEvent(new Event('change', { bubbles: true })); expect(dom.menu.hasAttribute('inert')).toBe(false); expect(dom.link.hasAttribute('tabindex')).toBe(false); }); it('removes aria-hidden and inert from menu when viewport is desktop', () => { setViewportWidth(1024); window.dispatchEvent(new Event('resize')); expect(dom.menu.hasAttribute('aria-hidden')).toBe(false); expect(dom.menu.hasAttribute('inert')).toBe(false); expect(dom.link.hasAttribute('tabindex')).toBe(false); }); it('adds aria-hidden and inert when resizing from desktop to mobile', () => { setViewportWidth(1024); window.dispatchEvent(new Event('resize')); expect(dom.menu.hasAttribute('aria-hidden')).toBe(false); expect(dom.menu.hasAttribute('inert')).toBe(false); setViewportWidth(400); window.dispatchEvent(new Event('resize')); expect(dom.menu.getAttribute('aria-hidden')).toBe('true'); expect(dom.menu.hasAttribute('inert')).toBe(true); expect(dom.link.getAttribute('tabindex')).toBe('-1'); }); it('closes menu and syncs aria when a menu item is clicked', () => { dom.checkbox.checked = true; dom.checkbox.dispatchEvent(new Event('change', { bubbles: true })); expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('true'); dom.item.click(); expect(dom.checkbox.checked).toBe(false); expect(dom.toggleButton.getAttribute('aria-expanded')).toBe('false'); expect(dom.menu.getAttribute('aria-hidden')).toBe('true'); expect(dom.menu.hasAttribute('inert')).toBe(true); expect(dom.link.getAttribute('tabindex')).toBe('-1'); }); // Keyboard open (Enter/Space on toggle button) is not asserted here: jsdom’s KeyboardEvent // often does not set e.key, so the keydown handler may not run. Opening and sync are covered // by “sets aria-hidden to false when menu is open” and “removes inert and link tabindex when // menu is open”; keyboard open can be covered in e2e. });