아티클

CustomElements API 소개: 접근성이 갖춰진 커스텀 Tab 요소를 태그로 보는 CustomElements

2022-05-23 10:58:44

안녕하세요, 엔비전스 입니다. 지난해 5월 아티클인 Chrome 최신 버전에 추가된 ARIA Reflection 자바스크립트 API와 AOM 소개를 통해 CustomElement API를 잠깐 언급한 적이 있습니다.

최근에 웹페이지에서는 탭 컨트롤과 유사한 구조의 레이아웃을 많이 쓰고 있습니다. 또한, WAI-ARIA도 많이 알려져, role을 활용하는 사례도 많이 있습니다. 일반적으로는 다른 태그의 CSS CLASS나 ID를 가져와서 탭 컨트롤에 맞게 기능을 만들어 덮어씌우지만, 처음부터 아예 없던 태그를 만들 수도 있습니다. 그것을 가능하게 해주는 것이 Web Components API의 하위 API인 Custom elements API입니다. Custom elements API 셋은 프레임워크나 라이브러리 없이 커스텀 태그를 만들기 위한 API로, 최신 브라우저에서 대부분 호환됩니다.

이번 시간에는 이 Web Component API를 통해 탭 컨트롤을 만드는 방법에 대해 소개하고자 합니다.

 

빈 Custom Element 클래스 만들기

먼저, 커스텀 요소 자바스크립트 클래스를 만듭니다.

class TabLayoutElement extends HTMLElement {
    constructor() {
        super();
        this.innerHTML = this.getAttribute('text') ? this.getAttribute('text') : "";
        // text라는 속성은 테스트를 위한 가상의 속성입니다.
    }
    connectedCallback () {
        console.log(this.innerHTML,this);
    }
}

자, 아무 기능도 하지 않는 빈 커스텀 요소 클래스가 완성되었습니다. 이것을 직접 태그로 사용하려면, customElements.define("tag-name",Constructor)을 통해 이 클래스를 태그 이름과 함께 등록해 주어야 합니다.

    customElements.define('tab-layout',TabLayoutElement);

주의할 점은 반드시 커스텀 Element는 HTMLElement를 상속받아야 한다는 점이며, 태그 이름은 케밥 케이스 형식으로 지어야 합니다 

다 되었다면, 아무 문서에나 해당 자바스크립트 파일을 불러오고, 지정된 태그 이름으로 마크업 합니다.

<tab-layout text="connecting Test"></tab-layout>

text 속성에 있는 값이 잘 나타나고, 개발자 도구 콘솔에서 로그가 잘 출력되는 것을 볼 수 있습니다.

 

CustomElements의 생명주기

위 예제를 보셨다면 connectedCallback()이라는 콜백 메소드가 있는 것을 볼 수 있습니다.

이는 생명주기(life-cycle) 콜백입니다. 우리가 커스텀 태그를 사용하고, 삭제하고, 옮길 때마다 이 생명주기 콜백이 자동 실행됩니다.

  • connectedCallback() : 위에서 보았던 메소드로, 문서에 해당 태그 사용이 감지되었고, 문서와 연결을 시도했을 때 호출됩니다.
  • disconnectedCallback() : 눈치가 빠르시다면 대번에 아실 것입니다. 반대로 요소가 문서에서 연결이 해제 되었을 때, 즉 삭제되었을 때 호출되는 콜백입니다.
  • adoptedCallback() : 잘 쓸일 없는 콜백일지도 모릅니다. frame과 같은 다른 문서로 커스텀 요소가 이동되었을 때 호출되는 콜백 메소드입니다.
  • attributeChangedCallback(AttributeName, OldValue, NewValue) : 해당 태그에서 등록된 속성 변경을 감지합니다. 감시할 속성을 등록하기 위해서는 static get observedAttributes(){ return [‘attr1’,‘attr2’] }과 같이 등록해야 합니다.

개발과 함께 사용할 기술들

  • ShadowDOM: 스타일 캡슐화 등을 위해 사용하는 문서와 분리된 DOM Tree를 별도로 사용하는 기술입니다.
  • template 태그: 말 그대로 템플릿입니다. content.cloneNode(true)를 통해 미리 마크업된 내부 내용을 찍어낼 수 있습니다.
  • slot 태그: slot 태그는 플레이스 홀더로, 특정 요소를 해당 슬롯에 위치하게끔 할 때 사용합니다.

 

