아티클

모두를 위한 React 컴포넌트 1부 – 햄버거 메뉴와 모달 접근성(Chapter 1)

2020-09-23 12:35:55

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

웹은 1989년부터 지금까지 쉬지 않고 숨 가쁘게 발전해왔습니다. 대중적으로 사용하는 브라우저 또한 여러 차례 바뀌었으며, 웹을 아름답게 꾸미기 위해 CSS가 탄생했으며, 웹을 동적으로 표현하기 위한 스크립트도 여러 번 바뀌었습니다.

ES(ECMAScript)는 Netscape Navigator의 LiveScript부터 시작하여 모바일앱이나 서버를 만들 때도 사용할 정도로 광범위한 언어로 발전하였습니다. Javascript가 발전함으로 인해 여러 개의 html 파일을 만들어 페이지를 만드는 방법에서 벗어나서 한 페이지 내에서 화면이 전환되는 SPA(Single Page Application)이 탄생했으며, 우리가 익히 알고 있는 React나 Vue 같은 Javascript의 Framework가 생겨났습니다.

React는 특히나 인기 있는 프레임워크로 많은 사랑을 받는 만큼, React로 만드는 컴포넌트의 접근성을 어떻게 개선하는가를 중점으로 아티클을 이어 나가고자 합니다.

첫 번째 주제는 레이어 팝업으로, 좁게는 대화상자와 같은 모달, 넓게는 햄버거 메뉴까지 사람에 따라 레이어 팝업의 범주로 보기도 합니다. 이번에는 햄버거 메뉴 구현을 중점으로 레이어 팝업을 React에서 어떻게 제공해야 하는가를 다루고자 합니다.

※ 이 아티클은 class component가 아닌 React Hooks를 기준으로 합니다. 이점을 유의해 주시기 바랍니다.

※ 1부는 Chapter 1과 Chapter 2로 나누어 작성할 예정입니다.

프로젝트 생성

C:\Users\nv\> npm install create-react-app -g

React 관련된 아티클은 CRA(create-react-app)를 이용하여 빠르게 react 앱을 만들 것이기 때문에 NPM에서 create-react-app을 전역 모듈로 다운로드합니다.

전역 모듈로 잘 설치됐다면 create-react-app으로 새 프로젝트를 생성합니다. 잘 설치가 잘 됐는지 확인하려면 전역 모듈이므로 어디서나 create-react-app --version으로 확인할 수 있습니다.

그리고 프로젝트 이름은 a11y-hamburger라고 지어줍시다.

C:\Users\nv\desktop\ > create-react-app a11y-hamburger && cd a11y-hamburger
C:\Users\nv\desktop\a11y-hamburger\ > npm start

프로젝트가 생성되었다면, 명령 창의 위치를 변경해 주고, mpm start로 react app을 실행합니다. 브라우저가 실행되며, React App이라는 페이지가 올바르게 불러와지면 프로젝트 생성이 끝난 것입니다.

코드 작성

src에 있는 App.js를 텅 빈 상태로 수정해 줍니다.

App.js(1)__

import React from 'react';
import './App.CSS';

function App() {
  return (
    <div className="App">
    </div>
  );
}

export default App;

새 컴포넌트 파일을 만듭니다.

menu.js (1)

// 1. 파일 설정
import React, {useState,useContext,useRef, useEffect} from 'react';
/* 1-2. Hooks에서 state와 ref, effect를 사용하기 위해 
위와 같이 React 모듈과 API를 불러옵니다.
*/
const MenuControllerContext = React.createContext();
/*1-2. 부모/자식 컴포넌트 간의 
  빠른 state와 function 전달을 위해 컨텍스트를 만듭니다.
*/

