PopoverとDialogについて - React Ariaの実装読むぞ
こんにちは、フロントエンドエンジニアの mehm8128 です。
今日は Popover と Dialog について書いていきます。
usePopover
とuseDialog
とは
ポップオーバーやダイアログを作るための hooks です。
使用例
ドキュメントからそのまま取ってきています。
function Popover({ children, state, offset = 8, ...props }: PopoverProps) {
let popoverRef = React.useRef(null);
let { popoverProps, underlayProps, arrowProps, placement } = usePopover(
{
...props,
offset,
popoverRef,
},
state
);
return (
<Overlay>
<div {...underlayProps} className="underlay" />
<div {...popoverProps} ref={popoverRef} className="popover">
<svg
{...arrowProps}
className="arrow"
data-placement={placement}
viewBox="0 0 12 12"
>
<path d="M0 0 L6 6 L12 0" />
</svg>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
function Dialog({ title, children, ...props }: DialogProps) {
let ref = React.useRef(null);
let { dialogProps, titleProps } = useDialog(props, ref);
return (
<div {...dialogProps} ref={ref} style={{ padding: 30 }}>
{title && (
<h3 {...titleProps} style={{ marginTop: 0 }}>
{title}
</h3>
)}
{children}
</div>
);
}
本題
APG はこちらです。
ダイアログを開いたときに最初にフォーカスする要素
APG の 1 つ目の Note の 1 に書いてあることを要約します。
基本的にダイアログ内の最初のフォーカス可能要素にフォーカスされますが、以下のような場合には例外となります。
- リスト、表、複数の段落など複雑なものの場合、先頭に
tabindex="-1"
な要素を追加してそこに最初にフォーカスする - 最初のインタラクティブ要素にフォーカスするとダイアログがスクロールされてしまう場合は、
tabindex="-1"
な要素を追加してそこに最初にフォーカスする - ダイアログ内に破壊的なアクションボタンが含まれている場合、最も破壊的でないボタンにフォーカスを設定する
- 情報を提供したり処理を続行したりするインタラクションボタンに限られている場合、「OK」など最もよく使われるボタンにフォーカスする
閉じたときにフォーカスする要素
APG の 1 つ目の Note の 2 に書いてあることを要約します。
基本的にはダイアログを開くトリガーとなったボタンにフォーカスを戻しますが、以下の場合にはワークフロー上の別の要素にフォーカスするとよいです。
- トリガーボタンが消えたとき
- トリガーボタンをすぐに再度押す可能性がほとんどないときや、ダイアログ内のタスクを完了すると次のステップに進む場合
例えば grid で行を追加するボタンを押し、ダイアログで追加する行数を入力するような場合には、行を追加するボタンにフォーカスを戻すのではなく、追加された行の最初のセルにフォーカスするようにするとよいです。
モーダル化
React Aria のポップオーバーで特徴的なのはこれです。
ダイアログがモーダルになっているのは自然なのですが、ポップオーバーでもモーダルなのはあまり見たことがありませんでした。ポップオーバーを開くとポップオーバー外の要素へのインタラクションができなくなり、背景のスクロールもできなくなります。最近気づいたのですが、Notion のポップオーバーはこれですね。
これは意図せずポップオーバーが閉じてしまったりポップオーバー外の要素にアクセスしてしまったりするのを防いでいます。
その他詳細な理由がこの discussion で述べられています。
また、モーダルにするときはaria-modal="true"
にするとよいのですが、Safari でフォーカス制御が上手くいかないことがあることから、React Aria では代わりに外側の要素にaria-hidden="true"
をつけています。
VoiceOver on Chrome のバグ
VoiceOver で以下の 2 つのパターンでダイアログ(ポップオーバー)が閉じてしまうというバグがありました。
これらは VoiceOver on Chrome のバグとして報告されました。
その上で、React Aria でも workaround な対応がされたので、それを見ていきます。
e.relatedTarget
とは、今回はonBlur
イベントを見ているので、e.target
がフォーカスを失う要素を表しているのに対して、e.relatedTarget
は逆にこのタイミングで新たにフォーカスを受け取る要素を表します。
試したい人は以下のような HTML で、aaa
ボタンからbbb
にフォーカスを移動すると、e.target
がaaa
ボタン、e.relatedTarget
がbbb
ボタンになることが確認できます。
<button id="button">aaa</button>
<button>bbb</button>
<script>
const ele = document.getElementById("button");
ele.addEventListener("blur", (e) => console.log(e.target, e.relatedTarget));
</script>
修正 PR では条件式が変更されただけなのですが、if 文がtrue
になる条件をまとめました。
条件式 | 修正前 | 修正後 |
---|---|---|
e.relatedTarget = truthy & isElementInChildOfActiveScope = true |
true | true |
e.relatedTarget = truthy & isElementInChildOfActiveScope = false |
false | false |
e.relatedTarget = falsy & isElementInChildOfActiveScope = true |
false | true |
e.relatedTarget = falsy & isElementInChildOfActiveScope = false |
false | true |
よって、e.relatedTarget
が falsy (null
) になるときにも結果がtrue
になって、ダイアログ(ポップオーバー)が閉じられないようになったことが分かります。
まとめ
明日の担当は @mehm8128 さんで、 Listbox についての記事です。お楽しみにー
Discussion