Slide 1

Slide 1 text

TypeScriptとReactで、 WAI-ARIAの属性を正しく利⽤する @ymrl TSKaigi 2025 2025年5⽉23⽇

Slide 2

Slide 2 text

⾃⼰紹介 ● ymrl (⼭本 伶) ○ 「ymrl」は「やまある」と読みます ○ フリー株式会社 デザイナー/エンジニア ● Reactコンポーネントのデザイン/実装をやっていて、 ほぼ毎⽇TypeScriptを書いています ● 「Webアプリケーションアクセシビリティ」共著者 ● Software Design 6⽉号の特集に記事を書きました

Slide 3

Slide 3 text

WAI-ARIAの属性

Slide 4

Slide 4 text

WAI-ARIAの属性とは ● Webブラウザは、スクリーンリーダーなどの「⽀援技術」に対して、 「Accessibility Tree(アクセシビリティツリー)」を提供している ○ WAI-ARIAは、この連携のあり⽅を定義している仕様 ● WAI-ARIAには、Accessibility Treeの情報を操作することができる role属性とaria-*属性が定義されている ○ HTMLやSVGでは表現できなかった情報を⽀援技術に伝えられる ○ 今回はこのrole属性とaria-*属性について話をします ○ aria-ではじまる属性名をまとめて「aria-*属性」として扱います

Slide 5

Slide 5 text

role属性とaria-*属性の使⽤例
検索して追加 ファイルから追加
Webアプリケーションアクセシビリティ――今⽇から始める現場からの改善(伊原⼒也, ⼩林⼤輔, 桝⽥草⼀, ⼭本伶 著 技術評論社)より⼀部改変

Slide 6

Slide 6 text

Accessibility Treeを⾒る ブラウザの開発者ツールにはAccessibility Treeを⾒られる機能がある Google Chrome Mozilla Firefox

Slide 7

Slide 7 text

WAI-ARIAロール ● WAI-ARIAでは、WebコンテンツやGUIに出現する要素の種類を分類し、 「ロール(role)」として定義している ● ロールは、要素の意味や使い⽅を⽀援技術に伝えるためのものであり、 ⽀援技術のユーザーにも、⽀援技術を通してそれらを伝える ● HTMLやSVGの要素のもつ暗黙のロールが使われることもあれば、 role属性により、ロールを明⽰することもある ○ 暗黙のロールで充分な場合には、ロールを明⽰する必要はない

Slide 8

Slide 8 text

ロールによる制約 ● ロールごとに、どのステートとプロパティが サポートされるかが決められている ● サポートされないaria-*属性を使⽤した場合 ブラウザや⽀援技術の振る舞いは未定義 ○ 多くの場合は無視されるが、振る舞いに 影響することもある ● 右の画像はbuttonロールの例

Slide 9

Slide 9 text

Reactで、role属性とaria-*属性を使う

Slide 10

Slide 10 text

Reactの型定義における、WAI-ARIAのロールと属性 ● @types/react に、AriaAttributesとAriaRoleとして、aria-*属性とrole属性の 型定義が存在する ○ AriaAttributesは、属性名と値の型を定義するinterface ○ AriaRoleは、値を列挙したUnion type ● それぞれWAI-ARIA 1.1に基づくものであるとコメントがある ○ AriaAttributesの追加が2017年、AriaRoleの追加が2021年であり、 当時はまだWAI-ARIA 1.2は勧告に⾄っていない ○ そのためWAI-ARIA 1.2で追加されたroleが、AriaRoleに存在しない

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

TypeScript Playground

Slide 13

Slide 13 text

No content

Slide 14

Slide 14 text

めでたし めでたし

Slide 15

Slide 15 text

React + TypeScriptの気になるポイント

Slide 16

Slide 16 text

aria-*属性の型は、ロールによる制約が反映されていない TypeScript Playground

Slide 17

Slide 17 text

存在しないロール名をrole属性の値に指定できる TypeScript Playground

Slide 18

Slide 18 text

propsに存在しない属性が型チェックされない TypeScript Playground

Slide 19

Slide 19 text

ハイフンのあるpropsが無法地帯 名前にハイフン(-)を含むpropsは、コンポーネント定義になくても付けられる (名前にハイフンを含まないpropsはエラーになる) TypeScript Playground

