ここで作るのは次のようなUIです。
aria-expandedを変更するべきです。このようなシンプルなケースでは、StimulusのValuesステートを使わずに、aria-expandedだけで十分に管理できそうです。<% set_breadcrumbs [["DropDown", component_path(:dropdown)]] %> <%= render 'template', title: "DropDown", description: "" do %> <div class="mx-auto w-48"> <!-- Profile dropdown --> <div class="relative ml-3 w-8" data-controller="dropdown" data-action="mouseleave->dropdown#hide"> <button type="button" class="peer relative flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" id="user-menu-button" aria-expanded="false" data-dropdown-target="switch" data-action="mouseenter->dropdown#show" aria-haspopup="true"> <span class="absolute -inset-1.5"></span> <span class="sr-only">Open user menu</span> <%= image_tag("content_images/avatar.webp", class: "h-8 w-8 rounded-full") %> </button> <!-- Dropdown menu, show/hide based on menu state. Entering: "transition ease-out duration-200" From: "transform opacity-0 scale-95" To: "transform opacity-100 scale-100" Leaving: "transition ease-in duration-75" From: "transform opacity-100 scale-100" To: "transform opacity-0 scale-95" --> <div id="user-menu" class="absolute left-0 z-10 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 focus:outline-none transition-all transform opacity-0 collapse scale-95 ease-in duration-75 peer-aria-[expanded=true]:opacity-100 peer-aria-[expanded=true]:scale-100 peer-aria-[expanded=true]:visible peer-aria-[expanded=true]:ease-out peer-aria-[expanded=true]:duration-200" role="menu" aria-orientation="vertical" aria-labelledby="user-menu-button" tabindex="-1"> <!-- Active: "bg-gray-100", Not Active: "" --> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-300" role="menuitem" tabindex="-1" id="user-menu-item-0">Your Profile</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-300" role="menuitem" tabindex="-1" id="user-menu-item-1">Settings</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-300" role="menuitem" tabindex="-1" id="user-menu-item-2">Sign out</a> </div> </div> </div> <% end %>
data-controller="dropdown"でStimulus Controllerと繋げています。ボタン(顔の写真があるところ)とメニューを囲むようにStimulus Controllerを繋げますaria-expanded="false"をステートとします。これが"true"かどうかをCSS擬似セレクタ(peer-aria-[expanded=true]:)で読み取って、CSSでメニューを表示・非表示にしますdata-action="mouseenter->dropdown#show"とdata-action="mouseleave->dropdown#hide"のところです。mouseenterとmouseleaveイベントに反応してStimulus controllerのshow()とhide()を呼び出していますimport { Controller } from "@hotwired/stimulus" // Connects to data-controller="dropdown" export default class DropdownController extends Controller { static targets = ["switch"] connect() { } show(event) { this.switchTargets.forEach((target) => target.ariaExpanded = "true") } hide(event) { this.switchTargets.forEach((target) => target.ariaExpanded = "false") } }
static targets = ["switch"]はtargetを指定しています。switchはボタン(顔のアイコンがあるもの)に指定してありますshow(), hide()のイベントでtargetのariaExpandedの値を切り替えています。先ほど説明したように、CSSは擬似要素を介してここのaria-expandedを監視し、aria-expanded="true"となるとメニューを表示するようにしていますドロップダウンメニューをStimulusで作るにあたって以下のことを検討しました
aria-expanded属性に保持するaria-expanded="true"を監視するCSS擬似セレクタを使い、CSSでメニューを表示する<% set_breadcrumbs [["DropDown", component_path(:dropdown)]] %> <%= render 'template', title: "DropDown Native", description: "" do %> <div class="mx-auto w-48"> <!-- Profile dropdown --> <div class="relative ml-3 w-8" data-controller="dropdown-native" data-action="mouseleave->dropdown-native#hide"> <button type="button" class="[anchor-name:--user-menu] peer relative flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" id="user-menu-button" data-action="mouseenter->dropdown-native#show" data-dropdown-native-target="button" popovertarget="user-menu"> <span class="absolute -inset-1.5"></span> <span class="sr-only">Open user menu</span> <%= image_tag("content_images/avatar.webp", class: "h-8 w-8 rounded-full") %> </button> <div id="user-menu" class="dropdown-native" popover role="menu" aria-orientation="vertical" aria-labelledby="user-menu-button" tabindex="-1"> <!-- Active: "bg-gray-100", Not Active: "" --> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-300" role="menuitem" tabindex="-1" id="user-menu-item-0">Your Profile</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-300" role="menuitem" tabindex="-1" id="user-menu-item-1">Settings</a> <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-300" role="menuitem" tabindex="-1" id="user-menu-item-2">Sign out</a> </div> </div> </div> <% end %>
data-controller="dropdown_native"でStimulus Controllerと繋げています。NativeのpopoverはJavaScriptなしの場合はclickには反応しますが、mouseenter, mouseleaveには反応しないため、そこはStimulus Controllerで補っていますdata-action="mouseenter->dropdown-native#show"とmouseleave->dropdown-native#hideのところです。mouseenterとmouseleaveイベントに反応してStimulus controllerのshow()とhide()を呼び出していますpopovertarget="user-menu"とpopover属性で制御します[anchor-name:--user-menu]および[position-anchor:--user-menu]を使います@starting-styleを使用しますが、これはTailwindだと生成できないのでapp/assets/stylesheets/components/dropdown.cssに記述しています。
collapse/visible, opacity-0/opacity-100で表示・非表示を切り替えていますが、Nativeのpopoverはdisplay:block/noneで表示・非表示を切り替えますので、@starting-styleが必要になります。 import { Controller } from "@hotwired/stimulus" // Connects to data-controller="dropdown" export default class DropdownNativeController extends Controller { static targets = ["button"] connect() { } show(event) { this.#popoverElement().showPopover() } hide(event) { this.#popoverElement().hidePopover() } #popoverElement() { const popoverId = this.buttonTarget.getAttribute("popovertarget") return document.getElementById(popoverId) } }
mouseenter, mouseleaveのイベントに反応させるためにはStimulusを使用していますpopovertargetで指定したIDのHTML要素に対してshowPopover(), hidePopover(), togglePopover()のメソッドが用意されています。Stimulus controllerのshow(), hide()イベントハンドラはこれらのメソッドを呼び出しています。aria-expanded属性として持ちましたが、Native版ではステート管理が不要になっています。