Skip to content

Commit d54f107

Browse files
authored
Merge pull request #32 from brylie/fix-hamburger-menu
Refactor hamburger menu component and improve mobile navigation interactions
2 parents 2969a31 + 67726f9 commit d54f107

File tree

5 files changed

+260
-104
lines changed

5 files changed

+260
-104
lines changed

src/components/Hamburger.astro

+190-18
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,206 @@
33
---
44

55
<button
6-
id="menu-button"
7-
class="md:hidden flex flex-col justify-center items-center w-10 h-10 space-y-1.5 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-teal-500 dark:focus:ring-teal-500 rounded-lg transition-colors"
8-
aria-label="Toggle menu"
6+
class="hamburger"
7+
aria-label="Toggle navigation menu"
98
aria-expanded="false"
109
aria-controls="nav-links"
10+
tabindex="0"
1111
>
12-
<span
13-
class="block w-5 h-0.5 bg-gray-600 dark:bg-gray-300 transition-all duration-300 transform origin-center"
14-
></span>
15-
<span
16-
class="block w-5 h-0.5 bg-gray-600 dark:bg-gray-300 transition-all duration-300"
17-
></span>
18-
<span
19-
class="block w-5 h-0.5 bg-gray-600 dark:bg-gray-300 transition-all duration-300 transform origin-center"
20-
></span>
12+
<span class="line" aria-hidden="true"></span>
13+
<span class="line" aria-hidden="true"></span>
14+
<span class="line" aria-hidden="true"></span>
2115
</button>
2216

2317
<style>
24-
#menu-button[aria-expanded="true"] span:first-child {
25-
transform: translateY(8px) rotate(45deg);
18+
.hamburger {
19+
cursor: pointer;
20+
padding: 0.5rem;
21+
background-color: transparent;
22+
border-radius: 0.5rem;
23+
transition: background-color 0.3s ease;
24+
border: none;
2625
}
2726

28-
#menu-button[aria-expanded="true"] span:nth-child(2) {
27+
.hamburger:hover {
28+
background-color: rgba(0, 0, 0, 0.05);
29+
}
30+
31+
.hamburger:focus {
32+
outline: 2px solid #14b8a6; /* teal-500 */
33+
outline-offset: 2px;
34+
}
35+
36+
.hamburger:focus:not(:focus-visible) {
37+
outline: none;
38+
}
39+
40+
.dark .hamburger:hover {
41+
background-color: rgba(255, 255, 255, 0.1);
42+
}
43+
44+
.hamburger .line {
45+
display: block;
46+
width: 1.25rem;
47+
height: 2px;
48+
margin-bottom: 5px;
49+
background-color: #4b5563; /* gray-600 */
50+
transition: all 0.3s ease-in-out;
51+
}
52+
53+
.dark .hamburger .line {
54+
background-color: #d1d5db; /* gray-300 */
55+
}
56+
57+
.hamburger .line:last-child {
58+
margin-bottom: 0;
59+
}
60+
61+
/* Animation for the hamburger to X transformation */
62+
.expanded .hamburger .line:nth-child(1) {
63+
transform: translateY(7px) rotate(45deg);
64+
}
65+
66+
.expanded .hamburger .line:nth-child(2) {
2967
opacity: 0;
30-
transform: translateX(-8px);
3168
}
3269

