아티클

WAI-ARIA Pattern Library 제작하기 1부 - 버튼(Button)

2021-04-19 16:33:05

안녕하세요, 엔비전스입니다.

Pattern Library 제작하기라는 새로운 시리즈로 찾아뵙게 되어 반갑습니다 :)

WAI-ARIA는 커스텀 요소에 접근성을 보장해 주는 기술이나, 뜨거운 감자이기도 합니다.

WAI-ARIA는 코드의 단순한 정보 객체에 지나지 않는데요. 스크린리더에 요소에 대한 특징을 전달하고, 스크린리더에 모드 변경을 지시할 수는 있지만 WAI-ARIA 자체가 별도로 요소의 기능을 가져다주지는 않습니다.

role=“button”을 태그에 추가해도 요소에 누르기 동작이 정의되지는 않아요. WAI-ARIA 바르게 사용하기에서도 늘 이 부분을 강조해 왔습니다.

WAI-ARIA 바르게 사용하기는 순수하게 role이나 “aria-” 접미사가 붙은 속성이 어떤 식으로 사용하고, 어떠한 요소를 구현할 때 사용하는지에 초점이 맞춰져 있기에 실제로 어떻게 만들어야 하는지에 대한 부분은 잘 언급되지 않았습니다.

WAI-ARIA Pattern Library 제작하기WAI-ARIA 바르게 사용하기의 내용을 토대로 어떻게 동작을 추가해야 하는지에 대해 초점을 맞추어 작성합니다.

 

Button 만들어 보기

가장 좋은 것은 커스텀 요소를 되도록 쓰지 않는 것입니다. 하지만, 반드시 커스텀 요소로 만들어야 하는 상황도 있지요.

사실 <button> 요소는 커스텀 요소로 만들 이유가 매우 적습니다. 버튼 요소는 CSS만으로도 충분히 아름다운 모습으로 바꿀 수 있고, 목적도 오로지 눌렀을 때 어떠한 기능을 제공하는 데에만 초점이 맞춰져있기 때문입니다.

다만, 웹 개발에서 시멘틱이 강조되기 이전에는 버튼 대신에 a(anchor) 링크 태그가 버튼을 대신하여 많이 사용되었습니다. 이러한 웹페이지를 보수할 때는 어쩔 수 없이 일괄적으로 role=“button”을 적용하는 것이 편리할 수도 있겠지요.

말이 너무 길었네요. 따분한 얘기는 여기까지 하도록하고, 어찌 되었든, 가장 기본이 되는 누르기 동작을 지원하는 버튼을 한번 만들어 보도록 합시다.

 

원시적인 방식

페이지 규모가 작고, 단순한 코드가 필요하다면, 굳이 코드를 Instance로 만들 필요가 없습니다. 반복적으로 특정 요소에 기능을 추가하는 function을 만들고, 이것을 문서에 넣어주기만 하면 되지요.

Pattern Library 아티클은 이 방법을 사용하지 않을 예정이지만 첫 시간에는 이에 대해서도 잠깐 다뤄보도록 하겠습니다.

  //implementButtonRole.js
  function implementStaticButtons (selector) {
    /*role=button을 구현할 요소를 담습니다. selector 파라미터에는 
    selector 스트링을 사용하는 함수입니다. */

    var buttons = document.querySelectorAll(selector);
    /* 반복처리를 위해 NodeList(querySelectorAll)나 HTMLCollection(getElementsBy~)로
     요소 유사배열 객체를 가저옵니다.
    */

    function setInitialize(element){
      //element는 HTMLElement를 담아야 합니다.

      /*
        키보드로도 클릭할 수 있는 이벤트를 만들어 등록해줘야 합니다.
        keyboardClickSupport라는 핸들러 함수를 뒤에 만들 겁니다.
      */
      
      /*
        기본 disabled 상태도 초기화해줍니다.
      */
      element.setAttribute('role','button');
      element.setAttribute('aria-disabled','false');
      element.setAttribute('tabindex','0');
      element.addEventListener('keydown',keyboardClickSupport)
    }

    //키보드로도 클릭 이벤트를 동작할 수 있도록 하는 이벤트 핸들러
    function keyboardClickSupport(evt){
      //switch문을 사용하여 evt.code값을 검사합시다.
      //evt.key로 대체할 수 있으나, IE에서는 KeyboardEvent.code를 지원하지 않습니다.
      //EI를 지원해야 한다면 반드시 유의하여야 하며, KeyboardEvent.key로 대체할 수 있습니다.
      //key와 code는 반환되는 string에 꽤 많은 차이가 있으니 신중하게 사용해야 합니다.

      switch (evt.code){
        case "Enter": // key에서도 Enter를 누르면 동일하게 "Enter" 스트링이 반환됨
        case "Space": // key를 사용한다면 "space"가 아닌 " "을 사용.
          //Enter나 Space를 눌렀을 때 click dispatch 메소드를 호출
          evt.target.click();
        break;
      }
    }

    for(var i=0; i < buttons.length; i++){
      var element = buttons[i];
      setInitialize(element);
    }
  }
  
  implementStaticButtons('[role="button"]');
  전, 제 문서 상에 role="button"이 쓰인 모든 요소에 위에 구현한 코드를 덮어씌울 겁니다.

