LikeLion-FE-React-Project04 / project-repo

멋쟁이사자처럼 프론트엔드 스쿨 4기에서 Final Project로 진행한 Karly입니다.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[FEATURE] 메인 모달창 웹접근성 고려하기

SeoMiYoung opened this issue · comments

👀 참고

  • #206
    • 석원님께서 얼마전에 구현하신 코드를 분석해서 코드를 짰습니다. (거의 복붙하다 싶이 짰으니, 혹시 궁금하신분들은 석원님 코드를 참고하시길 바랍니다..!)

참고로, 석원님 코드 분석하다가 "이 부분은 왜 이렇게 짰지?"했던 부분이 있었는데..
석원님께서 짜신 코드(CartModal.jsx)를 보면, 다음과 같은 코드 내용이 있습니다.

const focusableElements = [
   countMinusBtnRef,
   countPlusBtnRef,
   closeBtnRef.current,
   containBtnRef.current,
];

석원님께서 짜신 CartModal의 경우, tab으로 focuable할 수 있는 DOM요소들에 대한 참조를 focusableElements라는 배열로 묶어서 관리하는 것 처럼 보였습니다.
저 코드를 보고 가장 먼저 든 생각은, "왜, 어떤건 .current로 접근을 하고, 어떤 건 .current를 붙히지 않았을까..?"라는 궁금증이 생겼지만, 관련 코드를 보면 유추할 수 있었습니다.
제 생각에는 아마 석원님께서 -버튼과 +버튼을 CounterBtn.jsx로 공용컴포넌트처럼 사용하고 있는데,
CounterBtn.jsx에는 다음과 같은 코드가 들어있습니다.

useEffect(() => {
   if (type == 'minus') {
      setCountMinusBtnRef(btnRef.current);
   } else {
      setCountPlusBtnRef(btnRef.current);
   }
}, [btnRef]);

btnRef를 저장한게 아니라, btnRef.current값 자체를 리코일(CounterState.js에서 관리)의 값으로 넣어줬기 때문에 굳이 focusableElements라는 배열 안에서 .current를 써줄 필요가 없는 상태였겠구나~라고 추측을 했습니다.

📝 TASK 개요

[STEP1] button요소에 aria-label 속성 추가하기

aria-label속성을 button요소에 추가해줌으로써 어떤 button인지에 대한 정보를 제공할 수 있음.

Before...

<button  
   type="button" 
   onClick={closeModalDuringToday}
>
   오늘 하루 안 보기
</button>
<button
   className={styles.modalCloseBtn}
   type="button"
   onClick={closeModal}
>
   닫기
</button>

After...

<button  
   aria-label="오늘 하루 안 보기"
   type="button" 
   onClick={closeModalDuringToday}
>
   오늘 하루 안 보기
</button>
<button
   aria-label="닫기"
   className={styles.modalCloseBtn}
   type="button"
   onClick={closeModal}
>
   닫기
</button>

근데!!! aria-label이 없어도 이미 접근 가능한 이름이 존재하기 때문에 굳이 해당 버튼들에는 aria-label을 붙혀주지 않아도 된다는 정보를 들었다!!! 그래서 나중에는 지웠다.

[STEP2] 접근 가능한 이름에 레이블 포함하기

<Link 
   className={styles.popUpBeauty} 
   to="/" 
   ref={moveToBeautyKarlyRef}
   aria-label="뷰티컬리로 이동하기"
/>

[STEP3] focusable한 요소들을 배열로 묶어서 관리하기

키보드 접근성이란, 스크린 리더 사용자가 키보드를 통해 웹페이지의 정보들에 접근하는 것을 의미합니다.
Tab키를 눌러서 초점이동을 하면서 정보들에 접근할 수 있습니다.
기본적으로 초점을 받을 수 있는 태그는 다섯가지가 있습니다.

1. <a>
2. <button>
3. <input>
4. <select>
5. <textarea>

제가 작업해야 할 메인모달창의 경우는, focusable한 요소를 세가지로 보면 되었는데요,

1. 뷰티컬리로 가기(제니 사진이 있는 부분) => <Link>
2. 오늘 하루 안보기 버튼 => <button>
3. 닫기 버튼 => <button>

이렇게 세가지의 부분을 관리해야합니다.

그래서 저는 Mainmodal.jsx에 다음 코드를 추가하였습니다.

// DOM요소 접근을 위한 ref생성
const closeBtnRef = useRef();
const closeForADayBtnRef = useRef();
const moveToBeautyKarlyRef = useRef();

const focusableElements = [
   moveToBeautyKarlyRef.current,
   closeForADayBtnRef.current,
   closeBtnRef.current,
]

그리고 당연히, ref들을 요소에 연결시켜주었습니다.

