[웹 접근성] 슬라이더 접근성을 향상시키는 커스텀 훅 만들기

@eunjios·2024년 2월 5일
2
post-thumbnail

슬라이더의 웹 접근성 개선 필요성

정보통신접근성 (Web 접근성)은 「지능정보화기본법」에 따라 장애인이나 고령자분들이 웹 사이트에서 제공하는 정보를 비장애인과 동등하게 접근하고 이용 할 수 있도록 보장하는 것으로 웹 접근성 준수는 법적의무사항 입니다.

웹 접근성 개선 전 문제

위 화면을 보면 tab으로 보이지 않는 아이템에도 접근할 수 있다. 이 부분은 오히려 편하지 않을까? 라는 생각을 잠깐 했지만 다음과 같은 문제가 있었다.

마우스를 사용하는 비장애인의 경우, 위 슬라이더를 시각적으로 파악하고 이전/다음 버튼을 클릭하여 다른 상품들에 접근할 수 있다. 하지만 키보드와 스크린 리더를 사용하는 경우, 모든 상품을 탐색한 후 이전/다음 버튼에 접근할 수 있다. 버튼 뿐만 아니라 전체적인 웹 페이지를 탐색할 때도 불필요하게 키를 많이 눌러야 한다는 문제가 있다. 이는 마우스 사용자 또는 비장애인과 동등하게 접근할 수 없음을 의미한다. 또한 저시력 시각장애인의 경우 보이는 것과 실제 동작하는 것에 차이가 있어 혼란을 느낄 수 있다는 문제도 있다.


웹 접근성 개선 후

이전과는 다르게 다음 부분을 개선하였다.

  1. 이전/다음 버튼을 사용해야만 보이지 않는 아이템에 접근 가능
  2. 슬라이더 현재 페이지 및 총 페이지 정보 파악 가능
  3. 슬라이더 (페이지) 이동 후 상품에 focus

개인적으로 옆으로 넘어가는 애니메이션이 없어졌다는 점은 조금 아쉽지만 접근성을 고려하였을 때 좋은 선택이었다.


개선 방법

커스텀 훅

1. API로 받아온 데이터 가공하기

기존에는 API 로 받아온 모든 아이템의 데이터를 그대로 (1차원) 배열로 저장하였지만, 슬라이더에 보여지지 않는 아이템들에 접근하지 못하게 하기 위해 2차원 배열로 변경하였다. 데이터 자체는 paginated 되지 않았지만 UI적으로는 페이지처럼 구성하기 위해 데이터를 가공하였다.

react-query를 사용해서 다음과 같이 API에서 데이터를 받아온다.

const { data, error } = useSuspenseQuery({
  ['best-products', { category }], // qeury key
  getBestProducts({ category: category.id }), // query function
});

const products = data.products; // 가공되지 않은 응답 데이터 

이제 위 products 를 이차원 배열 productChunks 로 가공한다.

const chunkLength = 4; // 슬라이더에 한 번에 보여질 상품 개수

const productChunks = [];
for (let i = 0; i < products.length; i += chunkLength) {
  productChunks.push(products.slice(i, i + chunkLength));
}

페이지 관련 변수는 다음과 같이 정의하였다.

const [currentPage, setCurrentPage] = useState(0);
const totalPage = Math.ceil(products.length / chunkLength) - 1;

슬라이더에서 보여질 상품 배열은 다음과 같다.

const productChunk = productChuncks[currentPage];

2. 버튼 클릭 핸들러

현재 페이지를 이동하는 버튼 클릭 핸들러를 다음과 같이 구현하였다. 주요 기능은 다음과 같다.

  • 현재 페이지가 마지막 페이지라면 다음 버튼을 눌렀을 때 첫 번째 페이지로 이동
  • 현재 페이지가 첫 번째 페이지라면 이전 버튼을 눌렀을 때 마지막 페이지로 이동
  • 페이지 이동 버튼을 누르면 상품 컨테이너 요소에 focus 주기

focus 받을 요소를 focusRef 로 넘겨 받는다.

const useMainProducts = (
  focusRef?: React.RefObject<HTMLDivElement>
) => {
  // 중략 
}

다음과 같이 클릭 핸들러를 정의할 수 있다.

const toNextPage = () => {
  if (currentPage !== totalPage) {
    setCurrentPage((prev) => prev + 1);
  } else {
    setCurrentPage(0);
  }
  focusRef?.current?.focus();
};
const toPrevPage = () => {
  if (currentPage !== 0) {
    setCurrentPage((prev) => prev - 1);
  } else {
    setCurrentPage(totalPage);
  }
  focusRef?.current?.focus();
};

3. width 마다 chunkLength 지정

위에서는 chunkLength 를 4로 하드코딩 하였는데 width마다 슬라이더에 보여질 상품 개수를 다르게 할 수 있다. 예를 들어, 넓은 화면일 때는 4개씩, 중간 화면일 때는 3개씩, 모바일 화면일 때는 4개씩 보여준다고 가정하자.

반응형 웹을 만들 때 주로 CSS의 미디어 쿼리를 사용하였는데 이 경우 로직 자체에 변경이 필요하기 때문에 react-responsive 라이브러리를 사용하였다.

// import { useMediaQuery } from 'react-responsive';

const isMedium = useMediaQuery({ maxWidth: 1056 });
const isNarrow = useMediaQuery({ maxWidth: 767 });
let chunkLength = 4;
if (!isNarrow && isMedium) {
  chunkLength = 3;
}

