概要
dialogとInvoker Commands(command、commandfor)で作られたダイアログが、macのSafari+VoiceOverのときだけフォーカスがおかしいことに気付きました。
Chrome、Firefoxでは同じコードで問題は起こりません。
調べていくとほとんどWebKitのアクセシビリティ層のバグで、正しくマークアップするだけでは直らないものでした。
最終的に「閉じる直前に偽のSpaceキーイベントを発火する」みたいな対策までやることになったので、何が起きていてどう直したのかを書いておきます。
実際に動かして確かめられる検証ページを置いておきました。
細かい内容は検証ページをご覧ください。
何が起きたか
- ダイアログを開いてもVoiceOverカーソルが中に入らない(VO+→で背面をさまよう)
- 閉じてもVoiceOverカーソルがトリガーに戻らない(戻っているように見えても戻っていない)
- 閉じるボタンの上でEnterを押しっぱなしにすると開閉を繰り返す
- 中身を動的importとデータフェッチで組み立てているダイアログを閉じると、メインスレッドが十数秒フリーズする
- 一度おかしくなると、以降そのページのダイアログが全部壊れ続ける
ここが大事なのですが、Tabキーだけで操作している限り何も起きません。DOMフォーカス、Tab順、読み上げは正しいです。
Safariにはこの問題が昔からあるらしく、アクセシビリティツリーの再構築のパフォーマンスが低いことも原因になるのかもしれません。
壊れるのはVoiceOverカーソルの操作(VO+→など)を混ぜたときだけなので、最初は「再現しない」「不安定」に見えていました。
なおEnterの押しっぱなしで開閉を繰り返すやつは素のdialogでも起こるので、MDNのリファレンスのサンプルでも再現します。
1. 見出しに初期フォーカスを当てるとダイアログの中に入れない
ダイアログを開いていきなり閉じると読まれてもなんのことかわからないので、見出しを読ませたい。
なので、見出しにtabindex="-1"とautofocusを付けていましたが、これが原因でした。
素のHTML(JavaScriptなし、トランジションなし)で1つずつ変えて試した結果はこうです。
| 初期フォーカス先 | VoiceOverカーソルがダイアログに入るか |
|---|---|
| 閉じるボタン(最初のフォーカス可能な子孫) | 安定して入る |
見出し(autofocus tabindex="-1") |
入らないことがある |
見出し(tabindex="-1"だけ、autofocusなし) |
同じく入らないことがある |
3行目が大事で、autofocusを外すだけでは意味がありません。
dialogを開いた時autofocusがなくても「最初のフォーカス可能な子孫」が選ばれるので、見出しにtabindex="-1"が付いている限り初期フォーカスはそこに当たります。つまり犯人は属性ではなく、初期フォーカスが非対話要素に当たること自体でした。
上流のバグは302351です。報告されている再現コードもまさに<h2 tabindex="-1">で、同じことをやっていました。
結局どうしたか
ダイアログへの入場はブラウザに任せて実在のコントロールに当てさせ、開いたあと描画が済んでから(2フレーム後に)JavaScriptで見出しへ移すようにしました。
// 見出しにはautofocusもtabindexも付けない(付けると入場が壊れる)
const moveFocusToTitle = () => {
if (!dialog.open) return;
// 開いてから利用者がフォーカスを動かしていたら奪わない
if (document.activeElement !== initialFocused) return;
title.tabIndex = -1; // 移すときだけフォーカス可能にする
title.focus();
};
requestAnimationFrame(() => requestAnimationFrame(moveFocusToTitle));
タイミングは実機で決めました。マクロタスクだと描画前になって不安定で、逆に数百ミリ秒待つとVoiceOverが入場先のコントロールを読み上げ始めてしまいます。2フレーム(16〜32msくらい)が「ほぼ気付かない」ラインでした。
移動が行われるので一瞬「カカッ」と聞こえるのですが、今のところこれが一番ましです。
2. 閉じたあとカーソルが戻るかは「直前にSpaceが見えたか」で決まる
ここが一番のびっくりポイントです。
| 閉じ方 | 閉じたあとVoiceOverカーソルがトリガーに戻るか |
|---|---|
| ネイティブのSpace活性化(clickはkeyupで発火) | 戻る |
| ネイティブのEnter活性化(clickはkeydownで発火) | 戻らない |
合成したclickイベント |
戻らない |
dialog.close()の直呼び |
戻らない |
合成したSpaceのkeydownとkeyupを閉じる直前に発火 |
戻る |
trustedかどうかやネイティブの活性化かどうかではなく、キーイベントの列を見て判定しているようです。
というわけで、EnterやEscapeで閉じるときは閉じる直前に偽のSpaceを発火することにしました。正直意味がわからない対策ですが、合成イベントはネイティブの活性化(click)を起こさないので、閉じる以外の副作用はありません。
// 本物のSpaceで閉じたときは発火しない
// (重ねて発火するとVoiceOverからは「押したボタンが消える瞬間に
// 同じキーがもう一度押された」ように見えて、以降ずっと壊れます)
if (!realSpaceReleasedOn(target)) {
for (const type of ['keydown', 'keyup']) {
target.dispatchEvent(
new KeyboardEvent(type, { key: ' ', code: 'Space', bubbles: true, cancelable: true })
);
}
}
dialog.close();
さらに切り分けていくと、閉じたあとカーソルがトリガーに戻るための条件は3点セットで、どれか1つでも欠けると戻らないことがわかりました。
- 閉じている間
dialogをDOMから外す(macのSafariはアクセシビリティツリーの構築が遅く、閉じたdialogが残っていると復帰が壊れます) - EnterとEscapeはkeyupで閉じる
- 閉じる直前に偽のSpaceを発火する
close()を呼ばずにDOMから外すだけで閉じる方式も試しましたが、開いたままのモーダルを取り除くとSafariのtop layerやinertの後片付けが不完全になって、そのあとのfocus()自体が効かなくなりました。必ずclose()で閉じてから外します。
3. Enterを押しっぱなしにすると開閉を繰り返す
これはMDNでも確認できます。
Enterでボタンを押すとclickはkeydownで発火します。なので
- keydownのclickでダイアログが閉じる
- その瞬間、ブラウザのネイティブなフォーカス復帰が同期で走ってトリガーにフォーカスが移る
- 同じキー列の残り(オートリピートのkeydownなど)が復帰済みのトリガーに着弾してclickが発火し、開き直る
ということが起こります。VoiceOverをオンにしてイベントを採ってみたら、1回の物理押下でkeydownが二重に届いていました。
keydown key="Unidentified" code=Enter -> 閉じるボタン
click -> 閉じるボタン(閉じる)
focusin -> トリガー(ネイティブ復帰)
keydown key="Enter" code=Enter -> トリガー ← 閉じの8ms後に2回目のkeydown
click -> トリガー(開き直り)
keyup key="Enter" ← keyupは最後に1回だけ
結局どうしたか
- 閉じる系ボタンのEnterとEscapeは、keydownを
preventDefaultしてkeyupで閉じる(clickがkeyupで発火するSpaceと同じ状態にする) - トリガー側で、閉じた直後150msはclickを無視する
150msにしたのは、実測したゴーストが全部閉じてから60ms以内だったのと、人間の押し直しは「閉じたことに気付く」時間(単純反応時間で150〜200msくらい)を挟むので150ms以内には来ないからです。ポインタ操作(pointerdown)では即座に抑止を解除するので、マウスやタップには影響しません。
ちなみにこのループは初期フォーカスがボタンに当たっているときだけ起こります。1の対策前は初期フォーカスが見出しだったので、リピートのkeydownが非対話要素に当たってループが止まっていました。VoiceOverの入場を壊していた設定が、別のバグを隠していたことになります。ややこしいですね。
4. 中身が大きいダイアログを閉じると十数秒フリーズする
VoiceOverをオンにしているときだけ、閉じた瞬間にメインスレッドが16〜17秒止まりました。
開く時にめちゃくちゃ遅くなる時もあります。
条件が面白くて、静的に大きいDOM(pを2000個並べるなど)では再現しませんでした。
再現したのは、コンポーネントを動的import()で読み込んでそれぞれがデータフェッチする画面でした。アクセシビリティツリーが大きいだけでは足りず、開いている間に非同期で何度も組み替わっていることが効いているのかもしれません。
見た感じ、通信が発生してDOMが書き換わるとフリーズするみたいです。
結局どうしたか
beforetoggle(閉じ始め、close()の中で同期的に発火する)で中身だけを先に取り除いて、それからアンマウントするようにしました。
dialog.onbeforetoggle = (event) => {
if (event.newState === 'closed') dialog.replaceChildren();
};
dialog.close();
DocumentFragmentに「移動」して破棄を遅らせる方式も試しましたが、WebKitの内部では再親付け扱いになるようで、VoiceOverに「アンカーが消えた」ことが伝わらず、閉じたあとカーソルがトリガーに戻らなくなりました。replaceChildren()で本当に捨てるのが大事でした。
5. 一度おかしくなると以降ずっと壊れる
長いあいだ「不安定」「再現しない」に見えていたものの正体は2つでした。どちらもリロードするまで、そのページのダイアログが全部壊れ続けます。
押されたままになるキーの記録
VoiceOverはVO+Spaceなどのkeyupをページに渡さないことがあって、「今押されているキー」の記録が残り続けます(ウィンドウがblurするまで消えません)。この記録はページ全体で共有していたので、一度取り残しが起きると以降すべてのダイアログが道連れになり、閉じるたびに来ることのないkeyupを待つようになっていました。
各キーの最後のkeydownの時刻を持って、直近1秒以内のものだけを「押下中」として信じるようにしました。
偽Spaceの重ね掛け
本物のSpaceで閉じたときにさらに偽のSpaceを発火すると、VoiceOverからは同じキーがもう一度押されたように見えて、そのあとずっとカーソルの追従が壊れます。
同じ要素で直前300ms以内に本物のSpaceのkeyupがあったら発火しないようにしました。
結局どうしたか(まとめ)
macのSafariだけに適用しているもの(ほかの環境は素直な実装のまま)
- 閉じている間
dialogをDOMから外す - EnterとEscapeはkeydownを
preventDefaultしてkeyupで閉じる - 閉じる直前に偽のSpaceを発火する(本物のSpaceで閉じたときは発火しない)
-
beforetoggleでreplaceChildren()して、中身を先に捨ててからアンマウントする
環境を問わず適用しているもの
- 初期フォーカスを見出しに宣言的に当てない。入場はブラウザ任せにして、開いた2フレーム後にJavaScriptで見出しへ移す
- フォーカス復帰はマクロタスクで行う。ネイティブ復帰がすでに復帰先へ移していたら何もしない
- 閉じた直後150msはトリガーのclickを無視する
順番も効くので、閉じる処理はこの順に書いています。
- 偽Spaceを発火
- close()
- (beforetoggleで中身を除去)
- アンマウント
- マクロタスクでフォーカス復帰
環境判定はnavigator.vendorにAppleが含まれること、navigator.platformがMacで始まること、maxTouchPointsが0であることの3つで見ています。
素のJavaScriptに書き直した120行くらいの最小実装は検証ページに載せてあります。
完全には直っていません
閉じた直後に急いでVO+→で移動すると、フォーカスがbodyに吸われることがあります。
閉じてからフォーカスが落ち着くまでの数百ミリ秒はWebKit側のラグそのもので、そこをJavaScriptで埋めようとすると逆に壊れます(下に書きます)。
なので到達点は「完全に直った」ではなく「普通の速さで操作すれば期待どおりに動く」です。吸われてもTabを1回押せば戻ります。
やらなくてよかったこと
途中で入れて、最終的に全部撤去したものです。
-
aria-modal="true"を付ける(ネイティブのshowModal()では冗長で、効果なし) - 開いている間、ダイアログの外側に
aria-hidden="true"を付けて刈る(React AriaのariaHideOutsideみたいなやつ)- 閉じるときの復元が
dialogの消滅と同じバッチになるとSafariが取りこぼして、閉じたあとに見えないダイアログを読めてしまうという自作のバグを生みました
- 閉じるときの復元が
-
blur()のあとにfocus()して読み上げを撃ち直す(単発、150ms、500ms、2回撃ち、10ms間隔で10回まで試しました)- これは有効な手段として紹介されている記事を見ましたが、効果がないうえ、めっちゃフォーカスが移動するのでうるさかったです
どうやって調べたか
VoiceOverはWebDriverやPlaywrightから自動化できないので、実機で手で踏むしかありません。
なのでページ側にイベント列(keydown、keyup、click、focusin、beforetoggle、toggle、closeとactiveElement)を出す計測ページを作って、そこに修正前と修正後を並べる形にしました。
VoiceOverカーソルの位置はDOMに痕跡が残らないので、位置の確定には画面収録からのフレーム抽出も使いました。地味ですがこれが一番効きました。
この計測ページをそのまま公開したのが冒頭のリンクです。
上流のバグ
| Bug | 内容 | 状態 |
|---|---|---|
| 317754 | アニメーション中のフォーカス変更にVoiceOverカーソルが追従しない | NEW |
| 302351 |
dialogを開いてもVoiceOverが反応しない(初期フォーカスが非対話要素のとき) |
NEW |
| 276689 | VoiceOverがダイアログ内のフォーカスに追従しない | NEW |
| 273635 | ダイアログを閉じたあとVoiceOverカーソルがDOMフォーカスに同期しない | NEW |
| 246580 | フォーカスがモーダルの外にあるとモーダルの閉じ込めを解除する(背景。修正済み) | RESOLVED FIXED |
関連してSafari 18.4から26のリグレッションの報告、a11y-dialog#102(iOSの同種の報告)、whatwg/html#5678(dialog.close()でフォーカスを戻す提案、closed)あたりも見ていました。
感想
WebKit側が直ればこの記事の内容は一式まとめて外せます。それまでの繋ぎとして、同じところで困っている人の役に立てば嬉しいです。