Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

HTML: 「JavaScriptなし」で動く最新の多機能確認ダイアログを構築する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

以下のような複雑なスタイル付きダイアログをJavaScriptなしで構築できます。


https://play.tailwindcss.com/0V4LTBpdHCより

HTML: 「JavaScriptなし」で動く最新の多機能確認ダイアログを構築する(翻訳)

campsite/campsite - GitHub

Campsiteのリポジトリには、私の好きなWeb向けUIスタイルがいろいろあるので、自然と学びたい気持ちが湧いてきてソースを読んでみたところ、Reactコンポーネントがわざわざ<div>の内側で<div>をレンダリングしていることと、JavaScriptを大量に使っていることに気づきました。<dialog>要素を使えばJavaScript不要で意味も明快であるにもかかわらずです。

そこで、Campsiteのデザインだけを拝借して、以下の記事で取り上げたアフォーダンスCSSクラスによるセマンティックなHTMLやCSSを再構築してみました。私がどのような方法を選んだか、そしてそれらをどのように組み合わせたかについて見ていきたいと思います。

CSS: フロントエンドアーキテクチャに「アフォーダンス」層も必要な理由(翻訳)

🔗 ダイアログのHTML

以下は、完全な機能を持つダイアログのマークアップ構造です。

<dialog id="example-dialog" class="ui/dialog" 
        aria-labelledby="example-dialog-title" 
        aria-describedby="example-dialog-desc" 
        closedby="any">
  <header>
    <hgroup>
      <h2 id="example-dialog-title">Basic Dialog</h2>
      <p id="example-dialog-desc">This is a basic dialog with header, content, and footer sections.</p>
    </hgroup>
    <button type="button" class="ui/button/plain aspect-square" 
            commandfor="example-dialog" command="close" 
            aria-label="Close dialog">×</button>
  </header>
  <form method="POST" action="#">
    <article>
      <p>
        Dialog content goes here. This area can contain forms, text, images, 
        or any other content. The native <code></code> element 
        handles focus management and accessibility automatically.
      </p>
    </article>
    <footer>
      <button class="ui/button/flat" type="submit" 
              formmethod="dialog" formnovalidate value="cancel">Cancel</button>
      <button class="ui/button/primary" type="submit" autofocus>Confirm</button>
    </footer>
  </form>
</dialog>

🔗 1: 意味のわかるHTML要素を使う

はい、私はセマンティックなHTML要素を偏愛しています。何でもかんでも<div>要素で書くのではなく、<header>要素や<article><footer>要素を使い分けるようにしています。こうしておけば半年後にコードを見返したときにも構造が明確になりますし、CSSクラス名を新たにひねり出さなくてもCSSで要素を直接指定できます。

🔗 2: フォームのラッパー

上のHTMLでは、ダイアログの<article>要素と<footer>要素が<form>要素で囲まれています。初めて見た人は変に思うかもしれませんが、こうすることでダイアログの重要な機能が使えるようになるのです。
ダイアログでは、「リソースを作成する」「設定を更新する」「データを送信する」といった具体的な作業を行う必要が生じることがよくあります。<form>要素で囲んでおけば、ダイアログでそうした作業を行う準備が整うので便利です。
もっとシンプルな確認ダイアログであれば、<form>要素にmethod=dialog属性を追加するか、<button>要素にformmethod=dialog属性を追加しておけば、ダイアログを閉じるときにリクエストがネットワークに送信されることもありません。

🔗 3: ダイアログを閉じるための2つの方法

<header>要素の[x]ボタンにcommand=close属性を指定してある理由は、このxボタンが<form>要素の外に配置されているからです。

一方、<footer>要素の[Cancel]ボタンにformmethod=dialog属性を指定している理由は、このCancelボタンが<form>要素の内側に配置されているからです。この属性があることで、ネットワーク送信を行わずにダイアログを閉じるようになります。

🔗 4: フォーカスの処理

[Confirm]ボタンにはautofocus属性を指定しています。これにより、ダイアログが表示されたときにフォーカスが即座に[Confirm]ボタンに移動するので、キーボードでEnterキーを押すだけでメインの操作を実行できます。

🔗 5: 「背景をクリック」で閉じる機能

closedby=any属性を使うと、ダイアログの外側をクリックするだけで閉じる、いわゆる"light dismiss"機能も有効になります。
ブラウザに備わっているEscキーで閉じる機能と組み合わせることで、さまざまな直感的方法でダイアログを閉じられるようになります1。JavaScriptのイベントリスナーは不要です。

🔗 6: アクセシビリティ

