アクセス可能な反応アコーディオン成分


どのように簡単に反応するアクセス可能なアコーディオンコンポーネントを書くことになります小さな実験(それはチュートリアルに).アクセシビリティの以前の経験はありません(よく、多分いくつかの基本的なもの、例えば、Alt Paramの使用はボタンとしてリンクを使用しないように).
私は続けたAccordion Design Pattern in WAI-ARIA Authoring Practices 1.1 何もない.
このチュートリアルの焦点はA 11 Yです、そして、反応します、それで、我々はJSまたは何か他でNPMまたはCSSにパックする方法について気にかけません.この場合に開始する最も簡単な方法は、CREATERANTアプリです.

ブートストラップ


プロジェクトをブートストラップしましょう
npx create-react-app my-app
cd my-app
npm start
Remove all unrelated things .

デザインAPI


コンポーネントのAPIについて考える時間です.典型的なアコーディオンを見てみましょう

ルートコンポーネントとセクションがあります.各セクションのタイトルとコンテンツがあります.正しい?これに基づいてAPIがどのように見えるかを想像することができます.
const App = () => (
  <Accordion>
    <AccordionSection title="section 1">content 1</AccordionSection>
    <AccordionSection title="section 2" expanded>
      content 2
    </AccordionSection>
  </Accordion>
);
指定されたAPIのコンポーネントの最初のドラフトを書きましょう
import React from "react";

export const Accordion = ({ children }) => <div>{children}</div>;

export const AccordionSection = ({ children, title, expanded }) => (
  <>
    <div>{title}</div>
    <div>{expanded && children}</div>
  </>
);

A 11 Yを加える


