🚢

React18 の useId で a11y対応する

2022/05/18に公開

aria-controlsaria-errormessagearia-labelledbyは、一意の ID 属性で 2 つの異なる要素を紐づける必要があります。

エラー文言付き Textbox

例として、以下の様な「errorMessageを与えた時にエラー表示する」Textbox コンポーネントを見ていきます。

<Textbox
  title="お名前"
  inputProps={...register("name")}
  errorMessage="正しく入力してください"
/>

対象となるのは「input 要素・エラー文言要素」です。この 2 者を紐づけるためには、エラー文言要素を指す ID 属性が必要です(aria-errormessageとして input 要素に指定)これを達成するには、props で ID 指定するか、内部的に ID を生成する必要があります。前者は面倒なので、後者で対応したいところです。

aria-errormessage

useId

React18 で追加された新しい Hook の useIdは、このようなケースに適した Hook です。下記引用の通り、ハイドレーション時の不整合が起きないことが担保されます。

useId はハイドレーション時の不整合を防ぎつつクライアントとサーバで一意な ID を生成するためのフックです。これは主に、一意な ID を必要とするアクセシビリティ API を組み込むようなコンポーネントライブラリで有用なものです。

https://ja.reactjs.org/blog/2022/03/29/react-v18.html#useid

以下の様にerrorMessageIdという ID を内部生成し、input 要素とエラー文言要素を紐づけます。props のerrorMessage?: string;ひとつで、a11y に配慮した情報展開が可能なことが分かります。

import React, { useId } from "react";
import styles from "./style.module.scss";

export type Props = {
  title: string;
  inputProps: React.ComponentPropsWithoutRef<"input">;
  errorMessage?: string;
};

export const Textbox = ({ title, inputProps, errorMessage }: Props) => {
  const errorMessageId = useId(); // <- here
  return (
    <fieldset className={styles.module}>
      <label>
        <span role="heading">{title}</span>
        <input
          {...inputProps}
          type="text"
          aria-invalid={!!errorMessage}
          aria-errormessage={errMessageId} // <- here
        />
      </label>
      {!!errorMessage && (
        <p
          id={errorMessageId} // <- here
          className={styles.err}
        >
          {errorMessage}
        </p>
      )}
    </fieldset>
  );
};

a11y Testing

Storybook でエラー表示されるコンポーネントを作り、テストケースとして利用します。

export const ShowError: Story = {
  storyName: "エラー表示",
  args: { errorMessage: "正しく入力してください" },
};

@storybook/testing-reactcomposeStorieを利用します。次のテストは成功しますが、あまり良いテストではありません。もしこの様なテストコードが PR であがってきたら、a11y 観点で配慮されているか、確認が必要です。

const { ShowError } = composeStories(stories);

describe("components/molecules/Textbox", () => {
  describe("errorMessage を与えたとき", () => {
    test("エラー文言が表示されていること", async () => {
      const { getByText } = render(<ShowError />);
      // × 文言表示を確認しているのみで、a11y配慮が十分か不明
      expect(getByText("正しく入力してください")).toBeInTheDocument();
    });
  });
});

同じコンポーネントのテストですが、次のテストは a11y 観点で配慮されているといえます。アクセシブルな名前({ name: "お名前" })で特定・invalid 判定し、toHaveErrorMessage でエラー文言補足されていることが分かります。

const { ShowError } = composeStories(stories);

describe("components/molecules/Textbox", () => {
  describe("errorMessage を与えたとき", () => {
    test("textbox のエラー詳細が補足されていること", () => {
      const { getByRole } = render(<ShowError />);
      // ◯ アクセシブルな名前で特定できる
      const textbox = getByRole("textbox", { name: "お名前" });
      // ◯ textbox に誤りがあることが分かる
      expect(textbox).toBeInvalid();
      // ◯ エラー文言で textbox が補足されていることが分かる
      expect(textbox).toHaveErrorMessage("正しく入力してください");
    });
  });
});

FYI

aria-errormessageでエラー文言を紐づけるサンプルは、こちらを参考にしました。

https://www.w3.org/TR/wai-aria/#aria-errormessage

Discussion