aria-labelledby属性とaria-describedby属性を使うと、ダイアログの見出しと説明文を指定できます。これで、ダイアログが表示されたときにスクリーンリーダーがこの見出しと説明文を即座に読み取って、ユーザーが何をすべきかというコンテキストを適切に認識するようになります。

特に、確認を求めるダイアログにはrole=alertdialog属性を追加しておきましょう。こうすることで、そのダイアログが情報表示やフォームといった通常のものではなく、ユーザーの応答を必要とする重大なメッセージであることが示され、ブラウザや各種支援ツールはこのメッセージを緊急性の高い警告ダイアログとして扱えるようになります。

<dialog id="confirm-delete" 
        role="alertdialog"
        aria-labelledby="confirm-title" 
        aria-describedby="confirm-desc">
  <!-- ... -->
</dialog>

🔗 CSSのアーキテクチャ

このスタイルでは、Tailwind CSS v4の @utilityディレクティブを使って、ツリーシェイクとエディタでのオートコンプリートが効くユーティリティクラスを作成しています。以下に構造を示します。

@import "tailwindcss";

@theme {
  --shadow-dialog: 0px 0px 3.5px rgba(0, 0, 0, 0.04), 
    0px 0px 10px rgba(0, 0, 0, 0.04),
    0px 0px 24px rgba(0, 0, 0, 0.05), 
    0px 0px 80px rgba(0, 0, 0, 0.08);
  --shadow-dialog-dark: inset 0 0.5px 0 rgb(255 255 255 / 0.08),
    inset 0 0 1px rgb(255 255 255 / 0.24), 
    0 0 0 0.5px rgb(0 0 0 / 1),
    0px 0px 4px rgba(0, 0, 0, 0.08), 
    0px 0px 10px rgba(0, 0, 0, 0.12),
    0px 0px 24px rgba(0, 0, 0, 0.16), 
    0px 0px 80px rgba(0, 0, 0, 0.2);
}

上のレイヤー化シャドウのスタイルはCampsiteから直接拝借しました。四隅のradiusのぼかしを繊細に変えたシャドウスタイルのおかげで、より自然でアンビエントな光に照らされているような効果が生まれ、腕利きのデザイナーを雇ったのかとすら思えてきます。
さらにダークモードでは、内側につややかな反射を生み出すインセットシャドウによってパネルに奥行きを感じさせてくれます。

🔗 1: ベースとなるダイアログユーティリティ

@utility ui/dialog {
  :where(&) {
    @apply rounded-lg border-none bg-white p-0 text-zinc-900 shadow-dialog;
    @apply isolate flex w-full flex-col;
    @apply max-w-[calc(100vw-32px)] min-w-sm;
    @apply pointer-events-none invisible;

    @apply m-auto;
    @apply max-h-[calc(100dvh-env(safe-area-inset-bottom,0)-env(safe-area-inset-top,0)-32px)];

    @variant sm {
      @apply max-w-md;
    }

    @variant focus {
      @apply outline-0;
    }

    @variant open {
      @apply pointer-events-auto visible;
    }

    @variant dark {
      @apply bg-zinc-900 text-zinc-50 shadow-dialog-dark;
    }
  }
}

🔗 2: ダイアログが表示されたままになる問題を修正する

<dialog>display: flex属性を指定すると、ブラウザのデフォルトのdisplay: noneが上書きされるため、ダイアログを閉じても表示されっぱなしになってしまいます。

これを修正するには、Tailwindのpointer-events-noneinvisibleクラスを使います。

このクラスを適用したダイアログはDOM内に残りますが、ユーザーはそれを見ることも触ることもできません。[open]が適用されるとダイアログが表示されて操作可能になります。
また、ページを読み込んだときにダイアログの中身が一瞬表示されることも防止できます。

🔗 3: ダイアログのサイズに上限を指定する

ダイアログの幅については、max-w-[calc(100vw-32px)]を指定することで、小さい画面でダイアログがはみ出さなくなり、左右それぞれに16pxの余裕が常に確保されます。
min-w-sm(24remに相当)は、大画面でダイアログの横幅が狭くなりすぎないようにするためです。

ダイアログの高さについては、max-h-[calc(100dvh-env(safe-area-inset-bottom,0)-env(safe-area-inset-top,0)-32px)]でさらに多くの指定が行われています。
単位をdvh(動的なビューポートの高さ)にしているのは、モバイル版のChromeブラウザでの表示と非表示に対応するためです。
env(safe-area-inset-*)関数は、最新のスマホにあるノッチやホームインジケータに対応するためです。

以上を組み合わせて、ダイアログが理論上のビューポートだけでなく「現実に」利用可能なスペースにもうまく収まるようにしています。