본격적으로 만들어봅시다

저의 탭 컴포넌트는 다음과 같은 구조로 마크업 되게끔 만들 겁니다

  • layout
    • teblist
      • tab
    • tabpanel

총 4개의 컴포넌트 클래스를 만들어서 근사하게 탭 요소를 만들어 볼게요. 이중 우리가 주로 구현해야 할 것은 tablist, tab, tabpanel, 총 3가지의 컴포넌트이며

tab-layout은 제가 자체적으로 만든 컴포넌트로, 레이아웃 관리용입니다.

모든 코드에 주석을 달아놓았으므로 자세한 설명은 생략합니다.

tab-list 요소 클래스

다른 예제에서는 tab-list에서 다음 탭, 이전 탭 등을 관리하는 메소드를 작성하는 경우도 있지만, 저는 tab-list에서 등록하지 않을 겁니다..

탭의 개수, 탭의 방향, 작동 메커니즘 등을 설정하는 등의 보조적인 기능만을 넣었습니다.

(()=> {
    // 탭목록 컨테이너
    class TablistElement extends HTMLElement {
        constructor(  ){
            super(  );
            this.index = [...document.querySelectorAll('tab-list')].indexOf(this);
            this.id = "tab-list_"+(this.index+1)
            this.attachShadow({mode:"open"});
            const shadow = this.shadowRoot;
            const template = document.createElement('template');
            template.innerHTML = `
            <style>
            /*유연한 탭버튼이 나오도록 flex 디스플레이 사용*/
            :host {
                display:flex;
                overflow:hidden;
            }
    
            :host, *{
                box-sizing:border-box;
                margin:0; padding: 0;
            }
    
            :host([orientation="horizontal"]) {
                border-bottom:solid 0.01em;
            }
            
            :host([orientation="vertical"]) {
                flex-flow:column;
                margin-right:1em;
                height:100%;
                border-right:solid 0.01em;
            }
            </style>
            <!--다음에 만들 tab-item 컴포넌트가 담길 slot-->
            <slot name="tab-buttons"></slot>
            `;
    
            //초기화 및 세팅
            this.setAttribute( 'role' , "tablsit" ); // tablist 컴포넌트니 당연히 role tablist를 사용해야 하지요?
            shadow.append(template.content.cloneNode(true));
            this.activationMode = this.activationMode;
            this.orientation = this.orientation;
        }
    
    
        // 탭의 동작 설정 
        get activationMode (  ) {
            return this.getAttribute('activation-mode');
        }
        set activationMode ( str ) {
            const value = String(str);
            this.setAttribute('activation-mode', ( value === "manual" || value === "auto" ) ? value : "auto");
        }
        
        //탭의 방향 가져오기/설정
        get orientation() {
            return this.getAttribute('orientation')
        }
        set orientation(str) {
            const value = String(str);
            this.setAttribute('orientation', ( value === "horizontal" || value === "vertical" ) ? value : "horizontal");
        }
    
        //선택 여부와 관계없이 모든 자식 tab-item 가져오기
        get allTabs (  ) {
            return [...this.querySelectorAll("tab-item")];
        }
        // 선택할 수 있는 탭만 가져오기
        get tabs (  ) {
            return [...this.querySelectorAll("tab-item:not([disabled])")];
        }
    
        // 선택할 수 있는 자식 tab-item의 개수 가져오기
        get tab_count (  ){
            return this.tabs.length;
        }
    
        // 선택된 탭 가져오기
        get selectedTab (  ) {
            return this.querySelector("tab-item[selected]");
        }
    
        // 선택된 탭의 순서 가져오기
        get selectedIndex (  ) {
            return this.tabs.findIndex((el)=>el.selected);
        }


        static get observedAttributes() {
            return ["orientation"];
        } // 이 정적 메소드에 변경을 감지할 속성을 배열에 넣어 return합니다.
    
        connectedCallback(){ //tab-list가 문서에 연결되면
            
            //customElements.whenDefined는 Promise로, 요소가 정의되었을 때 발생합니다.
            
            customElements.whenDefined("tab-item").then(_=>{ 
                // tab-item이 정의되었을 때 tab-item들을 초기화/기본 설정으로 만듦
                this.allTabs.forEach(_=>_.slot = "tab-buttons");
                this.allTabs.forEach((el,idx)=>{                    
                    // 모든 탭의 기본 속성값 추가
                    el.setAttribute('aria-disabled',el.disabled);
                    el.setAttribute('aria-selected',el.selected);
                    el.setAttribute('tabindex',el.selected ? "0" : "-1");
                })
                if(!this.selectedTab ){ // 선택된 탭이 없으면
                    this.tabs[0].click();
                }

                if(this.selectedTab && this.selectedTab.disabled ){ // 선택된 탭이 있으나 disabled인 경우(마크업 안전장치)
                
                    this.tabs[0].click();
                }
                
                if (this.selectedTab ) { // 있는 경우, 해당 탭이 disabled가 아니면 해당 탭이 활성화된 상태로 로드
                    this.selectedTab.click();
                }
            })
        }
        attributeChangedCallback (name,oldVal,newVal) {
            if(name === "orientation" && newVal ){ // orientation 속성 변경 감지 및 aria-orientation 변경
                this.setAttribute('aria-orientation',newVal);
            }
        }
    }

 

tab-item 요소 클래스

탭 버튼 컴포넌트입니다. tab-list 하위에 쓰이는 탭 컨트롤 버튼 요소로 탭 상호작용과 밀접한 연관이 있는 getter와 setter 프로퍼티를 모두 이 요소에 담아놓았습니다.

    class TabElement extends HTMLElement { // tab-item 컴포넌트
        constructor(){
            super();
            this.attachShadow({mode:"open"});
            const shadow = this.shadowRoot;
            const template = document.createElement('template');
            template.innerHTML = `
                <style>
    
                * {
                    margin:0; padding:0; box-sizing:border-box;
                }
    
                #text {
                    padding:0.25em;
                }
    
                :host-context(tab-list) {
                    display:flex;
                    position:relative;
                    width:auto; height:fit-content;
                    border-style:solid;
                }
    
    
                :host-context(tab-list[orientation="horizontal"]):host {
                    flex-flow:column;
                    border-color:transparent currentColor;
                    border-width:0 0.01em 0 0;
                }
                :host-context(tab-list[orientation="vertical"]):host {
                    flex-flow:row;
                    border-width:0 0 0.01em 0;
                    border-color:currentColor transparent;
                }
    
                
                /*선택된 탭의 스타일*/
                :host-context(tab-list[orientation="vertical"]):before {
                    content:"";
                    background-color:transparent;
                    width:0.8em; min-height:100%;
                    margin-right:0.25em;
                }
                :host-context(tab-list[orientation="vertical"]):host([selected]):before {
                    background-color:currentColor;
                }
                :host-context(tab-list[orientation="horizontal"]):after {
                    content:"";
                    width:100%; height:0.25em;
                    background-color:transparent;
                }
                
                :host-context(tab-list[orientation="horizontal"]):host([selected]):after {
                    background-color:currentColor;
                }

                /*disabled 상태*/
                :host([disabled]) {
                    opacity:0.6;
                    pointer-events:none;
                }
    
    
                /*마지막 아이템에 그림자 추가*/
                
                :host-context(tab-list[orientation="vertical"]):host(:last-child) {
                    box-shadow: 0 0.3em 0.4em 0.01em rgba(0,0,0, 0.5);
                }
                :host-context(tab-list[orientation="horizontal"]):host(:last-child) {
                    box-shadow: 0.3em 0 0.4em 0.01em rgba(0,0,0, 0.5);
                }
                </style>
    
                <span id="text" role="none">
                    <slot></slot>
                </span>
                `;
            
            this.setAttribute('role','tab'); // tab요소니 tab role을 적용해줍시다.
            
            this.index = this.parentTablist.tabs.indexOf(this);
            
            this.id = this.parentTablist.id+"_tab-button_"+(this.index+1); // 레이블링을 위한 아이디가 자동으로 붙습니다.

            shadow.appendChild(template.content.cloneNode(true));
        }
    
        get linkedPanel() {
            const PANEL_ID = this.getAttribute('aria-controls');
            if(PANEL_ID) {
                const PANEL = document.querySelector('tab-panel#'+PANEL_ID);
                if (PANEL){
                    return PANEL;
                }
                return null;
            }
            return null;
        }
    
    
        /* [ 상태 getter/setter ] */
        
        get disabled (  ) {
            return this.hasAttribute( 'disabled' );
        }
    
        set disabled ( b ) {
            this.toggleAttribute('disabled',Boolean(b));
        }
        get selected (  ) {
            return this.hasAttribute( 'selected' );
        } 
        set selected ( b ) {
            this.toggleAttribute('selected',Boolean(b));
        }
    
        get parentTablist () {
            return this.closest('tab-list');
        }
    
        static get observedAttributes() { // 이 정적 메소드에 변경을 감지할 속성을 배열에 넣어 return합니다.
            return ['disabled','selected','orientation'];
        }
    
    // 현재 탭을 기준으로   한 다음 탭 형제를 가져옵니다.
        get nextTabSibling (  ) {
            const nxtTab = this.parentTablist.tabs[this.index+1]
            if(!nxtTab) {
                return this.firstTabSibling;
            } 
            return nxtTab;
        }
        // 현재 탭을 기준으로 선택 가능한 이전 탭 형제를 가져옵니다.
        get previousTabSibling (  ) {
            const prvTab = this.parentTablist.tabs[this.index-1];
            if(!prvTab){
                return this.lastTabSibling;
            }
            return prvTab;
        }
    
        // 선택 가능한 첫 탭 형제를 가져옵니다.
        get firstTabSibling (  ) {
            return this.parentTablist.tabs[0];
        }

        // 선택 가능한 마지막 탭 형제를 가져옵니다.
        get lastTabSibling (  ) {
            return this.parentTablist.tabs[this.parentTablist.tab_count-1];
        }

        // 패널이 있으면 표시하게 만드는 메소드입니다.
        showPanel() {
            if (this.linkedPanel){
                this.linkedPanel.show = true;
            }
        }

            // 패널이 있으면 패널을 숨기는 메소드입니다.
        hidePanel(){
            if(this.linkedPanel){
                this.linkedPanel.show = false;
            }
        }

        clickHandler (evt) { 
            // 클릭 이벤트 핸들러입니다
            if (!this.disabled){ // disabled가 아닌 경우에만 활성화를 가능하게 해야 합니다.
                this.parentTablist.allTabs.forEach((element,index)=>{
                    if ( element !== this ){ // 이 탭이 아닌 탭 요소를 선택해제, 패널을 숨김 처리합니다.
                        element.selected = false;
                        element.hidePanel();
                    } else { // 현재 탭을 선택합니다. 패널을 표시합니다.
                        this.selected = true;
                        this.showPanel();
                    }
                })
            }
        }

        keyboardNavigationHandler(evt){
            // 키보드 이벤트 핸들러입니다.
            // isVerticalTab은 탭이 세로 탭인지 확인하는 불린 변수입니다.
            //NXT(Next)와 PRV(Previous) 키는 위 isVerticalTab의 값에 따라 정해집니다.
            const isVerticalTab = this.parentTablist.orientation === "vertical";
            const NXT = isVerticalTab ? "ArrowDown" : "ArrowRight";
            const PRV = isVerticalTab ? "ArrowUp" : "ArrowLeft";
            const FST = "Home";
            const LST = "End";
            const CLICK1 = " ";
            const CLICK2 = "Enter";
            const TAB = "Tab";

            switch(evt.key) { // evt.key를 통해 누른 키를 감지합니다.
                case NXT:
                    evt.preventDefault(); // 방향키와 홈 앤드키 동작 시 문서가 스크롤되지 않도록 합니다.
                    if(this.parentTablist.activationMode === "auto") { //activation-mode가 auto 일 경우에만 selected가 붙도록 click을 dispatch합니다.
                        this.nextTabSibling.click();
                    }
                    this.nextTabSibling.focus();
                    break;
                    //아래는 모두 비슷하게 반복.
                case PRV:
                    evt.preventDefault();
                    if(this.parentTablist.activationMode === "auto") {
                        this.previousTabSibling.click();
                    }
                    this.previousTabSibling.focus();
                    break
                case FST:
                    evt.preventDefault();
                    if(this.parentTablist.activationMode === "auto") {
                        this.firstTabSibling.click();
                    }
                    this.firstTabSibling.focus();
                    break
                case LST:
                    evt.preventDefault();
                    if( this.parentTablist.activationMode === "auto" ) {
                        this.lastTabSibling.click();
                    }
                    this.lastTabSibling.focus();
                    break
                case CLICK1:
                case CLICK2: // Enter나 Space를 눌렀을 때 탭이 선택되도록 합니다. activation-mode 속성을 'manual'로 설정했을 경우 활성화할 수 있어야 하기 때문입니다.
                    this.click();
                    break;
                case TAB:
                    // 수동 활성화 탭일 경우, Tab 키를 누르면 초점이 패널이 아닌 선택된 탭으로 이동됩니다. 이걸 방지하기 위해 tabpanel로 보내지도록 해 줍시다.
                    if (!evt.shiftKey){ // Shift+Tab일 경우는 기존처럼 동작하게 하기 위해 evt.shiftKey 값을 가져와서 검증합니다.
                        evt.preventDefault();
                        if(this.parentTablist.selectedTab.linkedPanel){
                            this.parentTablist.selectedTab.linkedPanel.focus();
                        }
                    }
                    break;
            }
        }
        
        connectedCallback(){ //tab-item이 HTML 문서에 연결되면
            //이벤트 핸들러 등록
            this.addEventListener('click',this.clickHandler.bind(this));
            this.addEventListener('keydown',this.keyboardNavigationHandler.bind(this));

            // 패널이 있으면 panel을 탭컨트롤의 자동생성된 id와 연결합니다.
            if(this.linkedPanel){
                this.linkedPanel.setAttribute('aria-labelledby',this.id);
            }
        }
        disconnectedCallback(){ // 문서에서 연결이 해제됐을 때
            //이벤트 핸들러 삭제
            this.removeEventListener('click',this.clickHandler);
            this.removeEventListener('keydown',this.keyboardNavigationHandler);
        }
    
        attributeChangedCallback(name,oldVal,newVal) { // 등록된 속성 변경이 감지되었을 때 실행되는 콜백
            const isTrue = newVal ===  "";
            switch(name) {
                case "selected":

                // selected 속성이 감지되면 aria-selected와 tabindex 값을 조정
                    this.setAttribute('aria-selected',isTrue);
                    this.setAttribute('tabindex',isTrue ? "0" : "-1"); // 선택된 탭만 tab으로 탐색되어야 하므로, isTrue 일 대만 tabindex를 0으로 주도록 합니다.
                    break;
                    // disabled 속성이 감지되면 aria-disabled와 tabindex 값을 조정
                case "disabled":
                    this.setAttribute('aria-disabled',isTrue);
                    if (isTrue){
                        this.removeAttribute('tabindex');
                    }
                    this.setAttribute('tabindex','0');
                    break;
            }
        }
    }

tab-item 요소에는 속한 tab-list의 형제를 가져오는 메소드, 자신을 선택 상태로 만드는 메소드, aria-controls로 연결된 패널을 가져오는 메소드 등이 포함돼 있습니다.

또한, 키보드 접근성을 준수하기 위해 아래와 같은 Authoring Practices의 키보드 인터렉션을 구현했습니다.

키값 동작
왼쪽 화살표/위 화살표

이전 탭을 선택하거나 이전 탭에 초점을 보냅니다. 이전 탭이 없다면 마지막 탭을 선택하거나 마지막 탭에 초점을 보냅니다.

오른쪽 화살표 / 위 화살표 키

다음 탭을 선택하거나 다음 탭에 초점을 보냅니다. 다음 탭이 없다면 첫 번째 탭을 선택허가너 첫 번째 탭에 초점을 보냅니다.

Enter와 Space 수동 탭일 경우에 탭을 선택할 때 사용합니다.
Home 첫 번째 탭을 선택하거나 첫 번째 탭으로 초점이 이동합니다.
End 마지막 탭을 선택하거나 마지막 탭으로 초점이 이동합니다.

 

tab-panel 요소 클래스

    class tabPanelElement extends HTMLElement {
        constructor() {
            super();
            this.attachShadow({mode:"open"});
            this.setAttribute('tabindex','0');
            this.setAttribute('role','tabpanel'); // tabpanel이므로 tabpanel role을 적용해줍시다.
            const shadow = this.shadowRoot;
            const template = document.createElement("template");
            template.innerHTML = `
                <style>
                    :host(:not([show])) { /*show 속성이 없으면 나타나지 않도록 */
                        display:none; 
                    }
                </style>
                <slot></slot>  <!-- 이 슬롯으로 콘텐츠가 들어옵니다.-->
            `;
            this.setAttribute('role','tabpanel');
            shadow.appendChild(template.content.cloneNode(true));
        }

        get show () { // 현재 패널이 show 상태인지 가져옵니다. 이 getter는 굳이 필요하지 않지만, 형식상 넣습니다.
            return this.hasAttribute('show');
        }
    
        set show (b){ // show 속성을 조정하는 setter 프로퍼티,
            const value = Boolean(b);
            this.toggleAttribute('show',value);
        }
    
    }

 

tab-layout 요소 클래스

    class TabLayoutElement extends HTMLElement { // 레이아웃 컨테이너
        // orientation 속성에 따라 tablist의 orientation이 정해지고, 레이아웃이 그에 맞게 변경되는 레이아웃 컴포넌트입니다.
        constructor(  ) {
            super(  );
            this.attachShadow({mode:"open"});
            const shadow = this.shadowRoot;
            const template = document.createElement("template");
            template.innerHTML = `
                <style>
                *{margin:0; padding:0; box-sizing:border-box;}
                :host {
                    display:grid;
                    min-width:280px;
                    max-width:100%;
                    min-height:300px;
                    max-height:100%;
                    position:relative;
                    border:solid 0.01em currentColor;
                    overflow:hidden;
                    border-radius:0.5em;
                    margin:0.5em 0;
                }
    
                :host( [ orientation = "horizontal" ] ) {
                    grid-template-columns: 1fr;
                    grid-template-rows:auto 9fr;
                }
                :host( [ orientation = "vertical" ] ) {
                    grid-template-columns:minmax(auto,10em) 9fr;
                    grid-template-rows: 1fr;
                }
    
                .panel-region{
                    padding:1em;
                }
                </style>
    
                <div>
                <slot name="tablist"></slot>
                </div>
                
                <div class="panel-region">
                    <slot name="panel"> </slot>
                </div>
            `;
            shadow.append(template.content.cloneNode(true));
            this.orientation = this.orientation // orientation 초기화
            if(this.tabName){ // tabName이 설정된 경우, tablist의 aria-label을 설정합니다.
                this.tabName = this.tabName;
            }
        }
    
        get orientation() {
            return this.getAttribute('orientation')
        }
        set orientation(str) {
            const value = String(str);
            this.setAttribute('orientation', ( value === "horizontal" || value === "vertical" ) ? value : "horizontal");
        }

        set tabName(str){
            this.setAttribute('tab-name',String(str));
        }

        get tabName () {
            return this.getAttribute('tab-name');
        }

        get tabList() {
            return this.querySelector('tab-list')
        }

        get tabPanel(){
            return this.querySelectorAll('tab-panel');
        }
        
        static get observedAttributes () {
            return ["orientation","tab-name"];
        }

        connectedCallback() {
            Promise.all([
                customElements.whenDefined('tab-list'),
                customElements.whenDefined('tab-item'),
                customElements.whenDefined('tab-panel')
            ]).then(_=>{
                // tab-list와 tab-panel이 이곳에 정의되면
                this.tabList.orientation = this.orientation; // 탭의 방향을 이 레이아웃의 방향에 따르도록 설정
                this.tabList.slot = "tablist"; // tablist 슬롯에 등록
                this.tabPanel.forEach(_=>_.slot = "panel"); // 
            })
        }

        attributeChangedCallback(name,oldVal,newVal) {
            if (name === "orientation" && newVal ){
                if(this.tabList){
                    this.tabList.orientation = this.orientation;
                }
            }
            if (name === "tab-name" && newVal && newVal !== "" ){ 
                if(this.tabList){ // 탭컨트롤 명칭이 바뀌면 캡트롤 리스트가 aria-label을 갱신합니다.
                    this.tabList.setAttribute('aria-label',this.tabName);
                }
            }
        }
    }

 

요소로 등록하기

위에 만든 클래스들을 모두 등록합니다. 첫 번째 인자로 들어가 있는 태그 이름은 케밥 케이스 형식만 지키면 무엇으로든 바꿀 수 있습니다.

    // 만든 요소들을 등록합니다.
    customElements.define('tab-list',TablistElement);
    customElements.define('tab-item',TabElement);
    customElements.define('tab-panel',tabPanelElement);
    customElements.define('tab-layout',TabLayoutElement);
    })();

 

