Skip to content
This page has been auto-translated and may contain errors.View in English

접근 가능한 React

모든 React 앱은 브라우저가 항상 제공해온 것과 같은 HTML로 렌더링되며, 모든 보조 기술은 컴포넌트가 생성한 DOM에서 작동합니다. React의 접근성은 대부분 그 DOM에 관한 작은 선택들의 연속입니다: 렌더링하는 요소가 무엇인지, 어떻게 이름을 얻는지, 그리고 화면이 보이지 않는 사람 입장에서 변할 때 어떻게 처리할 것인지입니다.

JSX의 세부사항 하나를 먼저 짚고 가겠습니다. React는 classclassName으로, forhtmlFor로 이름을 바꾸지만, ARIA 속성은 하이픈을 유지합니다: aria-live, aria-label, 그리고 일반 role입니다.

의미론적 요소부터 시작합니다

<button>은 이미 많은 동작이 내장된 상태로 제공됩니다. 탭 순서에 포함되므로 키보드로 도달할 수 있습니다. Enter와 Space에서 클릭 핸들러를 실행합니다. 스크린 리더는 이를 버튼으로 표시하고 텍스트를 이름으로 읽어주며, 이는 음성 제어 소프트웨어가 대상으로 삼는 이름이기도 합니다. 브라우저가 비활성화 상태, 포커스 링, 활성화 스타일을 처리합니다.

jsx
// 브라우저가 포커스, 키보드 활성화, "버튼" 알림을 제공합니다
<button className="die" onClick={hold}>{value}</button>

onClick 핸들러를 가진 <div>는 해당 목록의 한 가지 항목만 얻습니다: 클릭입니다. Tab이 이를 건너뛰고, Enter와 Space는 작동하지 않으며, 스크린 리더는 이를 상호작용 시 무엇이 일어날 가능성이 없음을 시사하는 텍스트로 읽습니다.

일반적인 해결책은 role="button"tabIndex={0}인데, 이는 요소를 탭 순서에 넣고 표시되는 것을 변경합니다. 하지만 동작은 여전히 부족합니다. onKeyDown 핸들러를 추가하고, Enter와 Space를 확인하고, Space에서 preventDefault()를 호출해 페이지 스크롤을 멈추고, 수동으로 작성한 비활성화 상태를 스타일링과 동기화해야 합니다. 브라우저가 이미 제공하는 것을 다시 만들기 위해 꽤 많은 코드를 작성하게 됩니다. 실제 <button>에 도달하는 것이 더 짧은 방법이며, 브라우저가 변경되어도 계속 올바르게 작동합니다.

같은 논리가 나머지 마크업 전체에 적용됩니다: 내비게이션을 위한 <a href>, 스크린 리더가 사이를 이동할 수 있는 landmark인 <nav><main>, 사람들이 구조를 따라 내비게이션하는 순서대로 정렬된 제목들입니다. React 코드베이스의 대부분의 접근성 작업은 이미 그 역할을 하는 요소를 선택하는 것입니다.

변화를 알립니다

단일 페이지 앱은 제자리에서 업데이트됩니다. 스크린 리더에 무언가 일어났음을 알릴 페이지 로드가 없으므로, 화면 중간에 렌더링된 변화는 완전히 무음일 수 있습니다. 라이브 영역이 그 정보를 제공합니다: 스크린 리더가 감시하고 콘텐츠가 변할 때마다 표시하는 컨테이너입니다. 아래의 sr-only 클래스는 이를 시각적으로 숨기며, 이 장의 뒷부분에서 다루는 CSS 패턴을 사용합니다.

jsx
<div aria-live="polite" className="sr-only">
  {isGameWon && <p>승리했습니다! 새 게임을 누르면 다시 시작합니다.</p>}
</div>

래퍼는 매번 렌더링되며, 처음에는 비어 있고, React가 isGameWon이 바뀔 때 단락을 여기에 넣습니다. 이 순서가 사람들이 잘못 이해하는 부분입니다. aria-live를 가진 요소는 콘텐츠가 도착하기 전에 DOM에 있어야 합니다. 왜냐하면 스크린 리더는 라이브 영역을 만날 때 등록한 후 변경을 감시하기 때문입니다. 영역과 그 텍스트를 한 번의 렌더에서 함께 마운트하면 많은 스크린 리더가 아무것도 표시하지 않습니다: 전체가 일반적인 새 콘텐츠처럼 보입니다. 트리에 빈 영역을 유지하는 것은 비용이 들지 않으며 알림을 신뢰할 수 있게 만듭니다.