33-
#menu-button[aria-expanded="true"] span:last-child {
34-
transform: translateY(-8px) rotate(-45deg);
70+
.expanded .hamburger .line:nth-child(3) {
71+
transform: translateY(-7px) rotate(-45deg);
72+
}
73+
74+
/* Hide on larger screens */
75+
@media (min-width: 768px) {
76+
.hamburger {
77+
display: none;
78+
}
3579
}
3680
</style>
81+
82+
<script>
83+
// Enhanced toggle for hamburger menu with accessibility improvements
84+
document.addEventListener("astro:page-load", () => {
85+
const hamburger = document.querySelector(".hamburger");
86+
const navLinks = document.querySelector(".nav-links");
87+
const navWrapper = document.querySelector(".nav-wrapper");
88+
89+
if (hamburger && navLinks && navWrapper) {
90+
// Toggle menu function with accessibility updates
91+
const toggleMenu = (show: boolean) => {
92+
hamburger.setAttribute("aria-expanded", show.toString());
93+
if (show) {
94+
setTimeout(() => {
95+
navLinks.classList.toggle("expanded", show);
96+
navWrapper.classList.toggle("expanded", show);
97+
navLinks.classList.remove("was-expanded");
98+
const menuItems = navLinks.querySelectorAll('[role="menuitem"]');
99+
menuItems.forEach((item) => {
100+
item.setAttribute("tabindex", "0");
101+
(item as HTMLElement).blur();
102+
});
103+
navLinks.setAttribute("aria-hidden", "false");
104+
}, 50); // Small delay to ensure proper hover state registration
105+
} else {
106+
navLinks.classList.toggle("expanded", show);
107+
navWrapper.classList.toggle("expanded", show);
108+
navLinks.classList.add("was-expanded");
109+
navLinks.setAttribute("aria-hidden", "true");
110+
const menuItems = navLinks.querySelectorAll('[role="menuitem"]');
111+
menuItems.forEach((item) => {
112+
item.setAttribute("tabindex", "-1");
113+
});
114+
(hamburger as HTMLElement).focus();
115+
}
116+
};
117+
118+
// Handle hamburger button click
119+
hamburger.addEventListener("click", () => {
120+
const isExpanded = hamburger.getAttribute("aria-expanded") === "true";
121+
toggleMenu(!isExpanded);
122+
});
123+
124+
// Handle click outside of menu to close it
125+
document.addEventListener("click", (e: MouseEvent) => {
126+
const target = e.target as HTMLElement;
127+
const isMenuOpen = hamburger.getAttribute("aria-expanded") === "true";
128+
129+
// Check if menu is open and the click was outside both the menu and hamburger button
130+
if (
131+
isMenuOpen &&
132+
!navLinks.contains(target) &&
133+
!hamburger.contains(target)
134+
) {
135+
toggleMenu(false);
136+
}
137+
});
138+
139+
// Handle keyboard accessibility
140+
document.addEventListener("keydown", (e) => {
141+
// Close menu with Escape key
142+
if (
143+
e.key === "Escape" &&
144+
hamburger.getAttribute("aria-expanded") === "true"
145+
) {
146+
toggleMenu(false);
147+
}
148+
149+
// Handle arrow key navigation within menu
150+
if (hamburger.getAttribute("aria-expanded") === "true") {
151+
const menuItems = Array.from(
152+
navLinks.querySelectorAll('[role="menuitem"]'),
153+
);
154+
const focusedElementIndex = menuItems.findIndex(
155+
(item) => document.activeElement === item,
156+
);
157+
158+
// Arrow down/right: move to next item
159+
if (
160+
(e.key === "ArrowDown" || e.key === "ArrowRight") &&
161+
focusedElementIndex < menuItems.length - 1
162+
) {
163+
e.preventDefault();
164+
(menuItems[focusedElementIndex + 1] as HTMLElement).focus();
165+
}
166+
167+
// Arrow up/left: move to previous item
168+
if (
169+
(e.key === "ArrowUp" || e.key === "ArrowLeft") &&
170+
focusedElementIndex > 0
171+
) {
172+
e.preventDefault();
173+
(menuItems[focusedElementIndex - 1] as HTMLElement).focus();
174+
}
175+
176+
// Home: move to first item
177+
if (e.key === "Home") {
178+
e.preventDefault();
179+
(menuItems[0] as HTMLElement).focus();
180+
}
181+
182+
// End: move to last item
183+
if (e.key === "End") {
184+
e.preventDefault();
185+
(menuItems[menuItems.length - 1] as HTMLElement).focus();
186+
}
187+
}
188+
});
189+
190+
// Close menu on resize to desktop view
191+
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
192+
window.addEventListener("resize", () => {
193+
clearTimeout(resizeTimer);
194+
resizeTimer = setTimeout(() => {
195+
if (
196+
window.innerWidth >= 768 &&
197+
hamburger.getAttribute("aria-expanded") === "true"
198+
) {
199+
toggleMenu(false);
200+
}
201+
}, 100);
202+
});
203+
204+
// Initialize menu state
205+
navLinks.setAttribute("aria-hidden", "true");
206+
}
207+
});
208+
</script>

src/components/Header.astro

+8-3
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@ import { Icon } from "astro-icon/components";
66

