[웹접근성] 공통 팝업 + 포커스 회귀 접근성

kimsoyeon·2025년 12월 4일

웹 접근성이란?

  • 장애인이나 고령자 등 다양한 사용자가 웹사이트의 정보에 비장애인과 동등하게 접근하고 이해할 수 있도록 보장하는 것을 말합니다.
  • 단순히 장애인을 위한 것이 아니라, 지역, 나이, 기술 수준 등 다양한 제약을 고려하여 가능한 많은 사용자가 불편 없이 웹 서비스를 이용하도록 하는 기준입니다.

현 프로젝트의 웹 접근성 이슈 정리(포커스 회귀 문제)

운영 프로젝트 특성상 과거 구축 단계에서 사용하던 공통 팝업 스크립트가 계속 재사용되고 있는데, 이 과정에서 웹 접근성(특히 포커스 회귀) 관련 문제가 반복적으로 발생하고 있습니다.

현재 공통 팝업 JS에서 포커스 회귀를 위한 인자를 전달받도록 되어 있으나,

- 인자 검증이 제대로 이루어지지 않거나,

- 전달받은 인자가 정상적으로 실행되지 않는 경우가 잦습니다.

또한 팝업 가이드에 스크립트 활용 방식에 대한 명확한 기준이 없다 보니, 작업자마다

- 인자를 사용하는 경우와 사용하지 않는 경우가 혼재되어 있고,

- 인자를 주었음에도 회귀가 정상적으로 이루어지지 않거나,

- 반대로 인자를 주지 않았는데도 우연히 회귀가 정상 동작하는 등

로직이 일관적으로 적용되지 않는 상황입니다.

결과적으로, 공통 팝업 스크립트가 페이지별로 예측 불가능하게 동작하며, 접근성 검수 단계에서 포커스가 팝업 열림/닫힘 시 올바르게 이동하지 않는 문제가 지속적으로 발생하고 있습니다.

공통팝업 가이드 작업

HTML

<!-- 여러 개의 열기 버튼이 있어도 공통 스크립트로 처리 -->
<button
    type="button"
    class="btn-open-popup" <!--팝업 버튼 공통 클래스-->
    data-popup-target="#popupFilter" <!--각각 팝업에 대한 데이터 속성으로 팝업 타깃-->
>
  필터 팝업 열기
</button>

<button
    type="button"
    class="btn-open-popup"
    data-popup-target="#popupNotice"
>
  안내 팝업 열기
</button>

<!-- ===== 팝업 1: 필터 팝업 ===== -->
<div
    id="popupFilter"
    class="pop-dx pop_wrap point_filter_pop small_tf"
    role="dialog"
    aria-modal="true"
    aria-labelledby="popupFilterTitle"
    aria-describedby="popupFilterDesc"
>
  <div class="pop_overlay" data-popup-close="overlay"></div>

  <article class="popup popup_type01">
    <h2 id="popupFilterTitle">포인트 필터 설정</h2>
    <div id="popupFilterDesc" class="popup_content">
      기간, 사용처, 혜택 유형 등을 선택해 필터링할 수 있습니다.
    </div>

    <div class="pop_btn btn_wrap btn-wrap-dx type2">
      <button type="button">초기화</button>
      <button type="button" class="ui-pop-close" data-popup-close="button">적용</button>
    </div>

    <button type="button" class="btn_close ui-pop-close" data-popup-close="button">
      <span>팝업 닫기</span>
    </button>
  </article>
</div>

<!-- ===== 팝업 2: 안내 팝업 ===== -->
<div
    id="popupNotice"
    class="pop-dx pop_wrap"
    role="dialog"
    aria-modal="true"
    aria-labelledby="popupNoticeTitle"
    aria-describedby="popupNoticeDesc"
>
  <div class="pop_overlay" data-popup-close="overlay"></div>

  <article class="popup popup_type01">
    <h2 id="popupNoticeTitle">안내사항</h2>
    <div id="popupNoticeDesc" class="popup_content">
      시스템 점검으로 오늘 밤 1시부터 3시까지 일부 서비스 이용이 제한됩니다.
    </div>
    <div class="btn-wrap-dx">
      <button type="button" class="ui-pop-close" data-popup-close="button">
        확인
      </button>
    </div>
    <button type="button" class="btn_close ui-pop-close" data-popup-close="button">
      <span>팝업 닫기</span>
    </button>
  </article>
</div>

CSS

body {
    margin: 0;
    padding: 40px;
    line-height: 1.5;
}

h1 {
    margin-top: 0;
    margin-bottom: 24px;
}

button {
    font-family: inherit;
}

/* ==== 팝업 열기 버튼 ==== */
.btn-open-popup {
    padding: 10px 16px;
    border-radius: 4px;
    border: 1px solid #ddd;
    background: #fff;
    cursor: pointer;
    margin-right: 8px;
}

/* ==== 공통 팝업 레이아웃 ==== */
.pop_wrap {
    position: fixed;
    inset: 0;
    display: none;
    align-items: center;
    justify-content: center;
    z-index: 1000;
}
.pop_wrap.is-open {
    display: flex;
}