<Link 
   className={styles.popUpBeauty} 
   to="/" 
   ref={moveToBeautyKarlyRef}
   aria-label="뷰티컬리로 이동하기"
/>
<button  
   aria-label="오늘 하루 안 보기"
   type="button" 
   onClick={closeModalDuringToday}
   ref={closeForADayBtnRef}
>
   오늘 하루 안 보기
</button>
<button
   aria-label="닫기"
   className={styles.modalCloseBtn}
   type="button"
   onClick={closeModal}
   ref={closeBtnRef}
>
   닫기
</button>

[STEP4] 모달창 전체를 감싸는 태그에 키보드 이벤트 연결해주기

제가 짠 코드의 경우는, 모달창 코드 전체를 div태그로 묶었었는데요, 그래서 저는 그 div태그에다가 키보드 이벤트를 연결해주었습니다.

키보드 입력시 동작 시점에 따라 발생하는 키보드 이벤트는 세가지가 있습니다.

- onkeydown : 키를 눌렀을때 이벤트이다 (shift, alt, controll, capslock 등의 모든 키에 동작한다. 단 한영변환, 한자 등의 특수키는 인식 못한다).
- onkeyup : 키를 눌렀다가 땠을 때 이벤트이다 (onkeydown 에서 인식하는 키들을 인식 한다).
- onkeypress : 실제로 글자가 써질때 이벤트이다 (shift, tap, enter 등의 특수키는 인식 못한다).

이중에서 우리는 Tab(초점이동:좌측상단->우측하단) 또는 Shift+Tab(초점이동:우측하단->좌측상단)을 다루고 싶은것이므로,
onKeyDown이벤트를 사용해야합니다.

그래서 저는 모달창 전체를 감싸는 div태그에 onKeyDown이벤트를 연결해주었습니다.

[STEP5] onKeyDown 이벤트 핸들러 구현하기

const handleModalKeyEvent = (e) => {
    // focusable한 요소들 묶은 배열
    const focusableElements = [
      moveToBeautyKarlyRef.current,
      closeForADayBtnRef.current,
      closeBtnRef.current,
    ]

    const firstFocusableElement = focusableElements[0]; 
    const lastFocusableElement = focusableElements[focusableElements.length-1];

    // Tab, Shift키 누른 여부를 저장
    const isTabPressed = (e.key === 'Tab');
    const isShiftPressed = e.shiftKey;

    if(!isTabPressed) { // Tab키를 누르지 않은 경우
      console.log('Tab이 아닙니다.');

      return; // 아무 반응을 하지 않고 끝낸다
    }
    if(isShiftPressed) { // Shift+Tab 누른 경우
      // document.activeElement는 현재 focus요소를 가리킨다
      if(document.activeElement === firstFocusableElement) {
        lastFocusableElement.focus();
        e.preventDefault();
      }
    }
    else { // Shift를 누르지 않고, tab키만 눌렀을 경우
      if(document.activeElement === lastFocusableElement) {
        firstFocusableElement.focus(); // 다시 첫번째 요소로 focus
        e.preventDefault();
      }
    }
  }

지금 focus를 주면서 focusableElements 요소들을 계속 순환할 수 있게 코드를 짠거다.
쉽게 이해할 수 있을텐데.. 여기서 몇가지들이 헷갈렸다. 그 부분에 대해서 적어보도록 하겠다.

Q) 왜 e.preventDefault();를 써줬을까?

"어짜피 button이나 링크에 focus를 준다고 해도, focus만 될 뿐, 버튼 클릭이나, 링크 이동이 되지 않는데, 왜 굳이 e.preventDefault()를 써줬을까?"라는 궁금증이 들어서 이 코드를 짜신 분께 물어보았다.

A) Tab키의 기본동작 자체가 다음 요소로 focus이동이다.
우리는 근데, lastFocusableElement.focus();firstFocusableElement.focus();에서 강제로 다음 요소로 포커스를 이동시켜주고 있다.
image
그렇기 때문에 이중으로 이동하는것을 막고자 준거다.

Shift+Tab의 경우도 방향만 다르고, 같은 맥락이기 때문에 그건 설명을 생략하겠다.

if) e.preventDefault 두개를 주석처리한다면?

Animation
이렇게 닫기, 오늘하루안보기 버튼 두개만 왔다갔다 거린다.

if) e.preventDefault주석을 둘다 풀어서 적용시킨다면? 잘 순환됨

Animation

