コード例

サイドバーメニュー

作りたいの下記のUIです。 デモはこちらにあります。

要件

  • サイドバーメニューをクリックしてページ遷移ができること
  • “Teams"のところは最初は隠れていて、矢印をクリックすると展開されること
  • 新しい"Engineering"のページに遷移しても"Teams"のサブメニューは開いたままであること
  • "Engineering"のページで再読み込みしたときに、"Teams"サブメニューが閉じてしまっても問題ない(本当は開いた状態にしたいのですが)

MPAの場合は、現在のURLからサイドバーの状態を推測し、それを反映したHTMLをブラウザに送ります。通常はこれで問題がありませんし、UI/UXとして十分です。

しかし今回は別のアプローチを取ります。サイドバーの状態をブラウザのステートと考え、初回ロード以降はサーバからは変更しないやり方です

考えるポイント

  1. メニューの項目をクリックすると、ページは遷移します。データはサーバにありますので、Turboを使うことになります
  2. Turbo Drive, Turbo Frames, Turbo Streamsのいずれかを使うことになります。ただしこのようなUIはアドミ画面で使うことも多く、非常に多くのページで使います
    1. Turbo FramesやTurbo Streamsの場合は、すべてのページに<turbo-frame>タグやTurbo Stream用のタグをつける必要があり、煩雑です
    2. ページが多い場合は、なるべくMPAと全く同じ感覚で使えるTurbo Driveを使いたいところです
  3. "Teams"のサブメニューの矢印をクリックすると"Engineering"のリンクが表示されます。この動きはサーバとの通信が必要ありません
    1. サーバとの通信がありませんので、Stimulusを使うことになります
    2. Actionは矢印のクリック1つ、そして表示の変更は矢印の向きの変更および"Engineering"のリンクが見えるようになることの2つです。この程度であればCSSで十分に対応できそうです
    3. とはいえ、ステートはaria-expandedを使えばアクセシビリティと一石二鳥になりますので、HTMLaria-expanded属性でステートを持つようにします

さらに「"Teams"のサブメニューは開いたまま」という要件を満たすために次のことも行います。

  1. サイドバーのステートを維持します
    1. data-turbo-permanentを使用します
    2. これは通常のページ遷移とMorphingを使った場合の2通りありますが、Morphingはページリフレッシュの時だけに使用しますので、今回は該当しません
    3. こうすれば"Dashboard"から"Engineering"のページに遷移しても、サイドバーのステート("Teams"以下の開閉状態)は維持されます
  2. 選択されたリンクを灰色にする処理
    1. サーバ通信は発生しませんので、Turboを使用しません。Stimulusだけを使用します
    2. アクションはリンクをクリックするの1つだけで、表示の変更も背景を灰色にする1つだけです
    3. これならStimulus用のステートを別個に管理する必要はなく、ariaかCSSのいずれかのHTML属性で管理すれば十分です
    4. aria-currentというものが今回の用途にぴったりですので、ステートはCSSではなくaria属性でStimulusのステートを持たせます(この方がデザイン変更に強くなります)

コード

要件は多いのですが、コードは比較的シンプルです。

Viewのメインページ

app/content/pages/examples/sidebar.html.md
<div class="flex">
  <%= render 'sidebar' %>
  <div class="flex-grow">
    <%= image_tag "component_images/demo-dashboard.webp", class: "w-full" %>
  </div>
</div>
app/views/components/sidebar_other_page.html.erb
<div class="flex">
  <%= render 'sidebar' %>
  <div class="flex-grow">
    <%= image_tag "component_images/demo-engineering-team.webp", class: "w-full" %>
  </div>
</div>
  • 画面が2つあります。ただし内容は画像なので、非常にシンプルです。
  • サイドバーはsidebarパーシャルで表示しています。全く同じものを表示していることが確認できると思います。
