addEventListener의 동작
다음으로 소개해줄 코드의 실제 실행 흐름은 다음과 같다
1. menuHandler() → 이벤트 리스너 등록
2. a11yMenuHandler() → 이벤트 리스너 등록
3. 사용자가 클릭
4. 두 이벤트 리스너가 동시에 실행
하지만 이를 간과하고 각각의 코드를 컴포넌트화 시키기 위해서 분류를 하는 과정에서 오류 발생
menuHandler()
function menuHandler() {
const menuBtn = document.getElementById("menuBtn");
const menuExitBtn = document.getElementById("menuExitBtn");
const menuBar = document.getElementById("menuBar");
menuBtn.addEventListener("click", (e) => {
e.stopPropagation();
menuBar.classList.toggle("-translate-x-full");
});
menuExitBtn.addEventListener("click", () => {
menuBar.classList.toggle("-translate-x-full");
});
document.addEventListener("click", (e) => {
if (!menuBar.classList.contains("-translate-x-full")) {
if (!menuBar.contains(e.target) && !menuBtn.contains(e.target)) {
menuBar.classList.toggle("-translate-x-full");
}
}
});
}
menuHandler();
a11yMenuHandler()
function a11yMenuHandler() {
const menuBtn = document.getElementById("menuBtn");
const menuExitBtn = document.getElementById("menuExitBtn");
const menuBar = document.getElementById("menuBar");
let lastFocusElement = null;
function isMenuOpen() {
return !menuBar.classList.contains("-translate-x-full");
}
function moveFocusIntoMenu() {
const firstFocusable = menuBar.querySelector(
'button, a, input, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}
function restoreFocus() {
lastFocusElement?.focus();
}
menuBtn.addEventListener("click", () => {
if (isMenuOpen()) {
lastFocusElement = document.activeElement;
menuBtn.setAttribute("aria-expanded", "true");
menuBar.removeAttribute("inert");
menuBar.removeAttribute("aria-hidden");
moveFocusIntoMenu();
} else {
menuBtn.setAttribute("aria-expanded", "false");
menuBar.setAttribute("inert");
menuBar.setAttribute("aria-hidden", "true");
restoreFocus();
}
});
menuExitBtn.addEventListener("click", () => {
menuBtn.setAttribute("aria-expanded", "false");
menuBar.setAttribute("inert");
menuBar.setAttribute("aria-hidden", "true");
restoreFocus();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && isMenuOpen()) {
menuBtn.setAttribute("aria-expanded", "false");
menuBar.setAttribute("inert");
menuBar.setAttribute("aria-hidden", "true");
restoreFocus();
}
});
}
a11yMenuHandler();
두 동작에 있어서 간과한 것은 순서 및 구조를 제대로 설계하지 않았다는 점이다.
접근방식
1. 동작의 코드를 다시 한번 정리한다.
// 메뉴가 열려있는지 판단
function isMenuOpen() {
return !menuBar.classList.contains("-translate-x-full");
}
// 메뉴가 열릴때, 메뉴 내부의 첫 번째 포커스 가능 요소로 포커스 이동
function moveFocusIntoMenu() {
const firstFocusable = menuBar.querySelector(
'button, a, input, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}
// 메뉴가 닫힐 때, 이전에 포커스가 있던 요소로 포커스 복원
function restoreFocus() {
lastFocusElement?.focus();
}
// 메뉴 여는 동작
function openMenu() {
menuBar.classList.remove("-translate-x-full");
menuBar.removeAttribute("inert");
menuBar.removeAttribute("aria-hidden");
menuBtn.setAttribute("aria-expanded", "true");
moveFocusIntoMenu();
}
// 메뉴 닫는 동작
function closeMenu() {
menuBar.classList.add("-translate-x-full");
menuBar.setAttribute("inert", "");
menuBar.setAttribute("aria-hidden", "true");
menuBtn.setAttribute("aria-expanded", "false");
restoreFocus();
}
필요한 정보
- 현재 메뉴바의 상태(닫힘, 열림)
- 각 상태에 따라 추가, 제거할 속성 정리(aria-hidden, aria-expanded, inert, 메뉴바의 width)
- 메뉴바가 열려있는지를 판단할 함수
- 이전에 포커스한 위치 인덱스 값 + 메뉴가 열림 상태일 때 메뉴 내에 가장 첫 번째로 포커스 가능한 인덱스 값
이를 토대로 메뉴버튼 핸들러에 함수를 추가해 주었다.
해결
function menuHandler() {
const menuBtn = document.getElementById("menuBtn");
const menuExitBtn = document.getElementById("menuExitBtn");
const menuBar = document.getElementById("menuBar");
let lastFocusElement = null;
// 메뉴가 열려있는지 판단
function isMenuOpen() {
return menuBar.classList.contains("-translate-x-full");
}
// 메뉴가 열릴때, 메뉴 내부의 첫 번째 포커스 가능 요소로 포커스 이동
function moveFocusIntoMenu() {
const firstFocusable = menuBar.querySelector(
'button, a, input, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}
// 메뉴가 닫힐 때, 이전에 포커스가 있던 요소로 포커스 복원
function restoreFocus() {
lastFocusElement?.focus();
}
// 메뉴 여는 동작
function openMenu() {
lastFocusElement = document.activeElement;
menuBar.classList.remove("-translate-x-full");
menuBar.removeAttribute("inert");
menuBar.removeAttribute("aria-hidden");
menuBtn.setAttribute("aria-expanded", "true");
moveFocusIntoMenu();
}
// 메뉴 닫는 동작
function closeMenu() {
menuBar.classList.add("-translate-x-full");
menuBar.setAttribute("inert", "");
menuBar.setAttribute("aria-hidden", "true");
menuBtn.setAttribute("aria-expanded", "false");
restoreFocus();
}
isMenuOpen();
menuBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (isMenuOpen()) {
openMenu();
} else {
closeMenu();
}
});
menuExitBtn.addEventListener("click", () => {
closeMenu();
});
document.addEventListener("click", (e) => {
if (
!menuBar.classList.contains("-translate-x-full") &&
!menuBar.contains(e.target) &&
!menuBtn.contains(e.target)
) {
closeMenu();
}
});
document.addEventListener("keydown", (e) => {
if(e.key === "Escape" && isMenuOpen()){
closeMenu();
}
})
}
menuHandler();
성장
- 로직을 구현할 때 가장 먼저 필요한 정보를 정리
- 정리된 아원자 또는 원소(변수), 원자(함수)의 동작 방식을 그려본다. (머리속 또는 실제로 그림을 그려보는 접근방법)
- 기존에 사용하던 코드에 새로운 기술을 도입하는 것은 매우 어려운 일이었다.

'문제해결' 카테고리의 다른 글
| [bfcache] - vanila + vite 환경에서 공용으로 쓰는 header html을 재사용 (0) | 2026.01.22 |
|---|---|
| [문제해결] - JavaScript에 의한 상태 변경 지연 - CSR/SSR (0) | 2026.01.20 |
| [문제해결] - Myblog[project] - JavaScript 스크롤기반 페이지 (0) | 2026.01.18 |