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

Accordion UI Script


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

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


들어가며

오늘은 작성할 코드는 Accordion UI Script이다.
Accordion 코드 같은 경우에는 button의 aria-expanded와 연결된 panel div에 role=“region” 같은 기본적인 웹 접근성 마크업 규칙만 따라 주면 웹 접근성을 위해서 특별히 Script가 추가되어야 하는 부분은 따로 없었다.



<div class="wrap-accordion-group" data-role="accordion-group" accordion-option="toggle">
  <button
    id="accordion-head-1"
    aria-level="2"
    aria-expanded="false"
    class="accordion-btn"
    aria-controls="accordion-region-1"
  >
    <span>Accordion Head 1</span>
  </button>
  <div
    class="wrap-accordion-contents"
    id="accordion-region-1"
    role="region"
    aria-labelledby="accordion-head-1"
  >
    <div class="inner-accordion-contents">Accordion contents 1</div>
  </div>
  <button
    id="accordion-head-2"
    aria-level="2"
    aria-expanded="false"
    class="accordion-btn"
    aria-controls="accordion-region-2"
  >
    <span>Accordion Head 2</span>
  </button>
  <div
    class="wrap-accordion-contents"
    id="accordion-region-2"
    role="region"
    aria-labelledby="accordion-head-2"
  >
    <div class="inner-accordion-contents">Accordion contents 2</div>
  </div>
  <button
    id="accordion-head-3"
    aria-level="2"
    aria-expanded="false"
    class="accordion-btn"
    aria-controls="accordion-region-3"
  >
    <span>Accordion Head 3</span>
  </button>
  <div
    class="wrap-accordion-contents"
    id="accordion-region-3"
    role="region"
    aria-labelledby="accordion-head-3"
  >
    <div class="inner-accordion-contents">Accordion contents 3</div>
  </div>
  <button
    id="accordion-head-4"
    aria-level="2"
    aria-expanded="false"
    class="accordion-btn"
    aria-controls="accordion-region-4"
  >
    <span>Accordion Head 4</span>
  </button>
  <div
    class="wrap-accordion-contents"
    id="accordion-region-4"
    role="region"
    aria-labelledby="accordion-head-4"
  >
    <div class="inner-accordion-contents">Accordion contents 4</div>
  </div>
</div>

Accordion Button

<h2>
  <button
    id="accordion-head-1"
    aria-expanded="false"
    class="accordion-btn"
    aria-controls="accordion-region-1"
  >
    <span>Accordion Head 1</span>
  </button>
</h2>
<button
  id="accordion-head-1"
  aria-level="2"
  aria-expanded="false"
  class="accordion-btn"
  aria-controls="accordion-region-1"
>
  <span>Accordion Head 1</span>
</button>
  1. Accordion의 button은 연결된 panel의 heading 요소로 이루어지기 때문에 위 첫 번째 코드처럼 heading 태그로 작성되거나, 위 두 번째 코드처럼 button에 aria-level을 사용해서 해당 요소가 heading 요소임을 명시해주어야 한다.
  2. aria-expanded는 확장 상태를 알려주는 aria 속성으로 Accordion의 panel이 표시되는 경우에는 true로 숨겨지는 경우에는 false로 표시한다.
  3. Accordion button과 연결될 panel div id를 aria-controls=“accordion panel div id”로 연결하여 두 콘텐츠가 연관 관계에 있다는 것을 알려준다.


Accordion Panel

  1. role=“region”으로 해당 div가 연결된 accordion button이 있는 accordion panel이라는 것을 명시해준다.
  2. 현재 accordion panel과 연결된 accordion button의 id를 aria-labelledby=“accordion button id”로 연결해준다.
  3. 활성화되지 않은 accordion panel은 스크린 리더기가 읽을 수 없게 hidden 속성을 넣어 숨겨준다.

