---
title: Filterable Index with Slide-Out Detail Drawer
slug: filterable-index-with-detail-drawer
category: Tables
summary: A Retool-style admin index — a condensed table showing only key columns, a collapsible left-hand filter/search panel, and a right-hand slide-out drawer that loads the full show/edit views for a row via one Turbo Frame, no page reloads.
tags: [stimulus, turbo, turbo-frames, drawer, filters, search, tables, admin, ux-default]
status: stable
visibility: public
source_project: leo-tozeb.leo.llamapress.ai
layers: [view, stimulus_js, controller]
related:
  - title: High-Quality Inline-Editable Table
    url: /cookbook/inline-editable-table
    summary: The sibling pattern — edit flat scalar columns directly in the cells. Use THIS guide instead when a row has more fields than the table shows and needs a real multi-field form.
  - title: Cmd+K Global Search
    url: /cookbook/global-search-command-palette
    summary: Cross-table search from anywhere. This guide's filter panel is per-table; the palette is app-wide. They compose well.
---

# 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](/cookbook/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 `Contact` model with
> `name / email / company / phone / website / status / notes` columns. 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

1. Put a **global drawer shell** (backdrop + fixed right panel + an empty
   `turbo_frame_tag "record_drawer"`) in `layouts/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.
2. Make `show.html.erb` and `edit.html.erb` render their content through a shared
   `shared/_drawer_content` partial that wraps everything in the same
   `turbo_frame_tag "record_drawer"`.
3. On the index, give each `<tr>` a `data-drawer-url` and point the row's name link and
   pencil link at the frame with `data: { turbo_frame: "record_drawer" }`.
4. 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.
5. In the controller's `index`, permit the filter params (arrays included), chain
   `.where` scopes conditionally, and paginate.
6. Scaffold-standard `create`/`update` already work: redirects are followed *inside*
   the frame, so a successful save swaps the drawer to the fresh `show` view, and
   validation errors re-render the form in place. Only `destroy` needs to break out to
   `_top`.

---

## Layer 1 — The layout: one global drawer shell + the JS

Add this once, near the end of `<body>`. Every resource reuses it.

```erb
<%# 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:

```erb
<%# 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:

```erb
<%# 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.

```erb
<%# 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 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.

```js
// 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

```ruby
# 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.

```erb
<%# 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 %>
```

```erb
<%# 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 `src` on close, not just its content.** Turbo won't re-navigate a
  frame whose `src` already equals the clicked URL. If close only clears `innerHTML`,
  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 no `turbo:frame-load` fires.)
- **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 @contact` swaps 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 with
  `data: { turbo_frame: "_top" }` to reload the whole filtered index after save.
- **`destroy` MUST 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 `_top` stamp guarantees a
  filter submit never gets captured by the drawer frame.
- **Permit array params explicitly.** `params.permit(:q, statuses: [])` — forget the
  `statuses: []` declaration and multi-selects silently arrive empty.
- **Guard date parsing.** A hand-edited URL like `?start_date=garbage` will 500 the index
  through `Date.parse`. Always rescue to `nil` (the `parse_date` helper above).
- **Direct visits to `/contacts/1` still 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, have `show` redirect HTML requests without a `Turbo-Frame` header to the
  index.
- **Sticky header needs the right scroll container.** `sticky top-0` on `<thead>` only
  works because the `overflow-auto` div is the table's nearest scroll ancestor. Put
  `overflow-x-auto` on a different wrapper and the header scrolls away.
- **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 on
  `turbo: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 inner `w-64` div 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

1. **Swap the model.** Replace `Contact` and its routes. Pick the 4–6 columns humans
   scan for the table; everything else lives only in the drawer's show/edit views.
2. **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_select` with `options: Category.order(:name).pluck(:name, :id)` and add
   `category_ids: []` to the permitted params + a `where(category_id: ...)` scope.
3. **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.
4. **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.
5. **Drawer too narrow/wide?** It's one class on the `<aside>`: `max-w-2xl`. Forms with
   many columns like `max-w-3xl`; a read-mostly log viewer is fine at `max-w-xl`.
6. **Want the filter panel open by default?** Change `connect()` to call `this.open()`
   when any filter param is present (pass them via a Stimulus value), so a shared
   filtered URL shows its filters.