[STEP6] 모달창 전체를 감싸는 태그에 추가한 속성들 살펴보기

  1. role="dialog"
    모달창의 접근성을 개선시키기 위해선, role속성으로 dialog영역임을 알려줘야 합니다. (중요)

  2. aria-modal="true"
    현재 대화 상자 아래의 창은 상호 작용에 사용할 수 없음(비활성)을 보조 기술에 알립니다.
    해당 속성을 줌으로써, dialog영역 이외에 본문이 선택되지 않도록 처리해야 합니다.

  3. aria-label="뷰티컬리에 대한 메인 모달창이 열렸습니다."
    모달창이 화면에 뜨면 스크린 리더가 저 부분을 읽어줍니다.

  • 참고로, 저는 위에 세가지만 추가했지만 석원님 코드에는 aria-atomic, aria-live, tabIndex와 같은 속성들도 추가되어있습니다. 궁금하신 분들은 참고하시길 바랍니다.
  • tabIndex의 경우 사실상 모달창을 감싸는 div태그의 경우, focus를 받을일이 없고, 그 안에 링크, 버튼 이런것들만 focus를 받으면 되는것이므로 tabIndex를 div태그에 적용시킬 필요는 없습니다. 그래서 그냥 뺐습니다.

[STEP7] 모달창이 화면에 뜰 때, focus를 닫기 버튼에 주기

모달창을 구현한 Mainmodal.jsx가 작동될 때, useEffect를 통해서, 화면에 모달창이 랜더링이 되고 난 이후에 닫기버튼에 focus가 될 수 있게 구현하였습니다.

useEffect(() => {
    closeBtnRef.current.focus();
 }, []);

또, 이 부분에 대해서는 석원님 코드부분이랑 고려해야될 점이 다른데, 석원님의 Cart모달의 경우는,
사용자가 1->2->3->모달->3(다시 focus)->4->5
이렇게 접근을 했을 때, 모달창이 닫아지면, 마지막에 보고 있던 3으로 다시 focus를 시켰습니다.

그러나 저는 Cart 모달과 다르게, 무조건 모달부터 시작해서, 모달->1->2->3->... 이 순서대로만 진행되기 때문에
석원님처럼 그 부분을 고려할 필요는 없었습니다.

  • #206
    => 아마 4번과 관련있는 것 같습니다

[STEP8] 포커스 영역 넓히기