aria-live="polite"는 알림을 큐에 넣습니다. 스크린 리더가 현재 읽고 있는 것을 마치고, 다음 자연스러운 일시 중지에서 메시지를 전달합니다. 이는 시각적 변화 후 약간 나중에 나타날 수 있습니다. 이 지연은 의도적이며, 폴라이트가 거의 모든 것에 적합한 설정입니다.

키보드 상호작용

Tab은 포커스 가능한 요소를 통해 앞으로 이동하고, Shift+Tab은 뒤로 이동하고, Enter는 링크와 버튼을 활성화하고, Space는 버튼을 활성화하고 체크박스를 토글합니다.

탭 순서는 DOM 순서를 따르므로 JSX가 렌더링하는 순서가 사람들이 이동하는 순서입니다. CSS로 시각적으로 순서를 바꾸면 탭 순서가 화면 주위를 뛰어다니게 되고, 긍정적인 tabIndex 값은 의도적으로 같은 혼란을 유발합니다. tabIndex={-1}이 유용한 것입니다: JavaScript에서 요소를 포커스 가능하게 만들면서 탭 순서에서 벗어나게 하며, 이것이 대화상자 제목과 같은 포커스 대상이 필요한 것입니다.

두 가지 규칙이 더 있습니다. 포커스를 보이게 유지합니다: outline: none을 피하십시오. 그것을 대체하는 :focus-visible 스타일이 있지 않다면 말입니다. 그리고 탈출 경로를 유지합니다: 의도적으로 포커스를 자신 안에 보유하는 모달은 닫기를 위해 Escape가 필요하고 포커스를 트리거로 반환해야 합니다.

포커스를 의도적으로 이동합니다

UI가 형태를 변경할 때, 포커스는 어디에도 없을 수 있습니다. 누군가 버튼을 활성화하면, 버튼이 제거되거나 교체되고, 포커스가 <body>로 떨어집니다. 다음 Tab은 페이지 상단에서 시작되고, 리더가 자신의 위치를 잃었습니다.

해결책은 포커스를 합리적인 곳으로 이동하는 것이며, 이는 ref의 정당한 용도 중 하나입니다:

jsx
function NewGameButton({ isGameWon, onNewGame }) {
  const buttonRef = useRef(null)

  useEffect(() => {
    if (isGameWon) {
      buttonRef.current.focus()
    }
  }, [isGameWon])

  return <button ref={buttonRef} onClick={onNewGame}>새 게임</button>
}

effect는 React가 해당 노드를 화면에 커밋한 후에 실행되므로, 요소가 포커스를 받을 준비가 되어 있습니다. isGameWon을 확인하면 모든 렌더에서 포커스를 훔치는 것을 방지합니다.

같은 패턴은 다른 일반적인 순간들을 다룹니다: 대화상자는 열 때 포커스를 받고 닫을 때 트리거에 반환하고, 유효성 검사 실패는 첫 번째 유효하지 않은 필드로 포커스를 보내고, 행 삭제는 포커스를 그것을 대체한 행으로 이동합니다. 아래의 규칙은 한 줄입니다: 코드가 포커스를 가진 것을 제거했다면, 코드가 다음 포커스가 가는 곳을 결정합니다.

시각적으로 숨겨진 텍스트

많은 상태가 레이아웃에서 명확하고 스크린 리더에서는 무음입니다: 필드 옆의 녹색 체크 표시, 누른 것처럼 보이는 주사위, 있는 위치에서 명확하게 읽히는 숫자입니다. 시각적으로 숨겨진 텍스트는 페이지를 듣고 있는 누구에게나 그것을 설명합니다.

관례는 sr-only라는 클래스입니다. React나 브라우저에 의미가 없습니다: 일반 클래스 이름이며, 이 CSS 규칙들이 작업을 수행합니다.

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

요소는 접근성 트리에 남아 있으면서 시각적 공간을 차지하지 않습니다. display: nonevisibility: hidden은 그것을 트리에서도 제거하여 모두로부터 숨기게 됩니다.

아이콘 전용 버튼이 일반적인 경우입니다. aria-label을 제공하거나 실제 텍스트를 내부에 넣고 시각적으로 숨깁니다:

