운영 프로젝트 특성상 과거 구축 단계에서 사용하던 공통 팝업 스크립트가 계속 재사용되고 있는데, 이 과정에서 웹 접근성(특히 포커스 회귀) 관련 문제가 반복적으로 발생하고 있습니다.
현재 공통 팝업 JS에서 포커스 회귀를 위한 인자를 전달받도록 되어 있으나,
- 인자 검증이 제대로 이루어지지 않거나,
- 전달받은 인자가 정상적으로 실행되지 않는 경우가 잦습니다.
또한 팝업 가이드에 스크립트 활용 방식에 대한 명확한 기준이 없다 보니, 작업자마다
- 인자를 사용하는 경우와 사용하지 않는 경우가 혼재되어 있고,
- 인자를 주었음에도 회귀가 정상적으로 이루어지지 않거나,
- 반대로 인자를 주지 않았는데도 우연히 회귀가 정상 동작하는 등
로직이 일관적으로 적용되지 않는 상황입니다.
결과적으로, 공통 팝업 스크립트가 페이지별로 예측 불가능하게 동작하며, 접근성 검수 단계에서 포커스가 팝업 열림/닫힘 시 올바르게 이동하지 않는 문제가 지속적으로 발생하고 있습니다.
<!-- 여러 개의 열기 버튼이 있어도 공통 스크립트로 처리 -->
<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>
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;
}
(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();
}
}
}
}
})();