사용하기

이제 제가 아까 구조화했던 것처럼 layout 안에 tablist와 tabpanel들을 넣어줍시다.

<!DOCTYPE html>
<html lang="en">
...
<body>
    <h1>Tab Control Example</h1>
    <tab-layout tab-name="automatic select example">
        <tab-list>
            <tab-item aria-controls="Content_A">A Content</tab-item>
            <tab-item aria-controls="Content_B">B Content</tab-item>
            <tab-item aria-controls="Content_C" selected>C Content</tab-item>
        </tab-list>
        <tab-panel id="Content_A">
            <div>
                <h2>It's an A Panel</h2>
            </div>
        </tab-panel>
        <tab-panel id="Content_B">
            <div>
                <h2>It's a B Panel</h2>
            </div>
        </tab-panel>
        <tab-panel id="Content_C">
            <div>
                <h2>It's a C Panel</h2>
            </div>
        </tab-panel>
    </tab-layout>


    <tab-layout tab-name="manual select example" orientation = "vertical" >
        <tab-list activation-mode = "manual">
            <tab-item aria-controls="M_Content_A">A Content(Press Enter key to Manul Select it)</tab-item>
            <tab-item aria-controls="M_Content_B">B Content(Press Enter key to Manul Select it)</tab-item>
            <tab-item aria-controls="M_Content_C">C Content(Press Enter key to Manul Select it)</tab-item>
        </tab-list>
        <tab-panel id="M_Content_A">
            <div>
                <h2>It's an A Panel</h2>
            </div>
        </tab-panel>
        <tab-panel id="M_Content_B">
            <div>
                <h2>It's a B Panel</h2>
            </div>
        </tab-panel>
        <tab-panel id="M_Content_C">
            <div>
                <h2>It's a C Panel</h2>
            </div>
        </tab-panel>
    </tab-layout>
