How to Ruby logo

How to create seamless modal forms with Turbo Drive

Turbo Frames inspired us to ask for a piece of html to work regardless if it is rendered on it’s own page, or as part of another page. I’m borrowing the same idea to apply it to modals. Goal is to not introduce any changes to the backend code (no Turbo Streams), but still be able to submit forms and see validation errors.

This has the benefits of:

  • easy development - one doesn’t need to keep clicking the “open modal” button to open and test the modal every time
  • ability to have a dedicated page for the modal content

Solution Overview

The solutions consists of a few pieces:

On the JavaScript side:
  1. Send additional header to tell the backend that we want to render the modal in a “modal context”
  2. If response comes up as a success -> follow the generic turbo drive behaviour, which is usually to redirect to the next page
  3. If response from server contains validation errors -> update the form with the html from the server containing the validation errors
On the Rails side:
  1. check if a “modal variant” of the page is requested and serve that variant instead
  2. “modal variant” includes the html for wrapping the modal
  3. remove the layout for “modal variant” requests

Let’s go through those one by one:

I’ll use the Modal from Tailwind Stimulus Components, but if you don’t fency including the library, feel free to copy paste the code from there. It has not other dependencies.

Create your own modal controller:

// app/javascript/controllers/modal_controller.js

import { Controller } from "stimulus"
import { Modal } from "tailwindcss-stimulus-components"
import Rails from "@rails/ujs"

import { focusFirstAutofocusableElement } from "helpers/focus-helper"

export default class extends Modal {
  initialize() {
    super.initialize()
    this.populate = this.populate.bind(this)
    this.beforeTurboRequest = this.beforeTurboRequest.bind(this)
  }

  connect() {
    super.connect()
    document.addEventListener("turbo:before-fetch-response", this.populate)
    document.addEventListener("turbo:submit-start", this.beforeTurboRequest)
  }

  disconnect() {
    document.removeEventListener("turbo:before-fetch-response", this.populate)
    document.removeEventListener("turbo:submit-start", this.beforeTurboRequest)
    super.disconnect()
  }

  open(event) {
    event.preventDefault()
    const url = event.currentTarget.href
    if (!url) {
      // modal is inlined, just open it
      super.open(event)
      return
    }

    this.openerElement = event.currentTarget

    Rails.ajax({
      type: "get",
      url: url,
      dataType: "html",
      beforeSend: (xhr, options) => {
        xhr.setRequestHeader("X-Show-In-Modal", true)
        return true
      },
      success: (data) => {
        if (this.hasContainerTarget) {
          this.containerTarget.replaceWith(data.body.firstChild)
        } else {
          this.element.appendChild(data.body.firstChild)
        }

        focusFirstAutofocusableElement(this.containerTarget)

        super.open(event)
      },
      error: (data) => {
        console.log("Error ", data)
      },
    })
  }

  beforeTurboRequest(event) {
    const {
      detail: { formSubmission },
    } = event
    formSubmission.fetchRequest.headers["X-Show-In-Modal"] = true
  }

  close(event) {
    if (this.hasContainerTarget) {
      super.close(event)
      if (this.openerElement) this.openerElement.focus()
    }
  }

  async populate(event) {
    const {
      detail: { fetchResponse },
    } = event

    if (!fetchResponse.succeeded) {
      event.preventDefault()
      this.containerTarget.outerHTML = await fetchResponse.responseText

      this.containerTarget.classList.remove("hidden", "animated", "anim-scale-in")
      focusFirstAutofocusableElement(this.containerTarget)
    }
  }

  _backgroundHTML() {
    return '<div id="modal-background" class="animated anim-fade-in"></div>'
  }
}

Notes:

  • The tickiest bit of it all is in the populate() which is called after a form on the modal is submitted.
    • If the request is successful, the standard Turbo Drive behaviour will be followed (usually redirect to the next page)
    • If the request is not successful however (422 :unprocessable_entity), then replace the modal html with the one sent from the server, which would likely include the validation errors.
  • Pay attention how we send an additonal custom header - X-Show-In-Modal - before each request.

Controllers

# app/controllers/concerns/alt_variant_without_layout.rb

module AltVariantWithoutLayout
  extend ActiveSupport::Concern

  included do
    layout -> { false if alt_variant_request? }
    etag { :alternative_variant if alt_variant_request? }
    before_action { request.variant = :modal if show_in_modal_request? }
  end

  private

  def alt_variant_request?
    show_in_modal_request?
  end

  def show_in_modal_request?
    request.headers["X-Show-In-Modal"].present?
  end
end

Notes

  • Check for the presence of a special header X-Show-In-Modal, and if so:
    • render the page without the layout
    • render an alternativa variant of the page
# Example controller. Only change is the inclusion of the above concern

class OrdersController < ApplicationController
  include AltVariantWithoutLayout # added in

  def new
    order_form = CreateOrderForm.new(
      user: current_user,
    )
    authorize(order_form)
    render locals: { order_form: order_form }
  end

  def create
    order_form = CreateOrderForm.new(
      user: current_user,
      params: order_params,
    )

    authorize(order_form)

    if order_form.save
      flash[:notice] = "Successfully created order!"
      redirect_to order_path(order)
    else
      render :new, locals: { order_form: order_form },
                    status: :unprocessable_entity
    end
  end
end

Views

We can now either call a modal with the contents of the #new action as well render the form on its own page as customary.

Call the modal from the index page
<!-- GET /orders -->
<!-- somewhere on orders/index page -->
<div data-controller="modal">
  <%= link_to(
    new_order_path,
    data: { action: "click->modal#open" }
  ) do %>
    Create a new order
  <% end %>
</div>
Render the form as ordinary directly on the New page
<!-- GET /orders/new -->
<!-- will render just the form -->

This is our nice and reusable modal with all accessibility properties catered for:

<!-- app/views/global/_modal.html.erb -->

<% random_identifier = SecureRandom.hex(5) %>

<!-- styling based on: https://github.com/excid3/tailwindcss-stimulus-components/blob/master/src/modal.js -->
<div
  data-modal-target="container"
  data-action="click->modal#closeBackground keyup@window->modal#closeWithKeyboard"
  class="hidden animated fadeIn fixed inset-0 overflow-y-auto flex items-center justify-center" style="z-index: 9999;"
  role="dialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-body-<%= random_identifier %>">

  <div class="max-h-screen w-full max-w-lg relative">
    <div class="m-1 bg-white rounded shadow">

    <header class="mb-8">
      <h2><%== header %></h2>
    </header>

      <div id="dialog-body-<%= random_identifier %>">
        <%= yield %>
      </div>
    </div>
  </div>
</div>

The “modal version” of the html includes the html for rendering the modal.

<!-- app/views/orders/new.html+modal.erb -->
<%= render 'global/modal', header: "Create Order" do %>
  <%= render 'form', form: form %>
<% end %>

Nothing special about the non-“modal version” of the page

<!-- app/views/orders/_form.html.erb -->
<%= form_with model: form, url: order_path do |form| %>
  <%= render 'shared/error_messages', resource: form.object %>

  <%= form.label :number, "Order Number" %>
  <%= form.text_field :number %>

  <%= form.submit "submit" %>
<% end %>
<!-- app/views/orders/new.html.erb -->
<%= render 'form', form: form %>
Final result

Any validation errors correctly update the modal. If no errors, a redirect is followed.

modal-article-preview


That’s all! Hope it’s useful! Send me a message at hello@howtoruby.com to let me know if you like it.