웹 접근성을 준수하는 코드 작성하기 #4

Popup Script


웹 접근성을 준수하는 코드 작성하기는 현재 총 5편의 시리즈로 구성되어 있습니다.

  1. 웹 접근성을 준수하는 코드 작성하기 #1 - 서론
  2. 웹 접근성을 준수하는 코드 작성하기 #2 - Tab UI Script
  3. 웹 접근성을 준수하는 코드 작성하기 #번외편 - 대체 텍스트 적용기
  4. 웹 접근성을 준수하는 코드 작성하기 #3 - Accordion UI Script
  5. 웹 접근성을 준수하는 코드 작성하기 #4 - Popup Script (현재글)
  6. 웹 접근성을 준수하는 코드 작성하기 #5 - Swiper.js 사용 시 웹 접근성 준수 하기


들어가며

오늘은 작성할 코드는 Popup Script이다.
웹 접근성을 준수하는 Popup Script 작성할 때는, 키보드 포커스를 고려해서 작성해야 해서 다른 UI Script보다 생각해야 하는 게 많다.



Popup Button

<button aria-haspopup="dialog" data-popup="팝업 고유 ID 값">팝업</button>
  1. aria-haspopup="dialog"으로 해당 버튼과 연결된 popup이 있다는 것을 알려준다.
  2. 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>
  1. popup div에 role=dialog를 넣어주어 해당 요소가 popup 요소라는 것을 알려준다.
  2. popup div에 data-popup="팝업 고유 ID 값"으로 popup button과 popup div에 매칭되는 같은 값을 넣어준다.
  3. popup에 heading 태그를 넣어서 popup title을 넣어준다. (popup title에 tabindex=“0” 추가 필요)
  4. popup title은 반드시 있어야 하며, 디자인상 title이 없을 때는 숨김으로 추가해 주어야 한다.
  5. popup 닫기 버튼에는 popup-close="팝업 고유 ID 값" 값이 필요하다. (popup close 이벤트 호출)
  6. popup 닫기 버튼에는 popup-close 값 외에 추가로 작성 가능한 값이 세 개가 있다.

    • popup-close-all="true" : 현재 활성화된 popup이 모두 닫힘 이벤트 호출
    • popup-confirm="팝업 고유 ID 값" : 컨펌형 팝업 확인 버튼 (callback 이벤트 사용)
    • popup-cancel="팝업 고유 ID 값" : 컨펌형 팝업 취소 버튼 (callback 이벤트 사용)
  7. popup 닫기 버튼은 항상 팝업 마크업 제일 마지막에 위치하며 공통 클래스가 필요하다. (btn-layer-close)
  8. 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에 적용된 사항

  1. popup 기능 구현
  2. 한 페이지에 여러 개의 popup이 있어도 오류 없이 동작
  3. popup open 시 바닥 페이지 focus 저장, popup 닫힐 때 저장된 focus로 focus 이동
  4. 자동으로 open된 popup 일 경우 저장된 focus가 없기 때문에 popup 닫기 시 body로 focus 이동
  5. popup 위에 popup이 뜨는 경우 오류 없이 동작 (focus 저장됨)
  6. popup이 여러 개 떠 있을 경우 팝업 모두 닫기 기능 구현 (focus는 첫 바닥 페이지에서 저장된 focus로 이동)
  7. 딤드 클릭 시 팝업 닫기 기능 구현 (popup이 여러 개 떠 있을 경우 전체 닫기로 구현)
  8. 키보드 ESC 버튼 눌렀을 경우 현재 팝업 닫기 기능 구현
  9. 팝업이 열려 있는 동안에는 탭 버튼으로 동작 시 팝업 안에서만 focus 이동
  10. confirm 형 버튼 이벤트 구현 (callback 이벤트 사용 가능)

(위 스크립트는 ES6 문법이 포함되어 있기 때문에 IE에서는 동작하지 않는다. IE 동작을 위해선 closest, forEach, 화살표 함수, 백틱에 관한 polyfill 적용이 필요하다.)


동작 예시 : https://jsfiddle.net/seulbiLee/baw9fyhL/5/



마치며

팝업 스크립트는 웹 접근성을 준수해서 만들기가 조금 까다로운 편이다. 팝업 창의 마크업의 경우, 보통 팝업창을 호출하는 버튼 다음에 위치하기보다는 개발 편의를 위해 body의 가장 끝에 위치하는 경우가 많아 팝업이 열리고 닫힐 때 포커스를 강제로 조정해주어야 했다. 이 부분이 팝업 스크립트를 처음 작성할 때 가장 어려웠고, 지난 작업 경험 때 팝업이 여러 개 떴을 때 한 번에 닫힐 경우, 호출 버튼 없이 자동으로 팝업이 열리는 경우 이 두 가지를 미리 생각하지 못해 많은 포커스 오류가 생겼었기에, 이번엔 그 부분까지 모두 고려된 팝업 스크립트를 작성해보았다. 덕분에 스크립트가 좀 길어지긴 했지만, 이 정도면 꽤 많은 케이스를 커버한 것으로 보여 만족스럽다.


현재 글쓴이가 작성 중인 웹 접근성 준수하는 코드 작성하기 시리즈는 wai-aria를 사용하긴 하지만 그에 대한 안내는 친절하지 않은 편이다. wai-aria에 대한 설명은 이선주 선임님이 잘 설명 해놓았기에 마지막으로 해당 글 링크 (WAI-ARIA란?)를 첨부하며 이 글을 마치도록 한다.


추천 글