app/views/components/_sidebar.html.erb
<div data-turbo-permanent id="sidebar" data-controller="sidebar">
  <div class="h-full w-40 flex shrink-0 flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6">
    <nav class="flex flex-1 flex-col">
      <ul role="list" class="flex flex-1 flex-col gap-y-7">
        <li>
          <ul role="list" class="-mx-2 space-y-1">
            <li>
              <!-- Current: "bg-gray-50", Default: "hover:bg-gray-50" -->
              <%= link_to "Dashboard", component_path(:sidebar),
                          aria: {current: "page"},
                          data: { action: "click->sidebar#setCurrent" },
                          class: "block rounded-md aria-[current=page]:bg-gray-50 py-2 pl-10 pr-2 text-sm/6 text-gray-700" %>
            </li>
            <li>
              <div>
                <button type="button" class="group peer flex w-full items-center gap-x-3 rounded-md p-2 text-left text-sm/6 font-semibold text-gray-700 hover:bg-gray-50"
                        data-action="click->sidebar#toggle"
                        aria-controls="sub-menu-teams"
                        aria-expanded="false">
                  <!-- Expanded: "rotate-90 text-gray-500", Collapsed: "text-gray-400" -->
                  <svg class="size-5 shrink-0 text-gray-400 group-aria-expanded:rotate-90 group-aria-expanded:text-gray-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" data-slot="icon">
                    <path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
                  </svg>
                  Teams
                </button>
                <!-- Expandable link section, show/hide based on state. -->
                <ul class="mt-1 px-2 hidden peer-aria-expanded:block" id="sub-menu-teams">
                  <li>
                    <%= link_to "Engineering", component_path(:sidebar_other_page),
                                aria: {current: "false"},
                                data: { action: "click->sidebar#setCurrent" },
                                class: "block rounded-md py-2 pl-9 pr-2 text-sm/6 text-gray-700 hover:bg-gray-50 aria-[current=page]:bg-gray-50" %>
                  </li>
                </ul>
              </div>
            </li>
          </ul>
        </li>
      </ul>
    </nav>
  </div>
</div>
  • サイドバーの部分になります
  • 一番上部で data-turbo-permanent id="sidebar"を設定しています。上述した通り、これによってサイドバーのHTML要素は固定されて、Turbo Driveで新しいページを読み込んでも、新しいHTMLで上書きされません。なおidが必須になります
  • 同じところにdata-controller="sidebar"があります。これでsidebar Stimulus controllerに接続されます
  • サイドバーに対するアクションのメインはリンクのクリックです
    • 各リンクは普通のaタグですので、Turbo Driveによるページ遷移をします
    • これに加えてdata-action="click->sidebar#setCurrent"があります。SidebarController Stimulus controllerのsetCurrent()メソッドが呼ばれます
    • 各リンクが選択されているかどうかのステートを保持するため(またアクセシビリティのため)、リックにはaria-current="page"または"false"をつけています
  • もう1つのアクションは"Teams"のボタンをクリックするとその下のサブメニューが開くところです
    • buttonタグのとこにdata-action="click->sidebar#toggle"をつけます。SidebarController Stimulus controllerのtoggle()メソッドが呼び出されます
    • またサブメニューの開閉状態のステートはaria-expandedに持ちますので、初期状態としてaria-expanded="false"をつけています

Stimulus controller

app/javascript/controllers/sidebar_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="sidebar"
export default class extends Controller {
  connect() {
  }

  toggle(event) {
    const button = event.currentTarget
    button.ariaExpanded = button.ariaExpanded === "true" ? "false" : "true"
  }

  setCurrent(event) {
    this.#resetAriaCurrent()
    const link = event.currentTarget
    link.ariaCurrent = "page"
  }

  #resetAriaCurrent() {
    this.element
      .querySelectorAll("[aria-current]")
      .forEach(e => e.ariaCurrent = "false")
  }
}
  • SidebarのStimulus Controllerです
  • このControllerは2つのActionを受け取ります
    • toggle(event)はサブメニュー開閉ボタンをトグルするものです。表示を変更するターゲットとなるHTML要素は自分自身(data-actionを持ったHTML要素自身)ですので、event.currentTargetで取得できます。このHTML要素のaria-expandedを適宜設定しています
    • setCurrent(event)は選択されたリンクの背景を灰色にするものです。先にthis.#resetAriaCurrent()で今まで選択されていたものをクリアしたのち、data-actionを受けたHTML要素(event.currentTargetで取得)にaria-current="page"を設定しています

まとめ

  • ステートを維持するサイドバーをTurboとStimulusで作成しました
  • ブラウザステートの維持はdata-turbo-permanentで行います
  • どのページでどのリンクを選択状態(背景が灰色)にするかや、どのサブメニューを開閉するかのロジックが不要になりますので、ページ数が増えてもメンテナンスが楽です
  • またサイドバーの状態はStimulusだけで更新されて、サーバのレスポンスを待ちませんので、レスポンスがもたつきません。楽観的UI (Optimistic UI)になります
  • 今回は実装していませんが、"Engineering"のページで画面をリロードした時、メニューの選択状態やサブメニューの開閉状態が初期状態に戻っています。これを正しく設定するにはサーバレンダリング時にステートを管理する必要があります

interactive-flow-hotwire.webp

  • 今回はaタグをクリックしたと同時にTurbo DriveとStimulusの双方が動いています。したがって2番目のの経路と、3番目のの経路を使った感じになっています
  • ブラウザのステートとサーバのステートは常に同期させるわけではなく、今回のようにブラウザステートをサーバと独立に管理したい場合があります。Hotwireではこのために主に2つの方法が用意されています
    • 1つはTurbo FramesやTurbo Streamsでブラウザステートを迂回する方法
    • もう1つは今回紹介した、data-turbo-permanent固定化する方法です。

個々のケースで最良のものを選択します。