const accordionGroups = document.querySelectorAll('[data-role="accordion-group"]');
if (accordionGroups) {
  const accordionEvt = (e) => {
    let currentTarget = e.target.tagName;
    currentTarget === 'BUTTON'
      ? (currentTarget = e.target)
      : (currentTarget = e.target.closest('button'));
    const accordionOption = currentTarget
        .closest('[accordion-option]')
        .getAttribute('accordion-option'),
      accordionWrapper = currentTarget.closest('[data-role="accordion-group"]'),
      targetContents = accordionWrapper.querySelector(
        `[aria-labelledby="${currentTarget.id}"]`,
      );
    // toggle type (default)
    if (currentTarget.ariaExpanded === 'true') {
      currentTarget.setAttribute('aria-expanded', 'false');
      targetContents.style.height = 0;
      targetContents.removeEventListener('transitionstart', accordionTransitionEvt);
      targetContents.addEventListener('transitionend', accordionTransitionEvt);
    } else {
      // 연결 된 accordion은 무조건 하나씩 만 열리는 type
      if (accordionOption === 'only') {
        const accordionBtns = accordionWrapper.querySelectorAll('.accordion-btn');
        const accordionContents = accordionWrapper.querySelectorAll('[role="region"]');
        // 기존에 선택 된 accordion 속성 false 로 변경
        accordionBtns.forEach((accordionBtn) => {
          accordionBtn.setAttribute('aria-expanded', 'false');
          if (accordionBtn.nextElementSibling !== null) {
            accordionBtn.nextElementSibling.style.height = 0;
            accordionBtn.nextElementSibling.removeEventListener(
              'transitionstart',
              accordionTransitionEvt,
            );
            accordionBtn.nextElementSibling.addEventListener(
              'transitionend',
              accordionTransitionEvt,
            );
          }
        });
      }
      // 선택 된 accordion 속성 true 상태로 변경
      currentTarget.setAttribute('aria-expanded', 'true');
      targetContents.removeAttribute('hidden');
      targetContents.style.height = `${targetContents.scrollHeight}px`;
    }
  };
  // height size transition event 후 hidden 속성 추가
  const accordionTransitionEvt = () => {
    const accordionContentsAll = document.querySelectorAll('.wrap-accordion-contents');
    accordionContentsAll.forEach((contents) => {
      if (contents.previousElementSibling.ariaExpanded === 'false')
        contents.setAttribute('hidden', 'true');
      else contents.removeAttribute('hidden');
    });
  };
  // 초기 셋팅 및 클릭 이벤트 제거/할당
  accordionGroups.forEach((accordionGroup) => {
    const accordionBtns = accordionGroup.querySelectorAll('.accordion-btn');
    accordionBtns.forEach((accordionBtn) => {
      // 초기 셋팅 : accordion contents height size 에 비례한 transition duration 수정 (height size 188px = 0.3s 기준)
      accordionBtn.nextElementSibling.style.transitionDuration = `${
        accordionBtn.nextElementSibling.scrollHeight * 0.0016
      }s`;
      // 초기 셋팅 : button 의 aria-expanded 값이 false 인 accordion contents 에 hidden 값 할당
      if (
        accordionBtn.ariaExpanded === 'false' &&
        accordionBtn.nextElementSibling !== null
      )
        accordionBtn.nextElementSibling.setAttribute('hidden', 'true');
      // 초기 셋팅 : button 의 aria-expanded 값이 true 인 accordion contents 에 height size 할당
      if (
        accordionBtn.ariaExpanded === 'true' &&
        accordionBtn.nextElementSibling !== null
      )
        accordionBtn.nextElementSibling.style.height = `${accordionBtn.nextElementSibling.scrollHeight}px`;
      accordionBtn.removeEventListener('click', accordionEvt);
      accordionBtn.addEventListener('click', accordionEvt);
    });
  });
}

현재 작성된 Accordion 규칙

  • accordion wrapper div에 data-role=“accordion-group” 값 추가로 accordion group을 묶어준다.
  • accordion 열림 속성을 한 가지 이상 작업 될 경우를 대비해 accordion-option 값으로 옵션값을 추가. 기본은 toggle type, only는 accordion button 선택 시 같은 그룹의 accordion panel은 무조건 하나만 열리는 스크립트를 추가로 작성하였다.
  • 마크업 자체에 accordion panel div에 hidden 값을 별도로 주지 않았고, accordion button 값에 따라 처음 화면 로딩 당시 accordion panel에 hidden 값이 들어가도록 작업 되었다.

현재 작성된 Accordion Script에 적용된 사항

  1. Accordion 기능 구현
  2. 한 페이지에 여러 개의 Accordion Group이 있어도 오류 없이 동작.
  3. 한 그룹의 활성화된 Accordion panel이 무조건 하나인 경우 고려. (accordion-option)
  4. Accordion panel transition 효과 적용.
  5. Accordion panel transition duration을 height size에 비례하게 적용

Accordion transition 효과 적용 시, Accordion panel이 비활성화가 될 때 바로 hidden 값을 넣으면 transition 효과가 보이지 않는다.
그렇기 때문에 transition 이벤트가 일어난 후에 hidden 속성 추가가 필요하다.
이 부분은 addEventListener transitionend를 이용해 적용하였다.


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


동작 예시 : https://jsfiddle.net/seulbiLee/sqn7bafe/1/



마치며

이번 스크립트 작성을 하면서 기존 작성하던 코드 방식을 바꿔서 작성해보았다. 좀 더 실 프로젝트에서 가져다가 공통에 바로 추가할 수 있게 코드를 작성하려고 노력하였고, 처음부터 너무 많은 기능을 고려하지 않고 정말 기본이 되는 기능만 넣으려고 노력하였는데, 생각보다 코드는 그렇게 많이 줄어들지 않았다. 어떻게 해야지 좀 더 간결하고 가독성이 높은 코드를 작성할 수 있을지 고민이 많이 필요할 것 같다.