macOS Safari + VoiceOver で <dialog> のフォーカスが壊れる問題

前提条件・わかったこと・上流バグ・解決方法(コードと before / after の比較)をまとめたページ。コードと挙動は左が修正前、右が修正後で並べてある。macOS の Safari + VoiceOver(Command + F5)で開くこと。他のブラウザでは症状自体が出ないので、対策の動きがログで追えるだけになる。

環境: 判定中

1. 前提条件

対象環境
項目内容
再現する環境macOS の Safari 26 系 + VoiceOver
再現しない環境Chrome / Firefox(同じコードで問題なし)。iOS / iPadOS は別系統の問題(わかったこと (10))
判定方法navigator.vendorApple を含む + navigator.platformMac 始まり + maxTouchPoints === 0(UA 文字列は Chrome も Safari を含むので使えない。iPadOS は platformMacIntel を名乗るのでタッチポイントで外す)

一部の症状は Safari 18.4 → 26 のリグレッションでもある(Apple Support Communities のスレッド)。

症状

  • 開いても VoiceOver カーソルがダイアログに入らない(VO + → で背面をさまよう)
  • 閉じても VoiceOver カーソルがトリガーに戻らない(DOM フォーカス・Tab 順・読み上げは正しい)
  • 閉じるボタンの上で Enter を押しっぱなしにすると、ダイアログが開閉を繰り返す(短く押した場合は「一瞬閉じてすぐ開き直る」として現れる)。素の <dialog> でも起きるネイティブ挙動で、MDN の <dialog> のリファレンスのサンプルでも再現する
  • 動的にコンポーネントを読み込み・データフェッチするダイアログを閉じると、メインスレッドが十数秒フリーズする
  • 「一度おかしくなると以降そのページのダイアログが全部壊れ続ける」

いずれも VoiceOver カーソルの操作(VO + → など)を混ぜたときだけで、Tab キーだけのキーボード操作では起きない