Slide 20

Slide 20 text

props に aria-* が無いのに、期待して使ってしまう // aria-* をpropsに持たないコンポーネント const Button = ({ children }:{ children: ReactNode }) => {children}; // aria-label が使えることを期待しているが……? {/* ... */} // 実際には当然 aria-label が DOM にレンダリングされることはない

Slide 21

Slide 21 text

aria-* props のスペルミスに気付けない // aria-labelledby を optional で props に持つコンポーネント const Button = ({"aria-labelledby": labelledby}:{"aria-labelledby"?: string}) => button; // aria-labelledby をスペルミスしてしまっている! // もちろん、aria-labeledby は aria-labelledby としてレンダリングされない

Slide 22

Slide 22 text

現場としてけっこう困っている ● デザインシステムのUIコンポーネントを、Reactコンポーネントとして提供 ○ WAI-ARIAをなるべく隠蔽しているが、APIとして露出させることもある ● エンジニアが、TypeScriptやHTMLに必ずしも熟達しているわけではない ○ エディタで補完されないことに違和感を抱かない ● axe-coreに提案されるなどで、aria-* 属性を使おうとすることがある ○ なぜ、aria-* 属性が反映されないのか、わからない ○ 試⾏錯誤のすえ、妙な aria-*属性がコードに残される

Slide 23

Slide 23 text

気になりポイント ● AriaAttributesの型とAriaRoleの型が別々に定義されているので、 属性がロールの制約を反映していないのは仕⽅がないだろう ● AriaRoleの型が定義されているのに、なぜAriaRoleにない値を role propに指定できてしまうのだろう ● ハイフン(-)を含むpropsが、定義されてなくても指定できてしまうのは どういうことなんだろう

Slide 24

Slide 24 text

なぜそうなっているのか?

Slide 25

Slide 25 text

roleに、定義にない値を指定できる件 ● AriaRoleの型定義は、最後の⾏が | (string & {}) となっている ○ ⽂字列であれば何でも受け付けてしまう ○ string ではなく string & {} という指定なのは、補完を効かせるハック ● role属性は、リストとして複数のロールを指定できる仕様がある ○ ロールが新しく実装された際に、 role="newbutton button" のように ロールのリストとして指定して、フォールバックさせられる仕様 ○ 定義済みロールのUnion Typeでは、新しいロールに対応できない

Slide 26

Slide 26 text

フォールバックを利⽤する例 以下の例は、associationlist などの新しいロールを、 generic ロールに フォールバックしている(「dl/dt/ddのスクリーンリーダーの読み上げをなんとかする」より、⼀部改変)
りんご
バラ科の落葉高木
みかん
ミカン科の常緑低木

Slide 27

Slide 27 text

@types/react の AriaRole ● WAI-ARIA 1.1 で定義されているロールの値を、エディタで補完できる ● WAI-ARIA 1.2で追加されたロールは、エディタでの補完ができない blockquote, caption, paragraph, generic, strong, emphasis, insertion, deletion, meter, subscript, superscript , timer, code ● 今後新しいロールの定義が追加された場合、型の修正なしに使⽤できる ○ 既存のロールにフォールバックするような、リストでの指定もできる ● 適切な値かどうかのチェックができるわけではない

Slide 28

Slide 28 text

名前にハイフンがあると、定義になくても使える件 ● @types/react では、それらしいことは何もしていない ● 同じコンポーネントとpropsでも、React.createElementを使⽤すると、 型エラーが指摘される(ことがある) ● @types/react ではなく、TypeScriptのJSXの処理に何かがあるのでは TypeScript Playground

Slide 29

Slide 29 text

TypeScriptのコンパイラ ● TypeScript Deep Dive の「TypeScriptコンパイラの内側」の章には、 microsoft/TypeScript の src/compiler の構成が解説されており、参考にした ● ASTでは、ハイフンの有無に関係なく JsxAttribute となり、差がない ○ つまりparseの時点では、ハイフンがあっても同じように扱われている ● となると、型チェックを⾏う checker.ts のなかで何かが起きているはず ○ このファイルは5万3千⾏以上、2.96MBあり、GitHub上で表⽰できない ○ cloneするか、github.devで開くかしないといけない

