웹 접근성을 준수하는 코드 작성하기는 현재 총 5편의 시리즈로 구성되어 있습니다.
- 웹 접근성을 준수하는 코드 작성하기 #1 - 서론
- 웹 접근성을 준수하는 코드 작성하기 #2 - Tab UI Script
- 웹 접근성을 준수하는 코드 작성하기 #번외편 - 대체 텍스트 적용기
- 웹 접근성을 준수하는 코드 작성하기 #3 - Accordion UI Script
- 웹 접근성을 준수하는 코드 작성하기 #4 - Popup Script (현재글)
- 웹 접근성을 준수하는 코드 작성하기 #5 - Swiper.js 사용 시 웹 접근성 준수 하기
들어가며
오늘은 작성할 코드는 Popup Script이다.
웹 접근성을 준수하는 Popup Script 작성할 때는, 키보드 포커스를 고려해서 작성해야 해서 다른 UI Script보다 생각해야 하는 게 많다.
Popup Button
<button aria-haspopup="dialog" data-popup="팝업 고유 ID 값">팝업</button>
aria-haspopup="dialog"
으로 해당 버튼과 연결된 popup이 있다는 것을 알려준다.data-popup="팝업 고유 ID 값"
으로 팝업 고유 ID 값을 넣어준다.
Popup
<!-- 일반 팝업 -->
<section class="wrap-layer-popup" data-popup="popup1" role="dialog">
<div class="inner-layer-popup">
<div class="wrap-layer-popup-title" tabindex="0">
<h1>타이틀</h1>
</div>
<div class="layer-popup-contents">
<div class="inner">내용</div>
</div>
<div class="layer-popup-bottom">
<button popup-close="popup1" popup-close-all="true">모두 닫기</button>
<button popup-close="popup1">현재 팝업 닫기</button>
</div>
<button class="btn-layer-close" popup-close="popup1">
<span class="hidden">현재 팝업 닫기</span>
</button>
</div>
</section>
<!-- confirm 팝업 -->
<section class="wrap-layer-popup" data-popup="popup2" role="dialog">
<div class="inner-layer-popup">
<div class="wrap-layer-popup-title" tabindex="0">
<h1>컴펌팝업</h1>
</div>
<div class="layer-popup-contents">
<div class="inner">내용</div>
</div>
<div class="layer-popup-bottom">
<button popup-close="popup2" popup-confirm="popup2">
<span>컨펌확인</span>
</button>
<button popup-close="popup2" popup-cancel="popup2">
<span>컨펌취소</span>
</button>
</div>
<button popup-close="popup2" class="btn-layer-close" popup-close-all="true">
<span class="hidden">팝업 전체 닫기</span>
</button>
</div>
</section>
- popup div에
role=dialog
를 넣어주어 해당 요소가 popup 요소라는 것을 알려준다. - popup div에
data-popup="팝업 고유 ID 값"
으로 popup button과 popup div에 매칭되는 같은 값을 넣어준다. - popup에 heading 태그를 넣어서 popup title을 넣어준다. (popup title에 tabindex=“0” 추가 필요)
- popup title은 반드시 있어야 하며, 디자인상 title이 없을 때는 숨김으로 추가해 주어야 한다.
- popup 닫기 버튼에는
popup-close="팝업 고유 ID 값"
값이 필요하다. (popup close 이벤트 호출) -
popup 닫기 버튼에는 popup-close 값 외에 추가로 작성 가능한 값이 세 개가 있다.
popup-close-all="true"
: 현재 활성화된 popup이 모두 닫힘 이벤트 호출popup-confirm="팝업 고유 ID 값"
: 컨펌형 팝업 확인 버튼 (callback 이벤트 사용)popup-cancel="팝업 고유 ID 값"
: 컨펌형 팝업 취소 버튼 (callback 이벤트 사용)
- popup 닫기 버튼은 항상 팝업 마크업 제일 마지막에 위치하며 공통 클래스가 필요하다. (btn-layer-close)
- popup 닫기 버튼이 한 개 이상일 경우, 마크업 제일 마지막에 위치한 팝업 닫기 버튼에만 공통 클래스를 적용한다.
Script
const popupBtnAll = document.querySelectorAll('[aria-haspopup="dialog"]');
if (popupBtnAll) {
let currentTarget, focusEl = [], popupDepth = 0, popupDimmed, keyEscapeEvt, KeyEvtEl;
const _$this = this,
popupAll = document.querySelectorAll('[role="dialog"]'),
popupCloseBtnAll = document.querySelectorAll('[popup-close]');
// ESC 누름 감지
const keyEvent = {å
get keyEscape() {
return this._state;
},
set keyEscape(state) {
this._state = state;
if (state) escKeyEvt(KeyEvtEl, keyEscapeEvt);
},
};
keyEvent;
// popup dimmed 생성
const createdDimmed = () => {
const createDiv = document.createElement('div');
createDiv.classList.add('popup-dimmed');
document.querySelector('body').appendChild(createDiv);
};
// popup dimmed click 시 팝업 닫기
const dimmedClick = (e) => {
if (e.target.classList.contains('wrap-layer-popup')) {
popupCloseAll();
keyEvent.keyEscape = false;
}
};
// popup open
const popupOpen = (e) => {
currentTarget = e.target.tagName;
currentTarget === 'BUTTON' || currentTarget === 'A' ? currentTarget = e.target : currentTarget = e.target.closest('button') || e.target.closest('a');
popupDimmed = document.querySelectorAll('.popup-dimmed');
if (popupDimmed.length === 0) createdDimmed();
popupAll.forEach((popupEl) => {
if (popupEl.getAttribute('data-popup') === currentTarget.getAttribute('data-popup')) {
popupDepth += 1; // popup depth 저장
focusEl.splice((popupDepth - 1), 0, currentTarget); // popup focus Element 저장
popupEl.classList.add('popup-open'); // open class add
popupEl.setAttribute('popup-depth', popupDepth); // popup depth 설정
// dimmed click 이벤트 할당
popupEl.removeEventListener('click', dimmedClick);
popupEl.addEventListener('click', dimmedClick);
document.body.classList.add('scroll-lock'); // popup scroll lock
popupEl.querySelector('.wrap-layer-popup-title').focus(); // popup 오픈 시 타이틀에 포커스
// shift+tab 또는 <- 화살표 키 키보드 동작 시 팝업 밖으로 포커스 이동 방지 이벤트 할당
popupEl.querySelector('.wrap-layer-popup-title').removeEventListener('keydown', titleKeyDown);
popupEl.querySelector('.wrap-layer-popup-title').addEventListener('keydown', titleKeyDown);
// popup 위 팝업 케이스 dimmed 수정
if (popupDepth > 1) document.querySelector(`[popup-depth='${popupDepth - 1}']`).classList.add('prev-popup');
KeyEvtEl = popupEl; // ESC 키 동작을 위한 현재 활성화 된 popup element 저장
};
});
};
// popup close
const popupClose = (e) => {
// 키보드 이벤트 ESC 일 경우 currentTarget 설정
if (e.key == 'Escape' || e.key == 'Esc') currentTarget = KeyEvtEl.querySelector('.btn-layer-close');
// 일반적인 클릭, 키보드 이벤트 일 경우 currentTarget 설정
else {
currentTarget = e.target.tagName;
currentTarget === 'BUTTON' || currentTarget === 'A' ? currentTarget = e.target : currentTarget = e.target.closest('button') || e.target.closest('a');
let popupId = currentTarget.getAttribute('popup-close');
if (currentTarget.getAttribute('popup-close-all') === 'true') return popupCloseAll();
if (currentTarget.getAttribute('popup-confirm')) confirmEvt[popupId]();
else if (currentTarget.getAttribute('popup-cancel')) cancelEvt[popupId]();
}
popupAll.forEach((popupEl) => {
if (popupEl.getAttribute('data-popup') === currentTarget.getAttribute('popup-close')) {
popupEl.classList.remove('popup-open');
// 저장된 focus element 가 있을 때
if (focusEl.length > 0) {
focusEl[popupDepth - 1].focus(); // focus 상태 재설정
focusEl.splice((popupDepth - 1), 1); // popup focus Element 삭제
popupDepth -= 1; // popup depth 재설정
KeyEvtEl = document.querySelector(`.wrap-layer-popup[popup-depth='${popupDepth}']`); // ESC 키 동작을 위한 현재 활성화 된 popup element 저장
} else { // 저장된 focus element 가 없을 때
document.body.setAttribute('tabindex', '0');
document.body.focus();
KeyEvtEl = null;
}
};
});
// 오픈 된 popup이 있는 지 확인
const openPopups = document.querySelectorAll(`.popup-open`);
if (openPopups.length === 0) popupCloseAll('none');
else if (openPopups.length > 0) { // 오픈된 popup이 있을 경우 popup dimmed 수정
const getPopupValue = currentTarget.getAttribute('popup-close') || currentTarget.getAttribute('data-popup');
const getPopupDepth = Number(document.querySelector(`.wrap-layer-popup[data-popup='${getPopupValue}']`).getAttribute('popup-depth'));
document.querySelector(`.wrap-layer-popup[popup-depth='${getPopupDepth - 1}']`).classList.remove('prev-popup');
document.querySelector(`.wrap-layer-popup[data-popup='${getPopupValue}']`).removeAttribute('popup-depth');
};
};
// popup close All
const popupCloseAll = (focusActionNone) => {
// dimmed 삭제
const popupDimmed = document.querySelector('.popup-dimmed');
popupDimmed.style.opacity = 0;
popupDimmed.addEventListener('transitionend', function() {
if (popupDimmed.parentNode !== null) popupDimmed.parentNode.removeChild(popupDimmed);
});
// popup depth 설정 삭제
popupAll.forEach((popupEl) => {
popupEl.classList.remove('prev-popup');
popupEl.removeAttribute('popup-depth');
});
// scroll lock 해지
document.body.classList.remove('scroll-lock');
// popupClose Event 통해서 focus 설정이 되지 않았을 경우 (popupCloseAll 단독 실행일 경우)
if (focusActionNone !== 'none') {
if (focusEl.length > 0) focusEl[0].focus(); // 저장된 focus element 가 있을 때
else { // 저장된 focus element 가 없을 때
document.body.setAttribute('tabindex', '0');
document.body.focus();
};
focusEl = []; // focus reset
}
popupAll.forEach((popupEl) => popupEl.classList.remove('popup-open')); // open class 삭제
popupDepth = 0; // popup depth reset
KeyEvtEl = null; // KeyEvtEl reset
};
// ESC 키보드 이벤트
const escKeyEvt = (El, e) => {
const openPopups = document.querySelectorAll(`.popup-open`);
// 팝업 열린 상태에서 키보드 ESC 키 이벤트 실행
if (openPopups.length > 0) popupClose(e);
};
// popup 닫기 키보드 이벤트
const closeBtnKeyDown = (e) => {
if ((e.key == 'Tab' && !e.shiftKey) || e.key == 'ArrowRight') {
e.preventDefault();
popupAll.forEach((popupEl) => {
if (popupEl.getAttribute('data-popup') === e.target.getAttribute('popup-close')) {
popupEl.querySelector('.wrap-layer-popup-title').focus();
};
});
};
};
// popup title 키보드 이벤트
const titleKeyDown = (e) => {
if ((e.key == 'Tab' && e.shiftKey) || e.key == 'ArrowLeft') {
e.preventDefault();
popupAll.forEach((popupEl) => {
if (popupEl.getAttribute('data-popup') === e.target.closest('.wrap-layer-popup').getAttribute('data-popup')) {
popupEl.querySelector('.btn-layer-close').focus();
};
});
};
};
// 키보드 ESC 키 누름 감지 이벤트
const escKeyDown = (e) => {
if (e.key == 'Escape' || e.key == 'Esc') {
keyEscapeEvt = e;
keyEvent.keyEscape = true;
};
};
// 클릭/키보드 팝업 이벤트 제거/할당
// 팝업 열기
popupBtnAll.forEach((popupBtn) => {
popupBtn.removeEventListener('click', popupOpen);
popupBtn.addEventListener('click', popupOpen);
});
// 팝업 닫기
popupCloseBtnAll.forEach((popupCloseBtn) => {
popupCloseBtn.removeEventListener('click', popupClose);
popupCloseBtn.addEventListener('click', popupClose);
if (popupCloseBtn.classList.contains('btn-layer-close')) {
popupCloseBtn.removeEventListener('keydown', closeBtnKeyDown);
popupCloseBtn.addEventListener('keydown', closeBtnKeyDown);
}
});
// ESC 키로 팝업 닫기
window.removeEventListener('keydown', escKeyDown);
window.addEventListener('keydown', escKeyDown);
}
// callback event
// confirm event
const confirmEvt = {
popup1: () => {
document.querySelector('#checkbox').checked = true;
},
};
// cancel event
const cancelEvt = {
popup1: () => {
// cancel evnet
},
};
현재 작성된 Popup Script에 적용된 사항
- popup 기능 구현
- 한 페이지에 여러 개의 popup이 있어도 오류 없이 동작
- popup open 시 바닥 페이지 focus 저장, popup 닫힐 때 저장된 focus로 focus 이동
- 자동으로 open된 popup 일 경우 저장된 focus가 없기 때문에 popup 닫기 시 body로 focus 이동
- popup 위에 popup이 뜨는 경우 오류 없이 동작 (focus 저장됨)
- popup이 여러 개 떠 있을 경우 팝업 모두 닫기 기능 구현 (focus는 첫 바닥 페이지에서 저장된 focus로 이동)
- 딤드 클릭 시 팝업 닫기 기능 구현 (popup이 여러 개 떠 있을 경우 전체 닫기로 구현)
- 키보드 ESC 버튼 눌렀을 경우 현재 팝업 닫기 기능 구현
- 팝업이 열려 있는 동안에는 탭 버튼으로 동작 시 팝업 안에서만 focus 이동
- confirm 형 버튼 이벤트 구현 (callback 이벤트 사용 가능)
(위 스크립트는 ES6 문법이 포함되어 있기 때문에 IE에서는 동작하지 않는다. IE 동작을 위해선 closest, forEach, 화살표 함수, 백틱에 관한 polyfill 적용이 필요하다.)
동작 예시 : https://jsfiddle.net/seulbiLee/baw9fyhL/5/
마치며
팝업 스크립트는 웹 접근성을 준수해서 만들기가 조금 까다로운 편이다. 팝업 창의 마크업의 경우, 보통 팝업창을 호출하는 버튼 다음에 위치하기보다는 개발 편의를 위해 body의 가장 끝에 위치하는 경우가 많아 팝업이 열리고 닫힐 때 포커스를 강제로 조정해주어야 했다. 이 부분이 팝업 스크립트를 처음 작성할 때 가장 어려웠고, 지난 작업 경험 때 팝업이 여러 개 떴을 때 한 번에 닫힐 경우, 호출 버튼 없이 자동으로 팝업이 열리는 경우 이 두 가지를 미리 생각하지 못해 많은 포커스 오류가 생겼었기에, 이번엔 그 부분까지 모두 고려된 팝업 스크립트를 작성해보았다. 덕분에 스크립트가 좀 길어지긴 했지만, 이 정도면 꽤 많은 케이스를 커버한 것으로 보여 만족스럽다.
현재 글쓴이가 작성 중인 웹 접근성 준수하는 코드 작성하기 시리즈는 wai-aria를 사용하긴 하지만 그에 대한 안내는 친절하지 않은 편이다. wai-aria에 대한 설명은 이선주 선임님이 잘 설명 해놓았기에 마지막으로 해당 글 링크 (WAI-ARIA란?)를 첨부하며 이 글을 마치도록 한다.