</body>
</html>

이 예제는 WAI-ARIA Authoring Practices에서 다루는 수동 활성화 탭자동 활성화 탭, 그리고 키보드 조작을 모두 만족하는 컴포넌트입니다.

수동 활성화는 키보드 조작과 동시에 선택이 되지 않고, ENTER로 선택해 줘야 하는 tab 컴포넌트 패턴이며, 자동은 키보드 방향 키가 눌림과 동시에 마치 라디오 버튼처럼 탭이 선택되는 방식입니다. 이렇듯, 커스텀 요소를 마치 네이티브 HTML을 마크업 하듯, 조금 더 시멘틱하게 마크업 하기 위해 CustomElements API를 사용할 수 있습니다.

 

그래서? 웹컴포넌트가 무슨 의미가 있나요? 그냥 구현해도 되잖아요?

위에서도 잠깐 설명했지만, Web Component는 최신 브라우저에 탑재된 기본 API입니다. 즉, 어떠한 라이브러리, 프레임워크 없이 사용할 수 있다는 장점이 존재합니다.

그 말인즉슨, React든 Vue든, Svelte든, 최근에 인기 있는 어떠한 프레임워크를 사용하든 섞어서 조화롭게 사용할 수 있다는 뜻으로, 이렇게 한번 규격을 정해놓고 접근성이 갖춰진 컴포넌트를 만들어 놓는다면, 어떤 프레임워크로 만들어진 페이지 프로젝트건 빠르게 접근성이 지켜진 컴포넌트를 만들 수 있다는 겁니다.

 