Animation
보시면, 현재 특히 뷰티컬리로 이동하는 링크의 포커스가 눈에 잘 보이지 않습니다.
사람인의 웹접근성 가이드를 참고하면(https://sri-fe1.notion.site/460cb5af6a354f4b909526c767314c62),
포커스 표시기는 눈에 보이도록 제공해야 합니다.

Mainmodal.module.scss에 추가한 코드입니다.

// Tab 접근성 고려한 focus 영역 스타일하기
.popUpContainer {
  button:focus-visible {
    position: relative;
    z-index: 100;
    outline: 4px solid black;
    outline-offset: 5px;
  }
  a:focus-visible {
    outline: 4px solid black;
    outline-offset: 5px;
  }
}

outline을 줘서 포커스를 스타일링 했습니다.

혹시, position:relative와 z-index를 왜 준건지 궁금하다면 다음 이슈를 참고하시오.

이전보다 focus 영역이 눈에 잘 보이는 걸 확인할 수 있습니다. 그러나, 추후에 스타일을 수정할 수도 있습니다.
Animation

[STEP9] 문제발생: 메인페이지가 랜더링되자마자 Tab키를 누르지도 않았는데 '닫기'버튼에 focus됨

Before...

Animation
이렇게 사용자가 화면을 딱! 키는 순간 Tab을 누르지도 않았는데 모달창으로 focus가 됨
그래서 사용자가 메인 화면에서 Tab을 누르면 모달창에 focus가 되었으면 좋겠음.

해결과정...

  1. Mainmodal.jsx에서, 메인모달 전체를 감싸는 태그를 참조하는 ref생성
    현재, Mainmodal.jsx에서는,
  // 메인모달 전체를 참조하는 ref생성
  const modalRef = useRef();

다음부분은, 메인모달 전체를 감싸는 div태그의 시작 부분입니다.

<div 
        aria-modal="true"
        className={styles.mainModalWrap}
        role="dialog"
        aria-label="뷰티컬리에 대한 메인 모달창이 열렸습니다."
        onKeyDown={handleModalKeyEvent}
        ref={modalRef}
        tabIndex="-1"
>

modalRef를 연결해주었고(DOM을 참조하기 위해), 원래 div태그는 focus를 받을 수 없는 요소인데, focus를 받을 수 있게 하기 위해, tabIndex="-1"을 넣어주었습니다.

  1. Mainmodal.jsx에서, useEffect(()=>{})내용을 바꿨습니다.
    [수정 전]
useEffect(() => {
    closeBtnRef.current.focus();
  }, []);

[수정 후]

  useEffect(() => {
    // closeBtnRef.current.focus();
    // 닫기 버튼에 주는게 아니라, 모달창 자체에 focus를 줌
    modalRef.current.focus();
  }, []);

원래 수정전에는 메인모달 컴포넌트가 랜더링이 되고 난 뒤, 바로 닫기 버튼에 focus되도록 했습니다. 그러나, 닫기버튼에 포커스를 주지 않고, 모달창 전체 자체에 포커스를 주는 방식으로 변경했습니다.

그러면, 맨 처음 랜더링이 되고 난 이후, 모달창 전체에 포커싱이 됩니다.
그러나, 그때, 표시가 나면 안되므로,

Mainmodal.module.scss에서, 포커스 될 때의 outline 스타일을 지워줍니다.

.mainModalWrap:focus {
  outline:none;
}

위의 스타일을 추가해줬습니다.
참고로, 모달창 전체에 focus가 될 때 outline:none을 주지 않으면 다음과 같이 됩니다.
Animation

After...

Animation

[STEP10] 문제발생: 메인모달창 내에서 포커스 이동이 되다가, 갑자기 외부영역을 클릭하면, 외부영역이 포커스가 잡힘

=> 뭐가 문제냐면, 모달창이 떠있는 이상, 외부 영역으로 포커스 이동이 되면 안됨

  • 같은 문제의 이슈 참고: #224

해결과정...

transparentFilterState.js에 투명필터가 포커스를 받을때를 관리하기 위한 atom생성

export const transparentFilterFocusState = atom({
  key: 'transparentFilterFocusState',
  default: null
})

그리고 Mainmodal.jsx에서 다음 코드를 추가

const setTransparentFilterFocusState = useSetRecoilState(transparentFilterFocusState);

그리고 Mainmodal.jsx안에서의 useEffect수정
[수정전]

  useEffect(() => {
    modalRef.current.focus();
  }, []);

[수정후]

  useEffect(() => {
    modalRef.current.focus();
    setTransparentFilterFocusState(modalRef.current); // 모달창 DOM자체를 저장해줌
  }, []);

모달창이 열리면, 모달창의 DOM자체를 transparentFilterFocusState안에 넣어준다.

그리고 TransparentFilter.jsx수정

function TransparentFilter() {
  let isActive = useRecoilValue(transparentFilterState);
  const transparentFilterFocus = useRecoilValue(transparentFilterFocusState); 

  const handleFilterClick = (e) => { // 투명필터를 클릭할때마다
    if (transparentFilterFocus) { // 모달창의 DOM이 들어있는 상태라면? 즉, 모달창이 떠있는 상황이라면?
      transparentFilterFocus.focus(); // 다시 모달창으로 focus를 시켜준다..
    }
  };

  return isActive? <div className={styles.transparentFilter} onClick={handleFilterClick}/>: null;
}

이렇게 수정을 함으로써,
사용자가 모달창이 열려있는 상태에서 투명 필터 클릭 => transparentFilterFocus안에 모달창의 DOM이 들어있으므로, 클릭 이벤트 핸들러가 발생해서, 다시 모달창으로 focus를 시켜줌 => 다시 모달창 내에서 tab키 순환 가능


포커스 스타일 이상한 것 같아서 수정

Animation
=> 관련 커밋을 참고하시오.

메인모달창 불필요한 주석 정리 + 디테일한 부분 수정

  • '컬리 -> 칼리'로 수정
  • 불필요한 주석 정리

Mainmodal => MainModal로 이름 변경

✅ To Do 및 진행상황

  • [STEP1] button요소에 aria-label 속성 추가하기
  • [STEP2] 접근 가능한 이름에 레이블 포함하기
  • [STEP3] focusable한 요소들을 배열로 묶어서 관리하기
  • [STEP4] 모달창 전체를 감싸는 태그에 키보드 이벤트 연결해주기
  • [STEP5] onKeyDown 이벤트 핸들러 구현하기
  • [STEP6] 모달창 전체를 감싸는 태그에 추가한 속성들 살펴보기
  • [STEP7] 모달창이 화면에 뜰 때, focus를 닫기 버튼에 주기
  • [STEP8] 포커스 영역 넓히기
  • [STEP9] 문제해결하기: 메인페이지가 랜더링되자마자 Tab키를 누르지도 않았는데 '닫기'버튼에 focus됨
  • [STEP10] 문제해결하기: 메인모달창 내에서 포커스 이동이 되다가, 갑자기 외부영역을 클릭하면, 외부영역이 포커스가 잡힘
  • 포커스 스타일 이상한 것 같아서 수정
  • 메인모달창 불필요한 주석 정리 + 디테일한 부분 수정
  • Mainmodal을 MainModal로 이름 변경