🔗 4: スタッキングコンテキスト

isolateプロパティは、前後の位置に関連するスタッキングコンテキストを新しく作成します。これによって、ダイアログ内のあらゆるz-index値が維持されるようになり、ドロップダウンやツールチップが隠れたり外部の要素に干渉したりしなくなります。

🔗 5: focus-visibleではなくfocusを使う理由

この例では@variant focus { @apply outline-0; }でアウトラインを消しています。autofocusが発火させるのは:focus-visibleではなく:focusなので、:focus-visibleにだけスタイルを設定するとオートフォーカスされた要素にスタイルが当たらなくなるためです。

普通なら:focus-visibleを使うところですが、ダイアログのボタンのautofocus属性がトリガーするのは:focus-visibleではなく:focusです。つまり、focus-visibleにだけスタイルを設定すると、オートフォーカスされる要素にスタイルが付かなくなります。

🔗 スロットクラスとセマンティック要素のセレクタ

ここは私がとても気に入っている部分です。
独立した「スロット(slot)」ユーティリティクラス(dialog/headerdialog/titleなど)をいくつか定義して、任意の要素に@applyできるようにします。

@utility dialog/header {
  :where(&) {
    @apply relative flex-none rounded-t-lg p-4 text-sm;

    &:has(> button[command="close"]) {
      @apply pr-12;
    }
  }
}

@utility dialog/title {
  :where(&) {
    @apply m-0 flex-1 font-semibold;
  }
}

@utility dialog/description {
  :where(&) {
    @apply m-0 mt-0.5 text-zinc-600;

    @variant dark {
      @apply text-zinc-300;
    }
  }
}

@utility dialog/content {
  :where(&) {
    @apply flex flex-1 flex-col overflow-y-auto p-4 pt-0 text-sm;
  }
}

@utility dialog/footer {
  :where(&) {
    @apply flex items-center rounded-b-lg border-t border-black/10 p-3;

    @variant dark {
      @apply border-white/12;
    }
  }
}

次に、親のui/dialogユーティリティでこれらのスロットをセマンティックなHTML要素(headerhgroupなど)に自動的に@applyします。

@utility ui/dialog {
  /* ... (ベーススタイル) ... */

  :where(& > header) {
    @apply dialog/header;
  }

  :where(& header hgroup :is(h1, h2, h3, h4, h5, h6)) {
    @apply dialog/title;
  }

  :where(& header hgroup p) {
    @apply dialog/description;
  }

  :where(& form > article) {
    @apply dialog/content;
  }

  :where(& form > footer) {
    @apply dialog/footer;
  }
}

これで、<header>などのセマンティックなHTML要素を書けば、スタイルが自動的に付与されます。
フレームワークでカスタムのマークアップが生成される場合は、<div>要素にdialog/contentを直接適用できます。

ここで& > headerなどのセレクタを:where()関数でラップしている理由は、詳細度(specificity)をゼロにするためです。こうしておかないと、ネストしたセレクタの詳細度が単一のユーティリティクラスより大きくなってしまい、何かをカスタマイズするたびに独自のスタイルを適用して回らなければならなくなる可能性があります2

🔗 CSSによるアニメーション

ほとんどのダイアログはフェードインとフェードアウトしか実装されていません。それでもいいのですが、まだ改善の余地がありそうです。

