コード例

アコーディオン

ここで作るのは下記のようなUIです。

デモはこちらに用意しています。

考えるポイント

interactive-flow-hotwire.webp

  1. 今回はサーバから非同期でデータを受け取る必要がありません
    1. Stimulusだけで実装します。つまり上図ののところだけ考えれば良いです。
    2. HTMLのcheckboxやradioを使う方法もありますが、今回は紹介しません。もし使うとしたら上図ののところになります
    3. HTMLのdetails, summaryを使う方法もありますが、今回は紹介しません。もし使うとしたら上図ののところになります
  2. Stimulus Controllerの制御範囲を考えます。つまり画面のどこをカバーするかです
    1. 今回のアコーディオンは、各行が独立して動いています。一方で一つの行を開いたら他の行が閉じるというアコーディオンも考えられますが、今回はそれではないです
    2. 各行が独立していますので、Stimulus Controllerの制御範囲は各行単位で良さそうです(もしお互いに関連していれば、すべての行を一つのControllerの下に束ねることを考えます)
  3. Stimulus Controllerのステートを検討します
    1. アクセシビリティを調べると、アコーディオンではaria-expandedを使うのが良さそうです
    2. 基本的にはaria-expandedをCSS擬似セレクタで読み取るアプローチを採用します
    3. ただしアコーディオンを拡大する時のCSSトランジションは、拡大時の高さが指定されないとうまくいきません。このため一部ではCSSではなくStimulus controller内でJavaScriptを使って直接HTML要素のstyleを変更します

コード

アコーディオンのview

app/views/components/accordion.html.erb
  <div>
    <h2 class="text-4xl pb-8 border-b border-gray-300">
      Frequently Asked Questions
    </h2>
    <%= render 'accordion_row',
               title: "携帯プランの変更はどうすればいいですか?" do %>
      携帯プランの変更は、店頭・公式アプリ・ウェブサイトから可能です。アプリやウェブでは24時間対応しており、数分で完了します。
    <% end %>
    <%= render 'accordion_row',
               title: "機種変更時のデータ移行はできますか?" do %>
      機種変更時、データ移行はスタッフがサポートします。また、クラウドサービスやアプリを使えば簡単に自分で移行も可能です。
    <% end %>
    <%= render 'accordion_row',
               title: "解約の手続き方法を教えてください。" do %>
      解約手続きは、契約者ご本人が店頭で行う必要があります。身分証明書をご持参ください。一部プランはウェブでの手続きも可能です。
    <% end %>
  </div>
  • アコーディオンを表示するERBです
  • アコーディオンの各行はaccordion_row partialを切っています
    • なおこのpartialはdo endブロックとyieldを使って、コードをスッキリさせています。この使い方はRails Guideでも紹介されています

accordion_row partial

app/views/components/_accordion_row.html.erb
<div class="py-4 border-b border-gray-300"
     data-controller="accordion">
  <h3 class="flex justify-between text-xl cursor-pointer">
    <span><%= title %></span>
    <button aria-expanded="false"
            class="aria-[expanded=true]:rotate-180 pt-2 transition-all duration-300"
            data-action="click->accordion#toggle">
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
        <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/>
      </svg>
    </button>
  </h3>
  <div data-accordion-target="revealable" class="h-0 overflow-hidden transition-all duration-300 text-sm">
    <div class="mt-4"><%= yield %></div>
  </div>
</div>
  • アコーディオンの各行をコードしているpartialです
  • data-controller="accordion"となっているところで、AccordionController Stimulus controllerに接続します
  • <button>のところはclass="aria-[expanded=true]:rotate-180"がありますので、aria-expanded属性の値によって表示が変えるCSS擬似セレクタです
  • data-action="click->accordion#toggle"のところはアコーディオン開閉ボタンです
    • data-action="click->accordion#toggle"は、「クリックしたらaccordion controllerのtoggle()メソッドを実行すること」という意味です。イベントハンドラになります
  • data-accordion-target="revealable"となっているところが、アコーディオンの開閉で見え隠れする箇所です
    • data-accordion-target="revealable"なので、Stimulus controllerから制御される箇所です
    • アニメーションを使いますので、単純にhiddenで隠す訳にはいきません。h-0 overflow-hiddenで隠して、徐々に大きくなるアニメーションができるような隠し方をしています
    • CSSだけでトランジションができないためにStimulusから制御する必要があります。そのためにtargetになっています

Accordion Controller

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

// Connects to data-controller="accordion"
export default class extends Controller {
  static targets = ["revealable"]

  connect() {
  }

  toggle(event) {
    event.currentTarget.ariaExpanded = event.currentTarget.ariaExpanded == "true" ? "false" : "true"
    this.#toggleRevealableTargets()
  }

  #toggleRevealableTargets() {
    this.revealableTargets.forEach(target => {
      /*
      * CSS transitions cannot transition if the destination height
      * is not explicitly specified (like height: auto).
      * Hence, we get the scrollHeight with JavaScript and
      * explicitly set that value as the destination height.
      * */
      if (parseInt(target.style.height)) {
        target.style.height = 0
      } else {
        const scrollHeight = target.scrollHeight
        target.style.height = scrollHeight + "px"
      }
    })
  }
}
  • Stimulus Controllerの作り方はここをご確認ください
  • 空のconnect() {}メソッド定義があります。これはbin/rails g stimulus [controller名]をやると自動的に作ってくれるもので私はそのまま残すことが多いです
    • Stimulus controllerを繋げるときは、一歩一歩進めることが大切です。このメソッドの中にalert('hello')ってやるとcontrollerがちゃんとHTMLと繋がったことがわかりますので、Stimulusを使う第一歩で私は必ずこの確認をしています
    • StimulusとHTMLの接続は非常に動的で、もちろんIDEが静的解析をしてエラーは吐いてくれることはありません(HTML自身が非常に動的なため)。このため、一歩一歩、動作確認しながらcontrollerやaction, targetを繋げていく姿勢が大切です。これさえやれば、動的でも困ることはありません。
  • static targets =を使って、先ほどHTMLで指定したdata-accordion-target="switch", data-accordion-target="revealable"と接続します
  • 今回はActionはtoggle()だけです。data-action="click->accordion#toggle"によって呼ばれます
    • toggle()が呼び出されると
      • アコーディオンの<button>aria-expanded属性が変化します。aria-expanded属性はCSS擬似セレクタで監視されていますので、ボタンの表示が変化します
      • #toggleRevealableTargets()メソッドが呼ばれ、revealableの表示・非表示が変わります(アニメーションの都合上,heightで制御しています)

まとめ

  • アコーディオンをStimulusで実装する方法を紹介しました
  • ステートは基本的にはaria-expandedに持たせていますが、アニメーションの都合上でうまくいかないところはStimulus ControllerからJavaScriptで操作しています

なお今回のアクセシビリティは簡易的にやっただけですので、抜けている箇所があります。この点はご了承ください。