Hotwire Modals in Ruby on Rails with Stimulus and Turbo Frames
Tags: ruby, rails
Published: 2024-03-06 11:17:29

Modals are widely used on the web, but they are rarely built with accessibility in mind. When a modal is displayed, the background is dimmed visually but it's still visible to screen readers and keyboard-only users.

In this post, the first of a two-part series, we'll look at presenting accessible modals in Rails using two different approaches powered by Hotwire's Turbo and Stimulus libraries.

But first, let's see what we need to do to make modals accessible.

## How Can We Make Modals Accessible?

To make modals accessible, we need to:

- Hide the background from screen readers.
- Implement "focus trapping" so keyboard users cannot focus on elements outside the modal.
- Shift focus to the first focusable element in the modal (if there is one).
- Dismiss the modal with the `Esc` key.

We could accomplish the first two by setting `inert="true"` on the background elements, but this attribute can't be set on an ancestor of the modal. We can do the last two with some more custom JavaScript. As you can imagine this is rather tedious.

The HTML `<dialog>` element gives us most of the above list for free, and is supported for [94.25% of global users](https://caniuse.com/?search=dialog), meaning it's ideal in most cases.

Let's look a bit more closely at the `<dialog>` element.

## The `<dialog>` Element

According to [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog):

> The HTML `<dialog>` element is used to create both modal and non-modal dialog boxes. Modal dialog boxes interrupt interaction with the rest of the page being inert, while non-modal dialog boxes allow interaction with the rest of the page.

This means that a `<dialog>` isn't necessarily a modal. It can be presented within the document flow as well.

### Presenting a `<dialog>`

To present a dialog as a modal, we need to use JavaScript:

```html
<dialog>Lorem ipsum ....</dialog>
```

```js
const modal = document.querySelector("dialog");
modal.showModal();
```

When presenting a dialog this way, the background is made inert, focus is trapped in the modal, and it can be dismissed using the `Esc` key.

To present a `<dialog>` in a non-modal context, and hence make it visible by default, we use the `open` attribute:

```html
<dialog open>Lorem ipsum ....</dialog>
```

We won't get the accessibility features using this method, though, as the dialog hasn't been presented "modally".

### Dismissing a `<dialog>`

A modally presented `<dialog>` can be dismissed using JavaScript:

```js
const modal = document.querySelector("dialog");
modal.showModal();
modal.close();
```

It can also be closed using a `<form>` with the `dialog` method. This is a great way to implement a "close" button.

```html
<dialog>
  <header>
    <h2>A modal dialog</h2>
    <form method="dialog">
      <button type="submit">Close</button>
    </form>
  </header>

  Lorem ipsum...
</dialog>
```

That covers the basics of the `<dialog>` element, so let's look at using it with Hotwire.

## Modal Presentation Using Stimulus in Ruby on Rails

[Stimulus](https://stimulus.hotwired.dev) is a JavaScript library under the [Hotwire](https://hotwired.dev) umbrella. It allows us to attach pieces of JavaScript logic to HTML elements encapsulated in _controllers_. This post assumes a basic familiarity with its API.

Let's start with a Rails controller and view. As an example use case, we'll use a _support_ page which has a button to display some contact details in a modal. Generate a controller and action using:

```shell
$ bin/rails generate controller support show
```

Amend the created route to a more user-friendly path:

```ruby
# config/routes.rb
Rails.application.routes.draw do
  # ...

  get '/support', to: "support#show"
end
```

Sketch out a button and dialog in the newly generated view file:

```erb
<%# app/views/support/show.html.erb %>

<button>
  Show contact details
</button>

<dialog aria-labelledby="modal_title">
  <header>
    <h2 id="modal_title">
      Contact details
    </h2>
    <form method="dialog">
      <button aria-label="close">X</button>
    </form>
  </header>

  <p>
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
    incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
    nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
  </p>
</dialog>
```

Run the Rails server and navigate to the `/support` path. You should see a button there, but it doesn't do anything at the moment. Let's create a Stimulus controller and wire it up.

### The Stimulus Controller in Rails

Use the generator to create a Stimulus controller.

```shell
$ bin/rails generate stimulus modal
```

When the button is clicked, we need to get a reference to the `<dialog>` and call `showModal()` on it. To keep the controller generic, we'll pass in the element's `id` as a _param_.

```js
// app/javascript/controllers/modal_controller.js

import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="modal"
export default class extends Controller {
  show(event) {
    const dialog = document.getElementById(event.params.dialog);
    dialog.showModal();
  }
}
```

We can now decorate the button with the required `data-` attributes:

```erb
<%# app/views/support/show.html.erb %>

<button
  data-controller="modal"
  data-action="modal#show"
  data-modal-dialog-param="contact_details_modal">
  Show contact details
</button>

<dialog id="contact_details_modal" aria-labelledby="modal_title">
  <%# ... %>
</dialog>
```

Refresh the page and the button should now work!

There's an opportunity to remove some boilerplate code here. The `modal` controller should always be attached to a button that shows the modal. We can remove the `data-action` from the markup and set it in the controller:

```js
// app/javascript/controllers/modal_controller.js

// ...
export default class extends Controller {
  connect() {
    this.element.dataset.action = "modal#show";
  }

  show(event) {
    // ...
  }
}
```

### Styling the Modal

The dialog looks fairly basic, so let's add some styles:

```scss
/* app/assets/stylesheets/application.scss */

dialog {
  width: 80vw;
  margin: auto;

  &::backdrop {
    background: red;
    opacity: 0.2;
  }

  header {
    display: flex;
    align-items: center;

    h2 {
      flex: 0 1 100%;
    }
  }
}
```

The `::backdrop` pseudo-element is a really great way to style the modal's background!

Try using the `Tab` key to navigate around the page and you'll see that the focus is trapped within the modal. It's worth reviewing the page [with a screen reader](https://github.com/basecamp/accessibility/blob/master/Section-1/Autiditing-Accessibility-On-The-Web.md#review-the-page-using-voiceover-mac-or-nvda-win-screen-reader) as well.

This is the simplest way to modally present a `<dialog>`. Next, we'll look at a server-driven way to present modals: Turbo Frame.

## Turbo Frame Powered Modals for Ruby

[Turbo Frames](https://turbo.hotwired.dev/reference/frames) are a subset of the [Turbo](https://turbo.hotwired.dev) library which is also under the Hotwire umbrella. It allows us to scope navigation to specific parts of a page, updating them in isolation from the rest of the page.

We can use Turbo Frames to present a modal rendered from the server.

### Setting Up

Let's add another button to display a modal _contact form_. We'll need a controller and action to render the form:

```shell
$ bin/rails generate controller support/tickets new create
```

Replace the auto-generated routes with:

```ruby
Rails.application.routes.draw do
  # ...

  namespace :support do
    resources :tickets, only: [:new, :create]
  end
end
```

We'll also need a global Turbo Frame to render modals, so let's put it in the main _application_ layout:

```erb
<%# app/views/layouts/application.html.erb %>

<!DOCTYPE html>
<html>
  <%# ... %>

  <body>
    <%= yield %>

    <%= turbo_frame_tag :remote_modal %>
  </body>
</html>
```

Add a link to show the contact form:

```erb
<%# app/views/support/show.html.erb %>

<button
  data-controller="modal"
  data-modal-dialog-param="contact_details_modal">
  Show contact details
</button>

<%= link_to new_support_ticket_path, data: { turbo_frame: :remote_modal } do %>
  Show contact form
<% end %>

<%# ... %>
```

And you're done!

### Rendering and Presenting the Form

Clicking the _Show contact form_ link will expect a `<turbo-frame>` with `id="remote_modal"` in the response and update the global Turbo Frame with its content. Fill in the view with the form:

```erb
<%# app/views/support/tickets/new.html.erb %>

<%= turbo_frame_tag :remote_modal do %>
  <dialog id="contact_form_modal" aria-labelledby="modal_title">
    <header>
      <h2 id="modal_title">
        Contact us
      </h2>
      <form method="dialog">
        <button aria-label="close">X</button>
      </form>
    </header>

    <%= form_with(url: support_tickets_path) do |form| %>
      <%= form.label :message, "Your message" %>
      <%= form.text_area :message, autofocus: true %>

      <%= form.button "Close", value: nil, formmethod: :dialog %>
      <%= form.button "Send" %>
    <% end %>
  </dialog>
<% end %>
```

We've now got a text area in the modal which is a focusable element. For accessibility, it should be focused by default when the modal is presented. The `autofocus` attribute is used to accomplish this.

Refresh the page and try clicking _Show contact form_. Nothing will happen visually, but on inspecting the HTML, you'll notice the `<dialog>` has been rendered in the `remote_modal` Turbo Frame. We haven't presented it yet, which is why it's invisible.

We could render it with the `open` attribute, but that would defeat the purpose as it'd be presented in a non-modal context without the modal accessibility features.

Let's create another Stimulus controller to present the form:

```shell
$ bin/rails generate stimulus remote_modal
```

```js
// app/javascript/controllers/remote_modal_controller.js

import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="remote-modal"
export default class extends Controller {
  connect() {
    this.element.showModal();
  }
}
```

And hook it up to the dialog:

```erb
<%# app/views/support/tickets/new.html.erb %>

<%= turbo_frame_tag :remote_modal do %>
  <dialog
    id="contact_form_modal"
    aria-labelledby="modal_title"
    data-controller="remote-modal">
    <%# ... %>
  </dialog>
<% end %>
```

Refresh the page and try viewing the contact form again. This time it should work. We've just rendered a modal from the server!

While this is quite convenient, it's not ideal to create a new Stimulus controller just to present a modal. There's another method we can use as well: Turbo Streaming Modals. We'll take a look at those in part two of this series.

## Up Next: Turbo Streaming Modals

In this post, we explored two different methods to present modals using Hotwire: Stimulus and Turbo Frames.

In part two, we'll look at another way to present modals: using Turbo Streams.

Until then, happy coding!

**P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, [subscribe to our Ruby Magic newsletter and never miss a single post](https://blog.appsignal.com/ruby-magic)!**