ここで作るのは下記のようなUIです。
デモはこちらに用意しています。
aria-expanded属性をステートとしますaria-hiddenやinertの属性を追加しますinterpolate-sizeのサポートが広がれば不要になる見込みです。<details>, <summary>を使った例も参考として紹介しますが、滑らかなトランジションに必要なinterpolate-sizeCSSがまだすべての主要ブラウザに広がっていないため、あくまでも参考扱いです<input type="checkbox">や<input type="radio">を使用する方法もありますが、ネイティブな<details>, <summary>での完全な実装が近い将来期待できますので、解説しませんimport {Controller} from "@hotwired/stimulus" // Connects to data-controller="accordion" export default class extends Controller { static targets = ["revealable", "trigger"] connect() { this.#syncContentA11y() } toggle() { this.triggerTarget.ariaExpanded = this.#isExpanded() ? "false" : "true" this.#toggleRevealableTargets() this.#syncContentA11y() } #isExpanded() { return this.triggerTarget.ariaExpanded == "true" } #syncContentA11y() { this.revealableTargets.forEach(target => { if (this.#isExpanded()) { target.ariaHidden = "false" target.inert = false } else { target.ariaHidden = "true" target.inert = true } }) } #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" } }) } }
toggle()だけなので、そこでステートがどのように変化するかを確認します。this.triggerTarget.ariaExpandedがステートだというのがわかります。toggle()を実行することでこの値の"true", "false"と変化します。this.triggerTarget.ariaExpandedステートは、のちに詳しく説明しますが、CSSセレクタで検知できます。つまりCSSこのステートを監視して、自動的にUIを更新してくれます#syncContentA11y()はCSSだけでは対応できない箇所を更新しています
this.#toggleRevealableTargets()はCSS transitionのための工夫、this.#syncContentA11y()はコンテンツに対してa11yのためのaria属性を変更したり、inert属性を追加したりしています。<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>
accordion_row partialを使ってコンポーネント化しています。
do endブロックとyieldを使って、コードをスッキリさせています。この使い方はRails Guideでも紹介されていますaccordion_row partial<div class="py-4 border-b border-gray-300" data-controller="accordion"> <button class="group w-full flex justify-between text-xl cursor-pointer" data-action="click->accordion#toggle" data-accordion-target="trigger" aria-expanded="false"> <span><%= title %></span> <div class="group-aria-[expanded=true]:rotate-180 pt-2 transition-all duration-300"> <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> </div> </button> <div data-accordion-target="revealable" class="h-0 overflow-hidden transition-all duration-300 text-sm"> <div class="mt-4"><%= yield %></div> </div> </div>
data-controller="accordion"となっている<div>で、上述のAccordionController Stimulus controllerに接続します<button>にdata-action="click->accordion#toggle"があります。つまりこのボタンがクリックされるとAccordionControllerのtoggle()メソッドが呼び出されます
toggle()メソッドはこの<button>のステートを変更し、aria-expanded="false"を"true"に書き換えます<div>にはclass="group-aria-[expanded=true]:rotate-180がついています。これは親の<button>要素のaria-expanded属性に反応して、"true"ならば180度回転するものです。つまりAccordionControllerのステートの変化に反応している表示を変えています。data-accordion-target="revealable"となっているところが、アコーディオンの開閉で見え隠れする箇所です
data-accordion-target="revealable"により、AccordionControllerからはthis.revealableTargetsとして簡単にアクセスできます。hiddenで隠す訳にはいきません。h-0 overflow-hiddenで隠して、徐々に高さを変える隠し方をしています。AccordionControllerの#toggleRevealableTargets()の処理です<div class="py-4 border-b border-gray-300"> <details class="group"> <summary class="flex cursor-pointer list-none items-start justify-between text-xl marker:content-none"> <span><%= title %></span> <span class="pt-2 transition-transform duration-300 group-open:rotate-180" aria-hidden="true"> <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> </span> </summary> <div class="text-sm"> <div> <div class="mt-4"><%= yield %></div> </div> </div> </details> </div>
<details>タグが管理するため、JavaScriptが不要になっています<summary>はnativeに表示非表示が切り替わりますのでJavaScriptはやはり不要です。滑らかなトランジションを実現するCSSがまだ主要ブラウザで十分にサポートされていないため、今回はトランジションさせていません。近い将来にトランジションもnativeで可能になるはずです<details>, <summary>はnativeにサポートしていませんが、開閉状態は<summary>のopen属性に反映されますので、class="group-open:rotate-180"で検知して、CSSを使って表示を変更していますaria-expandedに持たせていますが、トランジションの都合でうまくいかないところはStimulus ControllerからJavaScriptで操作しています。またJavaScriptでDOMを書き換えないといけない箇所(今回はa11y関連)はStimulus Controllerで対処しています