현재 width에 따라 isMediumisNarrow 가 업데이트 되므로 chunkLength 또한 업데이트 된다. 여기서 chunkLength 를 state로 관리하지 않았는데, chunkLength 를 state로 관리하면 너비가 달라질 때마다 커스텀 훅이 재평가 되어 currentPage 가 0으로 초기화 되기 때문이다.


전체 코드 및 컴포넌트에서의 사용

재사용성을 고려하여 typecategory focusRef 를 프로퍼티로 받도록 하였다.

  • type : fetch 할 데이터의 타입
  • category : fetch 할 데이터의 카테고리
  • focusRef : 슬라이더 이동 후 focus 줄 요소의 ref
const useMainProducts = (
  type: 'best-products' | 'gold-box' = 'best-products',
  category?: Category,
  focusRef?: React.RefObject<HTMLDivElement>
) => {
  
  // productChunk, totalPage, currentPage, toPrevPage, toNextPage 구현 
  
  return {
    productChunk,
    totalPage,
    currentPage,
    toPrevPage,
    toNextPage,
  };
};

export default useMainProducts;

실제 코드는 접근성 개선 부분 외에도 다른 부분이 포함되어 있기 때문에 이 포스트에서는 다루지 않겠다. 전체 코드가 궁금하다면 아래 링크를 확인하자.

🔗 hooks/useMainProducts.ts


위 훅을 컴포넌트에서는 다음과 같이 사용할 수 있다.

export default function Products() {
  const containerRef = useRef<HTMLDivElement>(null);
  const { productChunk } = useMainProducts(
    'best-products',
    '1001', // 임의로 카테고리 지정 
    containerRef,
  );
  
  return (
    <div role="group" tabIndex={-1} ref={containerRef}>
      {productChunk.map((product) => <Item product={product} />)}
    </div>
  );
}

주의할 점은 반드시 ref 요소의 tabIndex를 -1로 지정해야 한다는 것이다. 그래야 프로그래밍적으로 포커스를 줄 수 있다.


속성

이전에 포스팅 했던 carousel/slider와 modal 웹 접근성 개선을 위해 알아둘 팁들을 참고하여 다음과 같은 속성을 추가하였다.

1. role

슬라이더 전체를 감싸는 컨테이너 요소에는 role="region" 을 추가하고 아이템이 위치한 컨테이너 요소에는 role="group" 을 추가하여 슬라이더의 역할을 확실히 할 수 있다.

2. aria-label

슬라이더 현재 페이지 및 전체 페이지 정보, 버튼 정보를 aria-label 로 지정할 수 있다. 예를 들면 다음과 같이 사용할 수 있다.

interface Props {
  children: React.ReactNode;
  currentPage: number;
  totalPage: number;
}

export default function Slider({ 
  children, 
  currentPage, 
  totalPage }: Props) {
  const pageText = `${currentPage} / ${totalPage}`;
  const pageLabel = `${totalPage} 페이지 중 ${currentPage} 페이지`;
  
  return (
    <div>
      <div>{children}</div>
      <div>
        <button aria-label="이전">이전</button>
        <span aria-label={pageLabel}>{pageText}</span>
    	<button aria-label="다음">다음</button>
      </div>
    </div>
  );
};

3. tabIndex

tabIndex 는 위에서 다뤘으니 간단하게만 정리하면 다음과 같다. 참고로 HTML에는 tabindex="0" 과 같이 사용하지만 이 포스트는 React를 기준으로 설명한다.

  • tabIndex={-1} 프로그래밍적으로 포커스 가능
  • tabIndex={0} 탭 키로 포커스 가능
  • tabIndex={1} 가장 먼저 포커스

Troubleshooting

문제 상황

창의 너비가 달라질 때 TypeError: Cannot read properties of undefined (reading 'map') 에러가 발생했다.

문제 파악

사실 문제 파악과 해결은 크게 어렵지 않았다. 위 에러는 배열에 해당 index가 없을 때 발생한다는 것을 알고 있었지만 단지 에러 발생 전까지 이런 이슈가 발생할 수 있다는 것을 인지하지 못하고 있었다.

이 문제는 width에 따라 chunkLength productsChunkstotalPage 가 달라지기 때문에 발생하는 문제였다.

예를 들어 총 length가 20인 배열을 생각해 보자. 아이템을 3개씩 보여준다면 총 페이지가 7이고 아이템을 4개씩 보여준다면 총 페이지가 5가 된다. 만약 현재 페이지 7에 있는데 창 너비가 넓어진다면 페이지 7에 해당하는 아이템이 없어지고 때문에 undefined 에러가 발생하는 것이다.

해결 방법

// 기존 코드 
const [currentPage, setCurrentPage] = useState(0);
const totalPage = Math.ceil(products.length / chunkLength) - 1;

// 추가한 코드 
if (currentPage > totalPage) {
  setCurrentPage(totalPage);
}

기존 코드에서 currentPage 는 페이지 이동 버튼을 클릭할 때만 변경되고 있지만, currentPagetotalPage (마지막 페이지) 보다 커지면 currentPage 를 마지막 페이지로 지정하는 코드를 추가하여 해결하였다.

여기서 totalPagechunkLength 와 함께 업데이트 된다. 즉, 창의 너비가 달라질 때 업데이트 되므로 totalPage 를 state로 관리할 필요는 없다.

profile
growth

0개의 댓글