문제해결

addEventListener의 구조 이해 및 메뉴 UI 동작 + 접근성 로직을 결합

hyeeoooook 2026. 1. 22. 00:38

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();

 

성장

 

  1. 로직을 구현할 때 가장 먼저 필요한 정보를 정리
  2. 정리된 아원자 또는 원소(변수), 원자(함수)의 동작 방식을 그려본다. (머리속 또는 실제로 그림을 그려보는 접근방법)
  3. 기존에 사용하던 코드에 새로운 기술을 도입하는 것은 매우 어려운 일이었다.