Skip to content

Commit 85ca7e1

Browse files
committed
test(ui): verify navbar responsive menu behavior
1 parent 6ede310 commit 85ca7e1

1 file changed

Lines changed: 93 additions & 23 deletions

File tree

app/components/navbar.test.tsx

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,46 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { act, render, screen, fireEvent } from '@testing-library/react';
33
import Navbar from './navbar';
44
import type { ReactNode } from 'react';
55

6-
Object.defineProperty(window, 'matchMedia', {
7-
writable: true,
8-
value: vi.fn().mockImplementation((query) => ({
9-
matches: false,
10-
media: query,
11-
onchange: null,
12-
addListener: vi.fn(),
13-
removeListener: vi.fn(),
14-
addEventListener: vi.fn(),
15-
removeEventListener: vi.fn(),
16-
dispatchEvent: vi.fn(),
17-
})),
18-
});
6+
type MatchMediaChangeListener = (event: MediaQueryListEvent) => void;
7+
8+
function mockMatchMedia(initialMatches = false) {
9+
let matches = initialMatches;
10+
const listeners = new Set<MatchMediaChangeListener>();
11+
12+
Object.defineProperty(window, 'matchMedia', {
13+
writable: true,
14+
value: vi.fn().mockImplementation((query: string) => ({
15+
get matches() {
16+
return matches;
17+
},
18+
media: query,
19+
onchange: null,
20+
addListener: vi.fn((listener: MatchMediaChangeListener) => listeners.add(listener)),
21+
removeListener: vi.fn((listener: MatchMediaChangeListener) => listeners.delete(listener)),
22+
addEventListener: vi.fn((event: string, listener: MatchMediaChangeListener) => {
23+
if (event === 'change') listeners.add(listener);
24+
}),
25+
removeEventListener: vi.fn((event: string, listener: MatchMediaChangeListener) => {
26+
if (event === 'change') listeners.delete(listener);
27+
}),
28+
dispatchEvent: vi.fn(),
29+
})),
30+
});
31+
32+
return {
33+
setMatches(nextMatches: boolean) {
34+
matches = nextMatches;
35+
listeners.forEach((listener) => {
36+
listener({
37+
matches,
38+
media: '(min-width: 768px)',
39+
} as MediaQueryListEvent);
40+
});
41+
},
42+
};
43+
}
1944

2045
vi.mock('framer-motion', () => ({
2146
motion: {
@@ -34,6 +59,9 @@ vi.mock('lucide-react', () => ({
3459
describe('Navbar mobile menu', () => {
3560
beforeEach(() => {
3661
window.innerWidth = 500;
62+
mockMatchMedia(false);
63+
window.localStorage.clear();
64+
document.documentElement.className = '';
3765
});
3866

3967
it('menu is hidden by default', () => {
@@ -63,23 +91,65 @@ describe('Navbar mobile menu', () => {
6391
expect(screen.queryByText(/closeicon/i)).toBeNull();
6492
});
6593

66-
it('closes menu on resize to desktop', () => {
94+
it('keeps menu open when a plain resize event fires below desktop', () => {
6795
render(<Navbar />);
6896

6997
const button = screen.getByLabelText(/open menu/i);
7098

7199
fireEvent.click(button);
72100

73-
window.innerWidth = 1200;
74-
75-
window.matchMedia = vi.fn().mockImplementation(() => ({
76-
matches: true,
77-
addEventListener: vi.fn(),
78-
removeEventListener: vi.fn(),
79-
}));
80-
81101
window.dispatchEvent(new Event('resize'));
82102

83103
expect(button.getAttribute('aria-expanded')).toBe('true');
84104
});
85105
});
106+
107+
describe('Navbar responsive breakpoints', () => {
108+
beforeEach(() => {
109+
window.innerWidth = 500;
110+
window.localStorage.clear();
111+
document.documentElement.className = '';
112+
});
113+
114+
it('renders semantic navigation and mobile menu controls at small widths', () => {
115+
mockMatchMedia(false);
116+
117+
render(<Navbar />);
118+
119+
expect(screen.getByRole('navigation')).toBeTruthy();
120+
expect(screen.getByRole('link', { name: /go to home/i })).toBeTruthy();
121+
expect(screen.getByRole('button', { name: /open menu/i }).getAttribute('aria-expanded')).toBe(
122+
'false'
123+
);
124+
125+
fireEvent.click(screen.getByRole('button', { name: /open menu/i }));
126+
127+
expect(screen.getByRole('button', { name: /close menu/i }).getAttribute('aria-expanded')).toBe(
128+
'true'
129+
);
130+
expect(screen.getAllByRole('link', { name: /customization studio/i })).toHaveLength(2);
131+
expect(screen.getAllByRole('link', { name: /github repo/i })).toHaveLength(2);
132+
});
133+
134+
it('closes the open hamburger menu when crossing into the desktop breakpoint', () => {
135+
const matchMedia = mockMatchMedia(false);
136+
137+
render(<Navbar />);
138+
139+
fireEvent.click(screen.getByRole('button', { name: /open menu/i }));
140+
141+
expect(screen.getByRole('button', { name: /close menu/i }).getAttribute('aria-expanded')).toBe(
142+
'true'
143+
);
144+
145+
act(() => {
146+
matchMedia.setMatches(true);
147+
});
148+
149+
expect(screen.getByRole('button', { name: /open menu/i }).getAttribute('aria-expanded')).toBe(
150+
'false'
151+
);
152+
expect(screen.getAllByRole('link', { name: /customization studio/i })).toHaveLength(1);
153+
expect(screen.getAllByRole('link', { name: /github repo/i })).toHaveLength(1);
154+
});
155+
});

0 commit comments

Comments
 (0)