フューチャー技術ブログ

アクセシビリティに考慮した出力値のHTMLマークアップ

10/4追記: 本記事の中で不具合のような動作があると書いていたのですが、issueで報告したところ、すぐに修正されました。次のバージョン1.39で修正版がリリースされるようです。

以前書いたCypressの記事で、アクセシビリティ情報を使うことで壊れにくくなるよ、と(今では当たり前のようにみんな言っていますが)いう記事を書きました。

この時は出力に使うべきロールが何か? というのがわかっていませんでした。

サーバーからとってきた動的な値を表示するテキストって、DOM上は単なるテキストなのでロールを持っていないのですよね。テスト上はここにロールがあって要素取得できるととても助かる。

次のどちらかな、と思っているのですが、どちらの方がスクリーンリーダーユーザーにとって自然なロール指定のかはちょっとわかってません。とりあえず前者にしています。

先日、「実際使えるロールとか、それに適したタグって全部でどのぐらいあるんだろうか?」というのが気になってPlaywrightのソースコードを眺めていたところ、それが定義されているファイルがplaywright-core/src/server/injected/roleUtils.tsであることがわかり、その中で <output> タグがあるというのを知りました。<output>タグにはデフォルトでstatusというaria-roleがついていることがわかります。

多くのブラウザが aria-live の領域としてこのタグを扱ってくれるため、何かしらのロジックが走って結果が変わった時のとかにうまく変更をユーザーに知らせてくれるらしいです。ReactとかVueで計算した結果を出す先をマークアップするには良さそうですね。

<output>タグを使ってみる

MDNの説明を読むと、<output>タグはfor属性を使って、フォーム要素っぽく、HTMLとして<label>と関連付けられることがわかりました。これを使うのがHTMLのセマンティクス的に良さそうですね。

viteでReactの雛形を作って、サンプルアプリを作ってみました。2つの数値のフォームがあり、それに数値を入れると合計値、積算した結果を表示します。

import { useCallback, useState, ChangeEvent } from 'react'

function App() {
const [value1, setValue1] = useState(0)
const [value2, setValue2] = useState(0)
const updateValue1 = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue1(Number(e.target.value));
}, [setValue1])
const updateValue2 = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setValue2(Number(e.target.value));
}, [setValue2])
return (
<div className="grid grid-cols-2 gap-4">
<label htmlFor="value1">Value1</label>
<input type="number" id="value1" onChange={updateValue1}/>
<label htmlFor="value2">Value2</label>
<input type="number" id="value2" onChange={updateValue2}/>
<label htmlFor="sum">Sum</label>
<output id="sum">{value1 + value2}</output>
<label htmlFor="mul">Mul</label>
<output id="mul">{value1 * value2}</output>
</div>
)
}

export default App

Playwrightで書いてみたテストは次の通りです。<output>タグについたラベルを使って取得できています。

import { test, expect } from '@playwright/test';

test('calc', async ({ page }) => {
await page.goto('http://localhost:5173');

await page.getByLabel("Value1").fill("10")
await page.getByLabel("Value2").fill("20")
expect(await page.getByLabel("Sum")).toHaveText("30")
expect(await page.getByLabel("Mul")).toHaveText("200")
});
test('calc', async ({ page }) => { にハイライトがあたったPlaywright画面

# getByRole(‘status’)は現状使えなそう→1.39から使えます!

ラベルの選択ができたし、getByRole()ではどうかな、と試してみましたがうまくいきません。これは見つけられませんでした。

expect(await page.getByRole('status', { name: 'Sum' })).toBeVisible()

名前とロールは違うものだし、仕方ない気もしないでもないけど<input>タグの中にはラベルをnameで指定するのが普通の要素もあります。

{/* ボタンのテキスト */}
<button>Button Sample</button>

{/* labelで指定(checkbox) */}
<label>
<input type="checkbox" /> Subscribe
</label>

{/* 同様の記法でlabelを付与したoutput */}
<label>
Output <output>test</output>
</label>

Playwrightで実験してみると次のようになりました。

// 成功
expect(await page.getByRole('button', { name: "Button Sample" })).toBeVisible()

// 成功
expect(await page.getByRole('checkbox', { name: 'Subscribe' })).toBeVisible()

// 失敗(1.38)
expect(await page.getByRole('status', { name: 'Output' })).toBeVisible()

<output>はフォーム属性ではないせいか、ラベルをroleのnameとしては認識しないようです。checkboxはいけるんですけどね。

ARIA周りの仕様とかを完全に理解したわけではないので、これはissue報告すべきなのかどうなのか悩むところではありますが、現状 getByLabel()で情報が取れることはわかったので、一旦これでよしとしようかと思います。

→報告したところ修正されました。1.39で治るようです。

まとめ

数年前に発表した資料でもわからん、と書いていた、喉に使えた骨だった悩みが解決しました。一方でPlaywrightの挙動があるべきものなのか悩ましいのですが、今後は積極的に使ってみようと思います。

自分がスクリーンリーダー等を使っていなかったりもあって、アクセシビリティに関しては自信をもって「これだ!」とまではまだ言えないので、識者のフィードバックがあるといいな、と思いつつ技術ブログにしています。