Slide 30

Slide 30 text

JSXのハイフンの⼊った名前を判定する関数がある src/compiler/checker.js 33514⾏⽬あたり function isHyphenatedJsxName(name: string | __String) { return (name as string).includes("-"); }

Slide 31

Slide 31 text

なんか無視されそう src/compiler/checker.js 22106⾏⽬あたり function isIgnoredJsxProperty(source: Type, sourceProp: Symbol) { return getObjectFlags(source) & ObjectFlags.JsxAttributes && isHyphenatedJsxName(sourceProp.escapedName); }

Slide 32

Slide 32 text

typescript-go では internal/checker/relater.go 737⾏⽬あたり func isHyphenatedJsxName(name string) bool { return strings.Contains(name, "-") } internal/checker/relater.go 2777⾏⽬あたり func isIgnoredJsxProperty(source *Type, sourceProp *ast.Symbol) bool { return source.objectFlags&ObjectFlagsJsxAttributes != 0 && isHyphenatedJsxName(sourceProp.Name) }

Slide 33

Slide 33 text

TypeScriptの仕様として認識された挙動らしい ● microsoft/TypeScript の issue#32447 で、この問題が指摘されている ○ Issueが⽴てられたのは2019年7⽉18⽇ ○ 残念ながらcloseされている ● これは意図された挙動で、data-*属性やaria-*属性のためと説明されている ● 普通は属性の名前にハイフンをつけて定義したりしないだろうから、 問題になりにくいのではないかという認識らしい ● それ以降、コミッターの関与する積極的な議論はなさそう

Slide 34

Slide 34 text

これでいいのか、TypeScriptとaria-*属性

Slide 35

Slide 35 text

WAI-ARIAの型を正しく扱いたい ● aria-*属性の値が間違っていたら怒られたい ● 存在しないaria-*属性を指定したら正しく怒られたい ● roleによって使⽤できるaria-*属性以外が使われたら怒られたい

Slide 36

Slide 36 text

属性名はcamelCaseで指定したほうがいい ● kebab-caseによる指定は、名前の間違いをコンパイラが指摘しない ● コンポーネントの定義でaria-*属性を露出させる場合は、 propsをcamelCaseで定義し、HTML要素にはkebab-caseに変換して渡す const FooButton = ({ ariaLabel }: { ariaLabel?: string }) => ( {/* ... */} );

Slide 37

Slide 37 text

ロールごとに厳格なaria-*属性を定義する ● aria-attribute-types というパッケージを作った ● ロールごとに使⽤できる aria-* 属性のセットが含まれている ● camelCase と kebab-case でそれぞれ型を定義

Slide 38

Slide 38 text

