How to create seamless modal forms with Turbo Drive
07 Oct 2021Turbo 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:
- Send additional header to tell the backend that we want to render the modal in a “modal context”
- If response comes up as a success -> follow the generic turbo drive behaviour, which is usually to redirect to the next page
- If response from server contains validation errors -> update the form with the html from the server containing the validation errors
On the Rails side:
- check if a “modal variant” of the page is requested and serve that variant instead
- “modal variant” includes the html for wrapping the modal
- 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.
That’s all! Hope it’s useful! Send me a message at hello@howtoruby.com to let me know if you like it.