const MenuController = ()=>{ // 2. 메뉴를 관리할 부모 컴포넌트를 function으로 만듭니다.
    
    const [isOpen,setOpen] = useState(null);/* 2-1. 메뉴가 열고 닫힘을 알 수 있는 상태를 만듭니다.
      상당히 많은 곳에 사용됩니다. null 값을 기본값으로 주는 이유는 나중에 설명합니다.
    */
    const [isVisible,setVisible]=useState(()=>{ //2-2. html class에 true나 false를 서브클래스로 집어넣기 위한 상태를 만듭니다.
        return isOpen ? true : false;
    })
    

    const MenuExit = useRef();//2-3. 메뉴가 닫힐 때 메뉴 열기 버튼에 초점을 다시 보내줄 ref를 변수에 담습니다.


    const toggleState = () => { //2-4. 버튼을 클릭했을 때 state를 변경하는 토글 핸들러를 작성합니다.
        setOpen(!isOpen);//2-4-1. null은 false와 동등한 값이기 때문에 false나 null일 때, true, true일 때, false를 반환합니다.
        setVisible(!isOpen); //2-4-2. 클래스에는 null이라는 sub class가 들어가기를 원하지 않으므로, 아까 만들었던 state를 쓸 겁니다.
    }


    return (//3. 컴포넌트를 마크업합니다.
        <MenuControllerContext.Provider  value={
            {toggleState,isOpen,MenuExit,isVisible
        }}>/*3-1.  전에 React.createContext로 만들어놨던 MenuControllerContext의 Provider로 컴포넌트를 감쌉니다.
        value에는 자식 컴포넌트에 전달할 state, function을 넣습니다.*/
            <button aria-label="메뉴 보기" ref={MenuExit} onClick={toggleState} aria-haspopup={!isOpen} id="btnOpenMenu">
                <span className="material-icons md-auto">menu</span>
            </button>
            /*
              3-1-1. button의 ref로 MenuExit을 지정합니다.
              3-1-2. 메뉴가 열리지 않았을 경우, 팝업 메뉴임을 알 수 있도록 aria-haspopup의 값을 isOpen의 부정값으로 넣어줍니다.
              열리면 false로 설정되겠지요?
              3-1-3. label이나 id는 원하는대로 작성하고, click 이벤트로 아까 만들어줬던 toggleState를 넣어줍니다.
            */
            <MenuBox></MenuBox>/*4. 다음에 작성할 MenuBox 컴포넌트를 미리 적어놓습니다.*/
        </MenuControllerContext.Provider>
    )
}

이번 챕터에서 우리가 만들 햄버거 메뉴는 모달형이 아닌 컴포넌트(non-modal)이므로 메뉴 버튼에 aria-haspopup과 함께 aria-expanded를 사용할 수 있으나, aria-haspopup만 사용하여도 큰 문제는 없습니다. 챕터 2에서는 모달형으로 바꾸게 될 것이므로, aria-expanded는 주지 않도록 합시다.

위와 비슷한 방식으로 작성을 다 하였다면, MenuBox 하위 컴포넌트를 작성해 줍시다.

menu.js(2)

const MenuBox = ()=>{//5. MenuBox 컴포넌트를 function으로 작성합니다.
    const {toggleState,isOpen,MenuExit,isVisible} = useContext(MenuControllerContext);
    //5-1. 객체 구조로 3-1에서 value로 전달했던 변수를 동일한 이름으로 useContext를 사용하여 가져옵니다.
    const MenuEntrance = useRef();//5-2. 메뉴가 열리면 초점이 보내질 요소를 가리킬 ref를 생성합니다.

    const menuitems=[//5-3. map 메소드로 반복하여 항목 링크를 생성할 것이므로, 배열을 만들어줍시다.
        'Hello, NULI',
        'Hello, Javascript',
        'Hello, React'        
    ]

    useEffect(()=>{/*6. useEffect를 사용합니다. useEffect는 Virtual DOM에 변화가 있을 때 발생하는
    동작을CSS 지정합니다.*/
        if(isOpen){//6-1. isOpen이 true이면 메뉴가 열리고, 열림과 동시에 초점을 정해진 요소로 보냅니다.
            MenuEntrance.current.focus();
        }else if(isOpen === false){
        /*6-2. 정확히 boolean의 false 값일 때에는 메뉴가 닫힘과 동시에
        부모 컴포넌트에서 지정한 MenuExit ref가 가리키는 열기 버튼에 초점이 가도록 합니다.
        */
            MenuExit.current.focus();
        }
    },[isOpen,MenuExit,MenuEntrance])
    /*6-3. 2번째 파라미터는 dependency로, useEffect Callback에서 넘겨받아 사용할
    서버나 node측의 코드를 넣습니다. 우리는 if에 사용할 isOpen 상태와 ref 두 개를
    useEffect 내에서 사용하기 때문에 dependency로 넣었습니다.
    */
    
    return (/*7. 마찬가지로 마크업을 리턴해줍시다. */
    <div id="menu-wrapper" className={"global-menu isOpen "+isVisible}>/*
    7-1. class에 아까 isVisible 상태를 서브 클래스로 달아줍시다. 서브 클래스가 true면 메뉴를 표시하고
    false면 CSS에서 숨깁시다.*/
        <nav aria-label="콘텐츠 메뉴">
            <ul>
                {menuitems.map((el,idx)=>{//7-2. 아까 위에서 작성한 Menuitems를 map 함수 반복문으로 마크업합니다.
                    if(idx === 0){//7-3. 첫 번째 요소는 렌더링할 때, 메뉴가 열리면 초점이 갈 ref로 지정합니다.
                        return <li key={idx}><a href="#" ref={MenuEntrance}>{el}</a></li>
                    }
                    return <li key={idx}><a href="#">{el}</a></li>//7-4. ref가 없는 나머지도 리턴합니다.
                })}
            </ul>
        </nav>
        <button aria-label={'닫기'} onClick={toggleState} id="btnCloseMenu"><span className="material-icons">close</span></button>
        /*7-5. context에서 받아온 부모의 toggleState 함수로 자식 컴포넌트의 닫기 버튼이 클릭 되었을 때
        부모의 상태가 변경되도록 handler로 등록해줍니다. */
    </div>
    )    
}
export default MenuController;/*이제 menu.js를 모두 작성했으니 모듈을 추출해 줍시다.*/