いいね.開きましょうAccordion Design Pattern in WAI-ARIA Authoring Practices 1.1 をコピーします.
export const AccordionSection = ({ children, title, expanded, id }) => {
  const sectionId = `section-${id}`;
  const labelId = `label-${id}`;

  return (
    <>
      <div
        role="button"
        aria-expanded={expanded}
        aria-controls={sectionId}
        id={labelId}
        tabIndex={0}
      >
        {title}
      </div>
      <div
        role="region"
        aria-labelledby={labelId}
        id={sectionId}
        hidden={!expanded}
      >
        {expanded && children}
      </div>
    </>
  );
};
Accordion 変更する必要はありません.ヘッダーとパネルの2つの要素があります.ヘッダrole="button" ) has id and aria-controls ( id を返します.パネルrole="region" ) has id and aria-labelledby ( id を返します.aria-expanded を返します.hidden セクションが展開されるかどうかの反対.かなり簡単なIMO.
Let's add some styles .

Now it's time to add state and event handling .
状態とコールバックcontrolled component ):
function App() {
  const [expanded1, setExpanded1] = useState(false);
  return (
      <Accordion>
        <AccordionSection
      ...
          expanded={expanded1}
          onToggle={() => setExpanded1(!expanded1)}
        >
イベント処理
export const AccordionSection = ({
  ...
  expanded,
  onToggle
}) => {
  ...
  return (
    <>
      <div
        role="button"
        ...
        onClick={onToggle}
        onKeyDown={e => {
          switch (e.key) {
            case " ":
            case "Enter":
              onToggle();
              break;
            default:
          }
        }}
      >

休止しましょう


この時点で、すでにかなり良い結果です.我々は仕事の多くではなく、A 11 Yの要件の半分を果たす.

  • スペースまたは入力
  • 折り返しセクションのアコーディオンヘッダーにフォーカスがある場合は、セクションを展開します.

  • タブ
  • 次のフォーカス可能な要素にフォーカスを移動します.
  • アコーディオン内のすべてのフォーカス可能な要素は、ページのタブシーケンスに含まれています.

  • シフトタブ
  • 前のフォーカス可能な要素にフォーカスを移動します.
  • アコーディオン内のすべてのフォーカス可能な要素は、ページのタブシーケンスに含まれています.
  • あなたが少なくともこれをするならば、それはすでに何よりもよいでしょう.

    より多くのA 11 Y


    Next section もう少し複雑です.

  • ダウンアロー
  • フォーカスがアコーディオンヘッダーにある場合は、次のアコーディオンヘッダーにフォーカスを移動します.
  • フォーカスが最後のアコーディオンヘッダーにある場合は、最初のアコーディオンヘッダーにフォーカスを移動します.

  • 上矢
  • フォーカスがアコーディオンヘッダーにあるときは、前のアコーディオンヘッダーにフォーカスを移動します.
  • フォーカスが最初のアコーディオンヘッダーにあるときは、最後のアコーディオンヘッダーにフォーカスを移動します.
  • これを行うには、次の、または前のセクションを選択できるように、フォーカスがどこにあるかを追跡する必要があります.このアコーディオンあたり1回、変数として格納する必要があります.だからuseState ? しかし、フォーカスが変更されたときにコンポーネントの再描画をトリガーしたくない.Then useRef でしょう.
    export const Accordion = ({ children }) => {
      const focusRef = useRef(null);
    
    focusRef を含むid 現在集中したセクションまたはnull を返します.我々は追跡する必要がありますfocus and blur ヘッダーのイベント.
    <div
      role="button"
      ...
      onFocus={() => {
        focusRef.current = id;
      }}
      onBlur={() => {
        focusRef.current = null;
      }}
    
    では、どうやってパスしますかfocusRef からAccordion ダウントゥAccordionSection ? 私たちは小道具を介してこれを行うことができますReact.Childre.map and React.CloneElement ) または、コンテキストでこれを行うことができます.私はより多くのクリーンAPIを作成するので、コンテキストのアイデアをもっと好きです.
    コンテキストを作成する
    const AccordionContext = createContext({
      focusRef: {}
    });
    export const useAccordionContext = () => useContext(AccordionContext);
    
    パスfocusRef to Context 私はuseMemo 我々は2011年に更新のために不要なrerdersをトリガしないことを確認してくださいContext )
    const context = useMemo(
      () => ({
        focusRef
      }),
      []
    );
    
    return (
      <AccordionContext.Provider value={context}>
        {children}
      </AccordionContext.Provider>
    );
    
    AccordionSection
    const { focusRef } = useAccordionContext();
    
    OK、この方法で現在選択されているセクションをキャプチャできます.ここで、キーボードイベントに対応する必要があります.
    export const AccordionSection = ({}) => {
      ...
      return (
        <div
          onKeyDown={e => {
            switch (e.key) {
              case "ArrowDown":
                break;
              case "ArrowUp":
                break;
              case "Home":
                break;
              case "End":
                break;
            }
          }}
        >
          <AccordionContext.Provider value={context}>
    
    の場合ArrowDown 我々は、見つける必要がありますfocus エレメントインchildren と次のいずれかを選択します.すべての配列を得ることができますidchildren 元素
    const ids = React.Children.map(children, child => child.props.id);
    
    次に、フォーカスされた要素のインデックスを見つける
    const index = ids.findIndex(x => x === focusRef.current);
    
    次に、次の値
    if (index >= ids.length - 1) {
      return ids[0];
    } else {
      return ids[index + 1];
    }
    
    良い.しかし、どのように我々は実際にフォーカスの変更をトリガするのだろうか?🤔
    私たちは focus() メソッドです.DOM要素を取得するには、リファレンスを使用する必要があります.
    export const AccordionSection = ({}) => {
      const labelRef = useRef();
      ...
      return (
        <>
          <div
            role="button"
            ...
            ref={labelRef}
          >
    
    同様に我々は使用する必要がありますuseEffect DOM要素のメソッドを実際に呼び出すには問題はいつこの効果を引き起こすことですか?タブを変更するたびに、ユーザーがトリガするたびにトリガする必要がありますArrowDown or ArrowUp などを変更するたびにいくつかの変数とトリガ効果を格納する必要がありますので
    export const AccordionSection = ({}) => {
      ...
      useEffect(() => {
        if (id === selected && labelRef.current) {
          labelRef.current.focus();
        }
      }, [id, selected]);
    
    たびに選択の変更と選択された項目は、現在のものと同じですそれにフォーカスを置く.
    どこでストアselected 値?rootでは、1つの変数Accordion . どうやって通るの?文脈を通して、我々が通過したのと同じようにfocusRef . インAccordion :
    const focusRef = useRef(null);
    const [selected, setSelected] = useState(null);
    const context = useMemo(
      () => ({
        focusRef,
        selected
      }),
      [selected]
    );
    ...
    case "ArrowDown":
      {
        const ids = React.Children.map(children, child => child.props.id);
        const index = ids.findIndex(x => x === focusRef.current);
        if (index >= ids.length - 1) {
          setSelected(ids[0]);
        } else {
          setSelected(ids[index + 1]);
        }
      }
    
    AccordionSection :
    const { focusRef, selected } = useAccordionContext();
    
    フィル!私たちはそれを作りました.完全にアクセス可能なコンポーネント.論理を追加することを忘れないでください
  • ホーム
  • フォーカスがアコーディオンヘッダーにあるとき、最初のアコーディオンヘッダーにフォーカスを移動します.
  • 終わり
  • フォーカスがアコーディオンヘッダーにあるときは、最後のアコーディオンヘッダーにフォーカスを移動します.
  • 開発者経験


    開発者の世話をしましょう.我々は大いに依存しているid s、開発者がそれを提供するのを忘れるならば、彼らは非常に微妙な誤りを得ます.Let's check if it is present and warn otherwise :
    AccordionSection.propTypes = {
      id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
      title: PropTypes.string.isRequired,
      expanded: PropTypes.bool,
      onToggle: PropTypes.func
    };
    
    私たちはid sはユニークです.let's check it too :
    if (process.env.NODE_ENV === "development") {
      const uniqueIds = new Set();
      React.Children.forEach(children, child => {
        if (uniqueIds.has(child.props.id)) {
          console.warn(
            `AccordionSection id param should be unique, found the duplicate key: ${
              child.props.id
            }`
          );
        } else {
          uniqueIds.add(child.props.id);
        }
      });
    }
    
    今のところ、我々のコンポーネントのAPIは、そのIDにバインドされるか、各セクションのためにユニークであると仮定するontoggleコールバックを必要とします.このAPIは使いにくいです.Let's instead pass id to callback . この方法では、1つのストアと1つのコールバックをすべてのセクションで使用できます.
    const [expanded, setExpanded] = useState({ "2": true });
    const toggle = id => {
      setExpanded({
        ...expanded,
        [id]: !expanded[id]
      });
    };
    ...
    <AccordionSection
      title="section 1"
      id="1"
      expanded={expanded["1"]}
      onToggle={toggle}
    >
    ...
    <AccordionSection
      title="section 2"
      id="2"
      expanded={expanded["2"]}
      onToggle={toggle}
    
    私は、我々が繰り返しなければならないのが好きでありませんexpanded and onToggle 各セクションでは、代わりに、我々は一度それを渡すことができますAccordion :
    <Accordion expanded={expanded} onToggle={onToggle}>
      <AccordionSection title="section 1" id="id1">
      ...
      </AccordionSection>
      <AccordionSection title="section 2" id="id2">
      ...
      </AccordionSection>
    </Accordion>
    
    それはこのようにきれいに見えます.同様にいくつかの欠点があることを確認する必要があるIDの状態とIDAccordionSection sは同じです(さもなければ、いくつかのセクションは動作しないかもしれません).
    我々はさらに行くことができますprovide a custom hook for default behavior .
    import { useState } from "react";
    
    export const useAccordionState = intialState => {
      const [expanded, setExpanded] = useState(intialState);
      const onToggle = id => {
        setExpanded({
          ...expanded,
          [id]: !expanded[id]
        });
      };
      return { expanded, onToggle };
    };
    
    最終的なコードは次のようになります.
    function App() {
      const accordionProps = useAccordionState({ });
      return (
          <Accordion {...accordionProps}>
            <AccordionSection title="section 1" id="id1">
    

    結論


    思ったほど怖くなかった.Wai - ariaオーサリングの実践はよく書かれている👏. 私はあなたが適切なマークアップとキーボードイベントを使用するたびにonClick ). 完全にアクセス可能なコンポーネントを実装する楽しい学習運動することができます.
    オンラインデモhere . フルソースコードhere .

    PS


    私が怠惰でないならば、そして、このポストが関心を持つならば、私はサイプレスとこの構成要素をテストする方法と私がポストを書いた後に気がついた1つの卑劣なバグを修正する方法について書きます.