그리드 항목에 대한 사용자 정의 키보드 탐색 생성

9365 단어 a11yreacttypescript
최근에 긴 파일 목록 내에서 사용자 지정 키보드 탐색을 설정하는 문제에 직면했습니다. 그리드 보기와 목록 보기 모두에 표시됩니다. 인터넷 검색을 할 때 정확한 해결책을 찾지 못했기 때문에 여기에 내가 배운 것을 설명하는 기사가 있습니다. 영감을 주신 Ryan Mulligan's article에게 감사드립니다.

전제 조건: React에 대한 기본 지식.

Check out the working project demo site 우리가 만들고 있는 것을 볼 수 있습니다.

시작 코드 설정



Get starter GitHub repository을 참조하여 코드를 자세히 살펴보십시오. 저장소를 복제하거나 전체 파일을 다운로드할 수 있습니다.

선호하는 편집기에서 코드를 열고 명령을 실행하십시오yarn install && yarn start. 그러면 로컬 환경에서 코드가 실행됩니다.

프로젝트 시작



이제 코딩을 시작할 준비가 되었습니다. 먼저 제목을 추가하고 항목을 표시해 보겠습니다. 항목에 필요한 모든 데이터는 파일items.json에 있습니다. 그것들을 App로 가져와 각각을 반복할 수 있습니다.

import "./App.css";
import items from "./items.json";

function App() {
  return (
    <main className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          return <article>single item</article>;
        })}
      </section>
    </main>
  );
}

export default App;


현재 우리는 각 항목에 대해 동일한 텍스트만 표시합니다. 이미지와 확인란을 표시하기 위해 변경할 수 있습니다. Item 내부의 구성 요소src/components/Item.tsx를 열고 구성 요소를 업데이트합니다.

import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
};

function Item(props: ItemProps) {
  const { description, name, url } = props;

  return (
    <div className="item">
      <input className="input" type="checkbox" name={name} id={name} />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;


또한 ItemApp로 가져와 모든 소품을 전달해야 합니다. 여기서는 수동으로 각 소품을 추가하는 대신 모든 소품을 펼칩니다.

import "./App.css";
import Item from "./components/Item";
import items from "./items.json";

function App() {
  return (
    <main className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          return <Item {...item} />;
        })}
      </section>
    </main>
  );
}

export default App;


이제 두 구성 요소에 몇 가지 스타일을 추가하여 앱을 더 보기 좋게 만들 수 있습니다.
App.css에서 우리는 스타일을 기본으로 만들고 그리드 안에 항목을 표시합니다.

.main {
  margin: auto;
  max-width: 1000px;
  text-align: center;
}

.items {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
}

Item.css에서 이미지 스타일을 지정하고 절대적으로 위치 확인란을 지정합니다.

.item {
  position: relative;
  height: 150px;
  border-radius: 10px;
  overflow: hidden;
  cursor: pointer;
  border: 2px solid lightgray;
}

.image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.input {
  position: absolute;
  width: 18px;
  height: 18px;
  cursor: pointer;
}


다음에 할 일은 이미지를 클릭할 때 확인란을 선택하는 것입니다. 이렇게 하면 작은 확인란을 클릭하는 것보다 전체 사용자 경험이 훨씬 좋아집니다.

그렇게 하려면 확인란이 선택되었는지 추적하기 위해 내부에 상태Item를 추가해야 합니다. onClick 핸들러를 전체 항목에 추가하고 checked 속성을 통해 체크박스 상태를 수동으로 설정합니다.

import { useState } from "react";
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
};