App.js(2)

이제 MenuController 컴포넌트가 완성되었습니다. 이제 App.js에 이 파일을 불러와 메인 화면을 마크업하고, 컴포넌트를 넣어 줍시다.

import React from 'react';
import './App.CSS';

function App() {
  return (
    <div className="App">
      //Header 막대를 만들고, 아까 만든 컴포넌트와 대제목을 넣어줍시다.
      <header>
        <MenuController />
        <h1>React A11y Hamburger Menu</h1>
      </header>
    </div>
  );
}

export default App;

자, App.js 작성과 Menu.js 컴포넌트 작성이 모두 끝났습니다. 주석을 통해 무엇 때문에 해당 코드를 사용하는지는 설명하였지만, 설명하지 않은 부분이 있으니 덧붙여 설명하겠습니다.

우선, ref입니다. ref는 React에서 가상 DOM으로 인해 생성된 진짜 DOM에 접근할 때 사용합니다. 주로 focus를 주거나, input의 value를 가져오는 등의 동작을 수행합니다. 저는 ref를 focus를 보내기 위해 사용했습니다.

예제의 경우, 초점을 받거나 탐색할 콘텐츠가 많이 없어 별문제가 되지 않지만, 복잡한 사이트일수록 열린 메뉴로 접근하려면 적게는 몇 번, 많이는 수십 번 이상의 Tab 키를 눌러야 합니다. 따라서, 메뉴 버튼 다음 순서가 메뉴 목록이 아니라면, 메뉴가 열리면 초점을 보내주는 것이 바람직합니다.

또한 메뉴 닫기와 열기 버튼이 따로 있는 구조라면, 위 예제처럼 메뉴 열기 버튼으로 다시 초점을 보내줘야 합니다. 이 작업을 하지 않을 경우, 요소가 DOM으로부터 삭제되거나, display, visibility 등으로 숨겼을 때, 초점이 초기화되어 키보드 사용자는 페이지의 가장 첫 부분부터 다시 탐색하게 됩니다. 요소 자체를 삭제, 숨기지 않은 경우도 마찬가지로, 의도치 않은 버그는 발생하게 됩니다.

_menu.js(1)에서 메뉴 열림 상태에 대한 state를 null 값으로 준 것은 페이지가 처음 열렸을 때, 메뉴 버튼에 초점이 가지 않도록 하기 위함입니다. 만약, false 값으로 주었다면, 페이지가 열림과 동시에 메뉴를 여는 버튼에 초점이 바로 이동됩니다.

이는 문제가 없어 보이나, 막아야만 하는 이유가 있습니다. 보편적인 웹사이트에서는 header 영역의 주된 부분이 시작되기 전에 건너뛰기 내비게이션(skip navigation) 영역이 먼저 위치합니다.

만약, 페이지가 불러옴과 동시에 메뉴 버튼에 초점이 바로 간다면, 사용자는 본문으로 바로 가거나, 특정 영역으로 바로 가는 링크를 일부러 Shift + Tab 키나 Ctrl + Home 키 등으로 페이지 맨 위로 거슬러 올라가 눌러야 할 것입니다. 이러한 동작을 방지하기 위해 null 값을 주는 것입니다.

Chapter 1 이 모두 마무리되었습니다. React 코드 작성이 서툴러 코드가 깔끔하지 못한 점에 대해 양해 말씀드립니다. 이 코드와 이 방법이 정답은 아니며, 더 나은 방법이 있을 것입니다.

더 나은 방향으로 양질의 아티클을 여러분께 제공할 수 있도록 노력하겠습니다. 끝까지 읽어주셔서 감사드리며 Chapter 2로 찾아뵙겠습니다.

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