jsx
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">닫기</span>
</button>

aria-hidden="true"는 장식용 SVG를 알림에서 유지하고, 숨겨진 스팬이 이름을 제공합니다. aria-label에 대한 한 가지 주의: 이는 대화형 요소와 명시적 역할을 가진 모든 것에 접근 가능한 이름을 설정하며, 브라우저는 역할 없는 일반 <div> 또는 <span>에서 이를 자주 무시합니다. 버튼, 링크, 입력, 그리고 레이블이 있는 landmark에 유지합니다.

소스를 읽는 것은 이 중 어느 것이든 거기까지만 갈 것입니다. Cmd+F5로 VoiceOver를 켜서 자신의 앱을 듣고, 브라우저에서 axe DevTools를 실행하여 누락된 레이블과 명명되지 않은 컨트롤을 자동으로 잡습니다.

모든 폼 컨트롤에는 레이블이 필요하며, forms는 차이가 가장 분명한 곳입니다. 입력에 연결된 <label>은 필드에 접근 가능한 이름을 제공하므로, 스크린 리더는 포커스가 그곳에 닿을 때 "이메일 주소, 편집 텍스트"를 읽고, 레이블 텍스트는 필드를 위한 클릭 대상이 됩니다.

두 가지 연결 방법이 작동합니다. React의 htmlFor를 사용하여 HTML for 속성으로 id를 통해 레이블을 입력으로 가리킵니다:

jsx
<label htmlFor="email">이메일 주소</label>
<input id="email" type="email" name="email" />

또는 입력을 레이블로 감싸고 id를 완전히 건너뜁니다:

jsx
<label>
  이메일 주소
  <input type="email" name="email" />
</label>

래핑은 체크박스나 라디오에 적합하며, 텍스트가 이미 컨트롤 옆에 있습니다. htmlFor 버전은 레이아웃에 대해 더 자유로운 범위를 제공합니다.

email 같은 하드코딩된 id는 한 페이지의 한 폼에서는 유지됩니다. 마크업을 재사용 가능한 <TextField>로 올리고 같은 페이지에 두 개의 인스턴스를 만들면 같은 id를 내보내므로, htmlFor는 먼저 렌더링된 것에 바인딩되고 레이블은 그 이후의 모든 필드에 대해 조용히 작동 중지됩니다. useId는 컴포넌트 인스턴스당 고유한 id를 생성하며, 이는 React가 추가한 역할입니다:

jsx
function TextField({ label, ...props }) {
  const id = useId()

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </>
  )
}

관련 id에 대해 그 값을 접미사로 사용하십시오. 설명 요소에 대해 ${id}-hint를 사용하면 한 번의 호출로 전체 컨트롤을 다룹니다.

Placeholder 텍스트는 다른 역할을 합니다. placeholder는 누군가 문자를 입력하는 순간 사라지므로, 필드의 유일한 설명을 가진 경우, 그 설명은 답을 확인하기 위해 필요한 순간에 사라집니다. 기본 placeholder 스타일링은 밝은 회색이며, 일반적으로 대비 요구사항을 충족하지 못하며, 속성에 대한 스크린 리더 지원은 일관성이 없습니다. 예상 형식의 예를 들기 위해 사용하십시오. "이메일 주소"를 읽는 레이블 아래에 [email protected].

추가 도움말 텍스트와 오류 메시지는 aria-describedby와 함께 첨부되며, 텍스트를 보유한 요소의 id를 가리킵니다:

jsx
<label htmlFor="password">비밀번호</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
  aria-invalid={error ? true : undefined}
/>
<p id="password-hint">{error || '최소 12자.'}</p>

설명은 레이블과 필드 유형 다음에 읽혀지므로, 이름이 아닌 컨텍스트로 도착합니다. aria-invalid는 필드를 유효성 검사 실패로 표시하고, 오류 텍스트를 aria-describedby가 이미 가리키고 있는 요소로 교체하면 스크린 리더가 추적 중인 노드에서 알림을 유지합니다. 한 수준 위로, 라디오 버튼 집합은 질문을 가진 <legend>가 있는 <fieldset> 내부에 속합니다.

