Skip to content

Lesson 3 — Modal

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
# app/components/modal.rb
class Components::Modal < Components::Base
  prop :title, String
  prop :open,  _Boolean, default: -> { false }

  def view_template(&)
    vanish(&)

    div(
      class: "relative z-50",
      data:  {
        controller:       "modal",
        modal_open_value: @open
      }
    ) do
      render_backdrop
      render_panel
    end
  end

  def trigger(&) = @trigger = capture(&)
  def body(&)    = @body    = capture(&)
  def footer(&)  = @footer  = capture(&)

  private

  def render_backdrop
    div(
      class: "fixed inset-0 bg-black/50 transition-opacity",
      data:  { modal_target: "backdrop", action: "click->modal#close" }
    )
  end

  def render_panel
    div(
      role:  "dialog",
      aria:  { modal: true, labelledby: "modal-title" },
      class: "fixed inset-0 flex items-center justify-center p-4",
      data:  { modal_target: "panel" }
    ) do
      div(class: "bg-surface rounded-lg shadow-xl w-full max-w-md") do
        render_header
        div(class: "px-6 py-4") { raw safe(@body) }           if @body
        div(class: "px-6 py-4 border-t border-border") { raw safe(@footer) } if @footer
      end
    end
  end

  def render_header
    div(class: "flex items-center justify-between px-6 py-4 border-b border-border") do
      h2(id: "modal-title", class: "font-semibold text-text") { @title }
      button(
        type:  "button",
        class: "text-text-muted hover:text-text",
        data:  { action: "click->modal#close" },
        aria:  { label: "Close" }
      ) { "×" }
    end
  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
50
51
52
53
54
55
56
57
58
59
60
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["panel", "backdrop"]
  static values  = { open: Boolean }

  connect() {
    if (this.openValue) this.open()
    this.boundKeydown = this.handleKeydown.bind(this)
  }

  disconnect() {
    document.removeEventListener("keydown", this.boundKeydown)
  }

  open() {
    this.openValue = true
    document.addEventListener("keydown", this.boundKeydown)
    this.panelTarget.removeAttribute("hidden")
    this.backdropTarget.removeAttribute("hidden")
    this.trapFocus()
  }

  close() {
    this.openValue = false
    document.removeEventListener("keydown", this.boundKeydown)
    this.panelTarget.setAttribute("hidden", "")
    this.backdropTarget.setAttribute("hidden", "")
  }

  handleKeydown(event) {
    if (event.key === "Escape") this.close()
    if (event.key === "Tab")    this.handleTab(event)
  }

  trapFocus() {
    const focusable = this.panelTarget.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    if (focusable.length) focusable[0].focus()
    this.firstFocusable = focusable[0]
    this.lastFocusable  = focusable[focusable.length - 1]
  }

  handleTab(event) {
    if (!this.firstFocusable) return
    if (event.shiftKey) {
      if (document.activeElement === this.firstFocusable) {
        event.preventDefault()
        this.lastFocusable.focus()
      }
    } else {
      if (document.activeElement === this.lastFocusable) {
        event.preventDefault()
        this.firstFocusable.focus()
      }
    }
  }
}

The focus trap — cycling Tab through only the modal’s focusable elements — is the most important accessibility feature. Without it, keyboard users can navigate behind the modal while it’s open.

Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Modal(title: "Test Modal") do |m|
  m.body do
    p { "This is only a test. The 'Delete' button is not wired up." }
  end
  m.footer do
    div(class: "flex gap-3 justify-end") do
      Button(label: "Cancel",       variant: :outline, type: "button",
             data: { action: "click->modal#close" })
      Button(label: "Delete board", variant: :danger,  type: "submit")
    end
  end
end

You can test this in a similar way to the Alert component. Just add the usage sample to the start of the view_template again and verify that you can dismiss the modal