모든 코드를 다 작성했다면 HTML body에 script 태그로 불러오면 되겠네요.

<html>
  <head>
  ...
  </head>
  <body>
    <span id="hello" role="button"> Hello... </span>

    <script src="implementButtonRole.js"></script>
    <script>
      // 테스트를 위해 hello 버튼을 누르면 alert이 나오게끔 이벤트를 걸어봅시다.
      var hello = document.getElementById('hello');
      hello.addEventListener('click',function(){
        alert('World...라고 할 줄 알았지?');
      })
    </script>
  </body>
</html>

그리고 테스트를 위해 alert 이벤트를 샘플 버튼에 걸어주고, 스크린리더를 켜지 않고, Tab키로 버튼에 초점이 잘 가는지 확인하고, Space 키나 Enter 키로 click 이벤트가 잘 작동하는지 확인합니다. 잘 작동한다면 축하드립니다. 아주 기초적인 패턴 라이브러리를 완성하셨습니다.

다만 위 코드에서 빠진 것이 있다면, disabled 상태에 대한 부분만이 빠져있겠군요. 그것에 대해서는 뒤에서 다뤄보도록 하겠습니다.

 

인스턴스로 만들기

위에서는 단순히 반복문을 사용하여 덮어씌웠다면, 객체를 만들었지만, 이제는 더 반복 사용하기 좋고, 유지 보수하기 좋게 인스턴스로 만들어 봅시다.

저는 ES6의 class 문법을 사용하여 객체를 생성하는 방법을 선호합니다. class 문이 문법 설탕이라는 말은 하지만, 매우 편리한 기능을 제공하고, function으로 인스턴스를 만드는 것보다 눈으로 보기 예쁩니다.

이번에는 이 ES6의 class 문을 사용해서 disabled 상태와 a 태그의 기본동작도 한번 막아보도록 할게요.

class CustomButton {
  constructor(element){// constructor로 초기설정을 해줍시다.
    this.element = element;
    this.isAnchor = this.element instanceof HTMLAnchorElement;
    //a 태그인지 검사하는 프로퍼티입니다. 
    if(this.isAnchor){
      // isAnchor가 true면 링크 기본동작을 없애는 이벤트 리스너를 등록합니다.
      // tabindex를 별도로 줄 것이므로 #이나 javascript; 코드같은 같은 href는 없애도록 합시다.
      // 동작은 오로지 addEventListener로 추가하세요.
      // 만약 링크 href에 javascript 인라인 코드를 써야 한다면, 초점 관리를 href로 처리하세요.
      element.removeAttribute('href');
      element.addEventListener('click',function(evt){
        evt.preventDefault();
      });
      /* 다들 잘 아시겠지만, 이것을 해주지 않는다면 href에 의해 주소에 해시가 붙거나,
      구형 브라우저에서는 페이지가 리프래시되는 현상이 나타나기도 합니다. */
    }
    element.setAttribute('role','button');
    
    this.disabled = this.disabled;
    //this.disabled는 아직 만들지 않았습니다. 뒤에서 만들 getter/setter 속성입니다.
    //this.disabled에 this.disabled를 대입한다는 것은, setter에 getter를 대입하는 것입니다.
    
    const _this = this; 
    Object.defineProperty(this.element,'disabled',{
      // element.disabled로 호출할 수 있도록 getter,setter 연결
      set(v){
        if(typeof v === 'boolean'){
          _this.disabled = v;
        }
      },
      get(){
        return _this.disabled;
      }
    })

    // 아까처럼 키보드 지원을 추가해줍시다.
    this.element.addEventListener('keydown',KeyboardClickSupport);

    function KeyboardClickSupport(evt){
      switch (evt.code){
        case "Enter":
        case "Space":
          evt.target.click();
        break;
      }
    }
  }

  get disabled(){
    //특정 값을 반환해주는 getter입니다.
    //disabled 상태인지 아닌지를 반환해주는 Getter 속성을 삼항 연산자로 만들었습니다.
    //true 스트링이 아니라면 무조건 false를 반환합니다.
    //aria-disabled 속성을 정의해두지 않았다면 null이므로, 자연스럽게 false가 매칭됩니다.

    return this.element.getAttribute('aria-disabled') === 'true' ? true : false;
  }