aria-live는 세 가지 값을 취하며, 선택은 영역이 도움이 되는지 해를 끼치는지를 결정합니다. off는 기본값이며, 변경이 표시되지 않음을 의미합니다. polite는 알림을 큐에 넣고 스크린 리더가 이미 말하고 있는 것의 일시 중지에 도달할 때 전달합니다. assertive는 중단하여 현재 알림을 끊고 당신의 것을 전달합니다. Assertive는 거의 항상 잘못된 선택입니다: 세션이 10초 후에 만료되는 것처럼 진정으로 사람의 진행을 막는 것으로 예약하십시오. 저장 확인, 검색 결과 수, 게임 상태 변경은 모두 polite 영역에 속합니다.

두 가지 역할은 암시된 politeness를 가지며 일반 aria-live 속성보다 더 일관되게 표시되는 경향이 있습니다: role="status"는 polite로 작동하고, role="alert"는 assertive로 작동하고, role="status" 플러스 aria-live="polite"는 상태 영역에 대한 견고한 기본값입니다. aria-atomic="true"는 모든 변경 시 영역의 전체 콘텐츠를 읽으며, 이는 전체해야만 의미가 있는 짧은 문장에 적합합니다; 기본값은 변경된 것만 읽으며, 이는 각 줄이 독립적으로 서 있는 로그에 적합합니다.

이름을 지을 가치가 있는 실패 모드는 너무 많이 표시하는 영역입니다. 검색 상자 아래의 결과 수처럼 모든 키스트로크에서 업데이트되는 값에 연결하면, 모든 문자가 또 다른 알림을 큐에 넣습니다. Polite 전달은 큐를 대체하는 대신에 추가하므로, 사람은 여전히 입력하고 있는 필드 위에 오래된 숫자의 스트림을 듣고, 자신의 타이핑 에코는 묻혀 있습니다. 깜박이는 로딩 플래그나 세 개의 경쟁 영역이 같은 축적을 유발합니다.

그래서 라이브 영역을 적게 유지하고, 입력 중 구동되는 모든 것을 값이 정착할 때까지 디바운스하고, 보이는 사용자가 올려다볼 순간만 표시합니다. 아무것도 말하지 않는 앱은 적어도 탐색할 수 있습니다: 사람은 자신의 스크린 리더의 자체 명령으로 자신의 속도로 탐색할 수 있습니다. 지속적으로 말하는 앱은 사람들이 떠나는 것입니다.

Juno올바른 요소가 대부분의 작업을 수행합니다 무언가를 클릭할 수 있을 때 실제 button에 도달하고, 모든 입력 다음에 실제 label을 사용하십시오. 이 요소들은 키보드 지원과 스크린 리더가 읽을 수 있는 이름을 무료로 제공합니다. 페이지를 듣고 있는 사람이 그렇지 않으면 놓칠 수 있는 화면의 무언가가 변경되면, 짧은 문장을 aria-live="polite"가 있는 <div> 안에 넣고, 변경이 감지되도록 페이지에서 시작하여 그 div를 유지합니다.
Juno올바른 요소가 대부분의 작업을 수행합니다 의미론적 요소는 코드 없이 포커스, 키보드 활성화, 표시를 제공하며, 이것이 roletabIndex<div>를 패치하면 자신의 키 처리를 작성하게 되는 이유입니다. htmlFor 또는 래핑된 <label>로 모든 컨트롤에 레이블을 지정하고, placeholder를 형식 힌트로 취급하십시오. 누군가가 입력하는 순간 사라지기 때문입니다. aria-live="polite" 영역을 마운트된 상태로 유지하고 텍스트를 교체하고, 코드가 포커스를 가진 것을 제거할 때마다 ref를 사용하여 포커스를 이동합니다.
Juno올바른 요소가 대부분의 작업을 수행합니다 라이브 영역은 스크린 리더가 만날 때 등록되므로, 콘텐츠가 변경되기 전에 영역이 DOM에 있어야 하며, polite는 음성의 다음 일시 중지에서 전달되고 assertive는 중단하며 거의 항상 잘못된 선택입니다. role="status"role="alert"는 같은 politeness를 더 나은 일관성으로 가지며, aria-atomic은 전체 영역 또는 델타만 읽을 것인지를 결정합니다. 키스트로크로 구동되는 과도하게 열정적인 영역은 말할 수 있는 것보다 빠르게 알림을 큐에 넣으며, 이는 침묵보다 사용자에게 더 나쁩니다.

다음: 기본을 넘어, 기초를 넘어서 무엇이 있는지의 지도입니다.