77
<header
88
class="sticky top-0 z-50 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-b border-gray-200 dark:border-gray-800 py-3 px-4 mb-8 transition-colors"
9+
role="banner"
910
>
1011
<div class="max-w-6xl mx-auto flex items-center justify-between">
11-
<a href="/" class="group flex items-center gap-2 hover:no-underline">
12+
<a
13+
href="/"
14+
class="group flex items-center gap-2 hover:no-underline"
15+
aria-label="Brylie - Homepage"
16+
>
1217
<div
1318
class="w-8 h-8 bg-gradient-to-br from-teal-400 to-blue-500 dark:from-teal-500 dark:to-blue-600 rounded-lg flex items-center justify-center shadow-sm transition-transform group-hover:scale-110"
19+
aria-hidden="true"
1420
>
1521
<Icon name="mdi:code-braces" class="w-5 h-5 text-white" />
1622
</div>
@@ -20,8 +26,7 @@ import { Icon } from "astro-icon/components";
2026
Brylie
2127
</span>
2228
</a>
23-
24-
<div class="flex items-center gap-3">
29+
<div class="flex items-center gap-3 nav-wrapper" id="nav-container">
2530
<Navigation />
2631
<div class="flex items-center gap-2">
2732
<Hamburger />

src/components/Navigation.astro

+62-3
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ const navItems = [
1212
---
1313

1414
<!-- Desktop Navigation -->
15-
<nav class="hidden md:block">
16-
<ul class="flex space-x-1">
15+
<nav class="hidden md:block" aria-label="Main navigation">
16+
<ul class="flex space-x-1" role="menubar">
1717
{
1818
navItems.map(({ path, label }) => (
19-
<li>
19+
<li role="none">
2020
<a
2121
href={`/${path}`}
2222
class={`px-4 py-2 rounded-lg font-medium transition-all duration-200 hover:scale-105 ${
@@ -25,6 +25,7 @@ const navItems = [
2525
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800/80"
2626
}`}
2727
aria-current={currentPath === path ? "page" : undefined}
28+
role="menuitem"
2829
>
2930
{label}
3031
</a>
@@ -38,6 +39,8 @@ const navItems = [
3839
<div
3940
id="nav-links"
4041
class="nav-links md:hidden fixed w-full top-[70px] left-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm shadow-lg border-t border-gray-200 dark:border-gray-800"
42+
role="menu"
43+
aria-label="Mobile navigation"
4144
>
4245
{
4346
navItems.map(({ path, label }) => (
@@ -49,9 +52,65 @@ const navItems = [
4952
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800/50"
5053
}`}
5154
aria-current={currentPath === path ? "page" : undefined}
55+
role="menuitem"
56+
tabindex={currentPath === path ? 0 : -1}
5257
>
5358
{label}
5459
</a>
5560
))
5661
}
5762
</div>
63+
64+
<style>
65+
/* Mobile menu styling */
66+
.nav-links {
67+
display: none;
68+
transform: translateY(0);
69+
transition: all 0.3s ease-in-out;
70+
}
71+
72+
.nav-links.expanded {
73+
display: block;
74+
animation: slideDown 0.3s ease-in-out forwards;
75+
}
76+
77+
@keyframes slideDown {
78+
from {
79+
transform: translateY(-100%);
80+
opacity: 0;
81+
}
82+
to {
83+
transform: translateY(0);
84+
opacity: 1;
85+
}
86+
}
87+
88+
/* When the menu is not expanded but was previously expanded, animate it closing */
89+
.nav-links:not(.expanded).was-expanded {
90+
display: block;
91+
animation: slideUp 0.3s ease-in-out forwards;
92+
}
93+
94+
@keyframes slideUp {
95+
from {
96+
transform: translateY(0);
97+
opacity: 1;
98+
}
99+
to {
100+
transform: translateY(-100%);
101+
opacity: 0;
102+
display: none;
103+
}
104+
}
105+
</style>
106+
107+
<script>
108+
// This script handles menu state synchronization during page transitions
109+
document.addEventListener("astro:page-load", () => {
110+
// Make sure the mobile menu is closed when navigating to a new page
111+
const navLinks = document.querySelector(".nav-links");
112+
if (navLinks && navLinks.classList.contains("expanded")) {
113+
navLinks.classList.remove("expanded");
114+
}
115+
});
116+
</script>

src/layouts/BaseLayout.astro

-3
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,5 @@ const { pageTitle } = Astro.props;
8282
<slot />
8383
</main>
8484
<Footer />
85-
<script>
86-
import "../scripts/menu.js";
87-
</script>
8885
</body>
8986
</html>

0 commit comments

Comments
 (0)