実装側の前提

  • ネイティブの <dialog> + showModal()(モードレスは Popover API)を使う
  • 開閉は Invoker Commands(command="show-modal" / commandfor)に任せる。JavaScript が動かない環境でも開けるようにするため
  • ライトディスミスは closedby 属性
  • 開いたら見出し(ダイアログのタイトル)にフォーカスを当てる方針。閉じるボタンに当てると「ダイアログに差し掛かっていきなり『閉じる』」と読まれて体験が悪いため、ここは動かせない前提として扱った
  • 閉じたらトリガーへフォーカスを戻す。 command / commandfor にフォーカス復帰の概念はなく(whatwg/html#5678 は closed)、ネイティブの復帰先は「showModal() した時点の activeElement」でしかない。VoiceOver の操作は DOM フォーカスを動かさないことがあるので、自前の復帰が必要になる

検証の前提(ここが一番きつい)

  • VoiceOver は WebDriver / Playwright から自動化できない。 実機の手動確認しかない
  • そこで「ページ側にイベント列を出す計測ページ」を作り、keydown / keyup / click / focusin / beforetoggle / toggle / activeElement を採取して実機で踏む運用にした(このページの下部のログがそれ
  • VoiceOver カーソルの位置は DOM に痕跡が残らない。位置の確定には画面収録からのフレーム抽出も使った
  • 素の HTML に 1 変数ずつ足していく切り分け(<dialog> だけ → + トランジション → + autofocus → …)が最終的に一番効いた

2. わかったこと

(1) 初期フォーカスを非対話要素に当てると VO カーソルがダイアログに入らない

素の HTML(JS なし・トランジションなし)での切り分け
初期フォーカス先VO カーソルがダイアログに入るか
閉じるボタン(最初のフォーカス可能な子孫として選ばれる)安定して入る
見出し(autofocus tabindex="-1"入らないことがある
見出し(tabindex="-1" のみ、autofocus なし)同じく入らないことがある

3 行目が重要で、autofocus を外すだけでは意味がないdialog focusing stepsautofocus がなくても「最初のフォーカス可能な子孫」を選ぶので、見出しが tabindex="-1" を持っている限り初期フォーカスはそこに当たる。原因は属性ではなく「初期フォーカスが非対話要素に当たること」そのもの。

対策は二段構え。入場はブラウザに任せて実在のコントロールへ当てさせ、開いたダイアログが一度描画された後(2 フレーム後)に JS で見出しへ移す。 マクロタスクでは描画前になることがあって不安定、逆に数百ミリ秒待つと VoiceOver が入場先のコントロールを読み上げ始めてしまう。2 フレーム(約 16〜32ms)が「ほぼ気づかない」ラインだった。

(2) 閉じたあとの VO カーソル追従は「閉じる直前のイベント列に Space があるか」で決まる

閉じ方と VO カーソルの追従(実測)
閉じ方閉じたあと VO カーソルがトリガーへ戻るか
ネイティブの Space 活性化(trusted・click は keyup で発火)戻る
ネイティブの Enter 活性化(trusted・click は keydown で発火)戻らない
合成した click イベント戻らない
dialog.close() の直呼び戻らない
合成の Space keydown / keyup を閉じる直前に dispatchEvent するだけ戻る

trusted 性やネイティブの活性化ではなく、キー列の観測で判定されているとみられる。合成のキーイベントはネイティブの活性化(click)を起こさないので、発火しても閉じ以外の副作用はない。

(3) Enter は「閉じたあと」に残イベントを撒く(押しっぱなしで開閉が繰り返す)

閉じるボタンの上で Enter を押しっぱなしにすると、開いて閉じてを繰り返す。 素の <dialog> でも起きるネイティブ挙動で、MDN の <dialog> のリファレンスのサンプルでも再現する(Safari で顕著)。短く押した場合は「一瞬閉じてすぐ開き直る」として現れる。仕組みは同じで、Enter の click が keydown で発火することに由来する。

  • keydown の click でダイアログが閉じる
  • その瞬間、ブラウザのネイティブのフォーカス復帰が同期で走ってトリガーへフォーカスが移る
  • 同じキー列の残り(オートリピートの keydown・keyup・VoiceOver の二重 keydown)が復帰済みのトリガーに着弾して click を発火し、開き直る
keydown key="Unidentified" code=Enter -> 閉じるボタン
click -> 閉じるボタン(閉じる)
focusin -> トリガー(ネイティブ復帰)
keydown key="Enter" code=Enter -> トリガー   ← 閉じの 8ms 後に2回目の keydown
click -> トリガー(開き直り)
keyup key="Enter"                            ← keyup は最後に1回だけ

VoiceOver は 1 回の物理押下で keydown を二重に送ってくる(上は実機トレース)。対策は 2 つ。閉じる系ボタンの Enter と Escape は keydown を preventDefault して keyup で閉じる(Space と同じ「閉じた時点でキー列が完結している」状態にする)。加えてトリガー側で閉じた直後 150ms は click を無視する。150ms の根拠は、実測したゴーストがすべて閉じから 60ms 以内だったことと、人間の押し直しは「閉じたことの知覚(単純反応時間 150〜200ms)」を挟むので 150ms 以内には来ないこと。ポインタ操作(pointerdown)は即座に抑止を解除するのでマウス操作には影響しない。

このループは「開いたときの初期フォーカスがボタンに当たる」ことが条件。 リピートの keydown が閉じるボタンに当たり続けるから閉じ、閉じるとネイティブ復帰でトリガーへ移り、次のリピートがトリガーを活性化して開く——の繰り返しになる。逆に、このページの before のように初期フォーカスを見出しに当てていると、リピートの keydown が非対話要素に当たるのでループは止まる(皮肉なことに、VoiceOver の入場を壊している設定が、この別のバグを隠していた)。before 側で再現させたいときは ?beforeFocus=button を付けて、初期フォーカスを閉じるボタンにする。

(4) 閉じている間 <dialog> を DOM に残すと復帰がバグる(3 点セット)

macOS の Safari は AX ツリーの構築が遅く、閉じた <dialog> が DOM に残っているとフォーカス/VO カーソルの復帰がバグる。「閉じたあと VO カーソルがトリガーへ戻る」ための必要条件は次の 3 点で、どれを欠いても追従しない

  • 閉じている間 <dialog> を DOM から除去する
  • Enter / Escape を keyup で閉じる
  • 閉じる直前に偽 Space を発火する

逆に、それまで入れていた「Safari だけ開閉アニメーションを実質 0(0.01s)にする」対策は、この 3 点が揃うと不要になった(アニメーション中のプログラム的フォーカス変更に VO カーソルが追従しない 317754 への対処だった)。なお close() を呼ばずに DOM から除去するだけで閉じる方式も試したが、開いたままのモーダルを除去すると Safari の top layer / inert の後始末が不完全になり、以後の focus() 自体が効かなくなる。必ず close() で閉じてから除去する。

(5) 大きい subtree を持ったまま top layer 要素を除去すると AX 後始末が病的に遅い

利用側から「中身の大きいダイアログを閉じるとメインスレッドが十数秒フリーズする」という報告。実測で通知一覧が約 16 秒、地図が約 17 秒。VoiceOver(AX クライアント)が接続しているときだけ起きる。

条件はここが要点で、静的に大きい DOM(<p> を 2,000 個並べる、など)では再現しない。 再現したのは、コンポーネントを動的 import() で読み込み、それぞれがデータフェッチする画面だった。閉じる時点でまだ飛んでいるリクエストがある状態も絡む。「AX ツリーが大きい」だけでは足りず、開いている間に AX ツリーが非同期に何度も組み替わっていることが効いているとみられる(このページの中身もその形にしてある)。

beforetoggle(閉じ始め・close() 内で同期発火する)で <dialog>中身だけを先に除去してからアンマウントすると解消する。重要なのは本物の除去であることDocumentFragment などへ「移動」して破棄を遅らせる方式は、WebKit 内部では再親付け扱いになり、VoiceOver に「アンカーが消えた」信号が届かず、閉じたあとの VO カーソルがトリガーへ再アンカーされなくなった。

(6) 「一度おかしくなると以降ずっと壊れ続ける」の正体は 2 つ

  • 取り残されたキー押下記録: VoiceOver は VO + Space などの keyup をページへ渡さないことがあり、「今押されているキー」の記録にコードが残り続ける(ウィンドウが blur するまで消えない)。記録はページ全体で共有なので、一度取り残しが起きると以降すべてのダイアログが道連れになり、閉じるたびに来ることのない keyup を待ってフォールバックの期限まで閉じられなくなる → 各キーの最後の keydown 時刻を持ち、直近 1 秒以内のものだけを「押下中」として信じる
  • 偽 Space の重ね掛け: 本物の Space で閉じたときに、さらに合成 Space を撃つと、VoiceOver からは「押したボタンが消える瞬間に同じキーがもう一度押された」ように見え、以降そのページのカーソル追従が壊れ続ける → 同じ要素で直前 300ms 以内に trusted な Space の keyup があったら偽 Space は撃たない

(7) AX ツリーへの JS 介入は、積み上げるほど別の壊れ方を生む

途中で入れて、最終的に全部撤去したもの: aria-modal="true" の付与 / 開いている間ダイアログの外側へ aria-hidden="true" を付ける枝刈り(React Aria の ariaHideOutside 相当)/ 閉じている間 <dialog> 自身へ aria-hidden="true" / フォーカスの blur()focus() による「再通知」(単発・150ms / 500ms・2 回撃ち・10ms × 10 回の反復まで試した)。

とくに外側の枝刈りは、閉じるときの復元が <dialog> の消滅と同じバッチになると Safari が取りこぼし、「閉じたあとも見えないダイアログを読めてしまう」という自作のバグを生んだ。決定打になったのは「JS 介入を全部フラグでオフにして素の挙動を見る」切り分け。いまは「付けないこと」を理由付きで固定する回帰テストを置いて再導入を防いでいる。

補足: WebKit は背面の枝刈り自体は実装しているが、ARIA の「フォーカスがモーダル外にあるならモーダルへナビゲーションを限定するべきでない」に従って意図的に閉じ込めを解除する246580・RESOLVED FIXED)。VoiceOver の操作は DOM フォーカスを動かさないので、この解除条件を踏みやすい。

(8) 重いページでは「余計なことをやめる」ほうが効く

  • ネイティブ復帰が既に正しい位置へフォーカスを移しているなら何もしない。 要素数の多いページでは閉じた直後にメインスレッドが詰まり、自前の復帰処理が閉じてから 155ms 後に走った(軽いページでは 8ms)。VoiceOver がカーソルをトリガーへ落ち着けたあとから blur() すると、そのカーソルを弾き飛ばす
  • Space を keyup 待ちの対象から外す。 ボタンの Space 活性化は keyup の既定動作なので、click が届いた時点で Space は既に離れている。つまり click の時点で「Space 押下中」に見えるのは VoiceOver が keyup を握り潰したときだけで、待っても永久に来ない
  • 偽 Space は「どの閉じ経路でも」通す。 当初は「クリック時点で活性化キーが押下中と記録されている」経路でしか撃っておらず、AX ツリーが重いダイアログでは記録漏れ・keyup 遅延で静かに飛ばされて「重いダイアログだけ VO カーソルが戻らない」として顕在化していた
  • keyup を待つフォールバックは 1 秒では短い。AX ツリー再構築でメインスレッドが 1 秒近く止まり、正規の keyup より先に保険が発火し得たので 3 秒にした

(9) 直せないもの(既知の問題として受容した)

VO カーソル移動だけ(VO + → で閉じるボタンへ行って活性化)で閉じた場合、DOM フォーカス・Tab 順・読み上げがすべて正しくても、VoiceOver カーソルの内部位置が body に残ることがある。Web コンテンツから VO の内部カーソル位置を設定する手段は存在しない。遅延の調整(150 / 500ms)、blurfocus の間隔(0 / 30 / 50ms)、1 回撃ち / 2 回撃ち、inert の絞り込み、visibility の再生成、tabindex の付け直し——全部試して同じだった。利用者側は Tab を 1 回押すと再同期するので、既知の問題として受容し、WebKit 側の修正を待つ判断にした。

after でも完全には直っていない。 閉じた直後に急いで VO + → で移動すると、フォーカスが body に吸われることがある。閉じてからフォーカスが落ち着くまでの数百ミリ秒は WebKit 側のラグそのもので、そこを JS で埋めようとすると(過去に試した再通知の類)かえって壊れる。ゆっくり操作すればトリガーへ戻り、吸われた場合も Tab を 1 回押せば再同期する。

キーボードだけの操作(Tab / Shift + Tab)では、開き・閉じのどちらも問題は起きない。 一連の調査を通して DOM フォーカスと Tab 順は常に正しかった(開いたら見出し、閉じたらトリガー)。読み上げ内容も視覚的なフォーカスリングも正しい。ずれるのは VoiceOver カーソルの内部位置だけで、しかも VO カーソルの移動を混ぜたときに限られる。切り分けでも「Tab では正しい」を毎回の基準にしていた。

(10) iOS で「閉じてもフォーカスが戻らない」のは別原因(Popover 側で対処済み)

同じ「閉じてもフォーカスが戻らない」でも、iOS で踏んでいたのは macOS とは別の原因だった。記録は Popover(モードレス)側に残っている。

  • ネイティブの復帰先は「開いた時点の activeElement」で固定。<dialog>close() も Popover の hidePopover() も同じ(showPopover({ source })source は暗黙アンカーと toggle イベント用で、復帰には影響しない)
  • Safari はクリック・タップでボタンにフォーカスを移さない。 トリガーをタップして開いた時点の activeElement<body> なので、戻る先が最初から記録されていない
  • iOS の VoiceOver はスワイプ移動でも DOM フォーカスを動かさないため必ずこの状態になり、閉じても VO カーソルがトリガーへ戻らなかった(macOS の VoiceOver は VO カーソルに DOM フォーカスを同期するので、偶然動いていただけだった)
  • 対策は、ブラウザ任せにせずトリガー操作を明示的に記録して自前で戻すこと。もう 1 点、showPopover()showModal() と違ってフォーカスを移さないので、フォーカスがトリガーに残ったまま top layer への昇格と再配置が起こると iOS Safari の VoiceOver がカーソル再同期時に近くの別要素へ吸着する。タイトルへ DOM フォーカスを移して追従させることで解消した

この機構は JavaScript の有無に関係ない(ネイティブの復帰先の決め方と Safari のフォーカス挙動だけで説明できる)ので、属性だけで開閉する素のマークアップでも同じことが起きるはず。ただし素のマークアップでの iOS 実機確認は記録にない(観測しているのはデザインシステムの Popover でのこと)。確かめられるように、JS を一切書いていない実例を「JS なしのマークアップでも起きること」の節に置いた。

なお、この記事の macOS Safari 向けの 3 点セット(DOM からの除去・keyup 閉じ・偽 Space)は iOS / iPadOS には適用していない(環境判定で除外。iPadOS は platformMacIntel を名乗るのでタッチポイントの有無で外す)。iOS の VO カーソル追従自体は実機未検証、というのが記録に残っている状態。同種の報告として a11y-dialog#102(iOS でフォーカス復帰が近くの別要素へ行く)を紐付けてあり、273635 も報告自体は iOS の VoiceOver に対するもの。

3. 上流バグ(WebKit Bugzilla)

対象: macOS Safari 26 系(VoiceOver 有効時)。Chrome / Firefox では再現しない

報告されている上流バグ
Bugタイトル状態報告今回の関係
317754AX: VoiceOver cursor lags behind DOM focus during animated panel transitionsNEW2026-06-24アニメーション中のプログラム的なフォーカス変更に VO カーソルが追従しない
302351AX: VoiceOver not interacting with <dialog> elements even with autofocus or focus() method is usedNEW2025-11-11報告は macOS の Safari 26.1。再現コードが <h2 id="modalTitle" tabindex="-1"> で、修正前の実装と同型だった
276689AX: VoiceOver does not follow focus into dialogNEW2024-07-16開いたときに VO カーソルがダイアログのフォーカスへ追従せず、ページ先頭へ戻ってしまう
273635AX: VoiceOver cursor does not sync with browser focus after closing dialog when interactive element is positioned behind itNEW2024-05-02閉じたあと VO カーソルが DOM フォーカスに同期しない。報告自体は iOS の VoiceOver に対するもので、コメントに「タイムアウトを挟んで focus する」回避策が挙がっている
246580AX: WebKit does not break AX modality when focus is explicitly moved outside the modalRESOLVED FIXED2022-10-15背景。WebKit は ARIA に従い「フォーカスがモーダル外にあるならモーダルへの閉じ込めを解除する」。VoiceOver の操作は DOM フォーカスを動かさないので、この解除条件を踏みやすい

関連する報告(Bugzilla 以外)

  • Apple Support Communities: thread/256161078 — Safari 18.4 → 26 のリグレッションとして、閉じたあと VO カーソルが body に残る件。「Tab を1回押せば再同期する」の出どころ
  • KittyGiraudel/a11y-dialog#102 — iOS VoiceOver の同種報告(closed)。フォーカス復帰が近くの別要素へ行く
  • whatwg/html#5678 — dialog.close() で元の要素へフォーカスを戻す提案(closed)。ネイティブの復帰先が「showModal() した時点の activeElement」しかないという前提の出どころ

4. 解決方法とコード

macOS の Safari だけに適用するもの(他の環境では素直な実装のまま):

  • 閉じている間 <dialog> を DOM から外す(開くときにマウントして showModal()
  • Enter / Escape は keydown を preventDefault して keyup で閉じる
  • 閉じる直前に偽の Space(keydown / keyup)を発火する(本物の Space で閉じたときは撃たない)
  • beforetogglereplaceChildren() し、中身を先に捨ててからアンマウントする

環境を問わず適用するもの:

  • 初期フォーカスを見出しに宣言的に当てない。 入場はブラウザ任せにして、開いた 2 フレーム後に JS で見出しへ tabIndex = -1 + focus()(閉じたら tabindex を外す)
  • フォーカス復帰はマクロタスクで。 既にネイティブ復帰が復帰先へ移していれば何もしない
  • 閉じた直後 150ms はトリガーの click を無視する(閉じ操作のキー列の残りによる開き直りの抑止)

順序も効くので、閉じる処理は必ずこの順で書く: 偽 Space を撃つ → close() → (beforetoggle で中身を除去)→ アンマウント → マクロタスクでフォーカス復帰

command / commandfor は、この経路では外す必要がある。閉じている間 <dialog> が DOM にないので invoker は機能せず、遅延マウント後に invoker の既定動作が二重に走って「開いた直後に閉じる」などの事故になる。JavaScript が無効な環境向けの宣言的な経路は、「常に DOM に残す」側(macOS Safari 以外)で維持する。

この対策一式でも残る症状がある: 閉じた直後に急いで VO + → で移動すると、フォーカスが body に吸われることがある。after は「完全に直った状態」ではなく、「通常の操作速度なら期待どおりに動く状態」。

修正前 / 修正後のコード

初期フォーカス

見出し(非対話要素)へ初期フォーカスが当たると、VO カーソルがダイアログに入らないことがある(webkit.org/b/302351)。autofocus を外すだけでは効かない。dialog focusing steps が tabindex="-1" の見出しを「最初のフォーカス可能な子孫」として選ぶため。

上流バグ: webkit.org/b/302351 webkit.org/b/276689

before

<!-- 宣言で見出しを初期フォーカス先にする -->
<dialog aria-labelledby="title">
  <h2 id="title" tabindex="-1" autofocus>お知らせ</h2>
  ...
</dialog>

after

<!-- 見出しには autofocus / tabindex を付けない -->
<dialog aria-labelledby="title">
  <h2 id="title">お知らせ</h2>
  ...
</dialog>

<script>
// 入場はブラウザ任せ(実在のコントロールに当たる)。
// 開いたダイアログが一度描画された後(2 フレーム後)に見出しへ移す。
// マクロタスクでは描画前になり、数百 ms 待つと VoiceOver が
// 入場先のコントロールを読み上げ始めてしまう。
const moveFocusToTitle = () => {
  if (!dialog.open) return;
  // 開いてから利用者がフォーカスを動かしていたら奪わない
  if (document.activeElement !== initialFocused) return;
  title.tabIndex = -1; // 移すときだけフォーカス可能にする
  title.focus();
};
requestAnimationFrame(() => requestAnimationFrame(moveFocusToTitle));
</script>

閉じている間の <dialog>

macOS の Safari は AX ツリーの構築が遅く、閉じた <dialog> が DOM に残っているとフォーカス/VO カーソルの復帰がバグる。「除去・keyup 閉じ・偽 Space」の3点が揃って初めて追従する(どれを欠いても駄目)。

上流バグ: webkit.org/b/273635

before

<!-- 常に DOM に置いたまま。開閉は invoker commands に任せる -->
<button command="show-modal" commandfor="dlg">開く</button>
<dialog id="dlg"> ... </dialog>

after

<!-- 閉じている間は描画しない(開くときにマウントして自前で showModal) -->
<button onclick={open}>開く</button>
{#if shown}
  <dialog bind:this={dialog}> ... </dialog>
{/if}

<script>
const open = () => {
  shown = true;
  flushSync();          // マウントしてから
  dialog.showModal();   // ネイティブに表示する
};
// 閉じている間 <dialog> が無いので invoker は使えない。
// command / commandfor は外して JS に一本化する
// (JS 無効時の宣言的な経路は「常に DOM に残す」側で維持)
</script>

Enter / Escape の閉じ方

Enter は click が keydown で発火するため、閉じた後に同じキー列の残り(keyup・オートリピート・VoiceOver の二重 keydown)がネイティブ復帰済みのトリガーへ着弾して開き直る。押しっぱなしなら開閉を繰り返す(素の <dialog> でも起きるネイティブ挙動)。Space(click は keyup の既定動作)と同じ状態にするのが対策。

上流バグ: webkit.org/b/273635

before

<!-- keydown のネイティブ活性化でそのまま閉じる -->
<button command="close" commandfor="dlg">閉じる</button>
<!-- Escape はブラウザの close request(keydown)で閉じる -->

after

<button onclick={closeFromButton}>閉じる</button>

<script>
// 閉じるボタン: Enter が押下中なら keyup まで待ってから閉じる。
// Space は click が keyup で発火するので待たない(待っても来ない)
const closeFromButton = () => {
  const held = ['Enter', 'NumpadEnter'].find(isHeldNow);
  if (!held) return close();
  const onKeyUp = (e) => {
    if (e.code !== held) return;
    removeEventListener('keyup', onKeyUp, true);
    close();
  };
  addEventListener('keyup', onKeyUp, true);
  // VoiceOver が keyup を握り潰すことがあるので保険(3 秒)
};

// Escape: keydown のネイティブな閉じを止めて keyup で閉じる
dialog.onkeydown = (e) => {
  if (e.key !== 'Escape') return;
  e.preventDefault();
  escapeArmed = true;
};
dialog.onkeyup = (e) => {
  if (e.key === 'Escape' && escapeArmed) close();
};
</script>

閉じる直前の偽 Space

macOS Safari 26 は「閉じる直前のイベント列に Space があるか」で VO カーソルの追従を決めているとみられる(trusted 性やネイティブ活性化は無関係。合成の Space を撃つだけで追従する)。合成イベントは活性化を起こさないので副作用はない。

上流バグ: webkit.org/b/273635

before

// 何もしない(Enter / Escape で閉じると VO カーソルが戻らない)
close();

after

// 本物の Space で閉じたときは撃たない。重ね掛けすると VoiceOver からは
// 「押したボタンが消える瞬間に同じキーがもう一度押された」ように見え、
// 以降そのページのカーソル追従が壊れ続ける(実機トレースで確認)
if (!realSpaceReleasedOn(target)) {
  for (const type of ['keydown', 'keyup']) {
    target.dispatchEvent(
      new KeyboardEvent(type, { key: ' ', code: 'Space', bubbles: true, cancelable: true })
    );
  }
}
close();

中身の除去(フリーズ対策)

VoiceOver 接続下では「大きな subtree を配下に持ったままの top layer 要素の除去」に対する WebKit の AX 後始末が十数秒メインスレッドを止める(実測: 通知一覧 約16秒・地図 約17秒)。中身を先に除去しておけば、空のシェルの除去も中身自体の除去も速い。

上流バグ: 該当なし(上流バグとして未報告)

before

// 中身を持ったまま閉じる(= top layer から外れる)
dialog.close();

after

// beforetoggle は close() 内で同期発火するので、アンマウントより先に走る。
// DocumentFragment へ「移動」して破棄を遅らせる方式は WebKit 内部で
// 再親付け扱いになり、VoiceOver に「アンカーが消えた」信号が届かず
// VO カーソルが戻らなくなる。本物の除去であることが重要
dialog.onbeforetoggle = (event) => {
  if (event.newState === 'closed') dialog.replaceChildren();
};
dialog.close();

フォーカス復帰

invoker commands にフォーカス復帰の概念はなく、ネイティブの復帰先は「showModal() した時点の activeElement」。同期で focus() すると閉じ操作と同じキー列の中でトリガーへフォーカスが移り、後続イベントが着弾する。重いページでは blur → focus の撃ち直しが害になる。

上流バグ: webkit.org/b/273635 webkit.org/b/317754

before

// close イベント内で同期的にトリガーへ戻す
dialog.addEventListener('close', () => {
  trigger.focus();
});

after

// 復帰は常にマクロタスク。
// ネイティブ復帰が既に復帰先へ移していれば「何もしない」のが正しい。
// 要素数の多いページでは閉じた直後にメインスレッドが詰まり、
// 撃ち直しが 150ms 以上あとに走って、VoiceOver がトリガーへ
// 落ち着けたカーソルを弾き飛ばす(実測: やめると戻るようになった)
setTimeout(() => {
  if (document.activeElement === trigger) return;
  trigger.focus();
}, 0);

閉じた直後のトリガー

実測したゴーストはすべて閉じから 60ms 以内。人間の押し直しは「閉じたことの知覚(単純反応時間 150〜200ms)」を挟むので 150ms 以内には来ない。ポインタ操作は即座に抑止を解除するのでマウス操作には影響しない。

上流バグ: 該当なし(ブラウザのバグではなく、キーイベントの仕様どおりの挙動への対処)

before

// 何もしない(閉じ操作のキー列の残りで開き直る)

after

trigger.addEventListener('click', (event) => {
  // 閉じ操作のキー列の残りによる再活性化を無視する
  if (performance.now() - closedAt <= 150) {
    event.preventDefault();
    return;
  }
  open();
});

最低限のコード(素の JavaScript)

このページの実装(Svelte)から、環境判定・キー追跡・開閉・復帰の骨だけを抜いたもの。

<button type="button" id="trigger">ダイアログを開く</button>
<div id="mount"></div>

<!-- 閉じている間は DOM に置かないので、中身はテンプレートに持つ -->
<template id="dialog-template">
  <dialog aria-labelledby="dialog-title" closedby="closerequest">
    <!-- 見出しに autofocus / tabindex="-1" を付けない(付けると入場が壊れる) -->
    <h2 id="dialog-title">ダイアログのタイトル</h2>
    <p>本文</p>
    <button type="button" data-close>閉じる</button>
  </dialog>
</template>
/* ── 環境判定: macOS の Safari だけ特別扱いする ─────────────────────────
   UA 文字列は Chrome も "Safari" を含むので使わない。
   iPadOS は platform に "MacIntel" を名乗るのでタッチポイントで外す。 */
const isMacOsSafari = () => {
  const platform = navigator.platform ?? '';
  const isMacOs = platform.startsWith('Mac') && (navigator.maxTouchPoints ?? 0) === 0;
  return isMacOs && (navigator.vendor ?? '').includes('Apple');
};
const safariPath = isMacOsSafari();

/* ── ページ共有のキー押下追跡 ──────────────────────────────────────────
   ・key ではなく code で持つ(key は修飾キーの状態で keydown/keyup が食い違う)
   ・VoiceOver が keyup を握り潰すと記録が取り残されるので、
     「最後の keydown から 1 秒以内」だけを押下中として信じる
   ・本物の Space の keyup を覚えておき、偽 Space の重ね掛けを防ぐ */
const pressedAt = new Map();
let lastRealSpaceKeyup = { target: null, at: -Infinity };
addEventListener('keydown', (e) => pressedAt.set(e.code, performance.now()), true);
addEventListener('keyup', (e) => {
  pressedAt.delete(e.code);
  if (e.isTrusted && e.code === 'Space') {
    lastRealSpaceKeyup = { target: e.target, at: performance.now() };
  }
}, true);
addEventListener('blur', () => pressedAt.clear());
const isHeldNow = (code) => performance.now() - (pressedAt.get(code) ?? -Infinity) <= 1000;
const realSpaceReleasedOn = (el) =>
  lastRealSpaceKeyup.target === el && performance.now() - lastRealSpaceKeyup.at <= 300;

const trigger = document.getElementById('trigger');
const mount = document.getElementById('mount');
const template = document.getElementById('dialog-template');
let dialog = null;
let closedAt = -Infinity;
let escapeArmed = false;

/* ── 開く ───────────────────────────────────────────────────────────── */
// 閉じている間 DOM から外す経路(macOS Safari)では開くたびにこれを通る。
// 常に DOM に残す経路では最初の 1 回だけ。
const mountDialog = () => {
  const dialog = template.content.firstElementChild.cloneNode(true);
  dialog.querySelector('[data-close]').addEventListener('click', closeFromButton);
  dialog.addEventListener('keydown', (e) => {
    // Escape のネイティブな閉じ(close request)は keydown で走るので止めて…
    if (!safariPath || e.key !== 'Escape') return;
    e.preventDefault();
    escapeArmed = true;
  });
  dialog.addEventListener('keyup', (e) => {
    // …keyup で閉じる(閉じた時点でキー列が完結している状態にする)
    if (e.key !== 'Escape' || !escapeArmed) return;
    escapeArmed = false;
    close(null);
  });
  dialog.addEventListener('beforetoggle', (e) => {
    // 中身を持ったまま top layer 要素を除去すると、VoiceOver 接続下では
    // WebKit の AX 後始末がメインスレッドを十数秒ブロックする。
    // 先に中身だけを「本当に」除去しておく(移動では駄目)。
    if (e.newState === 'closed' && safariPath) dialog.replaceChildren();
  });
  mount.append(dialog);
  return dialog;
};

const open = () => {
  if (dialog?.open) return;
  if (!dialog) dialog = mountDialog();
  dialog.showModal();

  // 入場はブラウザ任せ(実在のコントロールに当たる)。描画が済んだ 2 フレーム後に
  // 見出しへ移す。マクロタスクでは描画前になり、数百 ms 待つと VoiceOver が
  // 入場先のコントロールを読み上げ始めてしまう。
  const moveFocusToTitle = () => {
    if (!dialog?.open) return;
    const title = dialog.querySelector('h2');
    const current = document.activeElement;
    const initialFocus = dialog.querySelector('button, [href], input, [tabindex]');
    // 開いてから利用者がフォーカスを動かしていたら奪わない
    if (!(current === document.body || current === dialog || current === initialFocus)) return;
    title.tabIndex = -1; // 初期状態では付けない。移すときだけフォーカス可能にする
    title.focus();
  };
  let moved = false;
  const run = () => { if (!moved) { moved = true; clearTimeout(timer); moveFocusToTitle(); } };
  const timer = setTimeout(run, 50); // rAF が進まない環境(非表示タブ)向け
  requestAnimationFrame(() => requestAnimationFrame(run));
};

/* ── 閉じる ─────────────────────────────────────────────────────────── */
const close = (closeButton) => {
  if (!dialog?.open) return;
  const target = closeButton?.isConnected ? closeButton : dialog;

  // macOS Safari 26 は「閉じる直前のイベント列に Space があるか」で VO カーソルの
  // 追従を決めているとみられる(trusted 性は無関係)。合成イベントは活性化を
  // 起こさないので副作用はない。ただし本物の Space で閉じたときに重ねると逆に壊れる。
  if (safariPath && !realSpaceReleasedOn(target)) {
    for (const type of ['keydown', 'keyup']) {
      target.dispatchEvent(
        new KeyboardEvent(type, { key: ' ', code: 'Space', bubbles: true, cancelable: true })
      );
    }
  }

  // close() は必ず呼ぶ。呼ばずに除去だけすると Safari の top layer / inert の
  // 後始末が壊れ、以後の focus() 自体が効かなくなる。
  dialog.close();
  if (safariPath) {
    dialog.remove(); // 閉じている間は DOM に置かない
    dialog = null;
  }
  closedAt = performance.now();

  // 復帰は常にマクロタスク。ネイティブ復帰が既に戻していれば何もしない
  // (重いページで撃ち直すと、落ち着いた VO カーソルを弾き飛ばす)。
  setTimeout(() => {
    if (document.activeElement !== trigger) trigger.focus();
  }, 0);
};

/* Enter は click が keydown で発火するので、そのまま閉じると同じキー列の残りが
   ネイティブ復帰済みのトリガーへ着弾して開き直る。keyup まで待って閉じる。
   Space の click は keyup の既定動作なので待つ必要はない(待つと永久に来ない)。 */
function closeFromButton() {
  const closeButton = document.activeElement;
  const held = safariPath && ['Enter', 'NumpadEnter'].find((code) => isHeldNow(code));
  if (!held) { close(closeButton); return; }
  const onKeyUp = (e) => {
    if (e.code !== held) return;
    removeEventListener('keyup', onKeyUp, true);
    clearTimeout(fallback);
    close(closeButton);
  };
  // keyup が届かない場合(VoiceOver の握り潰し・ウィンドウ外での離鍵)の保険。
  // AX ツリー再構築でメインスレッドが 1 秒近く止まるので、1 秒では短い。
  const fallback = setTimeout(() => {
    removeEventListener('keyup', onKeyUp, true);
    close(closeButton);
  }, 3000);
  addEventListener('keyup', onKeyUp, true);
}

/* 閉じ操作のキー列の残りによる再活性化(開き直り)を無視する。
   人間の押し直しは「閉じたことの知覚」を挟むため 150ms 以内には来ない。 */
trigger.addEventListener('click', (e) => {
  if (performance.now() - closedAt <= 150) { e.preventDefault(); return; }
  open();
});
このページで動いている実装の全文(before / after)

DialogBefore.svelte

<script>
	/**
	 * before(動かない実装): 素直に書いたダイアログ。
	 *
	 * - 見出しに `autofocus tabindex="-1"` を付けて初期フォーカス先にする
	 * - 閉じている間も `<dialog>` を DOM に置いたまま(中身も残る)
	 * - 開閉は invoker commands(`command` / `commandfor`)に任せる
	 * - フォーカス復帰は `close` イベント内で同期的に `focus()`
	 */
	import { log, describe } from './instrument.svelte.js';

	// initialFocus='closeButton' にすると、初期フォーカスが閉じるボタンに当たる。
	// Enter を押しっぱなしにしたときの開閉ループは「初期フォーカスがボタン」が条件で、
	// 既定(見出しフォーカス)だと非対話要素にリピートが当たるのでループが起きない。
	let { id, title, initialFocus = 'title', children } = $props();

	let dialog = $state(null);
	let trigger = $state(null);
	// 中身は「開いたときに読み込み、閉じても DOM に残る」(実アプリと同じ)
	let everOpened = $state(false);

	const measureFromHere = (label) => {
		const at = performance.now();
		setTimeout(() => log(`${label}: 次のタスクまで`, `${Math.round(performance.now() - at)}ms`));
		requestAnimationFrame(() =>
			log(`${label}: 次のフレームまで`, `${Math.round(performance.now() - at)}ms`)
		);
	};
</script>

<button type="button" class="trigger" bind:this={trigger} command="show-modal" commandfor={id}>
	before のダイアログを開く
</button>

<dialog
	{id}
	bind:this={dialog}
	aria-labelledby="{id}-title"
	closedby="closerequest"
	onbeforetoggle={(event) => {
		log('beforetoggle(before)', `${event.oldState} -> ${event.newState}`);
		if (event.newState === 'closed') measureFromHere('閉じ処理(before)');
	}}
	ontoggle={(event) => {
		log('toggle(before)', `${event.oldState} -> ${event.newState}`);
		if (event.newState === 'open') everOpened = true;
	}}
	onclose={() => {
		// Chromium は command="close" では close を発火せず toggle だけを送る。Safari は発火する
		log('close(before)', `activeElement=${describe(document.activeElement)}`);
		// 素直な実装: 同期でトリガーへ戻す
		trigger?.focus();
	}}
>
	<!-- svelte-ignore a11y_autofocus -->
	{#if initialFocus === 'title'}
		<h2 id="{id}-title" tabindex="-1" autofocus>{title}</h2>
	{:else}
		<h2 id="{id}-title">{title}</h2>
	{/if}
	<p>
		{#if initialFocus === 'title'}
			見出しが初期フォーカス先です(webkit.org/b/302351)。
		{:else}
			閉じるボタンが初期フォーカス先です(素の <code>&lt;dialog&gt;</code> と同じ状態。Enter
			を押しっぱなしにすると開閉を繰り返します)。
		{/if}
		閉じても <code>&lt;dialog&gt;</code> と中身は DOM に残ります。
	</p>
	{#if everOpened}
		{@render children?.()}
	{/if}
	<div class="actions">
		<!-- svelte-ignore a11y_autofocus -->
		<button type="button" command="close" commandfor={id} autofocus={initialFocus === 'closeButton'}>
			閉じる
		</button>
	</div>
</dialog>

<style>
	.trigger {
		font: inherit;
		padding: 0.5rem 1rem;
		border: 1px solid #0b5d8a;
		border-radius: 0.25rem;
		background: #0b5d8a;
		color: #ffffff;
		cursor: pointer;
	}
	dialog {
		max-width: min(40rem, calc(100vw - 2rem));
		max-height: calc(100vh - 4rem);
		overflow: auto;
		border: 1px solid #d1d5db;
		border-radius: 0.5rem;
		padding: 1.25rem;
		color: #1f2937;
		background: #ffffff;
		opacity: 0;
		translate: 0 1rem;
		transition:
			opacity 0.3s,
			translate 0.3s,
			overlay 0.3s allow-discrete,
			display 0.3s allow-discrete;
	}
	dialog:modal {
		opacity: 1;
		translate: 0 0;
	}
	@starting-style {
		dialog:modal {
			opacity: 0;
			translate: 0 1rem;
		}
	}
	dialog::backdrop {
		background: rgb(17 24 39 / 0.5);
	}
	h2 {
		font-size: 1.25rem;
		margin: 0 0 0.5rem;
	}
	.actions {
		position: sticky;
		bottom: 0;
		padding: 0.75rem 0 0;
		background: #ffffff;
	}
	.actions button {
		font: inherit;
		padding: 0.375rem 0.75rem;
		border: 1px solid #d1d5db;
		border-radius: 0.25rem;
		background: #f3f4f6;
		color: #1f2937;
		cursor: pointer;
	}
	@media (prefers-reduced-motion: reduce) {
		dialog {
			transition: none;
		}
	}
</style>

DialogAfter.svelte

<script>
	/**
	 * after(動く実装): デザインシステムに入れた対策込み。
	 *
	 * このページは macOS の Safari で見る前提なので、対策は常に有効にしてある。
	 * デザインシステム側では 1〜4 を `isMacOsSafari()` の判定でゲートしていて、
	 * それ以外の環境では before と同じ素直な経路(invoker commands・常時 DOM)で動く。
	 *
	 * macOS の Safari だけに効かせるもの
	 * 1. 閉じている間 `<dialog>` を DOM から外す(`{#if}` + 閉じたら即アンマウント)
	 * 2. Enter / Escape は keydown を preventDefault して keyup で閉じる
	 * 3. 閉じる直前に偽 Space を発火する(本物の Space で閉じたときは撃たない)
	 * 4. `beforetoggle` で中身を先に除去してからアンマウントする(フリーズ対策)
	 *
	 * 環境を問わず効かせるもの
	 * 5. 初期フォーカスはブラウザ任せ。開いた 2 フレーム後に JS で見出しへ移す
	 * 6. フォーカス復帰はマクロタスク。ネイティブ復帰が済んでいれば何もしない
	 * 7. 閉じた直後 150ms はトリガーの再活性化を無視する
	 */
	import { flushSync } from 'svelte';
	import { log, describe, isHeldNow, dispatchFakeSpace } from './instrument.svelte.js';

	let { id, title, children } = $props();

	const TRIGGER_REACTIVATION_GRACE_MS = 150;
	const FOCUS_TITLE_FALLBACK_MS = 50;
	const KEY_RELEASE_CLOSE_FALLBACK_MS = 3000;

	let shown = $state(false);
	let dialog = $state(null);
	let trigger = $state(null);
	let closedAt = Number.NEGATIVE_INFINITY;
	let escapeArmed = false;
	let closeOnKeyReleaseArmed = false;

	/** 開いたダイアログが一度描画された後(2 フレーム後)に見出しへフォーカスを移す */
	const focusTitleAfterOpen = (initialFocused) => {
		const moveFocusToTitle = () => {
			const node = dialog;
			if (!node?.open) return;
			const titleElement = node.querySelector(`#${id}-title`);
			if (!titleElement) return;
			// 開いてから利用者がフォーカスを動かしていたら奪わない。
			// 比較先は「開いた直後にブラウザが当てた要素」を控えておいたもの。
			// 中身が非同期に増えるダイアログでは「最初のフォーカス可能な子孫」を
			// その場で引き直すと、後から届いたリンクが先頭に来て判定を外す
			const current = document.activeElement;
			if (
				!(
					current === null ||
					current === document.body ||
					current === node ||
					current === trigger ||
					current === initialFocused
				)
			) {
				return;
			}
			// 見出しは初期状態ではフォーカス不可にしてある(付けると入場が壊れる)
			titleElement.tabIndex = -1;
			titleElement.focus();
			log('見出しへフォーカス(after)', describe(titleElement));
		};
		let moved = false;
		const run = () => {
			if (moved) return;
			moved = true;
			clearTimeout(timer);
			moveFocusToTitle();
		};
		// rAF が進まない環境(非表示タブなど)向けのフォールバック
		const timer = setTimeout(run, FOCUS_TITLE_FALLBACK_MS);
		requestAnimationFrame(() => requestAnimationFrame(run));
	};

	const open = () => {
		if (shown) return;
		shown = true;
		// マウントしてからネイティブに表示する(invoker は使えないので自前で showModal)
		flushSync();
		dialog?.showModal();
		log('showModal(after)', `activeElement=${describe(document.activeElement)}`);
		focusTitleAfterOpen(document.activeElement);
	};

	const close = (closeButton) => {
		const node = dialog;
		if (!node?.open) return;
		const target =
			closeButton?.isConnected ? closeButton
			: document.activeElement instanceof HTMLElement && node.contains(document.activeElement) ?
				document.activeElement
			:	node;
		dispatchFakeSpace(target);

		const startedAt = performance.now();
		// close() は必ず呼ぶ。呼ばずに除去だけすると Safari の top layer / inert の
		// 後始末が壊れ、以後の focus() 自体が効かなくなる
		node.close();
		// 閉じている間は DOM に置かない(即アンマウント)
		shown = false;
		flushSync();
		log('閉じ処理(after): 同期処理', `${Math.round(performance.now() - startedAt)}ms`);
		const afterAt = performance.now();
		setTimeout(() =>
			log('閉じ処理(after): 次のタスクまで', `${Math.round(performance.now() - afterAt)}ms`)
		);
		requestAnimationFrame(() =>
			log('閉じ処理(after): 次のフレームまで', `${Math.round(performance.now() - afterAt)}ms`)
		);

		closedAt = performance.now();
		// 復帰は常にマクロタスク。ネイティブ復帰が既に戻していれば何もしない
		// (重いページで撃ち直すと、落ち着いた VO カーソルを弾き飛ばす)
		setTimeout(() => {
			if (document.activeElement === trigger) {
				log('復帰不要(after)', 'ネイティブ復帰でトリガーに戻っている');
				return;
			}
			trigger?.focus();
			log('復帰(after)', describe(trigger));
		}, 0);
	};

	/**
	 * Enter は click が keydown で発火するので、そのまま閉じると同じキー列の残り
	 * (keyup・オートリピート・VoiceOver の二重 keydown)がネイティブ復帰済みの
	 * トリガーへ着弾して開き直る。keyup まで待って閉じる。
	 * Space の click は keyup の既定動作なので待たない(待っても keyup は来ない)。
	 */
	const closeFromButton = () => {
		const closeButton = document.activeElement instanceof HTMLElement ? document.activeElement : null;
		const held = ['Enter', 'NumpadEnter'].find((code) => isHeldNow(code));
		if (!held) {
			close(closeButton);
			return;
		}
		if (closeOnKeyReleaseArmed) return;
		closeOnKeyReleaseArmed = true;
		log('keyup 待ち(after)', `${held} が押下中`);
		const finish = () => {
			closeOnKeyReleaseArmed = false;
			removeEventListener('keyup', onKeyUp, true);
			clearTimeout(fallback);
			close(closeButton);
		};
		const onKeyUp = (event) => {
			if (event.code !== held) return;
			finish();
		};
		// keyup が届かない場合(VoiceOver の握り潰し・ウィンドウ外での離鍵)の保険。
		// AX ツリー再構築でメインスレッドが 1 秒近く止まるので 1 秒では短い
		const fallback = setTimeout(finish, KEY_RELEASE_CLOSE_FALLBACK_MS);
		addEventListener('keyup', onKeyUp, true);
	};
</script>

<button
	type="button"
	class="trigger"
	bind:this={trigger}
	onclick={(event) => {
		// 閉じ操作のキー列の残りによる再活性化を無視する。
		// 人間の押し直しは「閉じたことの知覚」を挟むため 150ms 以内には来ない
		if (performance.now() - closedAt <= TRIGGER_REACTIVATION_GRACE_MS) {
			event.preventDefault();
			log('再活性化を抑止(after)', `閉じてから ${Math.round(performance.now() - closedAt)}ms`);
			return;
		}
		open();
	}}
>
	after のダイアログを開く
</button>

{#if shown}
	<dialog
		{id}
		bind:this={dialog}
		aria-labelledby="{id}-title"
		closedby="closerequest"
		onbeforetoggle={(event) => {
			log('beforetoggle(after)', `${event.oldState} -> ${event.newState}`);
			// 中身を持ったまま top layer 要素を除去すると、VoiceOver 接続下では
			// WebKit の AX 後始末がメインスレッドを十数秒ブロックする。
			// 先に中身だけを「本当に」除去しておく(DocumentFragment への移動では駄目)
			if (event.newState === 'closed') dialog?.replaceChildren();
		}}
		ontoggle={(event) => log('toggle(after)', `${event.oldState} -> ${event.newState}`)}
		onkeydown={(event) => {
			// Escape のネイティブな閉じ(close request)は keydown で走るので止めて…
			if (event.key !== 'Escape' || !dialog?.open) return;
			event.preventDefault();
			escapeArmed = true;
		}}
		onkeyup={(event) => {
			// …keyup で閉じる(閉じた時点でキー列が完結している状態にする)
			if (event.key !== 'Escape' || !escapeArmed) return;
			escapeArmed = false;
			close(null);
		}}
	>
		<!-- 見出しには autofocus / tabindex を付けない(付けると VO の入場が壊れる) -->
		<h2 id="{id}-title">{title}</h2>
		<p>
			初期フォーカスは実在のコントロールに当たり、そのあと見出しへ移ります。閉じると
			<code>&lt;dialog&gt;</code> ごと DOM から外れます。
		</p>
		{@render children?.()}
		<div class="actions">
			<button type="button" onclick={closeFromButton}>閉じる</button>
		</div>
	</dialog>
{/if}

<style>
	.trigger {
		font: inherit;
		padding: 0.5rem 1rem;
		border: 1px solid #14602f;
		border-radius: 0.25rem;
		background: #14602f;
		color: #ffffff;
		cursor: pointer;
	}
	dialog {
		max-width: min(40rem, calc(100vw - 2rem));
		max-height: calc(100vh - 4rem);
		overflow: auto;
		border: 1px solid #d1d5db;
		border-radius: 0.5rem;
		padding: 1.25rem;
		color: #1f2937;
		background: #ffffff;
		opacity: 0;
		translate: 0 1rem;
		transition:
			opacity 0.3s,
			translate 0.3s,
			overlay 0.3s allow-discrete,
			display 0.3s allow-discrete;
	}
	dialog:modal {
		opacity: 1;
		translate: 0 0;
	}
	@starting-style {
		dialog:modal {
			opacity: 0;
			translate: 0 1rem;
		}
	}
	dialog::backdrop {
		background: rgb(17 24 39 / 0.5);
	}
	h2 {
		font-size: 1.25rem;
		margin: 0 0 0.5rem;
	}
	.actions {
		position: sticky;
		bottom: 0;
		padding: 0.75rem 0 0;
		background: #ffffff;
	}
	.actions button {
		font: inherit;
		padding: 0.375rem 0.75rem;
		border: 1px solid #d1d5db;
		border-radius: 0.25rem;
		background: #f3f4f6;
		color: #1f2937;
		cursor: pointer;
	}
	@media (prefers-reduced-motion: reduce) {
		dialog {
			transition: none;
		}
	}
</style>

5. 挙動(before / after)

before(修正前)

お知らせ(before)

見出しが初期フォーカス先です(webkit.org/b/302351)。 閉じても <dialog> と中身は DOM に残ります。

  • 見出しに autofocus tabindex="-1"
  • 閉じても <dialog> と中身が DOM に残る
  • 開閉は invoker commands 任せ(Enter は keydown で閉じる)
  • 復帰は close 内で同期 focus()

after(修正後)

  • 入場はブラウザ任せ → 2 フレーム後に見出しへ
  • 閉じている間 <dialog> を DOM から外す
  • Enter / Escape は keyup で閉じる + 閉じる直前に偽 Space
  • 中身を先に除去 / 復帰はマクロタスク / 150ms は再活性化を無視
  • 残る症状: 閉じた直後に急いで VO + → で移動すると、フォーカスが body に吸われることがある

両方を 1 ページに置いてある。片方の DOM がもう片方の再現に影響していないかを確かめたいときは ?only=before / ?only=after を付ける。

Enter を押しっぱなしにしたときの開閉ループは「初期フォーカスがボタンに当たる」ことが条件なので、before の既定(見出しフォーカス)では起きない。?beforeFocus=button で初期フォーカスを閉じるボタンにすると再現する(素の <dialog> と同じ状態。MDN の <dialog> のサンプルでも再現する)。

確認する手順

  1. VO + → でトリガーまで進み、VO + Space で開く。そのまま VO + → でダイアログの中を読み進められるか。
  2. 閉じるボタンまで VO + → で進み、Space / Enter / Escape のそれぞれで閉じる。
  3. 閉じたあとの VO + → がトリガーの隣から続くか(= VO カーソルがトリガーへ戻っているか)。
  4. 対照として、VoiceOver を切って Tab キーだけで同じ操作をする。before / after のどちらでも問題が起きないこと(開いたら見出し、閉じたらトリガー)を確認する。
  5. 閉じたときにメインスレッドが止まらないか(ログの ★ メインスレッド停止)。

中身の作り方(フリーズの再現条件)

静的に大きい DOM ではフリーズは再現しない。再現したのはパネルを動的 import() で読み込み、それぞれがデータフェッチする画面だったので、この設定でその形を作る。再現しないときは値を上げる。どの値で再現・しなくなるかがそのまま切り分けになる。

閉じる時点でも飛行中にするため

6. JS なしのマークアップでも同じか(iOS / Safari のタップ)

下の 2 つは、この節のためだけに置いたJavaScript を一切書いていないダイアログとポップオーバー。開閉はどちらも属性だけ(command / commandforpopovertarget)で、フォーカスの世話は何もしていない。

ネイティブの復帰先は「開いた時点の activeElement」で固定される。ここまでは JavaScript の有無に関係のない話で、この JS なしのマークアップで実際に測れる(下の表は Chromium での実測)。

JS なしのマークアップで、開き方を変えて閉じたあとのフォーカス(Chromium 実測)
開き方閉じたあとのフォーカス
トリガーにフォーカスを当てずに開く(Safari のタップ相当)トリガーへ戻らない。 実測では閉じた(display: none になった)<dialog> の中の閉じるボタンに activeElement が取り残されたまま(3回中3回。500ms 後も同じ)。閉じアニメーションの有無などで body に落ちることもある
トリガーにフォーカスを当ててから開くトリガー(戻る)
ポップオーバーをフォーカスなしで開くトリガーへ戻らないbody のまま)

つまり「トリガーにフォーカスが乗っていないまま開く」と、閉じてもフォーカスはトリガーへ移らない(記録された復帰先がないので、その場に取り残される)。取り残され先は body だったり閉じたダイアログの中の要素だったりと環境次第だが、いずれにせよトリガーには戻らない。あとは Safari のタップでフォーカスが乗らないかどうかだけの話で、乗らなければ iOS では常にこの状態になる。

未確認: 「Safari のクリック・タップではボタンにフォーカスが移らない」「iOS の VoiceOver はスワイプでも DOM フォーカスを動かさない」はデザインシステムの Popover を直した当時の記録(そこでは実際に復帰が壊れていた)で、この素のマークアップを iOS 実機で踏んだ記録はない。ここを確かめるためにこの節を置いている。

  • 確かめ方: iPhone / iPad の Safari(できれば VoiceOver をオン)で開いて閉じ、下のログを見る
  • 開くときに focusin -> button が来ていないなら、その時点で復帰先が記録されていない状態
  • 閉じたときの close / toggle の行の activeElementトリガー以外body や閉じたダイアログ内の要素)なら戻っていない。activeElement=button("JS なしのダイアログを開く") ならトリガーへ戻っている
  • Chrome / Firefox はクリックでトリガーにフォーカスを移すので、同じマークアップでも戻る。デスクトップの Chrome で試すと差が見えない点に注意

仮説どおりなら、マークアップだけでは直せないcommand / commandfor にも popovertarget にも復帰先を指定する仕組みはないので(whatwg/html#5678 は closed)、トリガー操作を JS で記録して自前で focus() するしかない(このページの after と同じ)。

モーダルダイアログ(command / commandfor

JS なしのダイアログ

属性だけで開閉しています。閉じたあとのフォーカスは「開いた時点の activeElement」に戻るので、タップで開いた Safari では <body> に戻ります。

ポップオーバー(popovertarget

JS なしのポップオーバー

showPopover()showModal() と違ってフォーカスを移しません。 閉じたあとの復帰先も「開いた時点の activeElement」です。

7. ログ

★ メインスレッド停止 の行が閉じたときのフリーズ。50ms 間隔のハートビートが遅れた分を記録している(Safari は PerformanceObserver の longtask を計測できないためこの方法。非表示タブでは計測を止める)。読み上げを邪魔しないようライブリージョンにはしていない。

(まだ何も記録されていません)
イベントログ