function Item(props: ItemProps) {
  const { description, name, url } = props;
  const [isChecked, setIsChecked] = useState(false);

  return (
    <div
      className={isChecked ? "item checked" : "item"}
      onClick={() => setIsChecked(!isChecked)}
    >
      <input
        className="input"
        type="checkbox"
        name={name}
        id={name}
        checked={isChecked}
      />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;


위의 코드에서 새 클래스 이름 checked 을 추가했으므로 Item.css 에도 추가해야 합니다.

.checked {
  border: 2px solid rgb(36, 36, 174);
}


사용자 정의 키보드 탐색 추가



현재 앱을 탭하면 포커스가 한 확인란에서 다른 확인란으로 변경됩니다. 항목 수가 적은 경우에도 괜찮습니다. 500개의 항목을 표시하고 키보드를 통해 앱의 다음 섹션으로 이동하려고 한다고 상상해 보십시오. 다음 섹션으로 이동하기 위해 500개 항목을 탭으로 이동하시겠습니까? 아마 아닐 겁니다. 다행히 긴 항목 목록을 탐색하는 더 좋은 방법이 있습니다.

아이디어는 간단합니다. tabIndex=0(포커스 가능)가 있는 확인란이 하나만 있고 다른 모든 확인란은 tabIndex=-1(키보드 탐색으로 무시됨)가 있습니다. 그런 다음 사용자가 클릭하는 화살표에 따라 각 항목을 프로그래밍 방식으로 변경합니다tabIndex.

우리 코드로 돌아가자. App 안에 refmain를 추가하여 내부에서 키보드 클릭만 듣도록 합니다. 또한 현재 초점이 맞춰진 항목을 알기 위해 추적 커서에 대한 상태를 추가합니다.

첫 번째useEffect 안에 이벤트 리스너를 추가합니다. 각 키보드 버튼에서 기능handleKey을 누르면 실행됩니다. 내부에서 화살표 키 누름을 찾고 이를 기반으로 커서 상태를 수정합니다. 단순화하기 위해 numberOfColumns는 하드 코딩되어 있습니다.

두 번째useEffect에서는 이름을 기반으로 내부에서 올바른 확인란main을 찾은 다음 초점을 맞춥니다.

import { keyboardKey } from "@testing-library/user-event";
import { useEffect, useRef, useState } from "react";
import "./App.css";
import Item from "./components/Item";
import items from "./items.json";

function App() {
  const itemsRef = useRef<HTMLElement>(null);
  const [cursor, setCursor] = useState(1);
  const numberOfColumns = 4;
  const totalNumberOfFiles = items.length;

  useEffect(() => {
    const handleKey = (event: keyboardKey) => {
      if (event.key === "ArrowRight") {
        setCursor((prevCursor) => {
          if (prevCursor === totalNumberOfFiles) {
            return totalNumberOfFiles;
          }

          return prevCursor + 1;
        });
      }

      if (event.key === "ArrowLeft") {
        setCursor((prevCursor) => {
          if (prevCursor === 0) {
            return 0;
          }

          return prevCursor - 1;
        });
      }

      if (event.key === "ArrowDown") {
        setCursor((prevCursor) => {
          if (prevCursor + numberOfColumns > totalNumberOfFiles) {
            return prevCursor;
          }

          return prevCursor + numberOfColumns;
        });
      }

      if (event.key === "ArrowUp") {
        setCursor((prevCursor) => {
          if (prevCursor - numberOfColumns < 0) {
            return prevCursor;
          }

          return prevCursor - numberOfColumns;
        });
      }
    };

    if (itemsRef.current) {
      const currentCursor = itemsRef.current;

      currentCursor.addEventListener("keyup", handleKey);

      return () => currentCursor.removeEventListener("keyup", handleKey);
    }
  }, [totalNumberOfFiles, numberOfColumns]);

  useEffect(() => {
    if (itemsRef.current) {
      const selectCursor = itemsRef.current.querySelector(
        `input[name='item ${cursor}']`
      );
      (selectCursor as HTMLInputElement)?.focus();
    }
  }, [cursor]);

  return (
    <main ref={itemsRef} className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          const tabIndex = cursor === item.id ? 0 : -1;

          return <Item {...item} tabIndex={tabIndex} />;
        })}
      </section>
    </main>
  );
}

export default App;


마지막으로 추가해야 할 것은 tabIndexItem입니다.

import { useState } from "react";
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
  tabIndex: number;
};

function Item(props: ItemProps) {
  const { description, name, url, tabIndex } = props;
  const [isChecked, setIsChecked] = useState(false);

  return (
    <div
      className={isChecked ? "item checked" : "item"}
      onClick={() => setIsChecked(!isChecked)}
    >
      <input
        className="input"
        type="checkbox"
        name={name}
        id={name}
        checked={isChecked}
        tabIndex={tabIndex}
      />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;



이 기사를 읽어 주셔서 감사합니다. 새로운 것을 배웠기를 바랍니다.

Link to the finished project GitHub repository

좋은 웹페이지 즐겨찾기