Internet Explorer는 지원되나요? 호환성 문제가 있을 것 같은데요.

당연히, 아쉽게도 안 됩니다. 하지만, Internet Explorer는 Microsoft에서도 공식적으로 지원을 중단하였으며, 서비스 종료 절차를 밟고 있습니다. 이에 발맞춰 우리나라의 웹 접근성 업계와 보조기술 업계에서도 Internet Explorer에 맞춰져 있던 지침과 소프트웨어를 최신 브라우저에 맞게 바꾸고 있습니다.

앞으로는 이러한 구형 브라우저 호환성 문제로 인해 쓰지 못했던 API를 더욱 적극적으로 쓸 수 있게 될 겁니다.

이상으로 아티클을 마치며, 부족한 글 끝까지 읽어주신 독자님들께 감사의 말씀을 드립니다. 감사합니다.

 

※ 참고사항

  • Shadow DOM은 현재 NVDA의 브라우즈 모드 버그가 있어 브라우즈 모드(가상커서 탐색)으로 탐색이 안되는 사례가 종종 발견됩니다.
  • 해당 컴포넌트는 https://a11y-nvisions.github.io/Solutions/WEB/new/index.html#about-tab 을 통해 체험할 수 있습니다.
댓글 0
댓글을 작성하려면 해주세요.