Filterable Index with Slide-Out Detail Drawer
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
The classic "Retool-style" admin screen, built with plain Rails + Hotwire. The index
page shows a condensed table — only the 4–6 columns a human scans, not every model
column. A thin strip on the left slides open a filter panel (search box, date
range, multi-select status/association filters) that submits as a normal GET form, so
every filtered view is a shareable URL. Clicking a row slides a drawer in from the
right and loads that record's full show view — every column — inside a Turbo
Frame. The pencil icon (or the Edit button inside the drawer) swaps the same drawer to
the edit form. Create/update/validation round-trips all happen inside the drawer;
the page underneath never reloads.
The trick that makes it cheap: the drawer is one global shell in the layout with one
turbo_frame_tag "record_drawer" inside. Your show.html.erb and edit.html.erb are
otherwise-normal views that render their content inside a matching frame tag. Turbo does
all the fetching and swapping; ~90 lines of vanilla JS handle open/close/escape/backdrop.
Adding the pattern to a second or third resource costs only the index markup — the
drawer, its JS, and the partials are shared.
When to use: any admin/back-office index where rows have more fields than fit in a table — CRM records, orders, signals, jobs. The moment you're tempted to cram a 12th column in, reach for this instead. When not to: if rows are flat and every field fits in the table, inline cell editing is faster (see inline-editable-table). If the "detail" is a whole workspace (tabs, sub-lists), give it a real page instead of a drawer.
The example uses a
Contactmodel withname / email / company / phone / website / status / notescolumns. The table shows only name, company, status, created — the drawer shows everything. Swap in your own model — see How to adapt at the bottom.
The 80/20 in one breath
- Put a global drawer shell (backdrop + fixed right panel + an empty
turbo_frame_tag "record_drawer") inlayouts/application.html.erb, with ~90 lines of vanilla JS: open the drawer whenever that frame receives content, close on ✕ / backdrop / Escape, and navigate the frame when a<tr data-drawer-url>is clicked. - Make
show.html.erbandedit.html.erbrender their content through a sharedshared/_drawer_contentpartial that wraps everything in the sameturbo_frame_tag "record_drawer". - On the index, give each
<tr>adata-drawer-urland point the row's name link and pencil link at the frame withdata: { turbo_frame: "record_drawer" }. - Wrap the whole index page in ONE
form_with method: :get— the left filter panel's inputs are just fields of that form. A small Stimulus controller slides the panel open/closed from a thin trigger strip. - In the controller's
index, permit the filter params (arrays included), chain.wherescopes conditionally, and paginate. - Scaffold-standard
create/updatealready work: redirects are followed inside the frame, so a successful save swaps the drawer to the freshshowview, and validation errors re-render the form in place. Onlydestroyneeds to break out to_top. - Make the sortable column headers links that set a
sortparam — the SAME param a "Sort by" dropdown in the filter panel uses, so there is one source of truth and the two controls can never disagree (see Layer 3.5).
Layer 1 — The layout: one global drawer shell + the JS
Add this once, near the end of <body>. Every resource reuses it.
<%# app/views/layouts/application.html.erb — just before </body> %>
<%# ── Slide-out detail drawer (right side) ─────────────────────────── %>
<div id="record-drawer-backdrop"
class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm z-40 opacity-0 pointer-events-none transition-opacity duration-200"
aria-hidden="true"></div>
<aside id="record-drawer"
class="fixed top-0 right-0 h-full w-full max-w-2xl bg-white shadow-2xl z-50 translate-x-full transition-transform duration-300 flex flex-col"
aria-hidden="true" role="dialog" aria-label="Details panel">
<div class="flex items-center justify-between px-5 h-12 border-b border-gray-200 bg-white shrink-0">
<div id="record-drawer-title" class="text-sm font-medium text-gray-500">Details</div>
<button type="button" id="record-drawer-close"
class="text-gray-500 hover:text-gray-800 px-2 py-1" aria-label="Close panel">
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto">
<%= turbo_frame_tag "record_drawer", class: "block" do %>
<%# Row / pencil clicks load show & edit views in here %>
<% end %>
</div>
</aside>
<style>
#record-drawer.is-open { transform: translateX(0); }
#record-drawer-backdrop.is-open { opacity: 1; pointer-events: auto; }
body.record-drawer-open { overflow: hidden; }
</style>
<script>
(function() {
'use strict';
const drawer = document.getElementById('record-drawer');
const backdrop = document.getElementById('record-drawer-backdrop');
const closeBtn = document.getElementById('record-drawer-close');
const titleEl = document.getElementById('record-drawer-title');
if (!drawer || !backdrop) return;
function openDrawer(title) {
drawer.classList.add('is-open');
backdrop.classList.add('is-open');
document.body.classList.add('record-drawer-open');
drawer.setAttribute('aria-hidden', 'false');
if (title && titleEl) titleEl.textContent = title;
}
function closeDrawer() {
drawer.classList.remove('is-open');
backdrop.classList.remove('is-open');
document.body.classList.remove('record-drawer-open');
drawer.setAttribute('aria-hidden', 'true');
if (titleEl) titleEl.textContent = 'Details';
// Reset the frame so re-opening the SAME record refetches it.
// BOTH lines matter — see Gotchas.
const frame = drawer.querySelector('turbo-frame#record_drawer');
if (frame) { frame.removeAttribute('src'); frame.innerHTML = ''; }
}
backdrop.addEventListener('click', closeDrawer);
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && drawer.classList.contains('is-open')) closeDrawer();
});
// Open the drawer whenever the frame receives content (NOT on click —
// a slow fetch would flash an empty panel).
document.addEventListener('turbo:frame-load', function(e) {
if (!e.target || e.target.id !== 'record_drawer') return;
if (e.target.innerHTML.trim()) {
const t = e.target.querySelector('[data-drawer-title]');
openDrawer(t ? t.textContent.trim() : null);
}
});
// Row clicks: navigate the drawer frame to the row's URL.
document.addEventListener('click', function(e) {
const row = e.target.closest('tr[data-drawer-url]');
if (!row) return;
// Don't hijack clicks on real interactive elements inside the row.
if (e.target.closest('a, button, input, select, textarea, [data-no-drawer]')) return;
e.preventDefault();
const frame = document.querySelector('turbo-frame#record_drawer');
if (frame) frame.src = row.dataset.drawerUrl;
});
window.RecordDrawer = { open: openDrawer, close: closeDrawer };
})();
</script>
Layer 2 — Shared partials
The frame wrapper every show/edit renders through:
<%# app/views/shared/_drawer_content.html.erb %>
<%# Drawer frame wrapper. Locals: title, subtitle (optional). Yields body. %>
<%= turbo_frame_tag "record_drawer" do %>
<div class="p-6 space-y-6">
<div class="pb-4 border-b border-gray-200">
<div class="text-[11px] font-semibold text-gray-400 uppercase tracking-wider" data-drawer-title><%= title %></div>
<% if local_assigns[:subtitle].present? %>
<div class="mt-1 text-lg font-semibold text-gray-800 leading-snug"><%= subtitle %></div>
<% end %>
</div>
<div class="drawer-body space-y-4 text-sm text-gray-700">
<%= yield %>
</div>
</div>
<% end %>
<style>
.drawer-body .field { margin-bottom: 0.75rem; }
.drawer-body label {
display: block; font-size: 0.75rem; font-weight: 600; color: #6B7280;
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;
}
.drawer-body input[type="text"], .drawer-body input[type="email"],
.drawer-body input[type="url"], .drawer-body input[type="number"],
.drawer-body input[type="date"], .drawer-body textarea, .drawer-body select {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #E5E7EB;
border-radius: 0.375rem; background: white; font-size: 0.875rem; color: #1F2937;
}
.drawer-body input:focus, .drawer-body textarea:focus, .drawer-body select:focus {
outline: none; border-color: #6366F1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
}
</style>
A reusable multi-select for the filter panel:
<%# app/views/shared/_filter_select.html.erb %>
<%# Locals: label, name (end it in [] for multi), options ([display, value] pairs), selected %>
<div>
<label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">
<%= label %>
</label>
<select name="<%= name %>" multiple size="1"
class="w-full rounded-md border border-gray-300 bg-white text-sm text-gray-700 px-2 py-1.5">
<% options.each do |display, value| %>
<option value="<%= value %>" <%= "selected" if Array(selected).map(&:to_s).include?(value.to_s) %>>
<%= display %>
</option>
<% end %>
</select>
</div>
Layer 3 — The index view
One GET form wraps the entire screen: trigger strip, filter panel, and table. Submitting any filter is a plain page navigation with clean URL params.
<%# app/views/contacts/index.html.erb %>
<% content_for :title, "Contacts" %>
<%
status_options = Contact.statuses.keys.map { |s| [s.humanize, s] }
page_size_options = [25, 50, 100, 200, 500]
filter_keys = [:q, :start_date, :end_date, :statuses]
filters_active = filter_keys.any? { |k| @filters[k].present? }
%>
<%= form_with url: contacts_path, method: :get, local: true, data: { turbo_frame: "_top" } do |f| %>
<div class="flex min-h-[calc(100vh-4rem)] bg-gray-100" data-controller="filter-drawer">
<!-- Thin trigger strip — always visible -->
<div data-action="click->filter-drawer#toggle"
class="shrink-0 w-10 flex flex-col items-center pt-5 gap-5 bg-white border-r border-gray-200 cursor-pointer hover:bg-gray-50 z-10">
<i data-filter-drawer-target="filterIcon"
class="fas fa-filter text-sm <%= filters_active ? 'text-indigo-600' : 'text-gray-400' %>"
title="Toggle filters"></i>
<i data-filter-drawer-target="closeIcon"
class="fas fa-times hidden text-red-500 text-lg leading-none" title="Close filters"></i>
<i class="fas fa-search text-sm text-gray-400" title="Search"></i>
</div>
<!-- Filter panel — collapsed by default, slides open -->
<aside data-filter-drawer-target="panel"
class="shrink-0 bg-white border-r border-gray-200 flex flex-col gap-4 text-sm overflow-hidden transition-all duration-200 ease-out w-0 px-0 opacity-0">
<div class="w-64 shrink-0 px-4 py-5 flex flex-col gap-4 min-h-full">
<div>
<label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Search</label>
<%= f.search_field :q, value: @filters[:q], placeholder: "Name, email, company…",
class: "w-full rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
</div>
<div>
<label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Date Created</label>
<div class="flex items-center gap-1.5">
<%= f.date_field :start_date, value: @filters[:start_date], class: "w-1/2 rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
<span class="text-gray-400 text-xs">–</span>
<%= f.date_field :end_date, value: @filters[:end_date], class: "w-1/2 rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
</div>
</div>
<%= render "shared/filter_select", label: "Status", name: "statuses[]",
options: status_options, selected: @filters[:statuses] %>
<div>
<label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Page Size</label>
<%= f.select :page_size, page_size_options.map { |n| [n.to_s, n] },
{ selected: @filters[:page_size] },
class: "w-full rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
</div>
<div class="mt-2 flex items-center gap-2">
<%= f.submit "Apply Filters", class: "flex-1 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 cursor-pointer" %>
<%= link_to "Reset", contacts_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2" %>
</div>
<div class="mt-auto pt-4 text-[11px] text-gray-400 text-right">
<%= @total_count %> total
</div>
</div>
</aside>
<main class="flex-1 flex flex-col min-w-0">
<div class="flex items-center justify-end gap-3 px-6 py-3 bg-white border-b border-gray-200">
<%= link_to new_contact_path, class: "rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 inline-flex items-center gap-2",
data: { turbo_frame: "record_drawer" } do %>
<i class="fas fa-plus text-xs"></i>New Contact
<% end %>
</div>
<div class="flex-1 overflow-auto bg-white">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-gray-50 border-b border-gray-200 text-gray-500 text-[11px] uppercase tracking-wider">
<tr>
<th class="text-left font-semibold px-4 py-2.5 min-w-[240px]">Name</th>
<th class="text-left font-semibold px-4 py-2.5">Company</th>
<th class="text-left font-semibold px-4 py-2.5">Status</th>
<th class="text-left font-semibold px-4 py-2.5 whitespace-nowrap">Created at</th>
<th class="w-12 px-2 py-2.5 text-right">Edit</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<% @contacts.each do |contact| %>
<tr class="hover:bg-gray-50 cursor-pointer" data-drawer-url="<%= contact_path(contact) %>">
<td class="px-4 py-2 text-gray-700 max-w-md">
<%= link_to contact.name.to_s.truncate(80), contact_path(contact),
class: "hover:text-indigo-600",
data: { turbo_frame: "record_drawer" } %>
</td>
<td class="px-4 py-2 text-gray-600 whitespace-nowrap"><%= contact.company.presence || "—" %></td>
<td class="px-4 py-2">
<%
badge_class = case contact.status
when "active" then "bg-emerald-50 text-emerald-700 border-emerald-200"
when "inactive" then "bg-amber-50 text-amber-700 border-amber-200"
else "bg-gray-100 text-gray-500 border-gray-200"
end
%>
<span class="inline-flex items-center px-2 py-0.5 rounded border text-[11px] font-medium <%= badge_class %>">
<%= contact.status.humanize %>
</span>
</td>
<td class="px-4 py-2 text-gray-500 whitespace-nowrap"><%= contact.created_at.strftime("%b %-d, %Y") %></td>
<td class="px-2 py-2 text-right whitespace-nowrap">
<%= link_to edit_contact_path(contact),
class: "inline-flex items-center justify-center w-7 h-7 rounded text-gray-400 hover:text-indigo-600 hover:bg-gray-100",
title: "Edit", data: { turbo_frame: "record_drawer" } do %>
<i class="fas fa-pen text-xs"></i>
<% end %>
</td>
</tr>
<% end %>
<% if @contacts.empty? %>
<tr>
<td colspan="5" class="text-center py-16">
<div class="flex flex-col items-center gap-2 text-gray-400">
<i class="fas fa-address-book text-3xl"></i>
<span class="text-sm">No contacts match these filters</span>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</main>
</div>
<% end %>
Layer 3.5 — Sortable column headers (server-side, one param with the dropdown)
Clickable headers are where people expect sorting to live; a "Sort by" dropdown in the
filter panel is still useful for sorts that have no column. Ship both, sharing ONE
sort param whose whitelist lives next to the filter code — never two sorting code
paths.
# app/controllers/contacts_controller.rb (or a query object)
SORTS = {
"newest" => "Newest first",
"oldest" => "Oldest first",
"name" => "Name A–Z",
"status" => "Status"
}.freeze
def sort
SORTS.key?(params[:sort].to_s) ? params[:sort].to_s : "newest"
end
# in index, after the filters:
scope = case sort
when "oldest" then scope.reorder(created_at: :asc)
when "name" then scope.reorder(Arel.sql("LOWER(name) ASC"))
when "status" then scope.reorder(status: :asc, created_at: :desc)
else scope.reorder(created_at: :desc)
end
Each header is a link that swaps sort while keeping every filter param (and dropping
page, so a re-sort lands on page 1). Give each sort key a FIXED direction and let the
primary date column toggle between its two keys — simpler and more predictable than
tri-state asc/desc/none on every column:
<%# app/views/contacts/index.html.erb — replace the static <th> labels %>
<%
sort_qp = request.query_parameters.except("page")
sort_header = lambda do |label, key, toggle_with: nil|
active = params_sort == key || (toggle_with && params_sort == toggle_with)
target = (params_sort == key && toggle_with) ? toggle_with : key
arrow = if !active then "fa-sort text-gray-300"
elsif params_sort == "oldest" then "fa-arrow-up-long text-indigo-600"
else "fa-arrow-down-long text-indigo-600"
end
link_to contacts_path(sort_qp.merge(sort: target)),
class: "inline-flex items-center gap-1 hover:text-gray-700 #{'text-indigo-700' if active}" do
safe_join([label, tag.i(class: "fas #{arrow} text-[9px]")], " ")
end
end
%>
<th class="text-left font-semibold px-4 py-2.5"><%= sort_header.call("Name", "name") %></th>
<th class="text-left font-semibold px-4 py-2.5"><%= sort_header.call("Status", "status") %></th>
<th class="text-left font-semibold px-4 py-2.5"><%= sort_header.call("Created", "newest", toggle_with: "oldest") %></th>
(params_sort above = the whitelisted sort reader from the controller/query object,
exposed however you like — an @sort ivar works.)
Sorting by a value that is NOT a database column (a score from a cache file, an
external API figure): you cannot ORDER BY it. If the ranked set is small, rank those
ids explicitly with array_position and leave everyone else in default order after
them — proven with a ~50-customer lifetime-revenue cache over an 11k-row table:
# ids already sorted by the external value, highest first; integers only —
# never interpolate user input into this array.
ranked_ids = ltv_cache.sort_by { |_, cents| -cents }.map { |id, _| id.to_i }
scope.reorder(Arel.sql(
"array_position(ARRAY[#{ranked_ids.join(',')}]::bigint[], contacts.id::bigint) ASC NULLS LAST, contacts.created_at DESC"
))
Layer 4 — Stimulus: the left filter panel
Width-transition open/close. Collapsed is w-0 px-0 opacity-0; the inner w-64 div
keeps the content from reflowing during the animation.
// app/javascript/controllers/filter_drawer_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["panel", "filterIcon", "closeIcon"]
connect() { this.close() }
toggle() {
this.panelTarget.classList.contains("w-0") ? this.open() : this.close()
}
open() {
this.panelTarget.classList.remove("w-0", "px-0", "opacity-0")
this.panelTarget.classList.add("w-64", "px-4", "opacity-100")
this.panelTarget.style.pointerEvents = ""
if (this.hasCloseIconTarget) this.closeIconTarget.classList.remove("hidden")
if (this.hasFilterIconTarget) this.filterIconTarget.classList.add("hidden")
}
close() {
this.panelTarget.classList.add("w-0", "px-0", "opacity-0")
this.panelTarget.classList.remove("w-64", "px-4", "opacity-100")
this.panelTarget.style.pointerEvents = "none"
if (this.hasCloseIconTarget) this.closeIconTarget.classList.add("hidden")
if (this.hasFilterIconTarget) this.filterIconTarget.classList.remove("hidden")
}
}
Layer 5 — Controller: permit the filters, chain the scopes
# app/controllers/contacts_controller.rb
class ContactsController < ApplicationController
before_action :set_contact, only: %i[ show edit update destroy ]
def index
@filters = index_filter_params
scope = Contact.order(created_at: :desc)
if @filters[:q].present?
q = "%#{Contact.sanitize_sql_like(@filters[:q])}%"
scope = scope.where("name ILIKE :q OR email ILIKE :q OR company ILIKE :q", q: q)
end
scope = scope.where("created_at >= ?", parse_date(@filters[:start_date])) if @filters[:start_date].present?
scope = scope.where("created_at <= ?", parse_date(@filters[:end_date])&.end_of_day) if @filters[:end_date].present?
scope = scope.where(status: @filters[:statuses]) if @filters[:statuses].present?
per_page = @filters[:page_size].to_i.clamp(1, 500)
@pagy, @contacts = pagy(scope, limit: per_page) # no pagy? see How to adapt
@total_count = Contact.count
end
def show; end
def edit; end
def new
@contact = Contact.new
end
# Scaffold-standard create/update work UNCHANGED with the drawer:
# the redirect is followed INSIDE the frame → drawer swaps to the fresh show view;
# validation errors re-render the form inside the drawer.
def create
@contact = Contact.new(contact_params)
if @contact.save
redirect_to @contact, notice: "Contact created."
else
render :new, status: :unprocessable_entity
end
end
def update
if @contact.update(contact_params)
redirect_to @contact, notice: "Contact updated.", status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@contact.destroy!
redirect_to contacts_path, notice: "Contact deleted.", status: :see_other
end
private
def set_contact
@contact = Contact.find(params[:id])
end
def contact_params
params.require(:contact).permit(:name, :email, :company, :phone, :website, :status, :notes)
end
def index_filter_params
permitted = params.permit(:q, :start_date, :end_date, :page_size, statuses: []).to_h.symbolize_keys
permitted[:page_size] = 100 if permitted[:page_size].blank?
permitted
end
# Bad date strings from the URL must not 500 the page.
def parse_date(value)
Date.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
end
Layer 6 — show & edit: normal views that land in the drawer
The drawer shows all the columns, even though the table showed four.
<%# app/views/contacts/show.html.erb %>
<%= render "shared/drawer_content", title: "Contact", subtitle: @contact.name do %>
<% { "Name" => @contact.name, "Email" => @contact.email,
"Company" => @contact.company, "Phone" => @contact.phone,
"Website" => @contact.website, "Status" => @contact.status.humanize,
"Notes" => @contact.notes,
"Created" => @contact.created_at.strftime("%b %-d, %Y %H:%M") }.each do |label, value| %>
<div>
<strong class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-0.5"><%= label %></strong>
<span class="text-gray-800"><%= value.presence || "—" %></span>
</div>
<% end %>
<div class="pt-4 mt-6 border-t border-gray-100 flex flex-wrap items-center gap-2">
<%= link_to edit_contact_path(@contact),
class: "rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-3 py-1.5 inline-flex items-center gap-1",
data: { turbo_frame: "record_drawer" } do %>
<i class="fas fa-pen text-xs"></i>Edit
<% end %>
<%= button_to @contact, method: :delete,
class: "rounded-md bg-rose-500 hover:bg-rose-600 text-white text-sm px-3 py-1.5 inline-flex items-center gap-1",
form: { data: { turbo_frame: "_top", turbo_confirm: "Delete this contact?" }, class: "inline" } do %>
<i class="fas fa-trash text-xs"></i>Delete
<% end %>
</div>
<% end %>
<%# app/views/contacts/edit.html.erb %>
<%= render "shared/drawer_content", title: "Editing Contact", subtitle: @contact.name do %>
<%= render "form", contact: @contact %>
<div class="pt-4 mt-2 border-t border-gray-100">
<%= link_to "Cancel", contact_path(@contact), class: "text-sm text-gray-500 hover:text-gray-700",
data: { turbo_frame: "record_drawer" } %>
</div>
<% end %>
new.html.erb is the same shape as edit (title "New Contact", render the form).
The _form partial is a scaffold-standard form_with model: contact — no frame
attributes needed on it; a form inside a frame targets that frame by default.
Gotchas (the hard-won stuff)
- Reset the frame's
srcon close, not just its content. Turbo won't re-navigate a frame whosesrcalready equals the clicked URL. If close only clearsinnerHTML, clicking the SAME row again silently does nothing and the drawer never reopens. The close handler must do both:frame.removeAttribute('src'); frame.innerHTML = ''. - Open the drawer on
turbo:frame-load, not on click. Opening on click flashes an empty white panel while the fetch is in flight. Waiting for the frame to load means the drawer slides in already populated. (Corollary: if the drawer "doesn't open", check the browser console — a failed frame fetch means noturbo:frame-loadfires.) - The row-click handler must ignore interactive descendants. Without the
e.target.closest('a, button, input, select, textarea, [data-no-drawer]')guard, the pencil link fires AND the row handler fires — double navigation.[data-no-drawer]is the escape hatch for any future in-row widget. - Scaffold redirects are followed INSIDE the frame — usually what you want. After a
successful update,
redirect_to @contactswaps the drawer to the fresh show view. Trade-off: the table row behind the drawer still shows stale values until the next page navigation. If that matters, break out instead: submit the form withdata: { turbo_frame: "_top" }to reload the whole filtered index after save. destroyMUST break out to_top. A delete that responds inside the frame leaves the dead record's row sitting in the table.button_to ... form: { data: { turbo_frame: "_top" } }makes the redirect reload the index so the row disappears.- Wrap the index in ONE GET form and stamp it
data: { turbo_frame: "_top" }. Everything in the filter panel is a plain field of that form, so "Apply Filters" is a normal navigation with clean, shareable URL params — and the_topstamp guarantees a filter submit never gets captured by the drawer frame. - Permit array params explicitly.
params.permit(:q, statuses: [])— forget thestatuses: []declaration and multi-selects silently arrive empty. - Guard date parsing. A hand-edited URL like
?start_date=garbagewill 500 the index throughDate.parse. Always rescue tonil(theparse_datehelper above). - Direct visits to
/contacts/1still work, but look bare. The frame renders inline in the main content area (the drawer shell stays closed). Fine for an admin tool; if it bothers you, haveshowredirect HTML requests without aTurbo-Frameheader to the index. - Sticky header needs the right scroll container.
sticky top-0on<thead>only works because theoverflow-autodiv is the table's nearest scroll ancestor. Putoverflow-x-autoon a different wrapper and the header scrolls away. Corollary for a table that scrolls WITH THE PAGE (no fixed-height layout): anoverflow-x-autowrapper — which wide tables need — becomes the scroll ancestor and kills vertical sticking against body scroll. The fix is to stop body-scrolling the table: cap the card (max-h-[calc(100vh-<header px>px)]), make the table wrapperflex-1 overflow-auto, and addz-10(plus thebg-gray-50) on the thead so row content doesn't paint over it. Pagination stays OUTSIDE the scrolling card. - Sort headers and a sort dropdown must share one param + one whitelist. Two sort
code paths (header links with their own
orderparam, dropdown with another) is how a page silently shows one order while claiming another. One whitelistedsortkey, onecasein the controller; the header link builder and the dropdown both read it. Build header links fromrequest.query_parameters.except("page")so re-sorting keeps every filter and returns to page 1. - Flash inside a frame response is invisible. A
notice:on the redirect renders in the layout, which frame responses strip — so nothing shows. Either live without toasts for drawer actions, or embed the flash inside the frame content in a hidden[data-drawer-flash]div and have the layout JS move it to a fixed toast host onturbo:frame-load(the source project does exactly this). - Font Awesome isn't guaranteed on every box. The icons here (
fa-filter,fa-times,fa-search,fa-pen,fa-plus,fa-trash) need Font Awesome in the layout. No FA? Use inline SVGs or text glyphs (✕, ✎) — the pattern doesn't care. - Collapse the panel with
w-0 px-0 opacity-0, keep a fixed-width inner div. Animating the outer width while an innerw-64div holds the content is what makes the slide smooth — animate the padding/width of the content itself and it reflows mid-animation.
Files this pattern touches
app/views/layouts/application.html.erb # drawer shell + CSS + JS (once, global)
app/views/shared/_drawer_content.html.erb # frame wrapper for show/edit/new
app/views/shared/_filter_select.html.erb # reusable multi-select filter
app/views/contacts/index.html.erb # trigger strip + filter panel + table
app/views/contacts/show.html.erb # all columns, in the drawer
app/views/contacts/edit.html.erb # form, in the drawer
app/views/contacts/new.html.erb # form, in the drawer
app/javascript/controllers/filter_drawer_controller.js
app/controllers/contacts_controller.rb # filter params + scopes
How to adapt to your schema
- Swap the model. Replace
Contactand its routes. Pick the 4–6 columns humans scan for the table; everything else lives only in the drawer's show/edit views. - Pick your filters. Text search + date range + one or two multi-selects covers most
admin tables. For an association filter (e.g. by category), render another
shared/filter_selectwithoptions: Category.order(:name).pluck(:name, :id)and addcategory_ids: []to the permitted params + awhere(category_id: ...)scope. - No pagy? Replace the pagy line with
@contacts = scope.limit(per_page).offset(([params[:page].to_i, 1].max - 1) * per_page)and simple prev/next links that carry the filter params. Keep the 500 cap — an unbounded index on a big table can eat a small box's RAM. - Second resource? Reuse the layout drawer, both shared partials, and the Stimulus controller as-is. You only write the new index markup and the show/edit bodies.
- Drawer too narrow/wide? It's one class on the
<aside>:max-w-2xl. Forms with many columns likemax-w-3xl; a read-mostly log viewer is fine atmax-w-xl. - Want the filter panel open by default? Change
connect()to callthis.open()when any filter param is present (pass them via a Stimulus value), so a shared filtered URL shows its filters.