  set disabled (val) {
    //값을 대입할 때 마다 아래 코드가 실행됩니다.
    if( typeof val === 'boolean' ) {
      //기초자료형, boolean 형이 대입될 때만 이 setter 속성이 동작합니다.
      const el = this.element // this.element는 너무 기니까 줄여줍시다.
      el.setAttribute('aria-disabled',val);
      
      val ? el.removeAttribute('tabindex') : el.setAttribute('tabindex','0');
      //다시 삼항 연산자를 사용하여 val의 부울 여부에 따라 tabindex를 설정합니다.
      /*
        disabled 상태에는 tab으로 초점을 보낼 수 없어야 하고, disabled가 false 상태라면
        tab 키로 초점을 보낼 수 있어야 합니다.
      */
    }
  }
}

//constructor에 정적 속성을 만들어줍시다. es2015를 쓴다면, 클래스 내부에 static으로 선언하세요.
CustomButton.autoCreation = function(selector){
  //Array.from으로 유사 배열 객체인 NodeList를 순수한 객체로 변환시킵니다.
  //foreach는 Array 타입에서만 사용할 수 있기에 Array.from으로 변환한 것입니다.
  const buttons = Array.from(document.querySelectorAll(selector));
  buttons.forEach(element => {
    new CustomButton(element);
  });
  //IE에서는 Array.from을 그냥 쓰면 동작하지 않습니다.
  //Babel+core-js Polyfill, preset-env, 각종 ES6 지원 플러그인을 설치하고, 
  //ES6 문법을 사용하는 대신 스크립트를 변환하는 것을 권장합니다.
  //전 바벨로 바꿀 것이므로, Internet Explorer에서 지원하지 않는 Array.from을 썼습니다.

}

주로 본 아티클에서는 Babel로 변환하는 이 방식을 사용할 것입니다.

하지만 필요에 따라 결정하는 것이 가장 좋겠지요. 더 길어젔지만, ES6의 클래스로 제작하면 extends 키워드로 특정 기능을 가진 새로운 객체를 만들거나, 특정 기능을 각각 클래스 조각으로 나누어

마치 부품 조립처럼 lodash 라이브러리와 함께 편리하게 모던 자바스크립트의 mixin 패턴으로 요소를 조립할 수 있습니다. 요소의 기능 확장이 용이해진다는 얘기이지요.

번외: Web Components API

이미 오래된 사이트가 아니라, 새로운 사이트를 만들 때는 기존의 요소를 덮어씌우는 것보다 처음부터 컴포넌트로 만드는 것이 좋습니다.

모던 웹 브라우저에서는 W3C 표준, Web Components API를 제공하고 있습니다. Web Components API로 제작한 컴포넌트는 스크립트의 이해도가 낮아도, HTML 마크업만 할 줄 안다면, 누구나 사용하기 쉬운 커스텀 태그를 만드는 표준 API입니다.

대표적으로 Apple이나 Microsoft, Google과 같은 글로벌 기업 운영하는 사이트에서 Web Components로 만든 커스텀 태그를 볼 수 있습니다.

대표적으로 youtube에서 볼 수 있는 웹 플레이어 컴포넌트인 <ytd-player>가 있습니다. Web Components API는 Vue.js 프레임워크에서도 많이 사용합니다.

Web Components API는 모던 브라우저(FireFox, Chromium{최신버전의 Chromium Edge, Chrome, Brave, Whale}, Safari) 에서 지원하며, 브라우저마다 지원하는 정도는 다르지만, Polyfill로 Internet Explorer에서 조차 사용이 가능합니다.

또한, Google에서 지원하는 Polymer 라이브러리, LitElement 등으로 더 깔끔한 컴포넌트를 제작할 수 있습니다.

내용이 너무 길어지므로, 아래 링크들을 참고해보세요.

 

마치며

버튼 요소는 기능이 단순하다 보니, 생각보다 role=“button”로 버튼을 구현한 국내 사이트 중, 키보드 접근성 등 디테일한 기능을 만들지 않은 사례가 많습니다.

버튼 요소는 상대적으로 만들기 까다롭지 않으며, 키보드 접근성을 구현하지 않는 실수가 많이 일어나기에 딱 좋은 소제로 생각하여 첫 시간에 다루게 되었네요 :)

이번 아티클은 첫 시간이므로, 가볍게 이 정도의 내용으로 끝을 맺으려고 합니다. 다음 시간에도 흥미로운 내용으로 2부에서 찾아뵐 수 있도록 노력하겠습니다. 감사합니다.

댓글 0
댓글을 작성하려면 해주세요.