.pop_overlay {
    position: absolute;
    inset: 0;
    background: rgba(0, 0, 0, 0.45);
}

.popup {
    position: relative;
    z-index: 1;
    background: #fff;
    border-radius: 8px;
    padding: 24px 24px 18px;
    max-width: 420px;
    width: 90%;
    box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}

.popup h2 {
    margin: 0 0 8px;
    font-size: 18px;
}

.popup .popup_content {
    font-size: 14px;
    margin-bottom: 16px;
}

.btn-wrap-dx {
    display: flex;
    gap: 8px;
    justify-content: flex-end;
    margin-top: 12px;
}

.btn-wrap-dx button {
    padding: 8px 14px;
    border-radius: 4px;
    border: 1px solid #ddd;
    background: #f8f8f8;
    cursor: pointer;
    font-size: 14px;
}

/* 우측 상단 닫기 버튼 */
.btn_close {
    position: absolute;
    top: 8px;
    right: 8px;
    border: none;
    background: none;
    cursor: pointer;
    padding: 6px;
}
.btn_close span {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}
.btn_close::before {
    content: "✕";
    font-size: 16px;
    line-height: 1;
}

Javascript

(function () {
  // ===== 공통: 포커스 가능한 요소 셀렉터 =====
  const FOCUSABLE_SELECTOR = [
    'a[href]',
    'area[href]',
    'button:not([disabled])',
    'input:not([disabled]):not([type="hidden"])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])'
  ].join(',');

  // 팝업별로 "어디서 열렸는지" 기억하는 Map ,, <-- 포커스 회귀인자를 따로 받지 않고 처리
  const focusPopUpMemory = new Map(); // popupElement -> triggerButton

  const openPopUpButtons = document.querySelectorAll('.btn-open-popup');

  openPopUpButtons.forEach((btn) => {
    btn.addEventListener('click', () => {
      const target = btn.getAttribute('data-popup-target');
      if (!target) return;
      const popup = document.querySelector(target);
      if (!popup) return;

      openPopup(popup, btn);
    });
  });

  function openPopup(popup, triggerButton) {
    // 1) 팝업을 어디서 열었는지 기억 (포커스 회귀용 ★핵심)
    focusPopUpMemory.set(popup, triggerButton);

    // 2) 팝업 열기
    popup.classList.add('is-open');

    // 3) 팝업 내부의 첫 번째 포커스 가능한 요소로 포커스 이동
    const focusableEls = popup.querySelectorAll(FOCUSABLE_SELECTOR);
    if (focusableEls.length > 0) {
      focusableEls[0].focus();
    } else {
      // 차선책: popup 자체에 tabindex를 주고 포커스
      popup.setAttribute('tabindex', '-1');
      popup.focus();
    }

    // 4) ESC, Tab 포커스 트랩, etc
    popup.addEventListener('keydown', handleKeyDown);
    // 5) 오버레이 / 닫기 버튼 공통 처리
    const overlay = popup.querySelector('[data-popup-close="overlay"]');
    if (overlay) {
      overlay.addEventListener('click', handleCloseEvent);
    }
    const closeButtons = popup.querySelectorAll('[data-popup-close="button"]');
    closeButtons.forEach((btn) => {
      btn.addEventListener('click', handleCloseEvent);
    });
  }

  function closePopup(popup) {
    popup.classList.remove('is-open');
    popup.removeEventListener('keydown', handleKeyDown);

    const overlay = popup.querySelector('[data-popup-close="overlay"]');
    if (overlay) {
      overlay.removeEventListener('click', handleCloseEvent);
    }
    const closeButtons = popup.querySelectorAll('[data-popup-close="button"]');
    closeButtons.forEach((btn) => {
      btn.removeEventListener('click', handleCloseEvent);
    });

    // ★★★ 포커스 회귀: 이 팝업을 열었던 버튼으로 포커스 이동 ★★★
    const triggerButton = focusPopUpMemory.get(popup);
    if (triggerButton && typeof triggerButton.focus === 'function') {
      triggerButton.focus();
    }

    // 기록 제거
    focusPopUpMemory.delete(popup);
  }

  function handleCloseEvent(event) {
    const popup = event.currentTarget.closest('[role="dialog"]');
    if (!popup) return;
    closePopup(popup);
  }

  function handleKeyDown(event) {
    const popup = event.currentTarget;
    const focusableEls = popup.querySelectorAll(FOCUSABLE_SELECTOR);
    const first = focusableEls[0];
    const last = focusableEls[focusableEls.length - 1];

    // ESC로 닫기
    if (event.key === 'Escape' || event.key === 'Esc') {
      event.preventDefault();
      closePopup(popup);
      return;
    }

    // Tab 포커스 트랩 (팝업 안에서만 돌도록)
    if (event.key === 'Tab') {
      if (focusableEls.length === 0) {
        event.preventDefault();
        return;
      }

      if (event.shiftKey) {
        if (document.activeElement === first) {
          event.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          event.preventDefault();
          first.focus();
        }
      }
    }
  }
})();
profile
i am korean dobby

0개의 댓글