Skip to content

Latest commit

 

History

History
260 lines (203 loc) · 8.52 KB

File metadata and controls

260 lines (203 loc) · 8.52 KB

Customization

Title and footer slots

The chrome partials yield a body and read two content_for blocks via generic helpers. The keys (:overlay_title, :overlay_footer) are intentionally generic so the same view renders correctly in either a modal or a drawer:

<%# app/views/users/new.html.erb %>
<% overlay_title "New User" %>

<%= form_with(model: @user) do |f| %>
  <%= f.text_field :name %>
<% end %>

<% overlay_footer do %>
  <%= modal_dismiss_link_to "Cancel", users_path, class: "btn btn-secondary" %>
  <button type="submit" class="btn btn-primary">Save</button>
<% end %>

The chrome partials render a default close ("×") button. When the view sets overlay_title, it sits inside the header; without a title it floats in the top-right corner. Suppress per overlay:

<%# in the rendered view %>
<% overlay_close false %>

<%# at the call site %>
<%= modal_link_to "Promo", promo_path, close: false %>

Variant templates per chrome

Drop variant templates alongside the standard one:

app/views/users/show.html.erb         # full-page version
app/views/users/show.html+modal.erb   # rendered when opened in a modal
app/views/users/show.html+drawer.erb  # rendered when opened in a drawer
app/views/users/show.html+popover.erb # rendered when opened in a popover

Rails picks the matching variant on overlay requests.

Footgun: overlay_title / overlay_footer in a full-page template

Both helpers write to content_for keys (:overlay_title, :overlay_footer) that only the overlay chrome partials yield. The application layout doesn't yield them, so on a full-page render — e.g. when a user opens the overlay link in a new tab — the title and every button inside overlay_footer silently disappear. The form still posts, but there's no visible submit control.

Two safe patterns:

  1. Use variant templates (recommended). Put overlay-only chrome in show.html+modal.erb and keep show.html.erb as a standalone page with its own header and buttons. The two never share code paths.

  2. Branch on overlay_request? inside a single template:

    <% if overlay_request? %>
      <% overlay_title "Edit user" %>
      <%= render "form", user: @user %>
      <% overlay_footer do %>
        <button type="submit" form="user-form" class="btn btn-primary">Save</button>
      <% end %>
    <% else %>
      <h1>Edit user</h1>
      <%= render "form", user: @user %>
      <button type="submit" form="user-form" class="btn btn-primary">Save</button>
    <% end %>

Either way, make sure every overlay-targeted action also renders sensibly as a standalone page — "open link in new tab" is a real user habit, and the URL is a real Rails route.

Stable ids and closing from server code

Give an overlay an id at open time and close it later from server code:

<%= modal_link_to "Edit user",
                  edit_user_path(@user),
                  overlay_id: "edit_user_#{@user.id}" %>
turbo_stream.overlay(:close, id: "edit_user_#{@user.id}")

When overlay_id: is omitted, the gem generates a random id. Inside the controller and views read it as turbo_overlay_id:

render turbo_stream: turbo_stream.overlay(:close, id: turbo_overlay_id)

Branching in controllers

The gem sets request.variant to :modal, :drawer, :popover, or :hint on overlay requests, and exposes overlay_request? plus per-type predicates (modal_request?, drawer_request?, popover_request?, hint_request?) on both controllers and views. Use either to fork an action's response.

respond_to with variant blocks

Because request.variant is set, standard Rails variant-block API works out of the box:

def show
  @user = User.find(params[:id])

  respond_to do |format|
    format.html.modal   { render :show_modal }
    format.html.drawer  { render :show_drawer }
    format.html         { render :show }  # full-page fallback
  end
end

For pure markup differences, variant templates (show.html+modal.erb etc.) are simpler — branch in the action itself only when you need to load different data or run different logic per chrome.

Closing on success, redirecting on failure

For most create/update actions, a plain redirect_to is all you need — the overlay closes on redirect by default and the browser visits the target:

def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user, notice: "Created."   # overlay closes; lands on /users/:id
  else
    render :new, status: :unprocessable_entity   # form re-renders in place
  end
end

Reach for a turbo-stream response when you want to update other parts of the page without navigating away — e.g. closing the overlay and prepending the new record to a list on the host page:

def create
  @user = User.new(user_params)
  if @user.save
    render turbo_stream: [
      turbo_stream.prepend("users", partial: "users/user", locals: { user: @user }),
      turbo_stream.overlay(:close)
    ]
  else
    render :new, status: :unprocessable_entity
  end
end

render :new, status: :unprocessable_entity works for both overlay and full-page submissions — Turbo re-renders the form in place either way, so validation errors don't need a separate code path.

Chrome partials

The install generator copies these into your app. They're yours — edit freely.

Chrome partials wrap the dialog/drawer/popover/hint:

  • app/views/turbo_overlay/_modal.html.erb
  • app/views/turbo_overlay/_drawer.html.erb
  • app/views/turbo_overlay/_popover.html.erb
  • app/views/turbo_overlay/_hint.html.erb

When editing a chrome partial, keep the <dialog> element with its data-controller="turbo-overlay" and data values, the <%= yield %> for the body, and the content_for(:overlay_title) / content_for(:overlay_footer) reads.

The chrome partials accept a loading: local; when true they render a static placeholder version (no Stimulus controller, no close button, no title/footer).

Body-only partials render inside the matching chrome — retheming the chrome flows through to both:

  • app/views/turbo_overlay/_confirm.html.erb — themed data-turbo-confirm body.
  • app/views/turbo_overlay/_loading.html.erb — loading placeholder body.

Add _confirm.html+<variant>.erb or _loading.html+<variant>.erb when you need chrome-specific markup; the variant wins over the shared file.

To switch themes, re-run install with --force:

bin/rails g turbo_overlay:install --theme tailwind --force

User-initiated dismissal

Out of the box the user can dismiss the top overlay with ESC or by clicking the backdrop. Both go through the same animated close path as the close button. Opt a specific overlay out of backdrop-click dismissal — e.g. a form with unsaved input — with:

<dialog ...
        data-controller="turbo-overlay"
        data-turbo-overlay-backdrop-dismiss-value="false">

On validation failure, just render :new, status: :unprocessable_entity — the overlay stays open and re-renders the form with errors in place. No special handling required.

Closing behavior

There are three close paths for modals, drawers, and popovers:

  1. The user dismisses — ESC key, backdrop click, or the chrome's close button. Always available.
  2. A form submission redirects — the overlay closes and the browser visits the redirect target. This is the default; opt out when you need to keep the overlay open across a redirect (wizard steps, multi-step inline edits) via keep_overlay_open_on_redirect: true on the trigger link or data-turbo-overlay-keep-open-on-redirect="true" on the form. Detection is a per-dialog turbo:submit-end listener that fires only when fetchResponse.redirected === true — raw 2xx responses don't trigger it, so searches/inline edits returning 200 stay open.
  3. The server explicitly closesturbo_stream.overlay(:close, …) from a non-redirect response. Useful when an action needs to update other parts of the page in the same response (see Closing on success, redirecting on failure).

Validation failures (render :new, status: :unprocessable_entity) are 422 responses — they don't redirect, so the overlay stays open and the form re-renders in place. No special handling required.

Hints work differently — they open from hover state and dismiss purely client-side on mouseout (with a grace window). There's no turbo_stream.overlay(:close, type: :hint) path and no submit-end close behavior (hints don't host forms).