💎

a11y を強化する ReactComponent の型推論

7 min read 1

次の Button は tag の指定により、HTML 要素を出し分ける Component です。出力結果は下段のとおりです。この Component を通し a11y を強化する型注釈を考察したので、メモとして投稿します。

<Button tag="button">+1</Button>
<Button tag="a" role="button">+1</Button>
<Button tag="input" type="button" value="+1" />
<button>+1</button>
<a role="button">+1</a>
<input type="button" value="+1" />

3 つの要件

【要件 1】要素として誤った指定をエラーにすること

tag="a" の場合、disabled は正しくない属性付与なので型でエラーにします。利用者は代わりに、aria-disabled 属性を付与します。

<Button tag="a" disabled>+1</Button> // ERROR!
<Button tag="a" aria-disabled>+1</Button> // GOOD!

tag="input" の場合、children を渡してはいけません。ランタイムエラーが起こるのですぐに気付きますが、こちらも型でエラーにします。

<Button tag="input" type="button">+1</Button> // ERROR!
<Button tag="input" type="button" value="+1" /> // GOOD!

このようにtagの指定により、本来の a や input として推論されることが第一要件です。

【要件 2】正しい Role 属性付与を促すこと

tag="a" の場合、role="button" の付与を型で強要するものとします。支援技術にもボタンとして認識されるようになります。

<Button tag="a">+1</Button> // ERROR!
<Button tag="a" role="button">+1</Button> // GOOD!

tag="a" 以外の場合、role 付与は拒否するものとします。元々セマンティックな要素に対し、多重 role 付与をさせないためです。

<Button tag="button">+1</Button>  // GOOD!
<Button tag="button" role="button">+1</Button>  // ERROR!

主旨である「a11y を強化する型推論」とは、このような制約を指します。

【要件 3】Ref Forwarding を施すこと

要素として扱いたい Component は Ref Forwarding を施しておくと便利です。例えば Next.js の Link コンポーネントは、子 anchor タグの ref を暗黙的に使用します。以下の様なユースケースでも問題なく利用できます。

<Link href="/path/to/page">
  <Button tag="a" role="button">
    learn more
  </Button>
</Link>

実装内容

次のコードで上記 3 要件が達成可能です。tag文字列をもってReact.createElementを実行しています。また、このtag文字列を利用し、型推論の分岐を行っています。

import React from "react";
// ______________________________________________________
//
type Tag = "button" | "input" | "a";
type Element = HTMLButtonElement | HTMLInputElement | HTMLAnchorElement;
// ______________________________________________________
//
type ButtonProps = { tag: "button"; role?: never };
type InputProps = { tag: "input"; role?: never; children?: never };
type AnchorProps = { tag: "a"; role: "button" };
// ______________________________________________________
//
type Props<T extends Tag> = (T extends "button"
  ? ButtonProps
  : T extends "a"
  ? AnchorProps
  : InputProps) &
  React.ComponentPropsWithRef<T>;
// ______________________________________________________
//
export const Button: <T extends Tag>(props: Props<T>) => JSX.Element =
  React.forwardRef<Element, Props<Tag>>(({ tag, ...props }, ref) =>
    React.createElement(tag, { ref, ...props })
  );

解説

const Button に注釈している、Lookup 関数型がポイントです。Conditional Types と Lookup 関数型を併用することで、tag に入力された文字列から、任意の Props を切り替えることが可能になります。

<T extends Tag>(props: Props<T>) => JSX.Element;

以下が切替え対象の型定義です。neverを与えることで、特定 prop の受付を拒否できます。

type ButtonProps = { tag: "button"; role?: never };
type InputProps = { tag: "input"; role?: never; children?: never };
type AnchorProps = { tag: "a"; role: "button" };

この様なコンポーネントの場合、いくらかの決まったスタイルを持っていることが通常です。Component に追加で color という prop を追加し、ユースケースに応じて切り替え可能なようにしておきます。

<Button color="primary" tag="button">+1</Button>
<Button color="primary" tag="a" role="button">+1</Button>
<Button color="secondary" tag="input" type="button" value="+1" />
CSS 指定まで含めた実装詳細
import React from "react";
import styles from "./style.module.scss";
import { mergeClassName } from "../utils";
// ______________________________________________________
//
type Tag = "button" | "input" | "a";
type Element = HTMLButtonElement | HTMLInputElement | HTMLAnchorElement;
// ______________________________________________________
//
type ButtonProps = { tag: "button"; role?: never };
type InputProps = { tag: "input"; role?: never; children?: never };
type AnchorProps = { tag: "a"; role: "button" };
// ______________________________________________________
//
type Color = "primary" | "secondary";
type SpecificProps = { color?: Color };
// ______________________________________________________
//
type Props<T extends Tag> = (T extends "button"
  ? ButtonProps
  : T extends "a"
  ? AnchorProps
  : InputProps) &
  SpecificProps &
  React.ComponentPropsWithRef<T>;
// ______________________________________________________
//
export const Button: <T extends Tag>(props: Props<T>) => JSX.Element =
  React.forwardRef<Element, Props<Tag>>(
    ({ tag, className, color = "primary", ...props }, ref) =>
      React.createElement(tag, {
        ref,
        tabIndex: 0,
        className: mergeClassName(styles.module, className),
        "data-color": color,
        ...props,
      })
  );

アクセシビリティを考慮する場合、tabIndex も併せて指定することと思いますが、こちらもデフォルトで設定しておきます。props destructuring を後方で展開しているため、上書きも可能です。

.module {
  display: inline-block;
  font-size: 1.6rem;
  padding: 1em;
  min-width: 3em;
  min-height: 3em;
  line-height: 1.2;
  border-radius: 5px;
  box-shadow: 0px 3px 1px -2px rgb(0 0 0 / 20%), 0px 2px 2px 0px rgb(0 0 0 / 14%),
    0px 1px 5px 0px rgb(0 0 0 / 12%);
  cursor: pointer;
  outline: none;

  &[data-color="primary"] {
    color: var(--primary-text-color);
    background-color: var(--primary-bg-color);
    &:hover {
      background-color: var(--primary-active-color);
    }
    &:focus {
      border-color: var(--primary-bg-color);
      box-shadow: 0px 0px 10px var(--primary-bg-color);
    }
    &:active {
      border-color: var(--primary-bg-color);
      background-color: var(--primary-bg-color);
      box-shadow: 0px 0px 0px var(--primary-bg-color);
    }
  }

  &[data-color="secondary"] {
    color: var(--secondary-text-color);
    background-color: var(--secondary-bg-color);
    &:hover {
      background-color: var(--secondary-active-color);
    }
    &:focus {
      border-color: var(--secondary-bg-color);
      box-shadow: 0px 0px 10px var(--secondary-bg-color);
    }
    &:active {
      border-color: var(--secondary-bg-color);
      background-color: var(--secondary-bg-color);
      box-shadow: 0px 0px 0px var(--secondary-bg-color);
    }
  }

  &[data-color] {
    &:disabled,
    &[aria-disabled="true"] {
      color: var(--disabled-text-color);
      background-color: var(--disabled-bg-color);
      pointer-events: none;
    }
  }
}

input.module {
  border: none;
}

a.module {
  text-decoration: none;
}

Discussion

要件 1 のところですが、aria-disabledaira-disabled になっていました。

ログインするとコメントできます