@keyframes dialog-slide-up-scale-fade {
  from {
    opacity: 0;
    transform: translateY(20px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes dialog-scale-down-fade {
  from {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
  to {
    opacity: 0;
    transform: translateY(0) scale(0.95);
  }
}

🔗 1: アニメーションを非対称にする

ダイアログを表示するときのアニメーションは、下から上にずりあがる(translateY20pxから0pxに)と同時に、ダイアログのサイズも大きくなるようにしています(scale0.98から1に)。
ダイアログを閉じるときは、サイズを小さくします(scale1から0.95に)が、ダイアログをずり下げる効果は違和感があるので追加していません(translateY0のまま)。

ダイアログを閉じるときにずり下げると、ダイアログがどこか他所に「飛んでいって」しまったのかと誤解される可能性があります(実際にはそうではありません)。ダイアログを閉じるときにサイズを小さくしつつフェードアウトすれば、ダイアログを閉じたことが十分ユーザーに伝わります。

🔗 2: アニメーションのタイミングをずらす

@utility ui/dialog {
  --dialog-entry-duration: 0.2s;
  --dialog-exit-duration: calc(var(--dialog-entry-duration) * 0.75);
  --backdrop-entry-duration: calc(var(--dialog-entry-duration) * 0.2);
  --backdrop-exit-duration: calc(var(--dialog-exit-duration) * 0.75);
}

ダイアログを閉じるときの持続時間(duration)は、ダイアログを表示するときの持続時間(0.2s)より短くしています(75%)。表示するときは「表示していますよ」感がユーザーに伝わるよう持続時間を長めに取り、閉じるときは素早く閉じてユーザーの邪魔にならないようにします。

ダイアログの背景を暗くするアニメーション効果は、ダイアログそのもののアニメーション効果よりもスピーディに設定してあります。

ダイアログを表示するときの背景アニメーションの持続時間は、ダイアログそのものの持続時間(0.2s)の20%に短縮してあるので、背景が変わってからダイアログが表示されます。

ダイアログを閉じるときの背景アニメーションの持続時間はさらに短くしてある(0.2x * 0.75 * 0.75)ので、ダイアログが元の明るい背景に取り残されることはありません。

🔗 3: 技術的な詳細

@utility ui/dialog {
  animation: dialog-scale-down-fade var(--dialog-exit-duration) var(--dialog-easing) forwards;
  transition:
    overlay var(--dialog-exit-duration) var(--dialog-easing) allow-discrete,
    display var(--dialog-exit-duration) var(--dialog-easing) allow-discrete;

  @variant open {
    animation: dialog-slide-up-scale-fade var(--dialog-entry-duration) var(--dialog-easing) forwards;

    @starting-style {
      animation: none;
    }
  }
}

transitiondisplayプロパティやoverlayプロパティにあるallow-discreteキーワードは、アニメーションを有効にするうえで必須です。

displayoverlay離散的(discrete)プロパティと呼ばれるもので、CSSアニメーションにおける中間の値が存在しません(たとえばdisplay: blockdisplay: noneは段階的に切り替えようがありません)。
これらのプロパティにallow-discreteキーワードを指定することで、CSSアニメーションが完了するまではこれらのプロパティを変化させず、アニメーションが完全に終わってからdisplay: noneなどに切り替わるようになります。
逆に、これらのプロパティにallow-discreteキーワードを指定しておかないと、アニメーションが開始した途端にdisplay: noneに切り替わってしまうため、アニメーションがまったく効かなくなり、ダイアログがいきなり消えてしまいます。

同じ問題はダイアログを表示するときのアニメーションでも発生します。上で使っている@starting-styleルールは、アニメーションを開始するタイミングを定義しています。この定義が存在しない場合、ダイアログのアニメーションがスキップされていきなり最終状態に進んでしまいます。つまり、ダイアログ表示のアニメーションが効かなくなってしまいます。

🔗 4: ボタンのスタイルについて

本記事のデモにはボタンのスタイルも含まれていますが、これについては近日公開予定の記事で取り上げます。

🔗 ブラウザのサポート状況

機能 Chrome Safari Firefox
command/commandfor 135以降 26.2以降 144以降
@starting-style 117以降 17.5以降 129以降
closedby 134以降 未サポート 141以降
allow-discrete 117以降 17.4以降 129以降

production環境で必要な場合は、以下のポリフィルを利用できます。

🔗 インタラクティブなデモ

実際に動くデモを以下に用意しました。[Open Dialog]をクリックし、「[x]をクリック」「[Cancel]をクリック」「[Confirm]をクリック」「Escキーを押す」「ダイアログの外側をクリック」などのさまざまな方法でダイアログを閉じられることを確認してみてください。

いろいろ実験してみたい方は、以下にTailwindのPlayground形式のデモも用意してありますので、スタイルの組み合わせがどうなっているかを存分にお確かめいただけます。

参考: Tailwind Play

次回

ダイアログは、Webアプリのちっぽけな部品に過ぎません。
現在の私は、「ボタン」「フォーム」「メニュー」「ポップオーバー」「タブ」「テーブル」など、あらゆるアフォーダンスクラスを構築中です。腕利きのデザイナーを雇ったのかと思われるほどの高品質なスタイルとブラウザネイティブな動作を実現するUIに、ぜひご期待ください👀。

CSS: フロントエンドアーキテクチャに「アフォーダンス」層も必要な理由(翻訳)

関連記事

CSSだけで星を"半押し"可能な10段階レーティング機能を実装する(翻訳)

CSS: flexコンテナでは常に`flex-wrap: wrap`を指定しよう(翻訳)


  1. たとえばclosedby="none"を指定すると、明示的に実装していないダイアログ外側クリックやEscキーによるクローズを無効にできます。ただし後述されているように、現時点ではSafariではclosedbyがサポートされていません。 
  2. 訳注: セレクタなどで詳細度が大きくなると、Tailwind CSSユーティリティクラスのスタイルが効かなくなるおそれがあります。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。