PopoverとDialogについて

React Ariaの実装読むぞ


目次
  1. 使用例
  2. 本題
    1. ダイアログを開いたときに最初にフォーカスする要素
    2. 閉じたときにフォーカスする要素
    3. モーダル化
    4. VoiceOver on Chrome のバグ
  3. まとめ
Warn

この記事は他サイトから移行したものです。

Note

この記事は React Aria の実装読むぞ - Qiita Advent Calendar 2024 の 8 日目の記事です。

こんにちは、フロントエンドエンジニアの mehm8128 です。 今日は Popover と Dialog について書いていきます。

https://react-spectrum.adobe.com/react-aria/usePopover.html https://react-spectrum.adobe.com/react-aria/useDialog.html

usePopoveruseDialog とは #

ポップオーバーやダイアログを作るための 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 はこちらです。 https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/

ダイアログを開いたときに最初にフォーカスする要素 #

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.targetaaaボタン、e.relatedTargetbbbボタンになることが確認できます。

<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 = truetruetrue
e.relatedTarget = truthy & isElementInChildOfActiveScope = falsefalsefalse
e.relatedTarget = falsy & isElementInChildOfActiveScope = truefalsetrue
e.relatedTarget = falsy & isElementInChildOfActiveScope = falsefalsetrue

よって、e.relatedTargetが falsy (null) になるときにも結果がtrueになって、ダイアログ(ポップオーバー)が閉じられないようになったことが分かります。

まとめ #

明日の担当は @mehm8128 さんで、 Listbox についての記事です。お楽しみにー