ドロップダウン

UI 要素名
DropDown, Menu
サーバ接続
不要
ステート管理
aria-expanded (Stimulus版), 不要 (Native版)
使用技術
Stimulus, Native HTML (Popover API)
デモ
関連ページ

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

考えるポイント

  1. サーバから非同期でデータをもらう必要はありません
    1. したがってStimulusだけで実装できます
    2. ネイティブでの実装(チェックボックスを使う)も可能ですが、今回は使いません
  2. 次はStimulus Controllerの制御範囲を考えます
    1. UI要素は大きく、ボタン(顔のアイコン)と表示されるメニューがあります
    2. 表示されるメニューからマウスが離れるとメニューが消えますが、これも制御対象になります
    3. したがって顔のアイコンとメニュー自身の双方を囲むStimulus Controllerを用意します
  3. ステートを変更するActionはマウスのホバー状態のだけで単純です。またまたメニューの表示・非表示の際にaria-expandedを変更するべきです。このようなシンプルなケースでは、StimulusのValuesステートを使わずに、aria-expandedだけで十分に管理できそうです。

コード (Stimulus版)

View

app/views/components/dropdown_menu.html.erb
<% 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でメニューを表示・非表示にします
  • Actionはdata-action="mouseenter->dropdown#show"data-action="mouseleave->dropdown#hide"のところです。mouseentermouseleaveイベントに反応してStimulus controllerのshow()hide()を呼び出しています

Stimulus controller

app/javascript/controllers/dropdown_controller.js
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版)

ドロップダウンメニューをStimulusで作るにあたって以下のことを検討しました

  • ステートはaria-expanded属性に保持する
  • aria-expanded="true"を監視するCSS擬似セレクタを使い、CSSでメニューを表示する

コード (Native版)

View

app/views/components/dropdown_menu_native.html.erb
<% 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で補っています
  • Actionはdata-action="mouseenter->dropdown-native#show"mouseleave->dropdown-native#hideのところです。mouseentermouseleaveイベントに反応してStimulus controllerのshow()hide()を呼び出しています
  • Nativeのpopoverはpopovertarget="user-menu"popover属性で制御します
  • またCSSのanchor positioningを使ってドロップダウンの表示位置を制御しています。Tailwindでは[anchor-name:--user-menu]および[position-anchor:--user-menu]を使います
  • Nativeのpopoverでトランジションを使う場合はCSSの@starting-styleを使用しますが、これはTailwindだと生成できないのでapp/assets/stylesheets/components/dropdown.cssに記述しています。
    • Stimulus版ではTailwindのcollapse/visible, opacity-0/opacity-100で表示・非表示を切り替えていますが、Nativeのpopoverはdisplay:block/noneで表示・非表示を切り替えますので、@starting-styleが必要になります。

Stimulus controller

app/javascript/controllers/dropdown_native_controller.js
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)
  }
}
  • NativeのpopoverはボタンをクリックするならJavaScriptなしで作成できますが、mouseenter, mouseleaveのイベントに反応させるためにはStimulusを使用しています
  • Nativeのpopoverではpopovertargetで指定したIDのHTML要素に対してshowPopover(), hidePopover(), togglePopover()のメソッドが用意されています。Stimulus controllerのshow(), hide()イベントハンドラはこれらのメソッドを呼び出しています。
  • Stimulus版はメニューの表示・非表示を管理するためにステートをaria-expanded属性として持ちましたが、Native版ではステート管理が不要になっています。
UI 要素名
DropDown, Menu
サーバ接続
不要
ステート管理
aria-expanded (Stimulus版), 不要 (Native版)
使用技術
Stimulus, Native HTML (Popover API)
デモ
関連ページ