aria-attribute-types の使⽤例 import { CamelCaseLinkRoleAriaAttributes, convertCamelizedAttributes } from "aria-attribute-types"; const Link = ({ children, ...rest }: { children: ReactNode; href: string; } & CamelCaseLinkRoleAriaAttributes // camelCaseで link ロールの aria-* 属性を受け取る ) => ( {children} );

Slide 39

Slide 39 text

role属性も使⽤可能な例 import { CamelCaseLinkRoleAriaAttributes, CamelCaseRoleAttributes, convertCamelizedAttributes } from "aria-attribute-types"; export const Link = ({ children, ...rest}: { children: ReactNode; href: string } & ( | CamelCaseLinkRoleAriaAttributes /* link ロールの aria-* 属性 */ | CamelCaseRoleAttributes /* すべての role 属性と aria-* 属性 */ )) => {children};

Slide 40

Slide 40 text

aria-* 属性の型は Template type で変換 export type AriaAttributeBodies = { // aria-* 属性の「ボディ」部分のみの型定義 activeDescendant?: string; atomic?: boolean | "true" | "false"; autoComplete?: "none" | "inline" | "list" | "both"; // … } export type KebabAria = { // ボディ部分のみの型定義を、 kebab-case に変換する [body in Extract as `aria-${Lowercase}`]?: T[body]; }; export type CamelAria = { // ボディ部分のみの型定義を、 camelCase に変換する [body in Extract as `aria${Capitalize}`]?: T[body]; }; export type KebabCaseAriaAttributes = KebabAria; export type CamelCaseAriaAttributes = CamelAria;

Slide 41

Slide 41 text

継承モデルに沿って、roleごとの aria-* 属性を定義する type RoletypeRoleAriaAttributeBodies = // すべての role は roletype ロールを継承した子孫になっている Pick; type StructureRoleAriaAttributeBodies = RoletypeRoleAriaAttributeBodies; type SectionRoleAriaAttributeBodies = StructureRoleAriaAttributeBodies; type WindowRoleAriaAttributeBodies = RoletypeRoleAriaAttributeBodies & Pick; export type AlertdialogRoleAriaAttributeBodies = AlertRoleAriaAttributeBodies & DialogRoleAriaAttributeBodies;

Slide 42

Slide 42 text

camelCaseに変換 import { CamelAria } from "../../Utilities"; import * as B from "./RoleAttributeBodies"; export type CamelCaseAlertRoleAriaAttributes = CamelAria; export type CamelCaseAlertdialogRoleAriaAttributes = CamelAria; export type CamelCaseApplicationRoleAriaAttributes = CamelAria; export type CamelCaseArticleRoleAriaAttributes = CamelAria; // ...

Slide 43

Slide 43 text

role属性とともに使えるようにする import * as C from "../RoleAttributes"; export type CamelCaseRoleAttributes = | { role: undefined } | ({ role: `${string} alert` | "alert" } & C.CamelCaseAlertRoleAriaAttributes) | ({ role: `${string} alertdialog` | "alertdialog"; } & C.CamelCaseAlertdialogRoleAriaAttributes) | ({ role: `${string} application` | "application"; } & C.CamelCaseApplicationRoleAriaAttributes) | ({ role: `${string} article` | "article"; } & C.CamelCaseArticleRoleAriaAttributes) | ({ role: `${string} banner` | "banner"; } & C.CamelCaseBannerRoleAriaAttributes) | ({ role: `${string} blockquote` | "blockquote"; } & C.CamelCaseBlockquoteRoleAriaAttributes) | ({ role: `${string} button` | "button"; } & C.CamelCaseButtonRoleAriaAttributes) // ...

Slide 44

Slide 44 text

kebab-caseのaria-*属性を使えなくする ● この部分は TypeScript には頼れないので、eslint 等でできなくするしかない rules: { 'no-restricted-syntax': [ 'error', { selector: 'JSXAttribute[name.name=/^aria-/]', message: 'Props starting with "aria-" are not allowed.' } ] }

Slide 45

Slide 45 text

aria-attribute-types の今後の課題 ● aria-*属性をroleごとに許可されたもののみに限定できるようになった ● HTMLの要素に対するロールの特定が難しい ○ フォーカス可否によって利⽤可能な属性が変化するseparatorロール ○ href属性の有無によってlinkとgenericロールになる要素 ○ 置いた場所によってロールが異なるランドマーク系要素 ● roleの継承モデルをなぞったため、属性の⾮推奨の情報を表現できていない ● kebab-caseとcamelCaseの両⽅をexportしているため、型名が⻑い

Slide 46

Slide 46 text

まとめ ● WAI-ARIAの属性について、Reactの型定義 (@types/react)では ○ role に⾃由な⽂字列が使え、role定義もWAI-ARIA 1.1のまま ● TypeScriptのJSXでは、属性名に - があると型が未定義でもスルーされる ● aria-* 属性は kebab-case ではなく camelCase で表現したほうが安⼼ ● camelCase で role に対して許可された aria-* 属性のみを使えるようにする aria-attribute-types を作ってみた

Slide 47

Slide 47 text

サイドイベントでもうちょっと喋ります 2025/05/29 19:00〜 フリー株式会社 ⼤崎オフィス + YouTube Live https://freee.connpass.com/event/351699/

Slide 48

Slide 48 text

楽天ブックスのキャンペーンのお知らせ 「Webアプリケーションアクセシビリティ」の アクリルキーホルダーまたはアクリルスタンドと、 サイン本のセットが楽天ブックスで販売中です 「モバイルアプリアクセシビリティ⼊⾨」 「Webを⽀える技術」 「オブジェクト指向UIデザイン」 「縁の下のUIデザイン」 でも、キャンペーン実施中です

Slide 49

Slide 49 text

ご清聴ありがとう ございました