a11y を強化する ReactComponent の型推論
次の 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-disabled
がaira-disabled
になっていました。