Skip to content

Lesson 4 — Dropdown

The component

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# app/components/dropdown.rb
class Components::Dropdown < Components::Base
  prop :label, String
  prop :align, Symbol, default: -> { :left }

  def view_template(&)
    vanish(&)

    div(
      class: "relative inline-block",
      data:  { controller: "dropdown" }
    ) do
      render_trigger
      render_menu
    end
  end

  def item(label, url: "#", method: :get, **attrs)
    @items ||= []
    @items << { label:, url:, method:, attrs: }
    nil
  end

  private

  def render_trigger
    button(
      type:  "button",
      class: "inline-flex items-center gap-1 px-3 py-2 text-sm " \
             "font-medium text-text rounded-md hover:bg-surface-alt",
      data:  {
        action:           "click->dropdown#toggle " \
                          "click@window->dropdown#closeOnOutsideClick",
        dropdown_target:  "trigger",
        aria_expanded:    "false",
        aria_haspopup:    "true"
      }
    ) do
      plain @label
      span(class: "text-text-muted") { "▾" }
    end
  end

  def render_menu
    div(
      role:   "menu",
      class:  menu_classes,
      hidden: true,
      data:   { dropdown_target: "menu" }
    ) do
      (@items || []).each { |item| render_item(item) }
    end
  end

  def render_item(item)
    if item[:method] == :delete
      form_with(url: item[:url], method: :delete) do
        button(type: "submit", role: "menuitem", class: item_classes) { item[:label] }
      end
    else
      a(href: item[:url], role: "menuitem", class: item_classes) { item[:label] }
    end
  end

  def menu_classes
    base = "absolute z-10 mt-1 w-48 bg-surface rounded-md shadow-lg " \
           "border border-border py-1"
    @align == :right ? "#{base} right-0" : "#{base} left-0"
  end

  def item_classes
    "block w-full text-left px-4 py-2 text-sm text-text " \
    "hover:bg-surface-alt focus:outline-none focus:bg-surface-alt"
  end
end

The Stimulus controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["trigger", "menu"]

  toggle() {
    this.menuTarget.hidden ? this.open() : this.close()
  }

  open() {
    this.menuTarget.removeAttribute("hidden")
    this.triggerTarget.setAttribute("aria-expanded", "true")
    document.addEventListener("keydown",
      this.boundKeydown ||= this.handleKeydown.bind(this))
  }

  close() {
    this.menuTarget.setAttribute("hidden", "")
    this.triggerTarget.setAttribute("aria-expanded", "false")
    document.removeEventListener("keydown", this.boundKeydown)
  }

  closeOnOutsideClick(event) {
    if (!this.element.contains(event.target)) this.close()
  }

  handleKeydown(event) {
    if (event.key === "Escape") { this.close(); this.triggerTarget.focus() }
    if (event.key === "ArrowDown") { event.preventDefault(); this.focusNext() }
    if (event.key === "ArrowUp")   { event.preventDefault(); this.focusPrevious() }
  }

  focusNext() {
    const items = this.menuItems
    const next  = items[items.indexOf(document.activeElement) + 1] || items[0]
    next?.focus()
  }

  focusPrevious() {
    const items = this.menuItems
    const prev  = items[items.indexOf(document.activeElement) - 1] || items[items.length - 1]
    prev?.focus()
  }

  get menuItems() {
    return Array.from(this.menuTarget.querySelectorAll('[role="menuitem"]'))
  }
}

Arrow key navigation makes the dropdown fully keyboard accessible — a requirement for WCAG AA compliance.

Usage

1
2
3
4
5
Dropdown(label: "Actions", align: :right) do |d|
  d.item "Edit board",     url: edit_board_path(@board)
  d.item "Manage members", url: board_members_path(@board)
  d.item "Delete board",   url: board_path(@board), method: :delete
end

You can test this in the same way - but you might want to put a dummy path for the “Manage members” url, because we don’t have that path yet.